chore: fix all ruff lint errors + format — 0 errors, 306 tests pass
This commit is contained in:
+22
-10
@@ -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
|
||||
)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user