feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user

This commit is contained in:
2026-05-19 16:26:26 +03:00
parent 688d043dad
commit 3529c39b22
304 changed files with 18826 additions and 1176 deletions
+176
View File
@@ -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,
},
)
+2
View File
@@ -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:
+6
View File
@@ -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"]
+29
View File
@@ -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])
+63
View File
@@ -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}
+204
View File
@@ -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)
+128
View File
@@ -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}
+96
View File
@@ -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)],
+178
View File
@@ -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
View File
@@ -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:
+35
View File
@@ -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)
+1
View File
@@ -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"
+1
View File
@@ -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"
+2
View File
@@ -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
+106
View File
@@ -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
)
+5
View File
@@ -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
View File
@@ -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")
+63
View File
@@ -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")
+2
View File
@@ -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):
+66
View File
@@ -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
+6
View File
@@ -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",
+42
View File
@@ -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
+101
View File
@@ -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
+222
View File
@@ -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
+112
View File
@@ -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
+106
View File
@@ -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,
)
+184
View File
@@ -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),
)
+240
View File
@@ -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()