feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""MetaAgent — Orchestrator agent for VoIdea.
|
||||
|
||||
Configurable from admin panel:
|
||||
- cron_schedule: str (default "0 9 * * 1,4")
|
||||
- weights: dict[agent_name, float]
|
||||
- sources_count: int (default 10)
|
||||
- auto_create: bool (default True)
|
||||
|
||||
Runs a subset of agents based on weights and collects reports.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||
from app.agents.registry import registry
|
||||
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"cron_schedule": "0 9 * * 1,4",
|
||||
"weights": {},
|
||||
"sources_count": 10,
|
||||
"auto_create": True,
|
||||
}
|
||||
|
||||
|
||||
class MetaAgent(BaseAgent):
|
||||
"""Orchestrator agent that coordinates other agents."""
|
||||
|
||||
name = "meta_agent"
|
||||
version = "1.0.0"
|
||||
description = "Orchestrates other agents based on configurable schedule and weights"
|
||||
triggers = [
|
||||
AgentTrigger.MANUAL,
|
||||
AgentTrigger.CRON,
|
||||
AgentTrigger.API,
|
||||
]
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None):
|
||||
super().__init__()
|
||||
self._config = {**DEFAULT_CONFIG, **(config or {})}
|
||||
|
||||
@property
|
||||
def config(self) -> dict[str, Any]:
|
||||
return self._config
|
||||
|
||||
@config.setter
|
||||
def config(self, value: dict[str, Any]) -> None:
|
||||
self._config = {**DEFAULT_CONFIG, **value}
|
||||
|
||||
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||
"""Execute meta-agent orchestration.
|
||||
|
||||
Context can contain:
|
||||
- action: str (orchestrate | report | status) — default "orchestrate"
|
||||
- target_agents: list[str] — subset of agents to run (default: all)
|
||||
- sources_count: int — override config
|
||||
"""
|
||||
await self.set_running("orchestration")
|
||||
|
||||
try:
|
||||
action = (context or {}).get("action", "orchestrate")
|
||||
|
||||
if action == "status":
|
||||
return await self._get_status()
|
||||
elif action == "report":
|
||||
return await self._generate_report()
|
||||
else:
|
||||
return await self._orchestrate(context or {})
|
||||
|
||||
except Exception as e:
|
||||
await self.set_error(str(e))
|
||||
return AgentResult(
|
||||
success=False,
|
||||
message=f"MetaAgent failed: {str(e)}",
|
||||
errors=[str(e)],
|
||||
)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return True
|
||||
|
||||
async def _orchestrate(self, context: dict[str, Any]) -> AgentResult:
|
||||
"""Run target agents and collect results."""
|
||||
target_agents: list[str] | None = context.get("target_agents")
|
||||
sources_count = context.get("sources_count", self._config["sources_count"])
|
||||
weights = self._config.get("weights", {})
|
||||
|
||||
all_agents = registry.list_agents()
|
||||
if not all_agents:
|
||||
await self.set_idle()
|
||||
return AgentResult(
|
||||
success=True,
|
||||
message="No agents registered",
|
||||
data={"orchestrated": 0},
|
||||
)
|
||||
|
||||
candidates = []
|
||||
for a in all_agents:
|
||||
name = a["name"]
|
||||
if name == self.name:
|
||||
continue
|
||||
weight = weights.get(name, 1.0)
|
||||
if weight <= 0:
|
||||
continue
|
||||
candidates.append((name, weight))
|
||||
|
||||
if not candidates:
|
||||
candidates = [(a["name"], 1.0) for a in all_agents if a["name"] != self.name]
|
||||
|
||||
if target_agents:
|
||||
candidates = [c for c in candidates if c[0] in target_agents]
|
||||
|
||||
max_sources = min(sources_count, len(candidates))
|
||||
selected = candidates[:max_sources]
|
||||
|
||||
results: dict[str, Any] = {}
|
||||
for name, _weight in selected:
|
||||
try:
|
||||
result = await registry.run_agent(name, {"trigger": "meta_agent"})
|
||||
results[name] = {
|
||||
"success": result.success,
|
||||
"message": result.message,
|
||||
"duration_ms": result.duration_ms,
|
||||
}
|
||||
except Exception as e:
|
||||
results[name] = {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
success_count = sum(1 for r in results.values() if r["success"])
|
||||
total_duration = sum(r.get("duration_ms", 0) for r in results.values())
|
||||
|
||||
await self.set_idle()
|
||||
return AgentResult(
|
||||
success=True,
|
||||
message=f"Orchestrated {len(results)} agents ({success_count} ok)",
|
||||
data={
|
||||
"orchestrated": len(results),
|
||||
"success_count": success_count,
|
||||
"agents": results,
|
||||
"total_duration_ms": total_duration,
|
||||
"config": {
|
||||
"cron_schedule": self._config["cron_schedule"],
|
||||
"sources_count": sources_count,
|
||||
"auto_create": self._config["auto_create"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def _generate_report(self) -> AgentResult:
|
||||
"""Generate summary report of all agent statuses."""
|
||||
all_agents = registry.list_agents()
|
||||
metrics = registry.get_metrics_all()
|
||||
|
||||
return AgentResult(
|
||||
success=True,
|
||||
message=f"Report: {len(all_agents)} agents",
|
||||
data={
|
||||
"agents": all_agents,
|
||||
"metrics": metrics,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
async def _get_status(self) -> AgentResult:
|
||||
return AgentResult(
|
||||
success=True,
|
||||
message="Meta-agent operational",
|
||||
data={
|
||||
"status": self.status.value,
|
||||
"config": self._config,
|
||||
"last_run": self._last_run.isoformat() if self._last_run else None,
|
||||
},
|
||||
)
|
||||
@@ -15,6 +15,7 @@ from app.agents.ui_test_agent import UITestAgent
|
||||
from app.agents.rollout_agent import RolloutAgent
|
||||
from app.agents.conductor_agent import ConductorAgent
|
||||
from app.agents.supervisor_agent import SupervisorAgent
|
||||
from app.agents.meta_agent import MetaAgent
|
||||
|
||||
|
||||
class AgentRegistry:
|
||||
@@ -36,6 +37,7 @@ class AgentRegistry:
|
||||
self.register(RolloutAgent())
|
||||
self.register(ConductorAgent())
|
||||
self.register(SupervisorAgent())
|
||||
self.register(MetaAgent())
|
||||
|
||||
def register(self, agent: BaseAgent) -> None:
|
||||
if not agent.name:
|
||||
|
||||
@@ -12,6 +12,9 @@ from app.api.v1.voice import router as voice_router
|
||||
from app.api.v1.config import router as config_router
|
||||
from app.api.v1.feedback import router as feedback_router
|
||||
from app.api.v1.tariffs import router as tariffs_router
|
||||
from app.api.v1.workspaces import router as workspaces_router
|
||||
from app.api.v1.collaboration import router as collaboration_router
|
||||
from app.api.v1.calendar import router as calendar_router
|
||||
|
||||
api_v1_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -25,5 +28,8 @@ api_v1_router.include_router(voice_router, prefix="/voice", tags=["voice"])
|
||||
api_v1_router.include_router(config_router, prefix="/config", tags=["config"])
|
||||
api_v1_router.include_router(feedback_router, prefix="/feedback", tags=["feedback"])
|
||||
api_v1_router.include_router(tariffs_router, prefix="/tariffs", tags=["tariffs"])
|
||||
api_v1_router.include_router(workspaces_router, prefix="/workspaces", tags=["workspaces"])
|
||||
api_v1_router.include_router(collaboration_router, prefix="", tags=["collaboration"])
|
||||
api_v1_router.include_router(calendar_router, prefix="", tags=["calendar"])
|
||||
|
||||
__all__ = ["api_v1_router"]
|
||||
|
||||
@@ -39,6 +39,8 @@ from app.schemas.admin import (
|
||||
SystemInfoResponse,
|
||||
UserAdminUpdate,
|
||||
)
|
||||
from app.agents.base import AgentResult
|
||||
from app.schemas.agent import AgentRunRequest
|
||||
from app.schemas.bot import BotCommandResponse, BotCommandUpdate
|
||||
from app.schemas.feedback import FeedbackResponse, FeedbackUpdate
|
||||
from app.schemas.tariff import TariffPlanCreate, TariffPlanResponse, TariffPlanUpdate, UserSubscriptionResponse
|
||||
@@ -101,6 +103,7 @@ def _agent_to_response(a: AgentConfig) -> AgentInfoResponse:
|
||||
description=getattr(a, "description", ""),
|
||||
is_enabled=a.is_enabled,
|
||||
version=a.version,
|
||||
config=a.config,
|
||||
last_run_at=a.last_run_at,
|
||||
)
|
||||
|
||||
@@ -191,12 +194,38 @@ async def update_agent(
|
||||
agent.description = body.description
|
||||
if body.is_enabled is not None:
|
||||
agent.is_enabled = body.is_enabled
|
||||
if body.config is not None:
|
||||
agent.config = body.config
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return _agent_to_response(agent)
|
||||
|
||||
|
||||
@router.post("/agents/{agent_name}/run", response_model=AgentResult)
|
||||
async def run_agent(
|
||||
agent_name: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
body: AgentRunRequest | None = None,
|
||||
):
|
||||
from app.agents.registry import registry
|
||||
|
||||
context = body.model_dump() if body else {}
|
||||
result = await registry.run_agent(agent_name, context)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/agents/run-all", response_model=dict[str, AgentResult])
|
||||
async def run_all_agents(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
body: AgentRunRequest | None = None,
|
||||
):
|
||||
from app.agents.registry import registry
|
||||
|
||||
context = body.model_dump() if body else {}
|
||||
return await registry.run_all(context)
|
||||
|
||||
|
||||
# ── Logs ──
|
||||
|
||||
@router.get("/logs", response_model=list[LogEntryResponse])
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Calendar API routes for VoIdea."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.services.calendar_service import CALENDAR_PROVIDERS, CalendarService
|
||||
from app.services.idea_service import IdeaService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/ideas/{idea_id}/calendar")
|
||||
async def add_idea_to_calendar(
|
||||
idea_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a calendar event from an idea."""
|
||||
idea_svc = IdeaService(db)
|
||||
idea = await idea_svc.get_by_id(idea_id)
|
||||
if not idea or str(idea.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||
|
||||
cal_svc = CalendarService(db)
|
||||
success = await cal_svc.create_idea_event(user, idea.title, idea.content)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Calendar not configured. Connect a calendar in Settings > Integrations.",
|
||||
)
|
||||
return {"success": True, "message": "Event created in calendar"}
|
||||
|
||||
|
||||
@router.get("/calendar/providers")
|
||||
async def list_calendar_providers():
|
||||
return {"providers": list(CALENDAR_PROVIDERS)}
|
||||
|
||||
|
||||
@router.get("/calendar/status")
|
||||
async def calendar_status(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
cal_svc = CalendarService(db)
|
||||
creds = await cal_svc.get_calendar_credentials(user)
|
||||
return {
|
||||
"connected": creds is not None,
|
||||
"provider": user.calendar_provider if hasattr(user, "calendar_provider") else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/calendar/disconnect")
|
||||
async def disconnect_calendar(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
cal_svc = CalendarService(db)
|
||||
await cal_svc.disconnect_calendar(user)
|
||||
return {"success": True}
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Collaboration API routes for VoIdea.
|
||||
|
||||
Voting, comments, activity log, and notifications.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.collaboration import Notification
|
||||
from app.models.user import User
|
||||
from app.schemas.collaboration import (
|
||||
ActivityResponse,
|
||||
CommentCreate,
|
||||
CommentResponse,
|
||||
NotificationResponse,
|
||||
NotificationUnreadCount,
|
||||
VoteCount,
|
||||
VoteResponse,
|
||||
VoteToggleRequest,
|
||||
)
|
||||
from app.services.collaboration_service import CollaborationService
|
||||
from app.services.idea_service import IdeaService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/ideas/{idea_id}/vote", response_model=VoteCount)
|
||||
async def toggle_vote(
|
||||
idea_id: str,
|
||||
body: VoteToggleRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
idea_svc = IdeaService(db)
|
||||
idea = await idea_svc.get_by_id(idea_id)
|
||||
if not idea or str(idea.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||
|
||||
await svc.toggle_vote(UUID(idea_id), user.id, body.vote)
|
||||
counts = await svc.get_vote_counts(UUID(idea_id))
|
||||
user_vote = await svc.get_user_vote(UUID(idea_id), user.id)
|
||||
return VoteCount(**counts, user_vote=user_vote)
|
||||
|
||||
|
||||
@router.get("/ideas/{idea_id}/votes", response_model=VoteCount)
|
||||
async def get_votes(
|
||||
idea_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
idea_svc = IdeaService(db)
|
||||
idea = await idea_svc.get_by_id(idea_id)
|
||||
if not idea or str(idea.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||
|
||||
counts = await svc.get_vote_counts(UUID(idea_id))
|
||||
user_vote = await svc.get_user_vote(UUID(idea_id), user.id)
|
||||
return VoteCount(**counts, user_vote=user_vote)
|
||||
|
||||
|
||||
# ── Comments ──
|
||||
|
||||
|
||||
@router.get("/ideas/{idea_id}/comments", response_model=list[CommentResponse])
|
||||
async def list_comments(
|
||||
idea_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
return await svc.list_comments(UUID(idea_id))
|
||||
|
||||
|
||||
@router.post("/ideas/{idea_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_comment(
|
||||
idea_id: str,
|
||||
body: CommentCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
parent_id = UUID(body.parent_id) if body.parent_id else None
|
||||
c = await svc.create_comment(UUID(idea_id), user.id, body.content, parent_id)
|
||||
|
||||
await svc.log_activity(
|
||||
UUID(idea_id), user.id, "comment",
|
||||
{"comment_id": str(c.id), "preview": body.content[:100]},
|
||||
)
|
||||
|
||||
return CommentResponse(
|
||||
id=str(c.id),
|
||||
idea_id=str(c.idea_id),
|
||||
user_id=str(c.user_id),
|
||||
content=c.content,
|
||||
parent_id=str(c.parent_id) if c.parent_id else None,
|
||||
display_name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
created_at=c.created_at,
|
||||
updated_at=c.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/ideas/{idea_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_comment(
|
||||
idea_id: str,
|
||||
comment_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
success = await svc.delete_comment(UUID(comment_id), user.id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||
|
||||
|
||||
# ── Activity ──
|
||||
|
||||
|
||||
@router.get("/ideas/{idea_id}/activity", response_model=list[ActivityResponse])
|
||||
async def list_activity(
|
||||
idea_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
return await svc.list_activities(UUID(idea_id), limit=limit)
|
||||
|
||||
|
||||
# ── Notifications ──
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=list[NotificationResponse])
|
||||
async def list_notifications(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
unread_only: bool = Query(False),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
notifications = await svc.list_notifications(user.id, unread_only=unread_only)
|
||||
return [
|
||||
NotificationResponse(
|
||||
id=str(n.id),
|
||||
type=n.type,
|
||||
title=n.title,
|
||||
body=n.body,
|
||||
link=n.link,
|
||||
is_read=n.is_read,
|
||||
created_at=n.created_at,
|
||||
)
|
||||
for n in notifications
|
||||
]
|
||||
|
||||
|
||||
@router.get("/notifications/unread-count", response_model=NotificationUnreadCount)
|
||||
async def unread_count(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
count = await svc.get_unread_count(user.id)
|
||||
return NotificationUnreadCount(count=count)
|
||||
|
||||
|
||||
@router.post("/notifications/{notification_id}/read", response_model=NotificationResponse)
|
||||
async def mark_read(
|
||||
notification_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
success = await svc.mark_notification_read(UUID(notification_id), user.id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(Notification).where(Notification.id == UUID(notification_id))
|
||||
)
|
||||
n = result.scalar_one()
|
||||
return NotificationResponse(
|
||||
id=str(n.id), type=n.type, title=n.title, body=n.body,
|
||||
link=n.link, is_read=n.is_read, created_at=n.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/notifications/read-all", response_model=NotificationUnreadCount)
|
||||
async def mark_all_read(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
count = await svc.mark_all_read(user.id)
|
||||
return NotificationUnreadCount(count=count)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Disk/Drive API routes for VoIdea."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.services.disk_service import DISK_PROVIDERS, DiskService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
provider: str
|
||||
code: str
|
||||
|
||||
|
||||
class DisconnectRequest(BaseModel):
|
||||
provider: str
|
||||
|
||||
|
||||
@router.get("/disk/providers")
|
||||
async def list_disk_providers():
|
||||
"""List available disk providers and user's connected ones."""
|
||||
return {"providers": list(DISK_PROVIDERS)}
|
||||
|
||||
|
||||
@router.get("/disk/status")
|
||||
async def disk_status(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get user's connected disk providers."""
|
||||
svc = DiskService(db)
|
||||
providers = await svc.get_connected_providers(user)
|
||||
return {"connected": providers, "count": len(providers)}
|
||||
|
||||
|
||||
@router.post("/disk/connect")
|
||||
async def connect_disk(
|
||||
body: ConnectRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Exchange OAuth code for tokens and store provider connection."""
|
||||
if body.provider not in DISK_PROVIDERS:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
|
||||
|
||||
tokens = None
|
||||
email = ""
|
||||
|
||||
if body.provider == "google":
|
||||
from app.integrations.oauth.google import exchange_code, get_user_info
|
||||
tokens = await exchange_code(body.code)
|
||||
if tokens:
|
||||
info = await get_user_info(tokens.get("access_token", ""))
|
||||
if info:
|
||||
email = info.email
|
||||
|
||||
elif body.provider == "yandex":
|
||||
from app.integrations.oauth.yandex import exchange_code, get_user_info
|
||||
token_result = await exchange_code(body.code)
|
||||
if token_result:
|
||||
tokens = {
|
||||
"access_token": token_result.access_token,
|
||||
"refresh_token": token_result.refresh_token,
|
||||
"expires_at": (
|
||||
token_result.expires_at.isoformat()
|
||||
if token_result.expires_at else None
|
||||
),
|
||||
}
|
||||
info = await get_user_info(token_result.access_token)
|
||||
if info:
|
||||
email = info.email
|
||||
|
||||
elif body.provider == "apple":
|
||||
from app.integrations.oauth.apple import exchange_code
|
||||
result = await exchange_code(body.code)
|
||||
if result:
|
||||
tokens = result
|
||||
|
||||
if not tokens:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to exchange authorization code",
|
||||
)
|
||||
|
||||
svc = DiskService(db)
|
||||
await svc.add_provider(user, body.provider, tokens, email=email)
|
||||
return {"success": True, "provider": body.provider}
|
||||
|
||||
|
||||
@router.post("/disk/disconnect")
|
||||
async def disconnect_disk(
|
||||
body: DisconnectRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Disconnect a disk provider."""
|
||||
svc = DiskService(db)
|
||||
await svc.remove_provider(user, body.provider)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/disk/oauth-url/{provider}")
|
||||
async def disk_oauth_url(provider: str):
|
||||
"""Get OAuth authorize URL for a disk provider."""
|
||||
if provider not in DISK_PROVIDERS:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
|
||||
|
||||
if provider == "google":
|
||||
from app.integrations.oauth.google import get_authorize_url
|
||||
url = await get_authorize_url()
|
||||
elif provider == "yandex":
|
||||
from app.integrations.oauth.yandex import get_authorize_url
|
||||
url = await get_authorize_url()
|
||||
elif provider == "apple":
|
||||
from app.integrations.oauth.apple import get_authorize_url
|
||||
url = await get_authorize_url()
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
|
||||
|
||||
return {"url": url}
|
||||
@@ -14,14 +14,18 @@ from app.models.idea import Idea
|
||||
from app.models.user import User
|
||||
from app.schemas.idea import (
|
||||
AnalysisResultResponse,
|
||||
FunnelTransitionRequest,
|
||||
IdeaAnalyzeResponse,
|
||||
IdeaCreate,
|
||||
IdeaResponse,
|
||||
IdeaUpdate,
|
||||
)
|
||||
from app.services.analysis_service import AnalysisService
|
||||
from app.services.collaboration_service import CollaborationService
|
||||
from app.services.disk_service import DiskService
|
||||
from app.services.export_service import export_content
|
||||
from app.services.idea_service import IdeaService
|
||||
from app.services.presentation_service import PresentationService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -186,6 +190,65 @@ async def export_idea(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{idea_id}/presentation")
|
||||
async def generate_presentation(
|
||||
idea_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
roles: str = Query("", description="Comma-separated role names (empty=all)"),
|
||||
destination: str = Query("download", regex="^(download|drive)$"),
|
||||
drive_provider: str = Query("", description="Specific drive provider (google/yandex/apple)"),
|
||||
):
|
||||
"""Generate HTML presentation for an idea.
|
||||
|
||||
Args:
|
||||
destination: "download" = return as file, "drive" = upload to cloud drive
|
||||
roles: Comma-separated agent roles (empty = all available)
|
||||
drive_provider: Specific provider to upload to (empty = auto-detect)
|
||||
"""
|
||||
idea_svc = IdeaService(db)
|
||||
idea = await idea_svc.get_by_id(idea_id)
|
||||
if not idea or str(idea.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||
|
||||
role_list = [r.strip() for r in roles.split(",") if r.strip()] if roles else None
|
||||
pres_svc = PresentationService(db)
|
||||
html = await pres_svc.generate(idea_id, roles=role_list, user_name=user.display_name)
|
||||
if not html:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||
|
||||
filename = f"prezentaciya_{idea.title[:40].replace(' ', '_')}.html"
|
||||
|
||||
if destination == "drive":
|
||||
disk_svc = DiskService(db)
|
||||
content_bytes = html.encode("utf-8")
|
||||
if drive_provider:
|
||||
success = await disk_svc.upload_to_provider(
|
||||
user, drive_provider, filename, content_bytes
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to upload to {drive_provider}",
|
||||
)
|
||||
return {"success": True, "provider": drive_provider, "filename": filename}
|
||||
else:
|
||||
result = await disk_svc.upload_to_any(user, filename, content_bytes)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No connected drive. Connect a cloud drive first.",
|
||||
)
|
||||
return {"success": True, "provider": result["provider"], "filename": filename}
|
||||
|
||||
from fastapi.responses import Response
|
||||
return Response(
|
||||
content=html,
|
||||
media_type="text/html; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{idea_id}/share", response_model=IdeaResponse)
|
||||
async def toggle_idea_share(
|
||||
idea_id: str,
|
||||
@@ -208,6 +271,39 @@ async def toggle_idea_share(
|
||||
return _idea_to_response(idea)
|
||||
|
||||
|
||||
@router.post("/{idea_id}/funnel", response_model=IdeaResponse)
|
||||
async def transition_funnel(
|
||||
idea_id: str,
|
||||
body: FunnelTransitionRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = IdeaService(db)
|
||||
idea = await service.get_by_id(idea_id)
|
||||
if not idea or str(idea.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||
|
||||
target = body.target_status
|
||||
valid_states = Idea.FUNNEL_STATES
|
||||
if target not in valid_states:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid funnel status. Valid: {', '.join(valid_states)}",
|
||||
)
|
||||
|
||||
idea.funnel_status = target
|
||||
await db.commit()
|
||||
await db.refresh(idea)
|
||||
|
||||
collab = CollaborationService(db)
|
||||
await collab.log_activity(
|
||||
UUID(idea_id), user.id, "funnel",
|
||||
{"from": idea.funnel_status, "to": target},
|
||||
)
|
||||
|
||||
return _idea_to_response(idea)
|
||||
|
||||
|
||||
@router.post("/demo", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_demo_idea(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Workspace API routes for VoIdea."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.workspace import (
|
||||
AddMemberRequest,
|
||||
MemberResponse,
|
||||
WorkspaceCreate,
|
||||
WorkspaceResponse,
|
||||
WorkspaceUpdate,
|
||||
)
|
||||
from app.services.workspace_service import WorkspaceError, WorkspaceLimitError, WorkspaceService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _ws_to_response(ws, member_count=0) -> WorkspaceResponse:
|
||||
return WorkspaceResponse(
|
||||
id=str(ws.id),
|
||||
name=ws.name,
|
||||
type=ws.type,
|
||||
owner_id=str(ws.owner_id),
|
||||
description=ws.description,
|
||||
member_count=member_count,
|
||||
created_at=ws.created_at,
|
||||
updated_at=ws.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[WorkspaceResponse])
|
||||
async def list_workspaces(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
|
||||
personal = await svc.create_personal(user.id)
|
||||
workspaces = await svc.list_user_workspaces(user.id)
|
||||
|
||||
result = []
|
||||
for ws in workspaces:
|
||||
members = await svc.list_members(ws.id)
|
||||
result.append(_ws_to_response(ws, member_count=len(members)))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workspace(
|
||||
body: WorkspaceCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
try:
|
||||
if body.type == "personal":
|
||||
ws = await svc.create_personal(user.id, body.name)
|
||||
else:
|
||||
ws = await svc.create_team(body.name, user.id)
|
||||
members = await svc.list_members(ws.id)
|
||||
return _ws_to_response(ws, member_count=len(members))
|
||||
except WorkspaceLimitError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
except WorkspaceError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{workspace_id}", response_model=WorkspaceResponse)
|
||||
async def get_workspace(
|
||||
workspace_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
ws = await svc.get_by_id(UUID(workspace_id))
|
||||
if not ws:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
membership = await svc.get_membership(ws.id, user.id)
|
||||
if not membership:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||
members = await svc.list_members(ws.id)
|
||||
return _ws_to_response(ws, member_count=len(members))
|
||||
|
||||
|
||||
@router.patch("/{workspace_id}", response_model=WorkspaceResponse)
|
||||
async def update_workspace(
|
||||
workspace_id: str,
|
||||
body: WorkspaceUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
try:
|
||||
ws = await svc.update_workspace(
|
||||
UUID(workspace_id), user.id, body.model_dump(exclude_none=True)
|
||||
)
|
||||
if not ws:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
members = await svc.list_members(ws.id)
|
||||
return _ws_to_response(ws, member_count=len(members))
|
||||
except WorkspaceError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_workspace(
|
||||
workspace_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
try:
|
||||
success = await svc.delete_workspace(UUID(workspace_id), user.id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
except WorkspaceError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
# ── Members ──
|
||||
|
||||
|
||||
@router.get("/{workspace_id}/members", response_model=list[MemberResponse])
|
||||
async def list_members(
|
||||
workspace_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
ws = await svc.get_by_id(UUID(workspace_id))
|
||||
if not ws:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
membership = await svc.get_membership(ws.id, user.id)
|
||||
if not membership:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||
members = await svc.list_members(ws.id)
|
||||
return [MemberResponse(**m) for m in members]
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/members", response_model=MemberResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def add_member(
|
||||
workspace_id: str,
|
||||
body: AddMemberRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
try:
|
||||
member = await svc.add_member(UUID(workspace_id), UUID(body.user_id), body.role)
|
||||
members = await svc.list_members(ws_id := UUID(workspace_id))
|
||||
m = next((m for m in members if m["id"] == str(member.id)), None)
|
||||
return MemberResponse(**m) if m else MemberResponse(
|
||||
id=str(member.id), user_id=str(member.user_id),
|
||||
workspace_id=str(member.workspace_id), role=member.role,
|
||||
joined_at=member.created_at,
|
||||
)
|
||||
except WorkspaceLimitError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
except WorkspaceError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(
|
||||
workspace_id: str,
|
||||
member_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
success = await svc.remove_member(UUID(workspace_id), UUID(member_id))
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
|
||||
+27
-1
@@ -1,18 +1,22 @@
|
||||
"""Seed data for VoIdeaAI.
|
||||
|
||||
Called from app lifespan when DB tables are empty.
|
||||
Creates default tariff plans and sets system owner by email.
|
||||
Creates default tariff plans, demo user, and sets system owner by email.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.agent import AgentConfig
|
||||
from app.models.tariff import TariffPlan
|
||||
from app.models.user import User
|
||||
from app.services.user_service import UserService
|
||||
|
||||
settings = get_settings()
|
||||
@@ -60,6 +64,7 @@ async def seed_database(db: AsyncSession) -> None:
|
||||
"""Seed initial data if tables are empty."""
|
||||
await _seed_tariff_plans(db)
|
||||
await _seed_agent_descriptions(db)
|
||||
await _seed_demo_user(db)
|
||||
await _seed_owner(db)
|
||||
|
||||
|
||||
@@ -118,6 +123,27 @@ async def _seed_agent_descriptions(db: AsyncSession) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _seed_demo_user(db: AsyncSession) -> None:
|
||||
"""Create demo@voidea.app / demo1234 if not exists."""
|
||||
result = await db.execute(select(User).where(User.email == "demo@voidea.app"))
|
||||
if result.scalar_one_or_none():
|
||||
return
|
||||
|
||||
demo = User(
|
||||
id=uuid4(),
|
||||
email="demo@voidea.app",
|
||||
password_hash=get_password_hash("demo1234"),
|
||||
display_name="Demo User",
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
role="admin",
|
||||
accepted_terms_at=datetime.now(timezone.utc),
|
||||
accepted_terms_version=settings.accepted_terms_version,
|
||||
)
|
||||
db.add(demo)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _seed_owner(db: AsyncSession) -> None:
|
||||
"""Set system owner by SYSTEM_OWNER_EMAIL if configured."""
|
||||
if not settings.system_owner_email:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Apple CalDAV stub implementation.
|
||||
|
||||
When credentials are empty, logs the operation and returns True.
|
||||
Real CalDAV implementation deferred.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleCalDavStub:
|
||||
"""Apple Calendar via CalDAV (stub — logs only)."""
|
||||
|
||||
async def create_event(
|
||||
self,
|
||||
summary: str,
|
||||
description: str,
|
||||
start_iso: str,
|
||||
end_iso: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
logger.info(
|
||||
"Apple CalDAV stub: create_event called — "
|
||||
"summary=%s, start=%s, end=%s",
|
||||
summary, start_iso, end_iso,
|
||||
)
|
||||
return True
|
||||
|
||||
async def list_events(self, max_results: int = 10) -> list[dict]:
|
||||
logger.info("Apple CalDAV stub: list_events called")
|
||||
return []
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return True
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Google Calendar API client for VoIdea.
|
||||
|
||||
Uses OAuth access token to create events via Google Calendar API v3.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GOOGLE_CALENDAR_URL = "https://www.googleapis.com/calendar/v3"
|
||||
|
||||
|
||||
class GoogleCalendarClient:
|
||||
def __init__(self, access_token: str):
|
||||
self._access_token = access_token
|
||||
|
||||
async def create_event(
|
||||
self,
|
||||
summary: str,
|
||||
description: str,
|
||||
start_iso: str,
|
||||
end_iso: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{GOOGLE_CALENDAR_URL}/calendars/primary/events",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self._access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"summary": summary,
|
||||
"description": description,
|
||||
"start": {"dateTime": start_iso, "timeZone": "UTC"},
|
||||
"end": {"dateTime": end_iso, "timeZone": "UTC"},
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
logger.error("Google Calendar create_event failed: %s %s", resp.status_code, resp.text)
|
||||
return False
|
||||
|
||||
async def list_events(self, max_results: int = 10) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{GOOGLE_CALENDAR_URL}/calendars/primary/events",
|
||||
headers={"Authorization": f"Bearer {self._access_token}"},
|
||||
params={"maxResults": max_results, "orderBy": "startTime", "singleEvents": "true"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("items", [])
|
||||
return []
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return bool(self._access_token)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Yandex Calendar API client for VoIdea.
|
||||
|
||||
Uses OAuth access token to create events via Yandex Calendar API.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
YANDEX_CALENDAR_URL = "https://calendar.yandex.ru/api"
|
||||
|
||||
|
||||
class YandexCalendarClient:
|
||||
def __init__(self, access_token: str):
|
||||
self._access_token = access_token
|
||||
|
||||
async def create_event(
|
||||
self,
|
||||
summary: str,
|
||||
description: str,
|
||||
start_iso: str,
|
||||
end_iso: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{YANDEX_CALENDAR_URL}/events",
|
||||
headers={
|
||||
"Authorization": f"OAuth {self._access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"summary": summary,
|
||||
"description": description,
|
||||
"start": {"dateTime": start_iso, "timeZone": "UTC"},
|
||||
"end": {"dateTime": end_iso, "timeZone": "UTC"},
|
||||
},
|
||||
)
|
||||
if resp.status_code in (200, 201):
|
||||
return True
|
||||
logger.error("Yandex Calendar create_event failed: %s %s", resp.status_code, resp.text)
|
||||
return False
|
||||
|
||||
async def list_events(self, max_results: int = 10) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{YANDEX_CALENDAR_URL}/events",
|
||||
headers={"Authorization": f"OAuth {self._access_token}"},
|
||||
params={"maxResults": max_results},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("events", [])
|
||||
return []
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return bool(self._access_token)
|
||||
@@ -18,6 +18,7 @@ SCOPES = " ".join([
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/drive.file",
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
])
|
||||
|
||||
VOIDEA_DRIVE_FOLDER = "VoIdeaAI"
|
||||
|
||||
@@ -28,6 +28,7 @@ SCOPES = " ".join([
|
||||
"cloud_api:disk.app_folder",
|
||||
"cloud_api:disk.read",
|
||||
"cloud_api:disk.info",
|
||||
"calendar:event.write",
|
||||
])
|
||||
|
||||
VOIDEA_DISK_FOLDER = "VoIdeaAI"
|
||||
|
||||
@@ -10,3 +10,5 @@ from app.models.log import LogEntry
|
||||
from app.models.conductor import ConductorInteraction
|
||||
from app.models.pipeline import PipelineStats
|
||||
from app.models.bot_command import BotCommand
|
||||
from app.models.workspace import Workspace, WorkspaceMembership
|
||||
from app.models.collaboration import IdeaVote, IdeaComment, IdeaActivity, Notification
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Collaboration models for VoIdea.
|
||||
|
||||
Voting, threaded comments, activity log, and notifications.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class IdeaVote(SQLBase, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "idea_votes"
|
||||
|
||||
idea_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ideas.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
vote: Mapped[str] = mapped_column(
|
||||
String(4), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("idea_id", "user_id", name="uq_idea_user_vote"),
|
||||
)
|
||||
|
||||
|
||||
class IdeaComment(SQLBase, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "idea_comments"
|
||||
|
||||
idea_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ideas.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
parent_id: Mapped[Optional[UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("idea_comments.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
class IdeaActivity(SQLBase, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "idea_activities"
|
||||
|
||||
idea_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ideas.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[Optional[UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False
|
||||
)
|
||||
details: Mapped[Optional[dict]] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
)
|
||||
|
||||
|
||||
class Notification(SQLBase, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
user_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False
|
||||
)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
link: Mapped[Optional[str]] = mapped_column(
|
||||
String(512), nullable=True
|
||||
)
|
||||
is_read: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False, index=True
|
||||
)
|
||||
@@ -36,5 +36,10 @@ class Idea(SQLBase, UUIDMixin, TimestampMixin):
|
||||
public_slug: Mapped[Optional[str]] = mapped_column(
|
||||
String(64), unique=True, nullable=True, index=True
|
||||
)
|
||||
funnel_status: Mapped[Optional[str]] = mapped_column(
|
||||
String(20), nullable=True, default=None, index=True
|
||||
)
|
||||
|
||||
FUNNEL_STATES = ["raw", "validated", "backlog", "in_progress", "launched", "retrospective"]
|
||||
|
||||
user = relationship("User", back_populates="ideas", lazy="selectin")
|
||||
|
||||
+12
-1
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy import Boolean, DateTime, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -60,4 +60,15 @@ class User(SQLBase, UUIDMixin, TimestampMixin):
|
||||
String(255), nullable=True
|
||||
)
|
||||
|
||||
calendar_provider: Mapped[Optional[str]] = mapped_column(
|
||||
String(20), nullable=True, default=None
|
||||
)
|
||||
calendar_credentials: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, default=None
|
||||
)
|
||||
|
||||
disk_providers: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(
|
||||
JSONB, nullable=True, default=None
|
||||
)
|
||||
|
||||
ideas = relationship("Idea", back_populates="user", lazy="selectin")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Workspace models for VoIdea.
|
||||
|
||||
Personal workspace (1 per user, free) + Team workspaces (tariff-gated).
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Workspace(SQLBase, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False
|
||||
)
|
||||
type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="personal", index=True
|
||||
)
|
||||
owner_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, default=""
|
||||
)
|
||||
|
||||
owner = relationship("User", backref="owned_workspaces", lazy="selectin")
|
||||
members = relationship(
|
||||
"WorkspaceMembership", back_populates="workspace",
|
||||
lazy="selectin", cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceMembership(SQLBase, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "workspace_memberships"
|
||||
|
||||
workspace_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="member"
|
||||
)
|
||||
|
||||
workspace = relationship(
|
||||
"Workspace", back_populates="members", lazy="selectin"
|
||||
)
|
||||
user = relationship("User", backref="workspace_memberships", lazy="selectin")
|
||||
@@ -41,12 +41,14 @@ class AgentInfoResponse(BaseModel):
|
||||
description: str = ""
|
||||
is_enabled: bool = True
|
||||
version: str = "1.0.0"
|
||||
config: Optional[str] = None
|
||||
last_run_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AgentUpdateRequest(BaseModel):
|
||||
description: Optional[str] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
config: Optional[str] = None
|
||||
|
||||
|
||||
class ServiceActionRequest(BaseModel):
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Collaboration schemas for VoIdea API."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VoteResponse(BaseModel):
|
||||
id: str
|
||||
idea_id: str
|
||||
user_id: str
|
||||
vote: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class VoteToggleRequest(BaseModel):
|
||||
vote: str = Field(pattern="^(up|down)$")
|
||||
|
||||
|
||||
class VoteCount(BaseModel):
|
||||
up: int = 0
|
||||
down: int = 0
|
||||
user_vote: Optional[str] = None
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
content: str = Field(min_length=1)
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
|
||||
class CommentResponse(BaseModel):
|
||||
id: str
|
||||
idea_id: str
|
||||
user_id: str
|
||||
content: str
|
||||
parent_id: Optional[str] = None
|
||||
display_name: str = ""
|
||||
avatar_url: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
replies: list["CommentResponse"] = []
|
||||
|
||||
|
||||
class ActivityResponse(BaseModel):
|
||||
id: str
|
||||
idea_id: str
|
||||
user_id: Optional[str] = None
|
||||
action: str
|
||||
details: Optional[dict[str, Any]] = None
|
||||
display_name: str = ""
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
body: str
|
||||
link: Optional[str] = None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class NotificationUnreadCount(BaseModel):
|
||||
count: int = 0
|
||||
@@ -19,6 +19,7 @@ class IdeaUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
tags: Optional[list[str]] = None
|
||||
is_public: Optional[bool] = None
|
||||
funnel_status: Optional[str] = None
|
||||
|
||||
|
||||
class IdeaResponse(BaseModel):
|
||||
@@ -30,10 +31,15 @@ class IdeaResponse(BaseModel):
|
||||
tags: Optional[list[str]] = None
|
||||
is_public: bool
|
||||
public_slug: Optional[str] = None
|
||||
funnel_status: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunnelTransitionRequest(BaseModel):
|
||||
target_status: str = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
AI_AGENT_ROLES = [
|
||||
"coordinator",
|
||||
"task_organizer",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Workspace schemas for VoIdea API."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WorkspaceCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
type: str = Field(default="team", pattern="^(personal|team)$")
|
||||
|
||||
|
||||
class WorkspaceUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class WorkspaceResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
owner_id: str
|
||||
description: Optional[str] = None
|
||||
member_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AddMemberRequest(BaseModel):
|
||||
user_id: str
|
||||
role: str = Field(default="member", pattern="^(admin|member)$")
|
||||
|
||||
|
||||
class MemberResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
workspace_id: str
|
||||
role: str
|
||||
email: str = ""
|
||||
display_name: str = ""
|
||||
joined_at: datetime
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Calendar service — unified interface for Google, Yandex, Apple CalDAV.
|
||||
|
||||
Manages calendar provider connections and event creation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CALENDAR_PROVIDERS = ("google", "yandex", "apple")
|
||||
|
||||
|
||||
class CalendarService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_calendar_credentials(self, user: User) -> Optional[dict[str, Any]]:
|
||||
"""Get stored calendar credentials for user."""
|
||||
if not hasattr(user, "calendar_provider") or not user.calendar_provider:
|
||||
return None
|
||||
if not hasattr(user, "calendar_credentials") or not user.calendar_credentials:
|
||||
return None
|
||||
try:
|
||||
return json.loads(user.calendar_credentials)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
async def set_calendar_credentials(
|
||||
self, user: User, provider: str, credentials: dict[str, Any]
|
||||
) -> None:
|
||||
user.calendar_provider = provider
|
||||
user.calendar_credentials = json.dumps(credentials)
|
||||
await self.db.commit()
|
||||
|
||||
async def disconnect_calendar(self, user: User) -> None:
|
||||
user.calendar_provider = None
|
||||
user.calendar_credentials = None
|
||||
await self.db.commit()
|
||||
|
||||
def _get_client(self, provider: str, credentials: dict[str, Any]):
|
||||
"""Get appropriate calendar client for provider."""
|
||||
if provider == "google":
|
||||
from app.integrations.calendar.google_calendar import GoogleCalendarClient
|
||||
return GoogleCalendarClient(access_token=credentials.get("access_token", ""))
|
||||
elif provider == "yandex":
|
||||
from app.integrations.calendar.yandex_calendar import YandexCalendarClient
|
||||
return YandexCalendarClient(access_token=credentials.get("access_token", ""))
|
||||
elif provider == "apple":
|
||||
from app.integrations.calendar.apple_caldav import AppleCalDavStub
|
||||
return AppleCalDavStub()
|
||||
raise ValueError(f"Unknown calendar provider: {provider}")
|
||||
|
||||
async def create_idea_event(self, user: User, idea_title: str, idea_content: str) -> bool:
|
||||
"""Create a calendar event from an idea."""
|
||||
creds = await self.get_calendar_credentials(user)
|
||||
if not creds:
|
||||
logger.warning("No calendar credentials for user %s", user.id)
|
||||
return False
|
||||
|
||||
provider = user.calendar_provider
|
||||
if not provider:
|
||||
return False
|
||||
|
||||
client = self._get_client(provider, creds)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_iso = now.isoformat()
|
||||
end_iso = (now + timedelta(hours=1)).isoformat()
|
||||
|
||||
return await client.create_event(
|
||||
summary=f"Idea: {idea_title[:100]}",
|
||||
description=idea_content[:1000],
|
||||
start_iso=start_iso,
|
||||
end_iso=end_iso,
|
||||
)
|
||||
|
||||
async def list_events(self, user: User, max_results: int = 10) -> list[dict]:
|
||||
creds = await self.get_calendar_credentials(user)
|
||||
if not creds or not user.calendar_provider:
|
||||
return []
|
||||
|
||||
client = self._get_client(user.calendar_provider, creds)
|
||||
return await client.list_events(max_results=max_results)
|
||||
|
||||
async def health_check(self, user: User) -> bool:
|
||||
creds = await self.get_calendar_credentials(user)
|
||||
if not creds or not user.calendar_provider:
|
||||
return False
|
||||
try:
|
||||
client = self._get_client(user.calendar_provider, creds)
|
||||
return await client.health_check()
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Collaboration service for VoIdea.
|
||||
|
||||
Voting, threaded comments, activity logging, and notifications.
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, func, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.collaboration import IdeaActivity, IdeaComment, IdeaVote, Notification
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class CollaborationService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ── Voting ──
|
||||
|
||||
async def toggle_vote(self, idea_id: UUID, user_id: UUID, vote: str) -> dict[str, Any]:
|
||||
"""Toggle vote. Returns current vote state."""
|
||||
result = await self.db.execute(
|
||||
select(IdeaVote).where(
|
||||
IdeaVote.idea_id == idea_id,
|
||||
IdeaVote.user_id == user_id,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
if existing.vote == vote:
|
||||
await self.db.delete(existing)
|
||||
await self.db.commit()
|
||||
return {"vote": None}
|
||||
existing.vote = vote
|
||||
await self.db.commit()
|
||||
return {"vote": vote}
|
||||
else:
|
||||
v = IdeaVote(idea_id=idea_id, user_id=user_id, vote=vote)
|
||||
self.db.add(v)
|
||||
await self.db.commit()
|
||||
return {"vote": vote}
|
||||
|
||||
async def get_vote_counts(self, idea_id: UUID) -> dict[str, Any]:
|
||||
"""Get vote counts and current user's vote."""
|
||||
up = await self.db.execute(
|
||||
select(func.count(IdeaVote.id)).where(
|
||||
IdeaVote.idea_id == idea_id, IdeaVote.vote == "up"
|
||||
)
|
||||
)
|
||||
down = await self.db.execute(
|
||||
select(func.count(IdeaVote.id)).where(
|
||||
IdeaVote.idea_id == idea_id, IdeaVote.vote == "down"
|
||||
)
|
||||
)
|
||||
return {
|
||||
"up": up.scalar() or 0,
|
||||
"down": down.scalar() or 0,
|
||||
}
|
||||
|
||||
async def get_user_vote(self, idea_id: UUID, user_id: UUID) -> Optional[str]:
|
||||
result = await self.db.execute(
|
||||
select(IdeaVote).where(
|
||||
IdeaVote.idea_id == idea_id, IdeaVote.user_id == user_id
|
||||
)
|
||||
)
|
||||
v = result.scalar_one_or_none()
|
||||
return v.vote if v else None
|
||||
|
||||
# ── Comments ──
|
||||
|
||||
async def create_comment(
|
||||
self, idea_id: UUID, user_id: UUID, content: str, parent_id: Optional[UUID] = None
|
||||
) -> IdeaComment:
|
||||
c = IdeaComment(
|
||||
idea_id=idea_id, user_id=user_id, content=content, parent_id=parent_id
|
||||
)
|
||||
self.db.add(c)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(c)
|
||||
return c
|
||||
|
||||
async def list_comments(self, idea_id: UUID) -> list[dict[str, Any]]:
|
||||
result = await self.db.execute(
|
||||
select(IdeaComment, User)
|
||||
.join(User, IdeaComment.user_id == User.id)
|
||||
.where(IdeaComment.idea_id == idea_id)
|
||||
.order_by(IdeaComment.created_at.asc())
|
||||
)
|
||||
rows = result.all()
|
||||
comments = []
|
||||
for c, u in rows:
|
||||
comments.append({
|
||||
"id": str(c.id),
|
||||
"idea_id": str(c.idea_id),
|
||||
"user_id": str(c.user_id),
|
||||
"content": c.content,
|
||||
"parent_id": str(c.parent_id) if c.parent_id else None,
|
||||
"display_name": u.display_name,
|
||||
"avatar_url": u.avatar_url,
|
||||
"created_at": c.created_at,
|
||||
"updated_at": c.updated_at,
|
||||
})
|
||||
return comments
|
||||
|
||||
async def delete_comment(self, comment_id: UUID, user_id: UUID) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(IdeaComment).where(IdeaComment.id == comment_id)
|
||||
)
|
||||
c = result.scalar_one_or_none()
|
||||
if not c or str(c.user_id) != str(user_id):
|
||||
return False
|
||||
await self.db.delete(c)
|
||||
await self.db.commit()
|
||||
return True
|
||||
|
||||
# ── Activity ──
|
||||
|
||||
async def log_activity(
|
||||
self, idea_id: UUID, user_id: Optional[UUID],
|
||||
action: str, details: Optional[dict] = None
|
||||
) -> IdeaActivity:
|
||||
a = IdeaActivity(
|
||||
idea_id=idea_id, user_id=user_id, action=action, details=details
|
||||
)
|
||||
self.db.add(a)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(a)
|
||||
return a
|
||||
|
||||
async def list_activities(self, idea_id: UUID, limit: int = 50) -> list[dict[str, Any]]:
|
||||
result = await self.db.execute(
|
||||
select(IdeaActivity, User)
|
||||
.join(User, IdeaActivity.user_id == User.id, isouter=True)
|
||||
.where(IdeaActivity.idea_id == idea_id)
|
||||
.order_by(IdeaActivity.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"id": str(a.id),
|
||||
"idea_id": str(a.idea_id),
|
||||
"user_id": str(a.user_id) if a.user_id else None,
|
||||
"action": a.action,
|
||||
"details": a.details,
|
||||
"display_name": u.display_name if u else "System",
|
||||
"created_at": a.created_at,
|
||||
}
|
||||
for a, u in rows
|
||||
]
|
||||
|
||||
# ── Notifications ──
|
||||
|
||||
async def create_notification(
|
||||
self, user_id: UUID, type: str, title: str,
|
||||
body: str, link: Optional[str] = None
|
||||
) -> Notification:
|
||||
n = Notification(
|
||||
user_id=user_id, type=type, title=title,
|
||||
body=body, link=link,
|
||||
)
|
||||
self.db.add(n)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(n)
|
||||
return n
|
||||
|
||||
async def list_notifications(
|
||||
self, user_id: UUID, unread_only: bool = False, limit: int = 50
|
||||
) -> list[Notification]:
|
||||
query = (
|
||||
select(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if unread_only:
|
||||
query = query.where(Notification.is_read == False)
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def mark_notification_read(self, notification_id: UUID, user_id: UUID) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(Notification).where(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == user_id,
|
||||
)
|
||||
)
|
||||
n = result.scalar_one_or_none()
|
||||
if not n:
|
||||
return False
|
||||
n.is_read = True
|
||||
await self.db.commit()
|
||||
return True
|
||||
|
||||
async def mark_all_read(self, user_id: UUID) -> int:
|
||||
result = await self.db.execute(
|
||||
select(func.count(Notification.id)).where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.is_read == False,
|
||||
)
|
||||
)
|
||||
count = result.scalar() or 0
|
||||
await self.db.execute(
|
||||
delete(Notification).where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.is_read == False,
|
||||
)
|
||||
)
|
||||
await self.db.commit()
|
||||
return count
|
||||
|
||||
async def get_unread_count(self, user_id: UUID) -> int:
|
||||
result = await self.db.execute(
|
||||
select(func.count(Notification.id)).where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.is_read == False,
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Disk service — unified interface for Google Drive, Yandex Disk, Apple iCloud.
|
||||
|
||||
Manages cloud drive provider connections and file uploads.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DISK_PROVIDERS = ("google", "yandex", "apple")
|
||||
|
||||
|
||||
class DiskService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_connected_providers(self, user: User) -> list[dict[str, Any]]:
|
||||
"""Get list of connected disk providers with basic info."""
|
||||
providers = user.disk_providers or []
|
||||
# Return sanitized list (no tokens)
|
||||
return [
|
||||
{
|
||||
"provider": p["provider"],
|
||||
"connected_at": p.get("connected_at", ""),
|
||||
"email": p.get("email", ""),
|
||||
"quota": p.get("quota"),
|
||||
}
|
||||
for p in providers
|
||||
]
|
||||
|
||||
async def is_connected(self, user: User, provider: str) -> bool:
|
||||
providers = user.disk_providers or []
|
||||
return any(p["provider"] == provider for p in providers)
|
||||
|
||||
async def add_provider(
|
||||
self, user: User, provider: str, tokens: dict[str, Any], email: str = ""
|
||||
) -> None:
|
||||
providers = user.disk_providers or []
|
||||
# Remove existing entry for same provider
|
||||
providers = [p for p in providers if p["provider"] != provider]
|
||||
providers.append({
|
||||
"provider": provider,
|
||||
"access_token": tokens.get("access_token", ""),
|
||||
"refresh_token": tokens.get("refresh_token", ""),
|
||||
"expires_at": tokens.get("expires_at"),
|
||||
"connected_at": __import__("datetime").datetime.utcnow().isoformat(),
|
||||
"email": email,
|
||||
})
|
||||
user.disk_providers = providers
|
||||
await self.db.commit()
|
||||
|
||||
async def remove_provider(self, user: User, provider: str) -> None:
|
||||
providers = user.disk_providers or []
|
||||
user.disk_providers = [p for p in providers if p["provider"] != provider]
|
||||
await self.db.commit()
|
||||
|
||||
async def get_access_token(self, user: User, provider: str) -> Optional[str]:
|
||||
providers = user.disk_providers or []
|
||||
for p in providers:
|
||||
if p["provider"] == provider:
|
||||
return p.get("access_token")
|
||||
return None
|
||||
|
||||
async def upload_to_provider(
|
||||
self, user: User, provider: str, file_name: str, file_content: bytes
|
||||
) -> bool:
|
||||
"""Upload file bytes to specified provider's VoIdea folder."""
|
||||
token = await self.get_access_token(user, provider)
|
||||
if not token:
|
||||
logger.warning("No token for provider %s (user %s)", provider, user.id)
|
||||
return False
|
||||
|
||||
if provider == "google":
|
||||
from app.integrations.oauth.google import ensure_app_folder, upload_file
|
||||
parent_id = await ensure_app_folder(token)
|
||||
return await upload_file(token, file_name, file_content, parent_id=parent_id)
|
||||
|
||||
elif provider == "yandex":
|
||||
from app.integrations.oauth.yandex import ensure_app_folder, upload_file
|
||||
import tempfile, os
|
||||
await ensure_app_folder(token)
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".html")
|
||||
try:
|
||||
tmp.write(file_content)
|
||||
tmp.close()
|
||||
return await upload_file(token, tmp.name, file_name)
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
elif provider == "apple":
|
||||
logger.info("Apple iCloud upload is a stub — file not actually uploaded")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def upload_to_any(
|
||||
self, user: User, file_name: str, file_content: bytes
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Upload to first available provider. Returns provider info."""
|
||||
providers = user.disk_providers or []
|
||||
for entry in providers:
|
||||
provider = entry["provider"]
|
||||
success = await self.upload_to_provider(user, provider, file_name, file_content)
|
||||
if success:
|
||||
return {"provider": provider, "success": True}
|
||||
return None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Notification service — multi-channel delivery for VoIdea.
|
||||
|
||||
Supported channels:
|
||||
- web (DB — stored as Notification model)
|
||||
- telegram (via TelegramBotClient)
|
||||
- email (via email_service)
|
||||
|
||||
Only web channel is required; telegram and email are optional.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.collaboration_service import CollaborationService
|
||||
|
||||
|
||||
class NotificationDeliveryService:
|
||||
"""Delivers notifications across multiple channels."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.collab = CollaborationService(db)
|
||||
|
||||
async def notify(
|
||||
self,
|
||||
user_id: UUID,
|
||||
type: str,
|
||||
title: str,
|
||||
body: str,
|
||||
link: Optional[str] = None,
|
||||
channels: Optional[list[str]] = None,
|
||||
) -> dict[str, bool]:
|
||||
"""Deliver notification to specified channels (default: web only).
|
||||
|
||||
Returns dict of channel -> success status.
|
||||
"""
|
||||
if channels is None:
|
||||
channels = ["web"]
|
||||
|
||||
results: dict[str, bool] = {}
|
||||
|
||||
if "web" in channels:
|
||||
try:
|
||||
await self.collab.create_notification(
|
||||
user_id=user_id, type=type,
|
||||
title=title, body=body, link=link,
|
||||
)
|
||||
results["web"] = True
|
||||
except Exception:
|
||||
results["web"] = False
|
||||
|
||||
if "telegram" in channels:
|
||||
try:
|
||||
await self._send_telegram(user_id, title, body, link)
|
||||
results["telegram"] = True
|
||||
except Exception:
|
||||
results["telegram"] = False
|
||||
|
||||
if "email" in channels:
|
||||
try:
|
||||
await self._send_email(user_id, title, body)
|
||||
results["email"] = True
|
||||
except Exception:
|
||||
results["email"] = False
|
||||
|
||||
return results
|
||||
|
||||
async def _send_telegram(
|
||||
self, user_id: UUID, title: str, body: str, link: Optional[str] = None
|
||||
) -> None:
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
if not settings.telegram_bot_token:
|
||||
return
|
||||
|
||||
from app.integrations.telegram.client import TelegramBotClient
|
||||
client = TelegramBotClient(token=settings.telegram_bot_token)
|
||||
|
||||
text = f"*{title}*\n{body}"
|
||||
if link:
|
||||
text += f"\n\n[Open]({link})"
|
||||
|
||||
# Find user's telegram chat_id — for now send to first available
|
||||
# In production this would look up stored chat_id for the user
|
||||
from app.models.user import User
|
||||
user = await self.db.get(User, user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
async def _send_email(
|
||||
self, user_id: UUID, title: str, body: str
|
||||
) -> None:
|
||||
from app.services.email_service import send_email
|
||||
from app.models.user import User
|
||||
|
||||
user = await self.db.get(User, user_id)
|
||||
if not user or not user.email:
|
||||
return
|
||||
|
||||
await send_email(
|
||||
to=user.email,
|
||||
subject=title,
|
||||
body=body,
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Presentation service — generates HTML slides from idea + analysis reports."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.agents.models import AgentReport
|
||||
from app.models.idea import Idea
|
||||
from app.services.disk_service import DiskService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROLE_EMOJI = {
|
||||
"coordinator": "🎯",
|
||||
"task_organizer": "📋",
|
||||
"business_analyst": "💼",
|
||||
"legal_expert": "⚖️",
|
||||
"financial_consultant": "💰",
|
||||
"solution_architect": "🏗️",
|
||||
"tester": "🧪",
|
||||
"ui_designer": "🎨",
|
||||
"smm_specialist": "📱",
|
||||
"life_coach": "🌟",
|
||||
"accessibility_expert": "♿",
|
||||
}
|
||||
|
||||
ROLE_LABELS = {
|
||||
"coordinator": "Координатор",
|
||||
"task_organizer": "Организатор задач",
|
||||
"business_analyst": "Бизнес-аналитик",
|
||||
"legal_expert": "Юрист",
|
||||
"financial_consultant": "Финансовый консультант",
|
||||
"solution_architect": "Архитектор решения",
|
||||
"tester": "Тестировщик",
|
||||
"ui_designer": "UI/Дизайнер",
|
||||
"smm_specialist": "SMM-специалист",
|
||||
"life_coach": "Лайф-коуч",
|
||||
"accessibility_expert": "Эксперт доступности",
|
||||
}
|
||||
|
||||
|
||||
REVEAL_HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title} — VoIdea Презентация</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/white.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');
|
||||
* {{ font-family: 'Inter', sans-serif; }}
|
||||
.reveal {{ font-size: 28px; }}
|
||||
.reveal h1 {{ color: #2563EB; font-weight: 700; }}
|
||||
.reveal h2 {{ color: #1E293B; font-weight: 600; }}
|
||||
.reveal h3 {{ color: #475569; font-weight: 600; }}
|
||||
.reveal .slides section {{ padding: 40px; }}
|
||||
.slide-title {{ text-align: center; padding-top: 15vh; }}
|
||||
.slide-title h1 {{ font-size: 3em; margin-bottom: 0.3em; }}
|
||||
.slide-title .meta {{ color: #94A3B8; font-size: 0.5em; }}
|
||||
.slide-role {{ text-align: left; }}
|
||||
.slide-role h2 {{ font-size: 1.2em; margin-bottom: 0.5em; }}
|
||||
.slide-role .emoji {{ font-size: 1.5em; margin-right: 0.3em; }}
|
||||
.slide-role .content {{ font-size: 0.7em; line-height: 1.6; color: #334155; }}
|
||||
.slide-exec {{ text-align: left; background: #EFF6FF; border-radius: 12px; padding: 30px !important; }}
|
||||
.footer {{ position: fixed; bottom: 12px; left: 0; right: 0; text-align: center; font-size: 12px; color: #94A3B8; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="reveal"><div class="slides">
|
||||
<section class="slide-title">
|
||||
<h1>{title}</h1>
|
||||
<div class="meta">
|
||||
{author} · {status_label} · {date}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{executive_summary}
|
||||
|
||||
{role_slides}
|
||||
|
||||
<section>
|
||||
<h2>Спасибо за внимание!</h2>
|
||||
<p style="color:#94A3B8;font-size:0.6em;">Создано в VoIdea · {date}</p>
|
||||
</section>
|
||||
</div></div>
|
||||
<div class="footer">VoIdea · {date}</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
|
||||
<script>Reveal.initialize({{ controls: true, progress: true, hash: true }});</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
class PresentationService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
idea_id: str,
|
||||
roles: Optional[list[str]] = None,
|
||||
user_name: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Generate HTML presentation for an idea.
|
||||
|
||||
Args:
|
||||
idea_id: UUID of the idea
|
||||
roles: List of roles to include (None = all available)
|
||||
user_name: Author display name
|
||||
|
||||
Returns:
|
||||
HTML string or None if idea not found
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Idea).where(Idea.id == idea_id)
|
||||
)
|
||||
idea = result.scalar_one_or_none()
|
||||
if not idea:
|
||||
return None
|
||||
|
||||
result = await self.db.execute(
|
||||
select(AgentReport).where(
|
||||
AgentReport.details["idea_id"].as_string() == idea_id
|
||||
).order_by(AgentReport.created_at.asc())
|
||||
)
|
||||
reports = result.scalars().all()
|
||||
|
||||
if roles is not None:
|
||||
reports = [
|
||||
r for r in reports
|
||||
if r.agent_id.replace("ai_", "") in roles
|
||||
]
|
||||
|
||||
funnel_labels = {
|
||||
"raw": "Сырая",
|
||||
"validated": "Подтверждена",
|
||||
"backlog": "Бэклог",
|
||||
"in_progress": "В работе",
|
||||
"launched": "Запущена",
|
||||
"retrospective": "Ретроспектива",
|
||||
}
|
||||
status_label = funnel_labels.get(
|
||||
idea.funnel_status or "", idea.funnel_status or "Без статуса"
|
||||
)
|
||||
|
||||
date_str = datetime.now(timezone.utc).strftime("%d.%m.%Y")
|
||||
|
||||
role_slides = []
|
||||
for r in reports:
|
||||
role_name = r.agent_id.replace("ai_", "")
|
||||
emoji = ROLE_EMOJI.get(role_name, "📄")
|
||||
label = ROLE_LABELS.get(role_name, role_name)
|
||||
content = r.message or "Нет данных"
|
||||
|
||||
role_slides.append(f"""
|
||||
<section class="slide-role">
|
||||
<h2><span class="emoji">{emoji}</span> {label}</h2>
|
||||
<div class="content">{content}</div>
|
||||
</section>""")
|
||||
|
||||
success_count = sum(1 for r in reports if r.success)
|
||||
|
||||
if role_slides:
|
||||
exec_summary = f"""
|
||||
<section class="slide-exec">
|
||||
<h2>📊 Executive Summary</h2>
|
||||
<p>Проанализировано ролей: <strong>{len(reports)}</strong></p>
|
||||
<p>Успешных анализов: <strong>{success_count}</strong></p>
|
||||
</section>"""
|
||||
else:
|
||||
exec_summary = ""
|
||||
|
||||
return REVEAL_HTML_TEMPLATE.format(
|
||||
title=idea.title,
|
||||
author=user_name or "Пользователь",
|
||||
status_label=status_label,
|
||||
date=date_str,
|
||||
executive_summary=exec_summary,
|
||||
role_slides="".join(role_slides),
|
||||
)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Workspace service with tariff-gated limits for VoIdea."""
|
||||
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.workspace import Workspace, WorkspaceMembership
|
||||
from app.models.user import User
|
||||
from app.services.tariff_service import TariffService
|
||||
|
||||
|
||||
class WorkspaceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WorkspaceLimitError(WorkspaceError):
|
||||
pass
|
||||
|
||||
|
||||
class WorkspaceService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ── Tariff gates ──
|
||||
|
||||
async def _get_tariff_features(self, user_id: UUID) -> dict[str, Any]:
|
||||
"""Get tariff plan features for a user."""
|
||||
tariff_svc = TariffService(self.db)
|
||||
sub = await tariff_svc.get_user_subscription(user_id)
|
||||
if not sub:
|
||||
return {}
|
||||
plan = await tariff_svc.get_plan_by_id(sub.plan_id)
|
||||
return plan.features if plan and plan.features else {}
|
||||
|
||||
async def _check_team_workspace_limit(self, user_id: UUID) -> None:
|
||||
"""Check if user can create a team workspace."""
|
||||
features = await self._get_tariff_features(user_id)
|
||||
max_team = features.get("max_team_workspaces", 0)
|
||||
if max_team <= 0:
|
||||
raise WorkspaceLimitError(
|
||||
"Your tariff plan does not allow team workspaces"
|
||||
)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(Workspace.id))
|
||||
.where(
|
||||
Workspace.owner_id == user_id,
|
||||
Workspace.type == "team",
|
||||
)
|
||||
)
|
||||
current_team_count = result.scalar() or 0
|
||||
if current_team_count >= max_team:
|
||||
raise WorkspaceLimitError(
|
||||
f"Team workspace limit reached ({max_team}). "
|
||||
"Upgrade your tariff to create more."
|
||||
)
|
||||
|
||||
async def _check_member_limit(self, workspace_id: UUID) -> None:
|
||||
"""Check if workspace can accept more members."""
|
||||
ws = await self.db.get(Workspace, workspace_id)
|
||||
if not ws:
|
||||
raise WorkspaceError("Workspace not found")
|
||||
|
||||
features = await self._get_tariff_features(ws.owner_id)
|
||||
max_members = features.get("max_members_per_workspace", 0)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(WorkspaceMembership.id))
|
||||
.where(WorkspaceMembership.workspace_id == workspace_id)
|
||||
)
|
||||
current_count = result.scalar() or 0
|
||||
if max_members > 0 and current_count >= max_members:
|
||||
raise WorkspaceLimitError(
|
||||
f"Member limit reached ({max_members}). "
|
||||
"Upgrade your tariff to add more members."
|
||||
)
|
||||
|
||||
async def _check_feature_gate(self, user_id: UUID, feature: str) -> None:
|
||||
"""Check if a specific workspace feature is enabled for the user's tariff."""
|
||||
features = await self._get_tariff_features(user_id)
|
||||
if not features.get(feature, False):
|
||||
raise WorkspaceLimitError(
|
||||
f"Feature '{feature}' is not available on your tariff plan"
|
||||
)
|
||||
|
||||
# ── CRUD ──
|
||||
|
||||
async def create_personal(self, user_id: UUID, name: str = "Personal") -> Workspace:
|
||||
"""Create or get existing personal workspace for user."""
|
||||
result = await self.db.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.owner_id == user_id,
|
||||
Workspace.type == "personal",
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
ws = Workspace(name=name, type="personal", owner_id=user_id)
|
||||
self.db.add(ws)
|
||||
await self.db.flush()
|
||||
|
||||
membership = WorkspaceMembership(
|
||||
workspace_id=ws.id, user_id=user_id, role="owner"
|
||||
)
|
||||
self.db.add(membership)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(ws)
|
||||
return ws
|
||||
|
||||
async def create_team(
|
||||
self, name: str, owner_id: UUID, description: str = ""
|
||||
) -> Workspace:
|
||||
"""Create a new team workspace (tariff-gated)."""
|
||||
await self._check_team_workspace_limit(owner_id)
|
||||
|
||||
ws = Workspace(name=name, type="team", owner_id=owner_id, description=description)
|
||||
self.db.add(ws)
|
||||
await self.db.flush()
|
||||
|
||||
membership = WorkspaceMembership(
|
||||
workspace_id=ws.id, user_id=owner_id, role="owner"
|
||||
)
|
||||
self.db.add(membership)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(ws)
|
||||
return ws
|
||||
|
||||
async def get_by_id(self, workspace_id: UUID) -> Optional[Workspace]:
|
||||
return await self.db.get(Workspace, workspace_id)
|
||||
|
||||
async def list_user_workspaces(self, user_id: UUID) -> list[Workspace]:
|
||||
"""List workspaces where user is a member."""
|
||||
result = await self.db.execute(
|
||||
select(Workspace)
|
||||
.join(WorkspaceMembership)
|
||||
.where(WorkspaceMembership.user_id == user_id)
|
||||
.order_by(Workspace.type, Workspace.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update_workspace(
|
||||
self, workspace_id: UUID, user_id: UUID, updates: dict[str, Any]
|
||||
) -> Optional[Workspace]:
|
||||
ws = await self.db.get(Workspace, workspace_id)
|
||||
if not ws:
|
||||
return None
|
||||
if ws.owner_id != user_id:
|
||||
raise WorkspaceError("Only the owner can update workspace")
|
||||
for key, value in updates.items():
|
||||
if hasattr(ws, key) and key not in ("id", "owner_id", "type", "created_at"):
|
||||
setattr(ws, key, value)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(ws)
|
||||
return ws
|
||||
|
||||
async def delete_workspace(self, workspace_id: UUID, user_id: UUID) -> bool:
|
||||
ws = await self.db.get(Workspace, workspace_id)
|
||||
if not ws:
|
||||
return False
|
||||
if ws.owner_id != user_id:
|
||||
raise WorkspaceError("Only the owner can delete workspace")
|
||||
if ws.type == "personal":
|
||||
raise WorkspaceError("Cannot delete personal workspace")
|
||||
await self.db.delete(ws)
|
||||
await self.db.commit()
|
||||
return True
|
||||
|
||||
# ── Members ──
|
||||
|
||||
async def add_member(
|
||||
self, workspace_id: UUID, user_id: UUID, role: str = "member"
|
||||
) -> WorkspaceMembership:
|
||||
"""Add a user to a workspace (tariff-gated for member count)."""
|
||||
await self._check_member_limit(workspace_id)
|
||||
|
||||
existing = await self.db.execute(
|
||||
select(WorkspaceMembership).where(
|
||||
WorkspaceMembership.workspace_id == workspace_id,
|
||||
WorkspaceMembership.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise WorkspaceError("User is already a member of this workspace")
|
||||
|
||||
membership = WorkspaceMembership(
|
||||
workspace_id=workspace_id, user_id=user_id, role=role
|
||||
)
|
||||
self.db.add(membership)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(membership)
|
||||
return membership
|
||||
|
||||
async def remove_member(self, workspace_id: UUID, user_id: UUID) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(WorkspaceMembership).where(
|
||||
WorkspaceMembership.workspace_id == workspace_id,
|
||||
WorkspaceMembership.user_id == user_id,
|
||||
)
|
||||
)
|
||||
membership = result.scalar_one_or_none()
|
||||
if not membership:
|
||||
return False
|
||||
await self.db.delete(membership)
|
||||
await self.db.commit()
|
||||
return True
|
||||
|
||||
async def list_members(self, workspace_id: UUID) -> list[dict[str, Any]]:
|
||||
result = await self.db.execute(
|
||||
select(WorkspaceMembership, User)
|
||||
.join(User, WorkspaceMembership.user_id == User.id)
|
||||
.where(WorkspaceMembership.workspace_id == workspace_id)
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"id": str(m.WorkspaceMembership.id),
|
||||
"user_id": str(m.User.id),
|
||||
"workspace_id": str(m.WorkspaceMembership.workspace_id),
|
||||
"role": m.WorkspaceMembership.role,
|
||||
"email": m.User.email,
|
||||
"display_name": m.User.display_name,
|
||||
"joined_at": m.WorkspaceMembership.created_at,
|
||||
}
|
||||
for m in rows
|
||||
]
|
||||
|
||||
async def get_membership(
|
||||
self, workspace_id: UUID, user_id: UUID
|
||||
) -> Optional[WorkspaceMembership]:
|
||||
result = await self.db.execute(
|
||||
select(WorkspaceMembership).where(
|
||||
WorkspaceMembership.workspace_id == workspace_id,
|
||||
WorkspaceMembership.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
Reference in New Issue
Block a user