chore: fix all ruff lint errors + format — 0 errors, 306 tests pass

This commit is contained in:
leocrm-bot
2026-06-29 17:43:56 +02:00
parent 316f323ff4
commit a2452cc04b
81 changed files with 2317 additions and 1128 deletions
+22 -10
View File
@@ -1,16 +1,28 @@
"""Service layer package."""
from app.services.plugin_service import PluginService, get_plugin_service
from app.services.ai_copilot_service import (
process_query as copilot_process_query,
execute_action as copilot_execute_action,
get_history as copilot_get_history,
execute_action as copilot_execute_action, # noqa: F401
)
from app.services.ai_copilot_service import (
get_history as copilot_get_history, # noqa: F401
)
from app.services.ai_copilot_service import (
process_query as copilot_process_query, # noqa: F401
)
from app.services.plugin_service import PluginService, get_plugin_service # noqa: F401
from app.services.workflow_service import (
create_workflow, list_workflows, get_workflow,
update_workflow, delete_workflow,
create_instance, list_instances, get_instance,
advance_instance, cancel_instance,
check_timeout, auto_reject_timeout,
find_workflows_for_event, start_instance_for_event,
advance_instance, # noqa: F401
auto_reject_timeout, # noqa: F401
cancel_instance, # noqa: F401
check_timeout, # noqa: F401
create_instance, # noqa: F401
create_workflow, # noqa: F401
delete_workflow, # noqa: F401
find_workflows_for_event, # noqa: F401
get_instance, # noqa: F401
get_workflow, # noqa: F401
list_instances, # noqa: F401
list_workflows, # noqa: F401
start_instance_for_event, # noqa: F401
update_workflow, # noqa: F401
)
+32 -24
View File
@@ -3,15 +3,15 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, func, desc, and_
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.llm_client import get_llm_client
from app.core.audit import log_audit
from app.core.auth import check_permission, filter_fields_by_permission
from app.core.auth import check_permission
from app.models.ai_conversation import AIConversation, AIMessage
from app.models.company import Company
from app.models.contact import Contact
@@ -136,7 +136,9 @@ async def process_query(
# Log to audit
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="query",
entity_type="ai_copilot",
entity_id=conversation.id,
@@ -173,7 +175,7 @@ async def execute_action(
select(AIConversation).where(
AIConversation.id == conv_uuid,
AIConversation.tenant_id == tenant_id,
)
)
)
conversation = result.scalar_one_or_none()
if conversation is None:
@@ -222,7 +224,9 @@ async def execute_action(
# Log to audit
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="execute",
entity_type="ai_copilot",
entity_id=conversation.id,
@@ -266,17 +270,23 @@ async def get_history(
items: list[dict[str, Any]] = []
for conv in conversations:
# Get messages for each conversation
msg_q = select(AIMessage).where(
AIMessage.conversation_id == conv.id,
AIMessage.tenant_id == tenant_id,
).order_by(AIMessage.message_index)
msg_q = (
select(AIMessage)
.where(
AIMessage.conversation_id == conv.id,
AIMessage.tenant_id == tenant_id,
)
.order_by(AIMessage.message_index)
)
msg_result = await db.execute(msg_q)
messages = msg_result.scalars().all()
items.append({
**_conversation_to_dict(conv),
"messages": [_message_to_dict(m) for m in messages],
})
items.append(
{
**_conversation_to_dict(conv),
"messages": [_message_to_dict(m) for m in messages],
}
)
return {
"items": items,
@@ -333,10 +343,7 @@ async def _exec_companies(
return {
"success": True,
"status_code": 200,
"data": [
{"id": str(c.id), "name": c.name, "industry": c.industry}
for c in companies
],
"data": [{"id": str(c.id), "name": c.name, "industry": c.industry} for c in companies],
}
elif method == "POST":
@@ -372,9 +379,13 @@ async def _exec_companies(
company = result.scalar_one_or_none()
if company is None:
return {"error": "Company not found", "status_code": 404, "success": False}
company.deleted_at = datetime.now(timezone.utc)
company.deleted_at = datetime.now(UTC)
await db.flush()
return {"success": True, "status_code": 200, "data": {"id": str(company.id), "deleted": True}}
return {
"success": True,
"status_code": 200,
"data": {"id": str(company.id), "deleted": True},
}
elif method == "PATCH":
if not entity_id or entity_id == "{id}":
@@ -424,10 +435,7 @@ async def _exec_contacts(
return {
"success": True,
"status_code": 200,
"data": [
{"id": str(c.id), "name": c.name, "email": c.email}
for c in contacts
],
"data": [{"id": str(c.id), "name": c.name, "email": c.email} for c in contacts],
}
elif method == "POST":
+29 -17
View File
@@ -2,9 +2,8 @@
from __future__ import annotations
import hashlib
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from typing import Any
import redis.asyncio as aioredis
@@ -12,14 +11,19 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.auth import (
create_session, get_session_data, hash_password, hash_token,
invalidate_session, update_session_tenant, verify_password,
)
from app.core.audit import log_audit
from app.core.auth import (
create_session,
get_session_data,
hash_password,
hash_token,
invalidate_session,
update_session_tenant,
verify_password,
)
from app.models.auth import PasswordResetToken
from app.models.user import User, UserTenant
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
class AuthService:
@@ -37,7 +41,7 @@ class AuthService:
Returns (session_id, csrf_token, user, tenant) or None.
"""
# Find user by email — need to check across tenants or use default tenant
q = select(User).where(User.email == email, User.is_active == True)
q = select(User).where(User.email == email, User.is_active == True) # noqa: E712
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
@@ -53,7 +57,7 @@ class AuthService:
Tenant.slug == tenant_slug
)
else:
ut_q = ut_q.where(UserTenant.is_default == True)
ut_q = ut_q.where(UserTenant.is_default == True) # noqa: E712
ut_result = await db.execute(ut_q)
user_tenant = ut_result.scalar_one_or_none()
@@ -75,7 +79,12 @@ class AuthService:
# Log the login in audit trail
await log_audit(
db, tenant.id, user.id, "login", "user", user.id,
db,
tenant.id,
user.id,
"login",
"user",
user.id,
changes={"email": email},
)
@@ -153,7 +162,7 @@ class AuthService:
tenant_id: uuid.UUID | None = None,
) -> bool:
"""Create a password reset token. Always returns True (no user enumeration)."""
q = select(User).where(User.email == email, User.is_active == True)
q = select(User).where(User.email == email, User.is_active == True) # noqa: E712
result = await db.execute(q)
user = result.scalar_one_or_none()
if user is None:
@@ -166,14 +175,15 @@ class AuthService:
)
prev_result = await db.execute(prev_q)
for prev_token in prev_result.scalars().all():
prev_token.used_at = datetime.now(timezone.utc)
prev_token.used_at = datetime.now(UTC)
# Create new token
import secrets
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
settings = get_settings()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours)
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
@@ -206,7 +216,7 @@ class AuthService:
if reset_token is None:
return False
if reset_token.expires_at < datetime.now(timezone.utc):
if reset_token.expires_at < datetime.now(UTC):
return False # Token expired
# Get user
@@ -218,7 +228,7 @@ class AuthService:
# Update password
user.password_hash = hash_password(new_password)
reset_token.used_at = datetime.now(timezone.utc)
reset_token.used_at = datetime.now(UTC)
await db.flush()
return True
@@ -229,6 +239,7 @@ class AuthService:
"""
# This is a test helper — in production the token goes via email only
import secrets
q = select(User).where(User.email == email)
result = await db.execute(q)
user = result.scalar_one_or_none()
@@ -238,7 +249,7 @@ class AuthService:
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
settings = get_settings()
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
expires_at = datetime.now(UTC) + timedelta(hours=settings.password_reset_expiry_hours)
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
@@ -253,6 +264,7 @@ class AuthService:
async def create_expired_reset_token(self, db: AsyncSession, email: str) -> str | None:
"""Create an already-expired reset token for testing."""
import secrets
q = select(User).where(User.email == email)
result = await db.execute(q)
user = result.scalar_one_or_none()
@@ -261,7 +273,7 @@ class AuthService:
raw_token = secrets.token_urlsafe(32)
token_hash = hash_token(raw_token)
expires_at = datetime.now(timezone.utc) - timedelta(hours=1) # Already expired
expires_at = datetime.now(UTC) - timedelta(hours=1) # Already expired
reset_token = PasswordResetToken(
tenant_id=user.tenant_id,
+87 -39
View File
@@ -5,15 +5,15 @@ from __future__ import annotations
import csv
import io
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, func, or_, desc, asc, delete
from sqlalchemy import asc, delete, desc, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit, log_deletion
from app.core.audit import log_audit
from app.models.company import Company
from app.models.contact import Contact, CompanyContact
from app.models.contact import CompanyContact, Contact
def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, Any]:
@@ -66,9 +66,7 @@ async def list_companies(
pattern = f"%{search}%"
base = base.where(
or_(
Company.search_tsv.op("@@")(
func.plainto_tsquery("english", search)
),
Company.search_tsv.op("@@")(func.plainto_tsquery("english", search)),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
@@ -131,16 +129,18 @@ async def get_company_detail(
contacts_result = await db.execute(contacts_q)
contacts_list = []
for contact, link in contacts_result.all():
contacts_list.append({
"id": str(contact.id),
"first_name": contact.first_name,
"last_name": contact.last_name,
"email": contact.email,
"phone": contact.phone,
"position": contact.position,
"role_at_company": link.role_at_company,
"is_primary": link.is_primary,
})
contacts_list.append(
{
"id": str(contact.id),
"first_name": contact.first_name,
"last_name": contact.last_name,
"email": contact.email,
"phone": contact.phone,
"position": contact.position,
"role_at_company": link.role_at_company,
"is_primary": link.is_primary,
}
)
data["contacts"] = contacts_list
return data
@@ -168,7 +168,12 @@ async def create_company(
await db.flush()
await db.refresh(company)
await log_audit(
db, tenant_id, user_id, "create", "company", company.id,
db,
tenant_id,
user_id,
"create",
"company",
company.id,
changes={"name": data["name"]},
)
return _company_to_dict(company)
@@ -224,7 +229,7 @@ async def soft_delete_company(
if company is None:
return False
company.deleted_at = datetime.now(timezone.utc)
company.deleted_at = datetime.now(UTC)
company.updated_by = user_id
if cascade:
@@ -238,7 +243,12 @@ async def soft_delete_company(
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "company", company_id,
db,
tenant_id,
user_id,
"delete",
"company",
company_id,
changes={"name": company.name, "cascade": cascade},
)
return True
@@ -288,7 +298,12 @@ async def link_contact(
existing.is_primary = is_primary
await db.flush()
await log_audit(
db, tenant_id, user_id, "link", "company_contact", existing.id,
db,
tenant_id,
user_id,
"link",
"company_contact",
existing.id,
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
)
return {
@@ -309,7 +324,12 @@ async def link_contact(
db.add(link)
await db.flush()
await log_audit(
db, tenant_id, user_id, "link", "company_contact", link.id,
db,
tenant_id,
user_id,
"link",
"company_contact",
link.id,
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
)
return {
@@ -342,7 +362,12 @@ async def unlink_contact(
await db.delete(link)
await db.flush()
await log_audit(
db, tenant_id, user_id, "unlink", "company_contact", link.id,
db,
tenant_id,
user_id,
"unlink",
"company_contact",
link.id,
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
)
return True
@@ -365,9 +390,7 @@ async def export_companies_csv(
pattern = f"%{search}%"
base = base.where(
or_(
Company.search_tsv.op("@@")(
func.plainto_tsquery("english", search)
),
Company.search_tsv.op("@@")(func.plainto_tsquery("english", search)),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
@@ -379,12 +402,22 @@ async def export_companies_csv(
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "name", "account_number", "industry", "phone", "email", "website", "description"])
writer.writerow(
["id", "name", "account_number", "industry", "phone", "email", "website", "description"]
)
for c in companies:
writer.writerow([
str(c.id), c.name, c.account_number or "", c.industry or "",
c.phone or "", c.email or "", c.website or "", c.description or "",
])
writer.writerow(
[
str(c.id),
c.name,
c.account_number or "",
c.industry or "",
c.phone or "",
c.email or "",
c.website or "",
c.description or "",
]
)
return output.getvalue()
@@ -407,9 +440,7 @@ async def export_companies_xlsx(
pattern = f"%{search}%"
base = base.where(
or_(
Company.search_tsv.op("@@")(
func.plainto_tsquery("english", search)
),
Company.search_tsv.op("@@")(func.plainto_tsquery("english", search)),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
@@ -422,13 +453,30 @@ async def export_companies_xlsx(
wb = Workbook()
ws = wb.active
ws.title = "Companies"
headers = ["id", "name", "account_number", "industry", "phone", "email", "website", "description"]
headers = [
"id",
"name",
"account_number",
"industry",
"phone",
"email",
"website",
"description",
]
ws.append(headers)
for c in companies:
ws.append([
str(c.id), c.name, c.account_number or "", c.industry or "",
c.phone or "", c.email or "", c.website or "", c.description or "",
])
ws.append(
[
str(c.id),
c.name,
c.account_number or "",
c.industry or "",
c.phone or "",
c.email or "",
c.website or "",
c.description or "",
]
)
buf = io.BytesIO()
wb.save(buf)
+47 -16
View File
@@ -3,15 +3,15 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, func, desc, asc, delete
from sqlalchemy import asc, delete, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit, log_deletion
from app.models.contact import Contact, CompanyContact
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
def _contact_to_dict(c: Contact, include_companies: bool = False) -> dict[str, Any]:
@@ -113,13 +113,15 @@ async def get_contact_detail(
companies_result = await db.execute(companies_q)
companies_list = []
for company, link in companies_result.all():
companies_list.append({
"id": str(company.id),
"name": company.name,
"industry": company.industry,
"role_at_company": link.role_at_company,
"is_primary": link.is_primary,
})
companies_list.append(
{
"id": str(company.id),
"name": company.name,
"industry": company.industry,
"role_at_company": link.role_at_company,
"is_primary": link.is_primary,
}
)
data["companies"] = companies_list
return data
@@ -177,8 +179,17 @@ async def create_contact(
await db.refresh(contact)
await log_audit(
db, tenant_id, user_id, "create", "contact", contact.id,
changes={"first_name": data["first_name"], "last_name": data["last_name"], "linked_companies": linked_companies},
db,
tenant_id,
user_id,
"create",
"contact",
contact.id,
changes={
"first_name": data["first_name"],
"last_name": data["last_name"],
"linked_companies": linked_companies,
},
)
return _contact_to_dict(contact)
@@ -202,7 +213,17 @@ async def update_contact(
return None
changes: dict[str, Any] = {}
for field in ("first_name", "last_name", "email", "phone", "mobile", "position", "department", "linkedin_url", "notes"):
for field in (
"first_name",
"last_name",
"email",
"phone",
"mobile",
"position",
"department",
"linkedin_url",
"notes",
):
if field in data and data[field] is not None:
old_val = getattr(contact, field)
changes[field] = {"old": old_val, "new": data[field]}
@@ -232,12 +253,17 @@ async def soft_delete_contact(
if contact is None:
return False
contact.deleted_at = datetime.now(timezone.utc)
contact.deleted_at = datetime.now(UTC)
contact.updated_by = user_id
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "contact", contact_id,
db,
tenant_id,
user_id,
"delete",
"contact",
contact_id,
changes={"first_name": contact.first_name, "last_name": contact.last_name},
)
return True
@@ -276,6 +302,11 @@ async def gdpr_hard_delete_contact(
# Deletion log (immutable snapshot)
await log_deletion(
db, tenant_id, user_id, "contact", contact_id, snapshot,
db,
tenant_id,
user_id,
"contact",
contact_id,
snapshot,
)
return True
+35 -12
View File
@@ -16,7 +16,6 @@ from app.models.contact import Contact
from app.services.company_service import _company_to_dict
from app.services.contact_service import _contact_to_dict
# Expected CSV columns for each entity type
COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"]
CONTACT_COLUMNS = ["first_name", "last_name", "email", "phone", "mobile", "position", "department"]
@@ -88,7 +87,12 @@ async def import_companies(
db.add(company)
await db.flush()
await log_audit(
db, tenant_id, user_id, "import", "company", company.id,
db,
tenant_id,
user_id,
"import",
"company",
company.id,
changes={"name": company.name},
)
created.append(_company_to_dict(company))
@@ -154,7 +158,12 @@ async def import_contacts(
db.add(contact)
await db.flush()
await log_audit(
db, tenant_id, user_id, "import", "contact", contact.id,
db,
tenant_id,
user_id,
"import",
"contact",
contact.id,
changes={"first_name": contact.first_name, "last_name": contact.last_name},
)
created.append(_contact_to_dict(contact))
@@ -198,19 +207,33 @@ async def export_contacts_csv(
tenant_id: uuid.UUID,
) -> str:
"""Export contacts as CSV string."""
q = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
).order_by(Contact.last_name, Contact.first_name)
q = (
select(Contact)
.where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
.order_by(Contact.last_name, Contact.first_name)
)
result = await db.execute(q)
contacts = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "first_name", "last_name", "email", "phone", "mobile", "position", "department"])
writer.writerow(
["id", "first_name", "last_name", "email", "phone", "mobile", "position", "department"]
)
for c in contacts:
writer.writerow([
str(c.id), c.first_name, c.last_name, c.email or "",
c.phone or "", c.mobile or "", c.position or "", c.department or "",
])
writer.writerow(
[
str(c.id),
c.first_name,
c.last_name,
c.email or "",
c.phone or "",
c.mobile or "",
c.position or "",
c.department or "",
]
)
return output.getvalue()
+30 -13
View File
@@ -9,8 +9,8 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.plugins.registry import PluginRegistry, get_registry
from app.plugins.migration_runner import MigrationValidationError
from app.plugins.registry import PluginRegistry, get_registry
logger = logging.getLogger(__name__)
@@ -48,7 +48,9 @@ class PluginService:
record = await self._registry.install(db, name)
if tenant_id and user_id:
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="plugin.install",
entity_type="plugin",
entity_id=getattr(record, "id", None),
@@ -64,9 +66,9 @@ class PluginService:
"message": "Plugin installed successfully",
}
except ValueError as exc:
raise ValueError(str(exc))
raise ValueError(str(exc)) from None
except MigrationValidationError as exc:
raise MigrationValidationError(str(exc))
raise MigrationValidationError(str(exc)) from None
async def activate_plugin(
self,
@@ -84,7 +86,9 @@ class PluginService:
was_already_active = record.active and record.status == "active"
if tenant_id and user_id:
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="plugin.activate",
entity_type="plugin",
entity_id=getattr(record, "id", None),
@@ -97,10 +101,12 @@ class PluginService:
"status": record.status,
"installed": record.installed,
"active": record.active,
"message": "Plugin is already active" if was_already_active else "Plugin activated successfully",
"message": "Plugin is already active"
if was_already_active
else "Plugin activated successfully",
}
except ValueError as exc:
raise ValueError(str(exc))
raise ValueError(str(exc)) from None
async def deactivate_plugin(
self,
@@ -118,7 +124,9 @@ class PluginService:
was_already_inactive = not record.active and record.status == "inactive"
if tenant_id and user_id:
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="plugin.deactivate",
entity_type="plugin",
entity_id=getattr(record, "id", None),
@@ -131,10 +139,12 @@ class PluginService:
"status": record.status,
"installed": record.installed,
"active": record.active,
"message": "Plugin is already inactive" if was_already_inactive else "Plugin deactivated successfully",
"message": "Plugin is already inactive"
if was_already_inactive
else "Plugin deactivated successfully",
}
except ValueError as exc:
raise ValueError(str(exc))
raise ValueError(str(exc)) from None
async def uninstall_plugin(
self,
@@ -153,10 +163,16 @@ class PluginService:
dropped_tables = getattr(record, "dropped_tables", [])
if tenant_id and user_id:
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="plugin.uninstall",
entity_type="plugin",
changes={"name": name, "remove_data": remove_data, "dropped_tables": dropped_tables},
changes={
"name": name,
"remove_data": remove_data,
"dropped_tables": dropped_tables,
},
)
return {
"name": name,
@@ -169,11 +185,12 @@ class PluginService:
"message": f"Plugin uninstalled{' and data tables dropped' if remove_data else ''}",
}
except ValueError as exc:
raise ValueError(str(exc))
raise ValueError(str(exc)) from None
def get_manifest_schema(self) -> dict[str, Any]:
"""Return the manifest schema documentation."""
from app.plugins.manifest import MANIFEST_SCHEMA_DOC
return MANIFEST_SCHEMA_DOC.model_dump()
+1 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select, func, or_
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import hash_password
+92 -46
View File
@@ -3,15 +3,15 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import select, func, desc, and_, or_
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
from app.models.notification import Notification
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
def _safe_iso(dt) -> str | None:
@@ -47,7 +47,12 @@ def _workflow_to_dict(w: Workflow) -> dict[str, Any]:
}
def _instance_to_dict(i: WorkflowInstance, include_history: bool = False, history: list | None = None, workflow_name: str | None = None) -> dict[str, Any]:
def _instance_to_dict(
i: WorkflowInstance,
include_history: bool = False,
history: list | None = None,
workflow_name: str | None = None,
) -> dict[str, Any]:
data = {
"id": str(i.id),
"workflow_id": str(i.workflow_id),
@@ -107,6 +112,7 @@ async def _log_step_history(
# ─── Workflow CRUD ───
async def create_workflow(
db: AsyncSession,
tenant_id: uuid.UUID,
@@ -134,7 +140,9 @@ async def create_workflow(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="create",
entity_type="workflow",
entity_id=workflow.id,
@@ -229,7 +237,9 @@ async def update_workflow(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="update",
entity_type="workflow",
entity_id=workflow.id,
@@ -261,7 +271,9 @@ async def delete_workflow(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="delete",
entity_type="workflow",
entity_id=wf_uuid,
@@ -272,6 +284,7 @@ async def delete_workflow(
# ─── Instance Lifecycle ───
async def create_instance(
db: AsyncSession,
tenant_id: uuid.UUID,
@@ -294,7 +307,7 @@ async def create_instance(
timeout_at = None
if timeout_hours:
timeout_at = datetime.now(timezone.utc) + timedelta(hours=timeout_hours)
timeout_at = datetime.now(UTC) + timedelta(hours=timeout_hours)
instance = WorkflowInstance(
tenant_id=tenant_id,
@@ -314,7 +327,9 @@ async def create_instance(
steps = workflow.steps or []
first_step = steps[0] if steps else {}
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=0,
step_type=first_step.get("type", "action"),
action="entered",
@@ -323,7 +338,9 @@ async def create_instance(
)
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="create",
entity_type="workflow_instance",
entity_id=instance.id,
@@ -383,16 +400,18 @@ async def get_instance(
return None
# Get workflow name
wf_result = await db.execute(
select(Workflow.name).where(Workflow.id == instance.workflow_id)
)
wf_result = await db.execute(select(Workflow.name).where(Workflow.id == instance.workflow_id))
workflow_name = wf_result.scalar_one_or_none()
# Get step history
hist_q = select(WorkflowStepHistory).where(
WorkflowStepHistory.instance_id == inst_uuid,
WorkflowStepHistory.tenant_id == tenant_id,
).order_by(WorkflowStepHistory.created_at)
hist_q = (
select(WorkflowStepHistory)
.where(
WorkflowStepHistory.instance_id == inst_uuid,
WorkflowStepHistory.tenant_id == tenant_id,
)
.order_by(WorkflowStepHistory.created_at)
)
hist_result = await db.execute(hist_q)
history = hist_result.scalars().all()
@@ -430,12 +449,13 @@ async def advance_instance(
return None
if instance.status not in ("pending", "in_progress"):
return {"error": f"Cannot advance instance with status {instance.status}", "status_code": 400}
return {
"error": f"Cannot advance instance with status {instance.status}",
"status_code": 400,
}
# Get workflow definition
wf_result = await db.execute(
select(Workflow).where(Workflow.id == instance.workflow_id)
)
wf_result = await db.execute(select(Workflow).where(Workflow.id == instance.workflow_id))
workflow = wf_result.scalar_one_or_none()
if workflow is None:
return {"error": "Workflow definition not found", "status_code": 404}
@@ -447,9 +467,11 @@ async def advance_instance(
if decision == "reject":
instance.status = "rejected"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=current_idx,
step_type=step_type,
action="rejected",
@@ -470,7 +492,9 @@ async def advance_instance(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="reject",
entity_type="workflow_instance",
entity_id=instance.id,
@@ -486,7 +510,9 @@ async def advance_instance(
# Log approval
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=current_idx,
step_type=step_type,
action="approved",
@@ -498,9 +524,11 @@ async def advance_instance(
if next_idx >= len(steps):
# Workflow complete
instance.status = "completed"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=current_idx,
step_type="complete",
action="completed",
@@ -510,7 +538,9 @@ async def advance_instance(
instance.current_step_index = next_idx
next_step = steps[next_idx]
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=next_idx,
step_type=next_step.get("type", "action"),
action="entered",
@@ -520,11 +550,17 @@ async def advance_instance(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="advance",
entity_type="workflow_instance",
entity_id=instance.id,
changes={"decision": decision, "step": current_idx, "next_step": next_idx if next_idx < len(steps) else None},
changes={
"decision": decision,
"step": current_idx,
"next_step": next_idx if next_idx < len(steps) else None,
},
)
return _instance_to_dict(instance)
@@ -549,21 +585,26 @@ async def cancel_instance(
return None
if instance.status in ("completed", "rejected", "cancelled"):
return {"error": f"Cannot cancel instance with status {instance.status}", "status_code": 400}
return {
"error": f"Cannot cancel instance with status {instance.status}",
"status_code": 400,
}
instance.status = "cancelled"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
# Get current step for history
wf_result = await db.execute(
select(Workflow).where(Workflow.id == instance.workflow_id)
)
wf_result = await db.execute(select(Workflow).where(Workflow.id == instance.workflow_id))
workflow = wf_result.scalar_one_or_none()
steps = workflow.steps if workflow else []
current_step = steps[instance.current_step_index] if instance.current_step_index < len(steps) else {}
current_step = (
steps[instance.current_step_index] if instance.current_step_index < len(steps) else {}
)
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type=current_step.get("type", "action"),
action="cancelled",
@@ -573,7 +614,9 @@ async def cancel_instance(
await db.flush()
await log_audit(
db, tenant_id, user_id,
db,
tenant_id,
user_id,
action="cancel",
entity_type="workflow_instance",
entity_id=instance.id,
@@ -591,7 +634,7 @@ async def check_timeout(instance: WorkflowInstance) -> bool:
return False
if instance.status not in ("pending", "in_progress"):
return False
return datetime.now(timezone.utc) > instance.timeout_at
return datetime.now(UTC) > instance.timeout_at
async def auto_reject_timeout(
@@ -601,17 +644,19 @@ async def auto_reject_timeout(
) -> dict[str, Any]:
"""Auto-reject a timed-out instance."""
instance.status = "rejected"
instance.completed_at = datetime.now(timezone.utc)
instance.completed_at = datetime.now(UTC)
wf_result = await db.execute(
select(Workflow).where(Workflow.id == instance.workflow_id)
)
wf_result = await db.execute(select(Workflow).where(Workflow.id == instance.workflow_id))
workflow = wf_result.scalar_one_or_none()
steps = workflow.steps if workflow else []
current_step = steps[instance.current_step_index] if instance.current_step_index < len(steps) else {}
current_step = (
steps[instance.current_step_index] if instance.current_step_index < len(steps) else {}
)
await _log_step_history(
db, tenant_id, instance.id,
db,
tenant_id,
instance.id,
step_index=instance.current_step_index,
step_type=current_step.get("type", "approval"),
action="auto_rejected",
@@ -665,7 +710,8 @@ async def start_instance_for_event(
instances: list[dict[str, Any]] = []
for wf in workflows:
inst = await create_instance(
db, tenant_id,
db,
tenant_id,
user_id or uuid.uuid4(),
str(wf.id),
context=context,