feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user
This commit is contained in:
@@ -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