feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
flutter/
|
||||
venv/
|
||||
webui/node_modules/
|
||||
.git/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
logs/
|
||||
*.md
|
||||
CHANGELOG/
|
||||
docs/
|
||||
tests/
|
||||
tools/
|
||||
template/
|
||||
@@ -0,0 +1,82 @@
|
||||
# VoIdeaAI — Production Environment
|
||||
# Copy to .env on server and fill secrets
|
||||
|
||||
PROJECT_NAME=VoIdeaAI
|
||||
PROJECT_VERSION=1.0.0
|
||||
PROJECT_ENV=production
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8020
|
||||
SERVER_EXTERNAL_URL=https://voideaai.ru
|
||||
|
||||
# Database (PostgreSQL on port 5444 — separate from aegisone)
|
||||
DATABASE_URL=postgresql+asyncpg://voidea:DB_PASS@localhost:5444/voidea
|
||||
|
||||
# Redis (port 6380 — separate from aegisone)
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6380
|
||||
REDIS_URL=redis://localhost:6380/0
|
||||
|
||||
# JWT — generate with: openssl rand -hex 64
|
||||
JWT_SECRET_KEY=
|
||||
JWT_RESET_SECRET_KEY=
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
# AI
|
||||
OPENAI_API_KEY=
|
||||
AI_YANDEX_KEY=
|
||||
AI_YANDEX_FOLDER_ID=
|
||||
AI_GIGACHAT_CLIENT_ID=
|
||||
AI_GIGACHAT_SECRET=
|
||||
AI_FALLBACK_MODEL=yandex_gpt
|
||||
|
||||
# OAuth
|
||||
OAUTH_YANDEX_ID=
|
||||
OAUTH_YANDEX_SECRET=
|
||||
OAUTH_GOOGLE_ID=
|
||||
OAUTH_GOOGLE_SECRET=
|
||||
OAUTH_GOOGLE_REDIRECT_URI=https://voideaai.ru/auth/google/callback
|
||||
OAUTH_APPLE_ID=
|
||||
OAUTH_APPLE_SECRET=
|
||||
OAUTH_APPLE_REDIRECT_URI=https://voideaai.ru/auth/apple/callback
|
||||
|
||||
# Telegram Bot
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
|
||||
# Email
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_DEFAULT=60/minute
|
||||
RATE_LIMIT_AUTH=10/minute
|
||||
|
||||
# Security — generate with: openssl rand -hex 32
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# System Owner
|
||||
SYSTEM_OWNER_EMAIL=
|
||||
|
||||
# Social Networks
|
||||
SOCIAL_TELEGRAM=voideaai
|
||||
SOCIAL_VK=voideaai
|
||||
SOCIAL_YOUTUBE=voideaai
|
||||
SOCIAL_TIKTOK=voideaai
|
||||
PROJECT_SLOGAN=VoIdeaAI — идеи рождаются вслух, решения приходят мгновенно!
|
||||
|
||||
# Tariffs
|
||||
TARIFFS_ENABLED=false
|
||||
TARIFFS_FREE_CODE=free
|
||||
|
||||
# Push
|
||||
FCM_SERVER_KEY=
|
||||
APNS_KEY_ID=
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
DEBUG_MODE_RETENTION_DAYS=14
|
||||
DEBUG_MODE_MAX_SIZE_MB=500
|
||||
@@ -0,0 +1,111 @@
|
||||
# Внимание: на сервере работает второй проект — VoIdeaAI
|
||||
|
||||
## Дата развёртывания
|
||||
2026-05-19
|
||||
|
||||
## Сервер
|
||||
81.177.141.34 — единый VPS для двух независимых проектов.
|
||||
|
||||
---
|
||||
|
||||
## 1. Проекты на сервере
|
||||
|
||||
### Проект A: aegisone
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Порты | 8000 (FastAPI), 8080/9000 (PHP), 5432 (PostgreSQL) |
|
||||
| Директория | `/opt/projects/aegisone-php/`, `/opt/projects/aegisone-py/` |
|
||||
| Домен | aegisone.ru, service.aegisone.ru |
|
||||
| Тип | PHP + Python FastAPI |
|
||||
|
||||
### Проект B: VoIdeaAI
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Порты | **8020** (FastAPI), **5444** (PostgreSQL), **6380** (Redis) |
|
||||
| Директория | `/opt/projects/voidea/` |
|
||||
| Домен | voideaai.ru, git.voideaai.ru (через nginx-proxy) |
|
||||
| Gitea | порт 3000, PostgreSQL 5433, git.voideaai.ru |
|
||||
| Тип | Python FastAPI + React Frontend |
|
||||
|
||||
---
|
||||
|
||||
## 2. Критические правила
|
||||
|
||||
### НЕ занимать порты VoIdeaAI
|
||||
```
|
||||
8020 — FastAPI app
|
||||
5444 — PostgreSQL
|
||||
6380 — Redis
|
||||
5433 — Gitea PostgreSQL
|
||||
3000 — Gitea web
|
||||
```
|
||||
|
||||
### nginx-proxy общий
|
||||
- Конфиги: `/opt/projects/nginx-proxy/conf.d/`
|
||||
- Файл VoIdea: `voideaai.ru.conf`
|
||||
- Файл Gitea: `git.voideaai.ru.conf`
|
||||
|
||||
**Перед рестартом nginx — всегда проверять:**
|
||||
```bash
|
||||
docker compose -p nginx-proxy exec nginx nginx -t
|
||||
```
|
||||
|
||||
### Docker
|
||||
- **Все контейнеры используют `network_mode: host`** (bridge не работает на этом VPS)
|
||||
- **Docker build** требует `network: host` в конфиге сборки
|
||||
- `docker system prune -af` удалит build cache ВСЕХ проектов — не запускать во время сборки
|
||||
- `docker compose -p voidea` — префикс для команд VoIdea
|
||||
- `docker compose -p gitea` — префикс для команд Gitea
|
||||
- `docker compose -p nginx-proxy` — префикс для nginx
|
||||
|
||||
### Диск
|
||||
- 20GB всего, ~13GB свободно (на момент деплоя)
|
||||
- Перед крупными операциями: `df -h /`
|
||||
- При нехватке места: `docker builder prune -af`
|
||||
|
||||
---
|
||||
|
||||
## 3. Команды для проверки состояния VoIdeaAI
|
||||
|
||||
```bash
|
||||
# Статус
|
||||
docker compose -p voidea ps
|
||||
|
||||
# Логи
|
||||
docker compose -p voidea logs --tail=20 app
|
||||
|
||||
# Проверка здоровья
|
||||
curl -s http://localhost:8020/health
|
||||
curl -s http://localhost:8020/api/v1/health
|
||||
|
||||
# Состояние БД
|
||||
docker exec voidea-db-1 psql -U voidea -h localhost -p 5444 -d voidea -c "\dt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Что делать при проблемах с VoIdeaAI
|
||||
|
||||
1. **Не работает voideaai.ru** — проверить nginx: `nginx -t`, проверить конфиг
|
||||
2. **VoIdea API не отвечает** — `docker compose -p voidea logs app`
|
||||
3. **VoIdea не может подключиться к БД** — проверить `localhost:5444`, логи `docker compose -p voidea logs db`
|
||||
4. **Gitea не открывается** — проверить порт 3000: `curl -s http://localhost:3000`
|
||||
|
||||
Если проблема серьёзная — остановить VoIdea без ущерба для aegisone:
|
||||
```bash
|
||||
docker compose -p voidea down
|
||||
# aegisone продолжит работу независимо
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Контакты
|
||||
|
||||
- По вопросам VoIdeaAI — обратиться к разработчику, ведущему проект VoIdea
|
||||
- Проекты независимы, команды разные
|
||||
- Изменения в nginx-proxy влияют на оба проекта — согласовывать
|
||||
|
||||
---
|
||||
|
||||
*Создан: 2026-05-19*
|
||||
*Не редактировать вручную без необходимости*
|
||||
@@ -7,10 +7,19 @@ RUN npm run build
|
||||
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend-builder /build/dist /app/webui/dist
|
||||
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
EXPOSE 8020
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8020"]
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
# VPS Infrastructure Guide for AI Agents
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the VPS infrastructure at **81.177.141.34** to help AI agents migrate or deploy new projects (e.g., `voideaai.ru`).
|
||||
|
||||
---
|
||||
|
||||
## 1. VPS Specifications
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| IP | 81.177.141.34 |
|
||||
| User | `angel` |
|
||||
| Password | `.-SHGWa_` |
|
||||
| Sudo | passwordless via `/etc/sudoers.d/angel` |
|
||||
| SSH key | added to `~/.ssh/authorized_keys` |
|
||||
| OS | Ubuntu 24.04 (Noble) |
|
||||
| Kernel | OpenVZ/LXC container — limited kernel modules |
|
||||
| Network interface | `venet0` (no bridge NAT support) |
|
||||
| Disk | 20GB total, ~13GB available |
|
||||
| Docker | 29.5.0, iptables in nft mode (`iptables-nft`) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Docker Constraints
|
||||
|
||||
### Critical: `network_mode: host` only
|
||||
|
||||
Bridge networking (`network_mode: bridge`) **does not work** on this VPS. The OpenVZ/LXC kernel does not support masquerade for bridge interfaces through `venet0`. All containers **must** use:
|
||||
|
||||
```yaml
|
||||
network_mode: host
|
||||
```
|
||||
|
||||
### Docker daemon config (`/etc/docker/daemon.json`)
|
||||
|
||||
```json
|
||||
{"dns": ["8.8.8.8", "8.8.4.4"]}
|
||||
```
|
||||
|
||||
### Build constraint
|
||||
|
||||
All Docker builds require:
|
||||
|
||||
```yaml
|
||||
build:
|
||||
network: host
|
||||
```
|
||||
|
||||
Without this, `apt-get` and `pip` cannot download packages (no bridge NAT).
|
||||
|
||||
### Docker Hub connectivity issues
|
||||
|
||||
Pulling large images (>100MB) from Docker Hub may time out (`failed to copy: read tcp... connection timed out`). Smaller images (nginx:alpine, postgres:16-alpine) work. Gitea and Portainer images **cannot be pulled** reliably.
|
||||
|
||||
**Solutions attempted:**
|
||||
- Retry with `--platform linux/amd64`
|
||||
- Increase `max-download-attempts` (not supported in Docker 29.5)
|
||||
- Clean build cache with `docker builder prune -af`
|
||||
- All fail on large image layers
|
||||
|
||||
**Workaround:** Pull images locally and SCP (`docker save → scp → docker load`), or use a registry mirror.
|
||||
|
||||
---
|
||||
|
||||
## 3. Port Allocation (all on host)
|
||||
|
||||
| Port | Service | Container |
|
||||
|---|---|---|
|
||||
| 80 | nginx-proxy HTTP | `nginx-proxy` |
|
||||
| 443 | nginx-proxy HTTPS | `nginx-proxy` |
|
||||
| 8080 | PHP nginx (aegisone.ru) | `aegisone-php-nginx` |
|
||||
| 9000 | PHP-FPM | `aegisone-php-fpm` |
|
||||
| 8000 | FastAPI (service.aegisone.ru) | `aegisone-app` |
|
||||
| 5432 | PostgreSQL (shared, aegisone) | `aegisone-postgres` |
|
||||
| 5433 | PostgreSQL (Gitea) | `gitea-db` (planned) |
|
||||
| 3000 | Gitea | `gitea` (planned) |
|
||||
| 9001 | Portainer HTTP | `portainer` (planned) |
|
||||
| 9443 | Portainer HTTPS | `portainer` (planned) |
|
||||
|
||||
### Address resolution between containers
|
||||
|
||||
Since all containers use `network_mode: host`, they communicate via `localhost:PORT`. **Docker DNS names do not resolve.** In `nginx.conf` and app configs, use:
|
||||
|
||||
```nginx
|
||||
proxy_pass http://localhost:8080; # NOT container_name:port
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Project Structure
|
||||
|
||||
```
|
||||
/opt/projects/
|
||||
nginx-proxy/ # Reverse proxy (nginx + certbot)
|
||||
docker-compose.yml
|
||||
nginx.conf
|
||||
conf.d/ # Per-domain configs
|
||||
certs/ # SSL certificates
|
||||
html/ # Webroot for certbot
|
||||
aegisone-php/ # PHP frontend (aegisone.ru)
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
nginx.conf # PHP nginx config (listen 8080)
|
||||
src/ # PHP source files
|
||||
aegisone-py/ # FastAPI backend (service.aegisone.ru)
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
app/ # Python source
|
||||
sql/ # DB schema
|
||||
gitea/ # Git hosting (planned)
|
||||
docker-compose.yml
|
||||
portainer/ # Docker UI (planned)
|
||||
docker-compose.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. nginx-proxy Configuration Pattern
|
||||
|
||||
### Global config (`nginx.conf`)
|
||||
- `resolver 127.0.0.11` — not used in host mode, kept for compatibility
|
||||
- SSL termination handled by nginx-proxy
|
||||
- Upstream protocols are HTTP (nginx-proxy terminates SSL and proxies via HTTP)
|
||||
|
||||
### Conf.d file structure
|
||||
Each domain has one `.conf` file with two server blocks:
|
||||
|
||||
```nginx
|
||||
# HTTP (no redirect — temporary, until LE certs)
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.ru;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:PORT;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS (self-signed, for future LE)
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name example.ru;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/live/aegisone.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/live/aegisone.ru/privkey.pem;
|
||||
|
||||
# ... same location block
|
||||
}
|
||||
```
|
||||
|
||||
Note: HTTP→HTTPS redirects (`return 301 https://...`) caused **redirect loops** externally due to provider/proxy intercepting port 443. If you add them, test thoroughly.
|
||||
|
||||
### SSL certificates
|
||||
Currently using self-signed certs generated via:
|
||||
```bash
|
||||
sudo openssl req -x509 -nodes -days 30 -newkey rsa:2048 \
|
||||
-keyout /opt/projects/nginx-proxy/certs/live/aegisone.ru/privkey.pem \
|
||||
-out /opt/projects/nginx-proxy/certs/live/aegisone.ru/fullchain.pem \
|
||||
-subj '/CN=aegisone.ru'
|
||||
```
|
||||
|
||||
For Let's Encrypt:
|
||||
```bash
|
||||
cd /opt/projects/nginx-proxy
|
||||
docker compose run --rm certbot certonly --webroot \
|
||||
--webroot-path=/var/www/certbot \
|
||||
-d example.ru \
|
||||
--email admin@example.ru \
|
||||
--agree-tos --non-interactive
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Deployment Workflow
|
||||
|
||||
### Initial setup
|
||||
1. Prepare files locally (this repo)
|
||||
2. Pack into tarball: `tar czf project.tar.gz .`
|
||||
3. SCP to VPS: `scp project.tar.gz angel@81.177.141.34:/home/angel/`
|
||||
4. SSH and extract: `ssh angel@81.177.141.34 "tar xzf project.tar.gz -C /opt/projects/project-name/"`
|
||||
5. Build and start: `ssh angel@81.177.141.34 "cd /opt/projects/project-name && docker compose up -d --build"`
|
||||
|
||||
### Updating a running project
|
||||
1. Edit files locally
|
||||
2. SCP changed files to `/home/angel/` on VPS
|
||||
3. SSH and copy to correct location: `sudo cp /home/angel/file /opt/projects/project-name/`
|
||||
4. Restart container: `docker compose -p project-name restart service-name`
|
||||
|
||||
### Copying files into a running container (alternative to rebuild)
|
||||
```bash
|
||||
docker cp /home/angel/file.py container-name:/app/path/file.py
|
||||
docker compose -p project-name restart app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Database
|
||||
|
||||
### PostgreSQL (aegisone shared DB)
|
||||
- Host: `localhost:5432`
|
||||
- User: `aegisone`
|
||||
- Password: `aegisone_pass`
|
||||
- Database: `aegisone`
|
||||
- Container: `aegisone-postgres` (postgres:16-alpine)
|
||||
|
||||
### Schema initialization
|
||||
SQL files in `/opt/projects/aegisone-py/sql/`:
|
||||
- `schema_postgresql.sql` — full schema
|
||||
- `seed_data.sql` — seed data
|
||||
|
||||
Init scripts run only on first volume creation. To add missing tables later:
|
||||
```bash
|
||||
docker exec -i aegisone-postgres psql -U aegisone -d aegisone < /opt/projects/aegisone-py/sql/schema_postgresql.sql
|
||||
```
|
||||
|
||||
### Key schema pattern
|
||||
- All tables use `BOOLEAN` for `is_active` (not integer)
|
||||
- Timestamps use `TIMESTAMP NOT NULL DEFAULT NOW()`
|
||||
- `updated_at` triggers via `update_updated_at_column()` function
|
||||
|
||||
---
|
||||
|
||||
## 8. Useful Commands
|
||||
|
||||
```bash
|
||||
# Check running containers
|
||||
docker ps -a
|
||||
|
||||
# Check compose projects
|
||||
docker compose ls
|
||||
|
||||
# View logs
|
||||
docker logs container-name --tail 50
|
||||
|
||||
# Restart service
|
||||
docker compose -p project-name restart service-name
|
||||
|
||||
# Rebuild and restart
|
||||
docker compose -p project-name up -d --build
|
||||
|
||||
# Clean build cache
|
||||
docker builder prune -af
|
||||
|
||||
# Test nginx config
|
||||
docker compose -p nginx-proxy exec nginx nginx -t
|
||||
|
||||
# Reload nginx
|
||||
docker compose -p nginx-proxy exec nginx nginx -s reload
|
||||
|
||||
# Check listening ports
|
||||
ss -tlnp | grep -E ':(80|443|8080|8000|9000|5432)'
|
||||
|
||||
# Check disk
|
||||
df -h /
|
||||
docker system df
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Adding a New Project (e.g., voideaai.ru)
|
||||
|
||||
### Steps:
|
||||
1. Create directory: `mkdir -p /opt/projects/voideaai`
|
||||
2. Create `docker-compose.yml` with `network_mode: host` and `build.network: host`
|
||||
3. Create `Dockerfile` (use `--only-binary :all:` for pip if avoiding apt)
|
||||
4. Create nginx-proxy config: `nginx/conf.d/voideaai.conf` (listen 80, proxy_pass localhost:PORT)
|
||||
5. Generate cert if needed
|
||||
6. Restart nginx-proxy: `docker compose -p nginx-proxy restart nginx`
|
||||
7. Start project: `cd /opt/projects/voideaai && docker compose up -d --build`
|
||||
|
||||
### Port allocation for new project
|
||||
Check existing ports first (`ss -tlnp`). Choose an unused port for the app and add it to the nginx-proxy config.
|
||||
|
||||
---
|
||||
|
||||
## 10. Known Issues
|
||||
|
||||
1. **Docker Hub timeouts on large images** — consider pulling on local machine and transferring with `docker save`/`docker load`
|
||||
2. **HTTPS redirect loops externally** — port 443 may be intercepted by provider/proxy; test HTTP first
|
||||
3. **Bridge networking doesn't work** — all containers must use `network_mode: host`
|
||||
4. **Docker build needs `network: host`** — without it, apt-get/pip fail
|
||||
5. **Config files with `$` in content** — when editing via SSH from PowerShell, `$variables` get expanded; use SCP (write locally, then copy)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Add workspace and workspace_memberships tables
|
||||
|
||||
Revision ID: 006
|
||||
Revises: 005
|
||||
Create Date: 2026-05-15
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "006"
|
||||
down_revision: Union[str, None] = "005"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"workspaces",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("type", sa.String(20), nullable=False, server_default=sa.text("'personal'"), index=True),
|
||||
sa.Column("owner_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"workspace_memberships",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("workspace_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("role", sa.String(20), nullable=False, server_default=sa.text("'member'")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("workspace_memberships")
|
||||
op.drop_table("workspaces")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Add collaboration tables: votes, comments, activities, notifications
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-05-15
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "007"
|
||||
down_revision: Union[str, None] = "006"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"idea_votes",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("idea_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("ideas.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("vote", sa.String(4), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("idea_id", "user_id", name="uq_idea_user_vote"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"idea_comments",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("idea_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("ideas.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("parent_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("idea_comments.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"idea_activities",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("idea_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("ideas.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True),
|
||||
sa.Column("action", sa.String(50), nullable=False),
|
||||
sa.Column("details", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"notifications",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("type", sa.String(50), nullable=False, index=True),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("link", sa.String(512), nullable=True),
|
||||
sa.Column("is_read", sa.Boolean(), nullable=False, server_default=sa.text("false"), index=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("notifications")
|
||||
op.drop_table("idea_activities")
|
||||
op.drop_table("idea_comments")
|
||||
op.drop_table("idea_votes")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Add funnel_status column to ideas table
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2026-05-15
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "008"
|
||||
down_revision: Union[str, None] = "007"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"ideas",
|
||||
sa.Column("funnel_status", sa.String(20), nullable=True, index=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ideas", "funnel_status")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Add calendar fields to users table
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2026-05-15
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "009"
|
||||
down_revision: Union[str, None] = "008"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("calendar_provider", sa.String(20), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("calendar_credentials", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "calendar_credentials")
|
||||
op.drop_column("users", "calendar_provider")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Add disk_providers JSONB field to users.
|
||||
|
||||
Revision ID: 010_add_disk_fields
|
||||
Revises: 009_add_calendar_fields
|
||||
Create Date: 2026-05-15
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "010_add_disk_fields"
|
||||
down_revision: Union[str, None] = "009_add_calendar_fields"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"disk_providers",
|
||||
postgresql.JSONB,
|
||||
nullable=True,
|
||||
default=None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "disk_providers")
|
||||
@@ -0,0 +1,176 @@
|
||||
"""MetaAgent — Orchestrator agent for VoIdea.
|
||||
|
||||
Configurable from admin panel:
|
||||
- cron_schedule: str (default "0 9 * * 1,4")
|
||||
- weights: dict[agent_name, float]
|
||||
- sources_count: int (default 10)
|
||||
- auto_create: bool (default True)
|
||||
|
||||
Runs a subset of agents based on weights and collects reports.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||
from app.agents.registry import registry
|
||||
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"cron_schedule": "0 9 * * 1,4",
|
||||
"weights": {},
|
||||
"sources_count": 10,
|
||||
"auto_create": True,
|
||||
}
|
||||
|
||||
|
||||
class MetaAgent(BaseAgent):
|
||||
"""Orchestrator agent that coordinates other agents."""
|
||||
|
||||
name = "meta_agent"
|
||||
version = "1.0.0"
|
||||
description = "Orchestrates other agents based on configurable schedule and weights"
|
||||
triggers = [
|
||||
AgentTrigger.MANUAL,
|
||||
AgentTrigger.CRON,
|
||||
AgentTrigger.API,
|
||||
]
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None):
|
||||
super().__init__()
|
||||
self._config = {**DEFAULT_CONFIG, **(config or {})}
|
||||
|
||||
@property
|
||||
def config(self) -> dict[str, Any]:
|
||||
return self._config
|
||||
|
||||
@config.setter
|
||||
def config(self, value: dict[str, Any]) -> None:
|
||||
self._config = {**DEFAULT_CONFIG, **value}
|
||||
|
||||
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||
"""Execute meta-agent orchestration.
|
||||
|
||||
Context can contain:
|
||||
- action: str (orchestrate | report | status) — default "orchestrate"
|
||||
- target_agents: list[str] — subset of agents to run (default: all)
|
||||
- sources_count: int — override config
|
||||
"""
|
||||
await self.set_running("orchestration")
|
||||
|
||||
try:
|
||||
action = (context or {}).get("action", "orchestrate")
|
||||
|
||||
if action == "status":
|
||||
return await self._get_status()
|
||||
elif action == "report":
|
||||
return await self._generate_report()
|
||||
else:
|
||||
return await self._orchestrate(context or {})
|
||||
|
||||
except Exception as e:
|
||||
await self.set_error(str(e))
|
||||
return AgentResult(
|
||||
success=False,
|
||||
message=f"MetaAgent failed: {str(e)}",
|
||||
errors=[str(e)],
|
||||
)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return True
|
||||
|
||||
async def _orchestrate(self, context: dict[str, Any]) -> AgentResult:
|
||||
"""Run target agents and collect results."""
|
||||
target_agents: list[str] | None = context.get("target_agents")
|
||||
sources_count = context.get("sources_count", self._config["sources_count"])
|
||||
weights = self._config.get("weights", {})
|
||||
|
||||
all_agents = registry.list_agents()
|
||||
if not all_agents:
|
||||
await self.set_idle()
|
||||
return AgentResult(
|
||||
success=True,
|
||||
message="No agents registered",
|
||||
data={"orchestrated": 0},
|
||||
)
|
||||
|
||||
candidates = []
|
||||
for a in all_agents:
|
||||
name = a["name"]
|
||||
if name == self.name:
|
||||
continue
|
||||
weight = weights.get(name, 1.0)
|
||||
if weight <= 0:
|
||||
continue
|
||||
candidates.append((name, weight))
|
||||
|
||||
if not candidates:
|
||||
candidates = [(a["name"], 1.0) for a in all_agents if a["name"] != self.name]
|
||||
|
||||
if target_agents:
|
||||
candidates = [c for c in candidates if c[0] in target_agents]
|
||||
|
||||
max_sources = min(sources_count, len(candidates))
|
||||
selected = candidates[:max_sources]
|
||||
|
||||
results: dict[str, Any] = {}
|
||||
for name, _weight in selected:
|
||||
try:
|
||||
result = await registry.run_agent(name, {"trigger": "meta_agent"})
|
||||
results[name] = {
|
||||
"success": result.success,
|
||||
"message": result.message,
|
||||
"duration_ms": result.duration_ms,
|
||||
}
|
||||
except Exception as e:
|
||||
results[name] = {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
success_count = sum(1 for r in results.values() if r["success"])
|
||||
total_duration = sum(r.get("duration_ms", 0) for r in results.values())
|
||||
|
||||
await self.set_idle()
|
||||
return AgentResult(
|
||||
success=True,
|
||||
message=f"Orchestrated {len(results)} agents ({success_count} ok)",
|
||||
data={
|
||||
"orchestrated": len(results),
|
||||
"success_count": success_count,
|
||||
"agents": results,
|
||||
"total_duration_ms": total_duration,
|
||||
"config": {
|
||||
"cron_schedule": self._config["cron_schedule"],
|
||||
"sources_count": sources_count,
|
||||
"auto_create": self._config["auto_create"],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def _generate_report(self) -> AgentResult:
|
||||
"""Generate summary report of all agent statuses."""
|
||||
all_agents = registry.list_agents()
|
||||
metrics = registry.get_metrics_all()
|
||||
|
||||
return AgentResult(
|
||||
success=True,
|
||||
message=f"Report: {len(all_agents)} agents",
|
||||
data={
|
||||
"agents": all_agents,
|
||||
"metrics": metrics,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
async def _get_status(self) -> AgentResult:
|
||||
return AgentResult(
|
||||
success=True,
|
||||
message="Meta-agent operational",
|
||||
data={
|
||||
"status": self.status.value,
|
||||
"config": self._config,
|
||||
"last_run": self._last_run.isoformat() if self._last_run else None,
|
||||
},
|
||||
)
|
||||
@@ -15,6 +15,7 @@ from app.agents.ui_test_agent import UITestAgent
|
||||
from app.agents.rollout_agent import RolloutAgent
|
||||
from app.agents.conductor_agent import ConductorAgent
|
||||
from app.agents.supervisor_agent import SupervisorAgent
|
||||
from app.agents.meta_agent import MetaAgent
|
||||
|
||||
|
||||
class AgentRegistry:
|
||||
@@ -36,6 +37,7 @@ class AgentRegistry:
|
||||
self.register(RolloutAgent())
|
||||
self.register(ConductorAgent())
|
||||
self.register(SupervisorAgent())
|
||||
self.register(MetaAgent())
|
||||
|
||||
def register(self, agent: BaseAgent) -> None:
|
||||
if not agent.name:
|
||||
|
||||
@@ -12,6 +12,9 @@ from app.api.v1.voice import router as voice_router
|
||||
from app.api.v1.config import router as config_router
|
||||
from app.api.v1.feedback import router as feedback_router
|
||||
from app.api.v1.tariffs import router as tariffs_router
|
||||
from app.api.v1.workspaces import router as workspaces_router
|
||||
from app.api.v1.collaboration import router as collaboration_router
|
||||
from app.api.v1.calendar import router as calendar_router
|
||||
|
||||
api_v1_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -25,5 +28,8 @@ api_v1_router.include_router(voice_router, prefix="/voice", tags=["voice"])
|
||||
api_v1_router.include_router(config_router, prefix="/config", tags=["config"])
|
||||
api_v1_router.include_router(feedback_router, prefix="/feedback", tags=["feedback"])
|
||||
api_v1_router.include_router(tariffs_router, prefix="/tariffs", tags=["tariffs"])
|
||||
api_v1_router.include_router(workspaces_router, prefix="/workspaces", tags=["workspaces"])
|
||||
api_v1_router.include_router(collaboration_router, prefix="", tags=["collaboration"])
|
||||
api_v1_router.include_router(calendar_router, prefix="", tags=["calendar"])
|
||||
|
||||
__all__ = ["api_v1_router"]
|
||||
|
||||
@@ -39,6 +39,8 @@ from app.schemas.admin import (
|
||||
SystemInfoResponse,
|
||||
UserAdminUpdate,
|
||||
)
|
||||
from app.agents.base import AgentResult
|
||||
from app.schemas.agent import AgentRunRequest
|
||||
from app.schemas.bot import BotCommandResponse, BotCommandUpdate
|
||||
from app.schemas.feedback import FeedbackResponse, FeedbackUpdate
|
||||
from app.schemas.tariff import TariffPlanCreate, TariffPlanResponse, TariffPlanUpdate, UserSubscriptionResponse
|
||||
@@ -101,6 +103,7 @@ def _agent_to_response(a: AgentConfig) -> AgentInfoResponse:
|
||||
description=getattr(a, "description", ""),
|
||||
is_enabled=a.is_enabled,
|
||||
version=a.version,
|
||||
config=a.config,
|
||||
last_run_at=a.last_run_at,
|
||||
)
|
||||
|
||||
@@ -191,12 +194,38 @@ async def update_agent(
|
||||
agent.description = body.description
|
||||
if body.is_enabled is not None:
|
||||
agent.is_enabled = body.is_enabled
|
||||
if body.config is not None:
|
||||
agent.config = body.config
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return _agent_to_response(agent)
|
||||
|
||||
|
||||
@router.post("/agents/{agent_name}/run", response_model=AgentResult)
|
||||
async def run_agent(
|
||||
agent_name: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
body: AgentRunRequest | None = None,
|
||||
):
|
||||
from app.agents.registry import registry
|
||||
|
||||
context = body.model_dump() if body else {}
|
||||
result = await registry.run_agent(agent_name, context)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/agents/run-all", response_model=dict[str, AgentResult])
|
||||
async def run_all_agents(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
body: AgentRunRequest | None = None,
|
||||
):
|
||||
from app.agents.registry import registry
|
||||
|
||||
context = body.model_dump() if body else {}
|
||||
return await registry.run_all(context)
|
||||
|
||||
|
||||
# ── Logs ──
|
||||
|
||||
@router.get("/logs", response_model=list[LogEntryResponse])
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Calendar API routes for VoIdea."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.services.calendar_service import CALENDAR_PROVIDERS, CalendarService
|
||||
from app.services.idea_service import IdeaService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/ideas/{idea_id}/calendar")
|
||||
async def add_idea_to_calendar(
|
||||
idea_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a calendar event from an idea."""
|
||||
idea_svc = IdeaService(db)
|
||||
idea = await idea_svc.get_by_id(idea_id)
|
||||
if not idea or str(idea.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||
|
||||
cal_svc = CalendarService(db)
|
||||
success = await cal_svc.create_idea_event(user, idea.title, idea.content)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Calendar not configured. Connect a calendar in Settings > Integrations.",
|
||||
)
|
||||
return {"success": True, "message": "Event created in calendar"}
|
||||
|
||||
|
||||
@router.get("/calendar/providers")
|
||||
async def list_calendar_providers():
|
||||
return {"providers": list(CALENDAR_PROVIDERS)}
|
||||
|
||||
|
||||
@router.get("/calendar/status")
|
||||
async def calendar_status(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
cal_svc = CalendarService(db)
|
||||
creds = await cal_svc.get_calendar_credentials(user)
|
||||
return {
|
||||
"connected": creds is not None,
|
||||
"provider": user.calendar_provider if hasattr(user, "calendar_provider") else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/calendar/disconnect")
|
||||
async def disconnect_calendar(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
cal_svc = CalendarService(db)
|
||||
await cal_svc.disconnect_calendar(user)
|
||||
return {"success": True}
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Collaboration API routes for VoIdea.
|
||||
|
||||
Voting, comments, activity log, and notifications.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.collaboration import Notification
|
||||
from app.models.user import User
|
||||
from app.schemas.collaboration import (
|
||||
ActivityResponse,
|
||||
CommentCreate,
|
||||
CommentResponse,
|
||||
NotificationResponse,
|
||||
NotificationUnreadCount,
|
||||
VoteCount,
|
||||
VoteResponse,
|
||||
VoteToggleRequest,
|
||||
)
|
||||
from app.services.collaboration_service import CollaborationService
|
||||
from app.services.idea_service import IdeaService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/ideas/{idea_id}/vote", response_model=VoteCount)
|
||||
async def toggle_vote(
|
||||
idea_id: str,
|
||||
body: VoteToggleRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
idea_svc = IdeaService(db)
|
||||
idea = await idea_svc.get_by_id(idea_id)
|
||||
if not idea or str(idea.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||
|
||||
await svc.toggle_vote(UUID(idea_id), user.id, body.vote)
|
||||
counts = await svc.get_vote_counts(UUID(idea_id))
|
||||
user_vote = await svc.get_user_vote(UUID(idea_id), user.id)
|
||||
return VoteCount(**counts, user_vote=user_vote)
|
||||
|
||||
|
||||
@router.get("/ideas/{idea_id}/votes", response_model=VoteCount)
|
||||
async def get_votes(
|
||||
idea_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
idea_svc = IdeaService(db)
|
||||
idea = await idea_svc.get_by_id(idea_id)
|
||||
if not idea or str(idea.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||
|
||||
counts = await svc.get_vote_counts(UUID(idea_id))
|
||||
user_vote = await svc.get_user_vote(UUID(idea_id), user.id)
|
||||
return VoteCount(**counts, user_vote=user_vote)
|
||||
|
||||
|
||||
# ── Comments ──
|
||||
|
||||
|
||||
@router.get("/ideas/{idea_id}/comments", response_model=list[CommentResponse])
|
||||
async def list_comments(
|
||||
idea_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
return await svc.list_comments(UUID(idea_id))
|
||||
|
||||
|
||||
@router.post("/ideas/{idea_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_comment(
|
||||
idea_id: str,
|
||||
body: CommentCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
parent_id = UUID(body.parent_id) if body.parent_id else None
|
||||
c = await svc.create_comment(UUID(idea_id), user.id, body.content, parent_id)
|
||||
|
||||
await svc.log_activity(
|
||||
UUID(idea_id), user.id, "comment",
|
||||
{"comment_id": str(c.id), "preview": body.content[:100]},
|
||||
)
|
||||
|
||||
return CommentResponse(
|
||||
id=str(c.id),
|
||||
idea_id=str(c.idea_id),
|
||||
user_id=str(c.user_id),
|
||||
content=c.content,
|
||||
parent_id=str(c.parent_id) if c.parent_id else None,
|
||||
display_name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
created_at=c.created_at,
|
||||
updated_at=c.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/ideas/{idea_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_comment(
|
||||
idea_id: str,
|
||||
comment_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
success = await svc.delete_comment(UUID(comment_id), user.id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||
|
||||
|
||||
# ── Activity ──
|
||||
|
||||
|
||||
@router.get("/ideas/{idea_id}/activity", response_model=list[ActivityResponse])
|
||||
async def list_activity(
|
||||
idea_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
return await svc.list_activities(UUID(idea_id), limit=limit)
|
||||
|
||||
|
||||
# ── Notifications ──
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=list[NotificationResponse])
|
||||
async def list_notifications(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
unread_only: bool = Query(False),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
notifications = await svc.list_notifications(user.id, unread_only=unread_only)
|
||||
return [
|
||||
NotificationResponse(
|
||||
id=str(n.id),
|
||||
type=n.type,
|
||||
title=n.title,
|
||||
body=n.body,
|
||||
link=n.link,
|
||||
is_read=n.is_read,
|
||||
created_at=n.created_at,
|
||||
)
|
||||
for n in notifications
|
||||
]
|
||||
|
||||
|
||||
@router.get("/notifications/unread-count", response_model=NotificationUnreadCount)
|
||||
async def unread_count(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
count = await svc.get_unread_count(user.id)
|
||||
return NotificationUnreadCount(count=count)
|
||||
|
||||
|
||||
@router.post("/notifications/{notification_id}/read", response_model=NotificationResponse)
|
||||
async def mark_read(
|
||||
notification_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
success = await svc.mark_notification_read(UUID(notification_id), user.id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(Notification).where(Notification.id == UUID(notification_id))
|
||||
)
|
||||
n = result.scalar_one()
|
||||
return NotificationResponse(
|
||||
id=str(n.id), type=n.type, title=n.title, body=n.body,
|
||||
link=n.link, is_read=n.is_read, created_at=n.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/notifications/read-all", response_model=NotificationUnreadCount)
|
||||
async def mark_all_read(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = CollaborationService(db)
|
||||
count = await svc.mark_all_read(user.id)
|
||||
return NotificationUnreadCount(count=count)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Disk/Drive API routes for VoIdea."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.services.disk_service import DISK_PROVIDERS, DiskService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
provider: str
|
||||
code: str
|
||||
|
||||
|
||||
class DisconnectRequest(BaseModel):
|
||||
provider: str
|
||||
|
||||
|
||||
@router.get("/disk/providers")
|
||||
async def list_disk_providers():
|
||||
"""List available disk providers and user's connected ones."""
|
||||
return {"providers": list(DISK_PROVIDERS)}
|
||||
|
||||
|
||||
@router.get("/disk/status")
|
||||
async def disk_status(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get user's connected disk providers."""
|
||||
svc = DiskService(db)
|
||||
providers = await svc.get_connected_providers(user)
|
||||
return {"connected": providers, "count": len(providers)}
|
||||
|
||||
|
||||
@router.post("/disk/connect")
|
||||
async def connect_disk(
|
||||
body: ConnectRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Exchange OAuth code for tokens and store provider connection."""
|
||||
if body.provider not in DISK_PROVIDERS:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
|
||||
|
||||
tokens = None
|
||||
email = ""
|
||||
|
||||
if body.provider == "google":
|
||||
from app.integrations.oauth.google import exchange_code, get_user_info
|
||||
tokens = await exchange_code(body.code)
|
||||
if tokens:
|
||||
info = await get_user_info(tokens.get("access_token", ""))
|
||||
if info:
|
||||
email = info.email
|
||||
|
||||
elif body.provider == "yandex":
|
||||
from app.integrations.oauth.yandex import exchange_code, get_user_info
|
||||
token_result = await exchange_code(body.code)
|
||||
if token_result:
|
||||
tokens = {
|
||||
"access_token": token_result.access_token,
|
||||
"refresh_token": token_result.refresh_token,
|
||||
"expires_at": (
|
||||
token_result.expires_at.isoformat()
|
||||
if token_result.expires_at else None
|
||||
),
|
||||
}
|
||||
info = await get_user_info(token_result.access_token)
|
||||
if info:
|
||||
email = info.email
|
||||
|
||||
elif body.provider == "apple":
|
||||
from app.integrations.oauth.apple import exchange_code
|
||||
result = await exchange_code(body.code)
|
||||
if result:
|
||||
tokens = result
|
||||
|
||||
if not tokens:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to exchange authorization code",
|
||||
)
|
||||
|
||||
svc = DiskService(db)
|
||||
await svc.add_provider(user, body.provider, tokens, email=email)
|
||||
return {"success": True, "provider": body.provider}
|
||||
|
||||
|
||||
@router.post("/disk/disconnect")
|
||||
async def disconnect_disk(
|
||||
body: DisconnectRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Disconnect a disk provider."""
|
||||
svc = DiskService(db)
|
||||
await svc.remove_provider(user, body.provider)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/disk/oauth-url/{provider}")
|
||||
async def disk_oauth_url(provider: str):
|
||||
"""Get OAuth authorize URL for a disk provider."""
|
||||
if provider not in DISK_PROVIDERS:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
|
||||
|
||||
if provider == "google":
|
||||
from app.integrations.oauth.google import get_authorize_url
|
||||
url = await get_authorize_url()
|
||||
elif provider == "yandex":
|
||||
from app.integrations.oauth.yandex import get_authorize_url
|
||||
url = await get_authorize_url()
|
||||
elif provider == "apple":
|
||||
from app.integrations.oauth.apple import get_authorize_url
|
||||
url = await get_authorize_url()
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
|
||||
|
||||
return {"url": url}
|
||||
@@ -14,14 +14,18 @@ from app.models.idea import Idea
|
||||
from app.models.user import User
|
||||
from app.schemas.idea import (
|
||||
AnalysisResultResponse,
|
||||
FunnelTransitionRequest,
|
||||
IdeaAnalyzeResponse,
|
||||
IdeaCreate,
|
||||
IdeaResponse,
|
||||
IdeaUpdate,
|
||||
)
|
||||
from app.services.analysis_service import AnalysisService
|
||||
from app.services.collaboration_service import CollaborationService
|
||||
from app.services.disk_service import DiskService
|
||||
from app.services.export_service import export_content
|
||||
from app.services.idea_service import IdeaService
|
||||
from app.services.presentation_service import PresentationService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -186,6 +190,65 @@ async def export_idea(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{idea_id}/presentation")
|
||||
async def generate_presentation(
|
||||
idea_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
roles: str = Query("", description="Comma-separated role names (empty=all)"),
|
||||
destination: str = Query("download", regex="^(download|drive)$"),
|
||||
drive_provider: str = Query("", description="Specific drive provider (google/yandex/apple)"),
|
||||
):
|
||||
"""Generate HTML presentation for an idea.
|
||||
|
||||
Args:
|
||||
destination: "download" = return as file, "drive" = upload to cloud drive
|
||||
roles: Comma-separated agent roles (empty = all available)
|
||||
drive_provider: Specific provider to upload to (empty = auto-detect)
|
||||
"""
|
||||
idea_svc = IdeaService(db)
|
||||
idea = await idea_svc.get_by_id(idea_id)
|
||||
if not idea or str(idea.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||
|
||||
role_list = [r.strip() for r in roles.split(",") if r.strip()] if roles else None
|
||||
pres_svc = PresentationService(db)
|
||||
html = await pres_svc.generate(idea_id, roles=role_list, user_name=user.display_name)
|
||||
if not html:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||
|
||||
filename = f"prezentaciya_{idea.title[:40].replace(' ', '_')}.html"
|
||||
|
||||
if destination == "drive":
|
||||
disk_svc = DiskService(db)
|
||||
content_bytes = html.encode("utf-8")
|
||||
if drive_provider:
|
||||
success = await disk_svc.upload_to_provider(
|
||||
user, drive_provider, filename, content_bytes
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to upload to {drive_provider}",
|
||||
)
|
||||
return {"success": True, "provider": drive_provider, "filename": filename}
|
||||
else:
|
||||
result = await disk_svc.upload_to_any(user, filename, content_bytes)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No connected drive. Connect a cloud drive first.",
|
||||
)
|
||||
return {"success": True, "provider": result["provider"], "filename": filename}
|
||||
|
||||
from fastapi.responses import Response
|
||||
return Response(
|
||||
content=html,
|
||||
media_type="text/html; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{idea_id}/share", response_model=IdeaResponse)
|
||||
async def toggle_idea_share(
|
||||
idea_id: str,
|
||||
@@ -208,6 +271,39 @@ async def toggle_idea_share(
|
||||
return _idea_to_response(idea)
|
||||
|
||||
|
||||
@router.post("/{idea_id}/funnel", response_model=IdeaResponse)
|
||||
async def transition_funnel(
|
||||
idea_id: str,
|
||||
body: FunnelTransitionRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = IdeaService(db)
|
||||
idea = await service.get_by_id(idea_id)
|
||||
if not idea or str(idea.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||
|
||||
target = body.target_status
|
||||
valid_states = Idea.FUNNEL_STATES
|
||||
if target not in valid_states:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid funnel status. Valid: {', '.join(valid_states)}",
|
||||
)
|
||||
|
||||
idea.funnel_status = target
|
||||
await db.commit()
|
||||
await db.refresh(idea)
|
||||
|
||||
collab = CollaborationService(db)
|
||||
await collab.log_activity(
|
||||
UUID(idea_id), user.id, "funnel",
|
||||
{"from": idea.funnel_status, "to": target},
|
||||
)
|
||||
|
||||
return _idea_to_response(idea)
|
||||
|
||||
|
||||
@router.post("/demo", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_demo_idea(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Workspace API routes for VoIdea."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.workspace import (
|
||||
AddMemberRequest,
|
||||
MemberResponse,
|
||||
WorkspaceCreate,
|
||||
WorkspaceResponse,
|
||||
WorkspaceUpdate,
|
||||
)
|
||||
from app.services.workspace_service import WorkspaceError, WorkspaceLimitError, WorkspaceService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _ws_to_response(ws, member_count=0) -> WorkspaceResponse:
|
||||
return WorkspaceResponse(
|
||||
id=str(ws.id),
|
||||
name=ws.name,
|
||||
type=ws.type,
|
||||
owner_id=str(ws.owner_id),
|
||||
description=ws.description,
|
||||
member_count=member_count,
|
||||
created_at=ws.created_at,
|
||||
updated_at=ws.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[WorkspaceResponse])
|
||||
async def list_workspaces(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
|
||||
personal = await svc.create_personal(user.id)
|
||||
workspaces = await svc.list_user_workspaces(user.id)
|
||||
|
||||
result = []
|
||||
for ws in workspaces:
|
||||
members = await svc.list_members(ws.id)
|
||||
result.append(_ws_to_response(ws, member_count=len(members)))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workspace(
|
||||
body: WorkspaceCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
try:
|
||||
if body.type == "personal":
|
||||
ws = await svc.create_personal(user.id, body.name)
|
||||
else:
|
||||
ws = await svc.create_team(body.name, user.id)
|
||||
members = await svc.list_members(ws.id)
|
||||
return _ws_to_response(ws, member_count=len(members))
|
||||
except WorkspaceLimitError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
except WorkspaceError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{workspace_id}", response_model=WorkspaceResponse)
|
||||
async def get_workspace(
|
||||
workspace_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
ws = await svc.get_by_id(UUID(workspace_id))
|
||||
if not ws:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
membership = await svc.get_membership(ws.id, user.id)
|
||||
if not membership:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||
members = await svc.list_members(ws.id)
|
||||
return _ws_to_response(ws, member_count=len(members))
|
||||
|
||||
|
||||
@router.patch("/{workspace_id}", response_model=WorkspaceResponse)
|
||||
async def update_workspace(
|
||||
workspace_id: str,
|
||||
body: WorkspaceUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
try:
|
||||
ws = await svc.update_workspace(
|
||||
UUID(workspace_id), user.id, body.model_dump(exclude_none=True)
|
||||
)
|
||||
if not ws:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
members = await svc.list_members(ws.id)
|
||||
return _ws_to_response(ws, member_count=len(members))
|
||||
except WorkspaceError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_workspace(
|
||||
workspace_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
try:
|
||||
success = await svc.delete_workspace(UUID(workspace_id), user.id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
except WorkspaceError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
# ── Members ──
|
||||
|
||||
|
||||
@router.get("/{workspace_id}/members", response_model=list[MemberResponse])
|
||||
async def list_members(
|
||||
workspace_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
ws = await svc.get_by_id(UUID(workspace_id))
|
||||
if not ws:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
membership = await svc.get_membership(ws.id, user.id)
|
||||
if not membership:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||
members = await svc.list_members(ws.id)
|
||||
return [MemberResponse(**m) for m in members]
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/members", response_model=MemberResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def add_member(
|
||||
workspace_id: str,
|
||||
body: AddMemberRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
try:
|
||||
member = await svc.add_member(UUID(workspace_id), UUID(body.user_id), body.role)
|
||||
members = await svc.list_members(ws_id := UUID(workspace_id))
|
||||
m = next((m for m in members if m["id"] == str(member.id)), None)
|
||||
return MemberResponse(**m) if m else MemberResponse(
|
||||
id=str(member.id), user_id=str(member.user_id),
|
||||
workspace_id=str(member.workspace_id), role=member.role,
|
||||
joined_at=member.created_at,
|
||||
)
|
||||
except WorkspaceLimitError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
except WorkspaceError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(
|
||||
workspace_id: str,
|
||||
member_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = WorkspaceService(db)
|
||||
success = await svc.remove_member(UUID(workspace_id), UUID(member_id))
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
|
||||
+27
-1
@@ -1,18 +1,22 @@
|
||||
"""Seed data for VoIdeaAI.
|
||||
|
||||
Called from app lifespan when DB tables are empty.
|
||||
Creates default tariff plans and sets system owner by email.
|
||||
Creates default tariff plans, demo user, and sets system owner by email.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.agent import AgentConfig
|
||||
from app.models.tariff import TariffPlan
|
||||
from app.models.user import User
|
||||
from app.services.user_service import UserService
|
||||
|
||||
settings = get_settings()
|
||||
@@ -60,6 +64,7 @@ async def seed_database(db: AsyncSession) -> None:
|
||||
"""Seed initial data if tables are empty."""
|
||||
await _seed_tariff_plans(db)
|
||||
await _seed_agent_descriptions(db)
|
||||
await _seed_demo_user(db)
|
||||
await _seed_owner(db)
|
||||
|
||||
|
||||
@@ -118,6 +123,27 @@ async def _seed_agent_descriptions(db: AsyncSession) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _seed_demo_user(db: AsyncSession) -> None:
|
||||
"""Create demo@voidea.app / demo1234 if not exists."""
|
||||
result = await db.execute(select(User).where(User.email == "demo@voidea.app"))
|
||||
if result.scalar_one_or_none():
|
||||
return
|
||||
|
||||
demo = User(
|
||||
id=uuid4(),
|
||||
email="demo@voidea.app",
|
||||
password_hash=get_password_hash("demo1234"),
|
||||
display_name="Demo User",
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
role="admin",
|
||||
accepted_terms_at=datetime.now(timezone.utc),
|
||||
accepted_terms_version=settings.accepted_terms_version,
|
||||
)
|
||||
db.add(demo)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _seed_owner(db: AsyncSession) -> None:
|
||||
"""Set system owner by SYSTEM_OWNER_EMAIL if configured."""
|
||||
if not settings.system_owner_email:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Apple CalDAV stub implementation.
|
||||
|
||||
When credentials are empty, logs the operation and returns True.
|
||||
Real CalDAV implementation deferred.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleCalDavStub:
|
||||
"""Apple Calendar via CalDAV (stub — logs only)."""
|
||||
|
||||
async def create_event(
|
||||
self,
|
||||
summary: str,
|
||||
description: str,
|
||||
start_iso: str,
|
||||
end_iso: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
logger.info(
|
||||
"Apple CalDAV stub: create_event called — "
|
||||
"summary=%s, start=%s, end=%s",
|
||||
summary, start_iso, end_iso,
|
||||
)
|
||||
return True
|
||||
|
||||
async def list_events(self, max_results: int = 10) -> list[dict]:
|
||||
logger.info("Apple CalDAV stub: list_events called")
|
||||
return []
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return True
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Google Calendar API client for VoIdea.
|
||||
|
||||
Uses OAuth access token to create events via Google Calendar API v3.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GOOGLE_CALENDAR_URL = "https://www.googleapis.com/calendar/v3"
|
||||
|
||||
|
||||
class GoogleCalendarClient:
|
||||
def __init__(self, access_token: str):
|
||||
self._access_token = access_token
|
||||
|
||||
async def create_event(
|
||||
self,
|
||||
summary: str,
|
||||
description: str,
|
||||
start_iso: str,
|
||||
end_iso: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{GOOGLE_CALENDAR_URL}/calendars/primary/events",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self._access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"summary": summary,
|
||||
"description": description,
|
||||
"start": {"dateTime": start_iso, "timeZone": "UTC"},
|
||||
"end": {"dateTime": end_iso, "timeZone": "UTC"},
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
logger.error("Google Calendar create_event failed: %s %s", resp.status_code, resp.text)
|
||||
return False
|
||||
|
||||
async def list_events(self, max_results: int = 10) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{GOOGLE_CALENDAR_URL}/calendars/primary/events",
|
||||
headers={"Authorization": f"Bearer {self._access_token}"},
|
||||
params={"maxResults": max_results, "orderBy": "startTime", "singleEvents": "true"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("items", [])
|
||||
return []
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return bool(self._access_token)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Yandex Calendar API client for VoIdea.
|
||||
|
||||
Uses OAuth access token to create events via Yandex Calendar API.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
YANDEX_CALENDAR_URL = "https://calendar.yandex.ru/api"
|
||||
|
||||
|
||||
class YandexCalendarClient:
|
||||
def __init__(self, access_token: str):
|
||||
self._access_token = access_token
|
||||
|
||||
async def create_event(
|
||||
self,
|
||||
summary: str,
|
||||
description: str,
|
||||
start_iso: str,
|
||||
end_iso: str,
|
||||
**kwargs,
|
||||
) -> bool:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{YANDEX_CALENDAR_URL}/events",
|
||||
headers={
|
||||
"Authorization": f"OAuth {self._access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"summary": summary,
|
||||
"description": description,
|
||||
"start": {"dateTime": start_iso, "timeZone": "UTC"},
|
||||
"end": {"dateTime": end_iso, "timeZone": "UTC"},
|
||||
},
|
||||
)
|
||||
if resp.status_code in (200, 201):
|
||||
return True
|
||||
logger.error("Yandex Calendar create_event failed: %s %s", resp.status_code, resp.text)
|
||||
return False
|
||||
|
||||
async def list_events(self, max_results: int = 10) -> list[dict]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{YANDEX_CALENDAR_URL}/events",
|
||||
headers={"Authorization": f"OAuth {self._access_token}"},
|
||||
params={"maxResults": max_results},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("events", [])
|
||||
return []
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return bool(self._access_token)
|
||||
@@ -18,6 +18,7 @@ SCOPES = " ".join([
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/drive.file",
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
])
|
||||
|
||||
VOIDEA_DRIVE_FOLDER = "VoIdeaAI"
|
||||
|
||||
@@ -28,6 +28,7 @@ SCOPES = " ".join([
|
||||
"cloud_api:disk.app_folder",
|
||||
"cloud_api:disk.read",
|
||||
"cloud_api:disk.info",
|
||||
"calendar:event.write",
|
||||
])
|
||||
|
||||
VOIDEA_DISK_FOLDER = "VoIdeaAI"
|
||||
|
||||
@@ -10,3 +10,5 @@ from app.models.log import LogEntry
|
||||
from app.models.conductor import ConductorInteraction
|
||||
from app.models.pipeline import PipelineStats
|
||||
from app.models.bot_command import BotCommand
|
||||
from app.models.workspace import Workspace, WorkspaceMembership
|
||||
from app.models.collaboration import IdeaVote, IdeaComment, IdeaActivity, Notification
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Collaboration models for VoIdea.
|
||||
|
||||
Voting, threaded comments, activity log, and notifications.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class IdeaVote(SQLBase, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "idea_votes"
|
||||
|
||||
idea_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ideas.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
vote: Mapped[str] = mapped_column(
|
||||
String(4), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("idea_id", "user_id", name="uq_idea_user_vote"),
|
||||
)
|
||||
|
||||
|
||||
class IdeaComment(SQLBase, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "idea_comments"
|
||||
|
||||
idea_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ideas.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
parent_id: Mapped[Optional[UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("idea_comments.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
class IdeaActivity(SQLBase, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "idea_activities"
|
||||
|
||||
idea_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ideas.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[Optional[UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False
|
||||
)
|
||||
details: Mapped[Optional[dict]] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
)
|
||||
|
||||
|
||||
class Notification(SQLBase, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
user_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False
|
||||
)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
link: Mapped[Optional[str]] = mapped_column(
|
||||
String(512), nullable=True
|
||||
)
|
||||
is_read: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False, index=True
|
||||
)
|
||||
@@ -36,5 +36,10 @@ class Idea(SQLBase, UUIDMixin, TimestampMixin):
|
||||
public_slug: Mapped[Optional[str]] = mapped_column(
|
||||
String(64), unique=True, nullable=True, index=True
|
||||
)
|
||||
funnel_status: Mapped[Optional[str]] = mapped_column(
|
||||
String(20), nullable=True, default=None, index=True
|
||||
)
|
||||
|
||||
FUNNEL_STATES = ["raw", "validated", "backlog", "in_progress", "launched", "retrospective"]
|
||||
|
||||
user = relationship("User", back_populates="ideas", lazy="selectin")
|
||||
|
||||
+12
-1
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy import Boolean, DateTime, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -60,4 +60,15 @@ class User(SQLBase, UUIDMixin, TimestampMixin):
|
||||
String(255), nullable=True
|
||||
)
|
||||
|
||||
calendar_provider: Mapped[Optional[str]] = mapped_column(
|
||||
String(20), nullable=True, default=None
|
||||
)
|
||||
calendar_credentials: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, default=None
|
||||
)
|
||||
|
||||
disk_providers: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(
|
||||
JSONB, nullable=True, default=None
|
||||
)
|
||||
|
||||
ideas = relationship("Idea", back_populates="user", lazy="selectin")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Workspace models for VoIdea.
|
||||
|
||||
Personal workspace (1 per user, free) + Team workspaces (tariff-gated).
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Workspace(SQLBase, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False
|
||||
)
|
||||
type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="personal", index=True
|
||||
)
|
||||
owner_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
description: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, default=""
|
||||
)
|
||||
|
||||
owner = relationship("User", backref="owned_workspaces", lazy="selectin")
|
||||
members = relationship(
|
||||
"WorkspaceMembership", back_populates="workspace",
|
||||
lazy="selectin", cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceMembership(SQLBase, UUIDMixin, TimestampMixin):
|
||||
__tablename__ = "workspace_memberships"
|
||||
|
||||
workspace_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="member"
|
||||
)
|
||||
|
||||
workspace = relationship(
|
||||
"Workspace", back_populates="members", lazy="selectin"
|
||||
)
|
||||
user = relationship("User", backref="workspace_memberships", lazy="selectin")
|
||||
@@ -41,12 +41,14 @@ class AgentInfoResponse(BaseModel):
|
||||
description: str = ""
|
||||
is_enabled: bool = True
|
||||
version: str = "1.0.0"
|
||||
config: Optional[str] = None
|
||||
last_run_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AgentUpdateRequest(BaseModel):
|
||||
description: Optional[str] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
config: Optional[str] = None
|
||||
|
||||
|
||||
class ServiceActionRequest(BaseModel):
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Collaboration schemas for VoIdea API."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VoteResponse(BaseModel):
|
||||
id: str
|
||||
idea_id: str
|
||||
user_id: str
|
||||
vote: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class VoteToggleRequest(BaseModel):
|
||||
vote: str = Field(pattern="^(up|down)$")
|
||||
|
||||
|
||||
class VoteCount(BaseModel):
|
||||
up: int = 0
|
||||
down: int = 0
|
||||
user_vote: Optional[str] = None
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
content: str = Field(min_length=1)
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
|
||||
class CommentResponse(BaseModel):
|
||||
id: str
|
||||
idea_id: str
|
||||
user_id: str
|
||||
content: str
|
||||
parent_id: Optional[str] = None
|
||||
display_name: str = ""
|
||||
avatar_url: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
replies: list["CommentResponse"] = []
|
||||
|
||||
|
||||
class ActivityResponse(BaseModel):
|
||||
id: str
|
||||
idea_id: str
|
||||
user_id: Optional[str] = None
|
||||
action: str
|
||||
details: Optional[dict[str, Any]] = None
|
||||
display_name: str = ""
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
body: str
|
||||
link: Optional[str] = None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class NotificationUnreadCount(BaseModel):
|
||||
count: int = 0
|
||||
@@ -19,6 +19,7 @@ class IdeaUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
tags: Optional[list[str]] = None
|
||||
is_public: Optional[bool] = None
|
||||
funnel_status: Optional[str] = None
|
||||
|
||||
|
||||
class IdeaResponse(BaseModel):
|
||||
@@ -30,10 +31,15 @@ class IdeaResponse(BaseModel):
|
||||
tags: Optional[list[str]] = None
|
||||
is_public: bool
|
||||
public_slug: Optional[str] = None
|
||||
funnel_status: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunnelTransitionRequest(BaseModel):
|
||||
target_status: str = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
AI_AGENT_ROLES = [
|
||||
"coordinator",
|
||||
"task_organizer",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Workspace schemas for VoIdea API."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WorkspaceCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
type: str = Field(default="team", pattern="^(personal|team)$")
|
||||
|
||||
|
||||
class WorkspaceUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class WorkspaceResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
owner_id: str
|
||||
description: Optional[str] = None
|
||||
member_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AddMemberRequest(BaseModel):
|
||||
user_id: str
|
||||
role: str = Field(default="member", pattern="^(admin|member)$")
|
||||
|
||||
|
||||
class MemberResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
workspace_id: str
|
||||
role: str
|
||||
email: str = ""
|
||||
display_name: str = ""
|
||||
joined_at: datetime
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Calendar service — unified interface for Google, Yandex, Apple CalDAV.
|
||||
|
||||
Manages calendar provider connections and event creation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CALENDAR_PROVIDERS = ("google", "yandex", "apple")
|
||||
|
||||
|
||||
class CalendarService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_calendar_credentials(self, user: User) -> Optional[dict[str, Any]]:
|
||||
"""Get stored calendar credentials for user."""
|
||||
if not hasattr(user, "calendar_provider") or not user.calendar_provider:
|
||||
return None
|
||||
if not hasattr(user, "calendar_credentials") or not user.calendar_credentials:
|
||||
return None
|
||||
try:
|
||||
return json.loads(user.calendar_credentials)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
async def set_calendar_credentials(
|
||||
self, user: User, provider: str, credentials: dict[str, Any]
|
||||
) -> None:
|
||||
user.calendar_provider = provider
|
||||
user.calendar_credentials = json.dumps(credentials)
|
||||
await self.db.commit()
|
||||
|
||||
async def disconnect_calendar(self, user: User) -> None:
|
||||
user.calendar_provider = None
|
||||
user.calendar_credentials = None
|
||||
await self.db.commit()
|
||||
|
||||
def _get_client(self, provider: str, credentials: dict[str, Any]):
|
||||
"""Get appropriate calendar client for provider."""
|
||||
if provider == "google":
|
||||
from app.integrations.calendar.google_calendar import GoogleCalendarClient
|
||||
return GoogleCalendarClient(access_token=credentials.get("access_token", ""))
|
||||
elif provider == "yandex":
|
||||
from app.integrations.calendar.yandex_calendar import YandexCalendarClient
|
||||
return YandexCalendarClient(access_token=credentials.get("access_token", ""))
|
||||
elif provider == "apple":
|
||||
from app.integrations.calendar.apple_caldav import AppleCalDavStub
|
||||
return AppleCalDavStub()
|
||||
raise ValueError(f"Unknown calendar provider: {provider}")
|
||||
|
||||
async def create_idea_event(self, user: User, idea_title: str, idea_content: str) -> bool:
|
||||
"""Create a calendar event from an idea."""
|
||||
creds = await self.get_calendar_credentials(user)
|
||||
if not creds:
|
||||
logger.warning("No calendar credentials for user %s", user.id)
|
||||
return False
|
||||
|
||||
provider = user.calendar_provider
|
||||
if not provider:
|
||||
return False
|
||||
|
||||
client = self._get_client(provider, creds)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
start_iso = now.isoformat()
|
||||
end_iso = (now + timedelta(hours=1)).isoformat()
|
||||
|
||||
return await client.create_event(
|
||||
summary=f"Idea: {idea_title[:100]}",
|
||||
description=idea_content[:1000],
|
||||
start_iso=start_iso,
|
||||
end_iso=end_iso,
|
||||
)
|
||||
|
||||
async def list_events(self, user: User, max_results: int = 10) -> list[dict]:
|
||||
creds = await self.get_calendar_credentials(user)
|
||||
if not creds or not user.calendar_provider:
|
||||
return []
|
||||
|
||||
client = self._get_client(user.calendar_provider, creds)
|
||||
return await client.list_events(max_results=max_results)
|
||||
|
||||
async def health_check(self, user: User) -> bool:
|
||||
creds = await self.get_calendar_credentials(user)
|
||||
if not creds or not user.calendar_provider:
|
||||
return False
|
||||
try:
|
||||
client = self._get_client(user.calendar_provider, creds)
|
||||
return await client.health_check()
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Collaboration service for VoIdea.
|
||||
|
||||
Voting, threaded comments, activity logging, and notifications.
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, func, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.collaboration import IdeaActivity, IdeaComment, IdeaVote, Notification
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class CollaborationService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ── Voting ──
|
||||
|
||||
async def toggle_vote(self, idea_id: UUID, user_id: UUID, vote: str) -> dict[str, Any]:
|
||||
"""Toggle vote. Returns current vote state."""
|
||||
result = await self.db.execute(
|
||||
select(IdeaVote).where(
|
||||
IdeaVote.idea_id == idea_id,
|
||||
IdeaVote.user_id == user_id,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
if existing.vote == vote:
|
||||
await self.db.delete(existing)
|
||||
await self.db.commit()
|
||||
return {"vote": None}
|
||||
existing.vote = vote
|
||||
await self.db.commit()
|
||||
return {"vote": vote}
|
||||
else:
|
||||
v = IdeaVote(idea_id=idea_id, user_id=user_id, vote=vote)
|
||||
self.db.add(v)
|
||||
await self.db.commit()
|
||||
return {"vote": vote}
|
||||
|
||||
async def get_vote_counts(self, idea_id: UUID) -> dict[str, Any]:
|
||||
"""Get vote counts and current user's vote."""
|
||||
up = await self.db.execute(
|
||||
select(func.count(IdeaVote.id)).where(
|
||||
IdeaVote.idea_id == idea_id, IdeaVote.vote == "up"
|
||||
)
|
||||
)
|
||||
down = await self.db.execute(
|
||||
select(func.count(IdeaVote.id)).where(
|
||||
IdeaVote.idea_id == idea_id, IdeaVote.vote == "down"
|
||||
)
|
||||
)
|
||||
return {
|
||||
"up": up.scalar() or 0,
|
||||
"down": down.scalar() or 0,
|
||||
}
|
||||
|
||||
async def get_user_vote(self, idea_id: UUID, user_id: UUID) -> Optional[str]:
|
||||
result = await self.db.execute(
|
||||
select(IdeaVote).where(
|
||||
IdeaVote.idea_id == idea_id, IdeaVote.user_id == user_id
|
||||
)
|
||||
)
|
||||
v = result.scalar_one_or_none()
|
||||
return v.vote if v else None
|
||||
|
||||
# ── Comments ──
|
||||
|
||||
async def create_comment(
|
||||
self, idea_id: UUID, user_id: UUID, content: str, parent_id: Optional[UUID] = None
|
||||
) -> IdeaComment:
|
||||
c = IdeaComment(
|
||||
idea_id=idea_id, user_id=user_id, content=content, parent_id=parent_id
|
||||
)
|
||||
self.db.add(c)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(c)
|
||||
return c
|
||||
|
||||
async def list_comments(self, idea_id: UUID) -> list[dict[str, Any]]:
|
||||
result = await self.db.execute(
|
||||
select(IdeaComment, User)
|
||||
.join(User, IdeaComment.user_id == User.id)
|
||||
.where(IdeaComment.idea_id == idea_id)
|
||||
.order_by(IdeaComment.created_at.asc())
|
||||
)
|
||||
rows = result.all()
|
||||
comments = []
|
||||
for c, u in rows:
|
||||
comments.append({
|
||||
"id": str(c.id),
|
||||
"idea_id": str(c.idea_id),
|
||||
"user_id": str(c.user_id),
|
||||
"content": c.content,
|
||||
"parent_id": str(c.parent_id) if c.parent_id else None,
|
||||
"display_name": u.display_name,
|
||||
"avatar_url": u.avatar_url,
|
||||
"created_at": c.created_at,
|
||||
"updated_at": c.updated_at,
|
||||
})
|
||||
return comments
|
||||
|
||||
async def delete_comment(self, comment_id: UUID, user_id: UUID) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(IdeaComment).where(IdeaComment.id == comment_id)
|
||||
)
|
||||
c = result.scalar_one_or_none()
|
||||
if not c or str(c.user_id) != str(user_id):
|
||||
return False
|
||||
await self.db.delete(c)
|
||||
await self.db.commit()
|
||||
return True
|
||||
|
||||
# ── Activity ──
|
||||
|
||||
async def log_activity(
|
||||
self, idea_id: UUID, user_id: Optional[UUID],
|
||||
action: str, details: Optional[dict] = None
|
||||
) -> IdeaActivity:
|
||||
a = IdeaActivity(
|
||||
idea_id=idea_id, user_id=user_id, action=action, details=details
|
||||
)
|
||||
self.db.add(a)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(a)
|
||||
return a
|
||||
|
||||
async def list_activities(self, idea_id: UUID, limit: int = 50) -> list[dict[str, Any]]:
|
||||
result = await self.db.execute(
|
||||
select(IdeaActivity, User)
|
||||
.join(User, IdeaActivity.user_id == User.id, isouter=True)
|
||||
.where(IdeaActivity.idea_id == idea_id)
|
||||
.order_by(IdeaActivity.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"id": str(a.id),
|
||||
"idea_id": str(a.idea_id),
|
||||
"user_id": str(a.user_id) if a.user_id else None,
|
||||
"action": a.action,
|
||||
"details": a.details,
|
||||
"display_name": u.display_name if u else "System",
|
||||
"created_at": a.created_at,
|
||||
}
|
||||
for a, u in rows
|
||||
]
|
||||
|
||||
# ── Notifications ──
|
||||
|
||||
async def create_notification(
|
||||
self, user_id: UUID, type: str, title: str,
|
||||
body: str, link: Optional[str] = None
|
||||
) -> Notification:
|
||||
n = Notification(
|
||||
user_id=user_id, type=type, title=title,
|
||||
body=body, link=link,
|
||||
)
|
||||
self.db.add(n)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(n)
|
||||
return n
|
||||
|
||||
async def list_notifications(
|
||||
self, user_id: UUID, unread_only: bool = False, limit: int = 50
|
||||
) -> list[Notification]:
|
||||
query = (
|
||||
select(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
if unread_only:
|
||||
query = query.where(Notification.is_read == False)
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def mark_notification_read(self, notification_id: UUID, user_id: UUID) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(Notification).where(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == user_id,
|
||||
)
|
||||
)
|
||||
n = result.scalar_one_or_none()
|
||||
if not n:
|
||||
return False
|
||||
n.is_read = True
|
||||
await self.db.commit()
|
||||
return True
|
||||
|
||||
async def mark_all_read(self, user_id: UUID) -> int:
|
||||
result = await self.db.execute(
|
||||
select(func.count(Notification.id)).where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.is_read == False,
|
||||
)
|
||||
)
|
||||
count = result.scalar() or 0
|
||||
await self.db.execute(
|
||||
delete(Notification).where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.is_read == False,
|
||||
)
|
||||
)
|
||||
await self.db.commit()
|
||||
return count
|
||||
|
||||
async def get_unread_count(self, user_id: UUID) -> int:
|
||||
result = await self.db.execute(
|
||||
select(func.count(Notification.id)).where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.is_read == False,
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Disk service — unified interface for Google Drive, Yandex Disk, Apple iCloud.
|
||||
|
||||
Manages cloud drive provider connections and file uploads.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DISK_PROVIDERS = ("google", "yandex", "apple")
|
||||
|
||||
|
||||
class DiskService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_connected_providers(self, user: User) -> list[dict[str, Any]]:
|
||||
"""Get list of connected disk providers with basic info."""
|
||||
providers = user.disk_providers or []
|
||||
# Return sanitized list (no tokens)
|
||||
return [
|
||||
{
|
||||
"provider": p["provider"],
|
||||
"connected_at": p.get("connected_at", ""),
|
||||
"email": p.get("email", ""),
|
||||
"quota": p.get("quota"),
|
||||
}
|
||||
for p in providers
|
||||
]
|
||||
|
||||
async def is_connected(self, user: User, provider: str) -> bool:
|
||||
providers = user.disk_providers or []
|
||||
return any(p["provider"] == provider for p in providers)
|
||||
|
||||
async def add_provider(
|
||||
self, user: User, provider: str, tokens: dict[str, Any], email: str = ""
|
||||
) -> None:
|
||||
providers = user.disk_providers or []
|
||||
# Remove existing entry for same provider
|
||||
providers = [p for p in providers if p["provider"] != provider]
|
||||
providers.append({
|
||||
"provider": provider,
|
||||
"access_token": tokens.get("access_token", ""),
|
||||
"refresh_token": tokens.get("refresh_token", ""),
|
||||
"expires_at": tokens.get("expires_at"),
|
||||
"connected_at": __import__("datetime").datetime.utcnow().isoformat(),
|
||||
"email": email,
|
||||
})
|
||||
user.disk_providers = providers
|
||||
await self.db.commit()
|
||||
|
||||
async def remove_provider(self, user: User, provider: str) -> None:
|
||||
providers = user.disk_providers or []
|
||||
user.disk_providers = [p for p in providers if p["provider"] != provider]
|
||||
await self.db.commit()
|
||||
|
||||
async def get_access_token(self, user: User, provider: str) -> Optional[str]:
|
||||
providers = user.disk_providers or []
|
||||
for p in providers:
|
||||
if p["provider"] == provider:
|
||||
return p.get("access_token")
|
||||
return None
|
||||
|
||||
async def upload_to_provider(
|
||||
self, user: User, provider: str, file_name: str, file_content: bytes
|
||||
) -> bool:
|
||||
"""Upload file bytes to specified provider's VoIdea folder."""
|
||||
token = await self.get_access_token(user, provider)
|
||||
if not token:
|
||||
logger.warning("No token for provider %s (user %s)", provider, user.id)
|
||||
return False
|
||||
|
||||
if provider == "google":
|
||||
from app.integrations.oauth.google import ensure_app_folder, upload_file
|
||||
parent_id = await ensure_app_folder(token)
|
||||
return await upload_file(token, file_name, file_content, parent_id=parent_id)
|
||||
|
||||
elif provider == "yandex":
|
||||
from app.integrations.oauth.yandex import ensure_app_folder, upload_file
|
||||
import tempfile, os
|
||||
await ensure_app_folder(token)
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".html")
|
||||
try:
|
||||
tmp.write(file_content)
|
||||
tmp.close()
|
||||
return await upload_file(token, tmp.name, file_name)
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
elif provider == "apple":
|
||||
logger.info("Apple iCloud upload is a stub — file not actually uploaded")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def upload_to_any(
|
||||
self, user: User, file_name: str, file_content: bytes
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Upload to first available provider. Returns provider info."""
|
||||
providers = user.disk_providers or []
|
||||
for entry in providers:
|
||||
provider = entry["provider"]
|
||||
success = await self.upload_to_provider(user, provider, file_name, file_content)
|
||||
if success:
|
||||
return {"provider": provider, "success": True}
|
||||
return None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Notification service — multi-channel delivery for VoIdea.
|
||||
|
||||
Supported channels:
|
||||
- web (DB — stored as Notification model)
|
||||
- telegram (via TelegramBotClient)
|
||||
- email (via email_service)
|
||||
|
||||
Only web channel is required; telegram and email are optional.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.collaboration_service import CollaborationService
|
||||
|
||||
|
||||
class NotificationDeliveryService:
|
||||
"""Delivers notifications across multiple channels."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.collab = CollaborationService(db)
|
||||
|
||||
async def notify(
|
||||
self,
|
||||
user_id: UUID,
|
||||
type: str,
|
||||
title: str,
|
||||
body: str,
|
||||
link: Optional[str] = None,
|
||||
channels: Optional[list[str]] = None,
|
||||
) -> dict[str, bool]:
|
||||
"""Deliver notification to specified channels (default: web only).
|
||||
|
||||
Returns dict of channel -> success status.
|
||||
"""
|
||||
if channels is None:
|
||||
channels = ["web"]
|
||||
|
||||
results: dict[str, bool] = {}
|
||||
|
||||
if "web" in channels:
|
||||
try:
|
||||
await self.collab.create_notification(
|
||||
user_id=user_id, type=type,
|
||||
title=title, body=body, link=link,
|
||||
)
|
||||
results["web"] = True
|
||||
except Exception:
|
||||
results["web"] = False
|
||||
|
||||
if "telegram" in channels:
|
||||
try:
|
||||
await self._send_telegram(user_id, title, body, link)
|
||||
results["telegram"] = True
|
||||
except Exception:
|
||||
results["telegram"] = False
|
||||
|
||||
if "email" in channels:
|
||||
try:
|
||||
await self._send_email(user_id, title, body)
|
||||
results["email"] = True
|
||||
except Exception:
|
||||
results["email"] = False
|
||||
|
||||
return results
|
||||
|
||||
async def _send_telegram(
|
||||
self, user_id: UUID, title: str, body: str, link: Optional[str] = None
|
||||
) -> None:
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
if not settings.telegram_bot_token:
|
||||
return
|
||||
|
||||
from app.integrations.telegram.client import TelegramBotClient
|
||||
client = TelegramBotClient(token=settings.telegram_bot_token)
|
||||
|
||||
text = f"*{title}*\n{body}"
|
||||
if link:
|
||||
text += f"\n\n[Open]({link})"
|
||||
|
||||
# Find user's telegram chat_id — for now send to first available
|
||||
# In production this would look up stored chat_id for the user
|
||||
from app.models.user import User
|
||||
user = await self.db.get(User, user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
async def _send_email(
|
||||
self, user_id: UUID, title: str, body: str
|
||||
) -> None:
|
||||
from app.services.email_service import send_email
|
||||
from app.models.user import User
|
||||
|
||||
user = await self.db.get(User, user_id)
|
||||
if not user or not user.email:
|
||||
return
|
||||
|
||||
await send_email(
|
||||
to=user.email,
|
||||
subject=title,
|
||||
body=body,
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Presentation service — generates HTML slides from idea + analysis reports."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.agents.models import AgentReport
|
||||
from app.models.idea import Idea
|
||||
from app.services.disk_service import DiskService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROLE_EMOJI = {
|
||||
"coordinator": "🎯",
|
||||
"task_organizer": "📋",
|
||||
"business_analyst": "💼",
|
||||
"legal_expert": "⚖️",
|
||||
"financial_consultant": "💰",
|
||||
"solution_architect": "🏗️",
|
||||
"tester": "🧪",
|
||||
"ui_designer": "🎨",
|
||||
"smm_specialist": "📱",
|
||||
"life_coach": "🌟",
|
||||
"accessibility_expert": "♿",
|
||||
}
|
||||
|
||||
ROLE_LABELS = {
|
||||
"coordinator": "Координатор",
|
||||
"task_organizer": "Организатор задач",
|
||||
"business_analyst": "Бизнес-аналитик",
|
||||
"legal_expert": "Юрист",
|
||||
"financial_consultant": "Финансовый консультант",
|
||||
"solution_architect": "Архитектор решения",
|
||||
"tester": "Тестировщик",
|
||||
"ui_designer": "UI/Дизайнер",
|
||||
"smm_specialist": "SMM-специалист",
|
||||
"life_coach": "Лайф-коуч",
|
||||
"accessibility_expert": "Эксперт доступности",
|
||||
}
|
||||
|
||||
|
||||
REVEAL_HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title} — VoIdea Презентация</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/white.css">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');
|
||||
* {{ font-family: 'Inter', sans-serif; }}
|
||||
.reveal {{ font-size: 28px; }}
|
||||
.reveal h1 {{ color: #2563EB; font-weight: 700; }}
|
||||
.reveal h2 {{ color: #1E293B; font-weight: 600; }}
|
||||
.reveal h3 {{ color: #475569; font-weight: 600; }}
|
||||
.reveal .slides section {{ padding: 40px; }}
|
||||
.slide-title {{ text-align: center; padding-top: 15vh; }}
|
||||
.slide-title h1 {{ font-size: 3em; margin-bottom: 0.3em; }}
|
||||
.slide-title .meta {{ color: #94A3B8; font-size: 0.5em; }}
|
||||
.slide-role {{ text-align: left; }}
|
||||
.slide-role h2 {{ font-size: 1.2em; margin-bottom: 0.5em; }}
|
||||
.slide-role .emoji {{ font-size: 1.5em; margin-right: 0.3em; }}
|
||||
.slide-role .content {{ font-size: 0.7em; line-height: 1.6; color: #334155; }}
|
||||
.slide-exec {{ text-align: left; background: #EFF6FF; border-radius: 12px; padding: 30px !important; }}
|
||||
.footer {{ position: fixed; bottom: 12px; left: 0; right: 0; text-align: center; font-size: 12px; color: #94A3B8; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="reveal"><div class="slides">
|
||||
<section class="slide-title">
|
||||
<h1>{title}</h1>
|
||||
<div class="meta">
|
||||
{author} · {status_label} · {date}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{executive_summary}
|
||||
|
||||
{role_slides}
|
||||
|
||||
<section>
|
||||
<h2>Спасибо за внимание!</h2>
|
||||
<p style="color:#94A3B8;font-size:0.6em;">Создано в VoIdea · {date}</p>
|
||||
</section>
|
||||
</div></div>
|
||||
<div class="footer">VoIdea · {date}</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
|
||||
<script>Reveal.initialize({{ controls: true, progress: true, hash: true }});</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
class PresentationService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
idea_id: str,
|
||||
roles: Optional[list[str]] = None,
|
||||
user_name: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Generate HTML presentation for an idea.
|
||||
|
||||
Args:
|
||||
idea_id: UUID of the idea
|
||||
roles: List of roles to include (None = all available)
|
||||
user_name: Author display name
|
||||
|
||||
Returns:
|
||||
HTML string or None if idea not found
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Idea).where(Idea.id == idea_id)
|
||||
)
|
||||
idea = result.scalar_one_or_none()
|
||||
if not idea:
|
||||
return None
|
||||
|
||||
result = await self.db.execute(
|
||||
select(AgentReport).where(
|
||||
AgentReport.details["idea_id"].as_string() == idea_id
|
||||
).order_by(AgentReport.created_at.asc())
|
||||
)
|
||||
reports = result.scalars().all()
|
||||
|
||||
if roles is not None:
|
||||
reports = [
|
||||
r for r in reports
|
||||
if r.agent_id.replace("ai_", "") in roles
|
||||
]
|
||||
|
||||
funnel_labels = {
|
||||
"raw": "Сырая",
|
||||
"validated": "Подтверждена",
|
||||
"backlog": "Бэклог",
|
||||
"in_progress": "В работе",
|
||||
"launched": "Запущена",
|
||||
"retrospective": "Ретроспектива",
|
||||
}
|
||||
status_label = funnel_labels.get(
|
||||
idea.funnel_status or "", idea.funnel_status or "Без статуса"
|
||||
)
|
||||
|
||||
date_str = datetime.now(timezone.utc).strftime("%d.%m.%Y")
|
||||
|
||||
role_slides = []
|
||||
for r in reports:
|
||||
role_name = r.agent_id.replace("ai_", "")
|
||||
emoji = ROLE_EMOJI.get(role_name, "📄")
|
||||
label = ROLE_LABELS.get(role_name, role_name)
|
||||
content = r.message or "Нет данных"
|
||||
|
||||
role_slides.append(f"""
|
||||
<section class="slide-role">
|
||||
<h2><span class="emoji">{emoji}</span> {label}</h2>
|
||||
<div class="content">{content}</div>
|
||||
</section>""")
|
||||
|
||||
success_count = sum(1 for r in reports if r.success)
|
||||
|
||||
if role_slides:
|
||||
exec_summary = f"""
|
||||
<section class="slide-exec">
|
||||
<h2>📊 Executive Summary</h2>
|
||||
<p>Проанализировано ролей: <strong>{len(reports)}</strong></p>
|
||||
<p>Успешных анализов: <strong>{success_count}</strong></p>
|
||||
</section>"""
|
||||
else:
|
||||
exec_summary = ""
|
||||
|
||||
return REVEAL_HTML_TEMPLATE.format(
|
||||
title=idea.title,
|
||||
author=user_name or "Пользователь",
|
||||
status_label=status_label,
|
||||
date=date_str,
|
||||
executive_summary=exec_summary,
|
||||
role_slides="".join(role_slides),
|
||||
)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Workspace service with tariff-gated limits for VoIdea."""
|
||||
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.workspace import Workspace, WorkspaceMembership
|
||||
from app.models.user import User
|
||||
from app.services.tariff_service import TariffService
|
||||
|
||||
|
||||
class WorkspaceError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class WorkspaceLimitError(WorkspaceError):
|
||||
pass
|
||||
|
||||
|
||||
class WorkspaceService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
# ── Tariff gates ──
|
||||
|
||||
async def _get_tariff_features(self, user_id: UUID) -> dict[str, Any]:
|
||||
"""Get tariff plan features for a user."""
|
||||
tariff_svc = TariffService(self.db)
|
||||
sub = await tariff_svc.get_user_subscription(user_id)
|
||||
if not sub:
|
||||
return {}
|
||||
plan = await tariff_svc.get_plan_by_id(sub.plan_id)
|
||||
return plan.features if plan and plan.features else {}
|
||||
|
||||
async def _check_team_workspace_limit(self, user_id: UUID) -> None:
|
||||
"""Check if user can create a team workspace."""
|
||||
features = await self._get_tariff_features(user_id)
|
||||
max_team = features.get("max_team_workspaces", 0)
|
||||
if max_team <= 0:
|
||||
raise WorkspaceLimitError(
|
||||
"Your tariff plan does not allow team workspaces"
|
||||
)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(Workspace.id))
|
||||
.where(
|
||||
Workspace.owner_id == user_id,
|
||||
Workspace.type == "team",
|
||||
)
|
||||
)
|
||||
current_team_count = result.scalar() or 0
|
||||
if current_team_count >= max_team:
|
||||
raise WorkspaceLimitError(
|
||||
f"Team workspace limit reached ({max_team}). "
|
||||
"Upgrade your tariff to create more."
|
||||
)
|
||||
|
||||
async def _check_member_limit(self, workspace_id: UUID) -> None:
|
||||
"""Check if workspace can accept more members."""
|
||||
ws = await self.db.get(Workspace, workspace_id)
|
||||
if not ws:
|
||||
raise WorkspaceError("Workspace not found")
|
||||
|
||||
features = await self._get_tariff_features(ws.owner_id)
|
||||
max_members = features.get("max_members_per_workspace", 0)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.count(WorkspaceMembership.id))
|
||||
.where(WorkspaceMembership.workspace_id == workspace_id)
|
||||
)
|
||||
current_count = result.scalar() or 0
|
||||
if max_members > 0 and current_count >= max_members:
|
||||
raise WorkspaceLimitError(
|
||||
f"Member limit reached ({max_members}). "
|
||||
"Upgrade your tariff to add more members."
|
||||
)
|
||||
|
||||
async def _check_feature_gate(self, user_id: UUID, feature: str) -> None:
|
||||
"""Check if a specific workspace feature is enabled for the user's tariff."""
|
||||
features = await self._get_tariff_features(user_id)
|
||||
if not features.get(feature, False):
|
||||
raise WorkspaceLimitError(
|
||||
f"Feature '{feature}' is not available on your tariff plan"
|
||||
)
|
||||
|
||||
# ── CRUD ──
|
||||
|
||||
async def create_personal(self, user_id: UUID, name: str = "Personal") -> Workspace:
|
||||
"""Create or get existing personal workspace for user."""
|
||||
result = await self.db.execute(
|
||||
select(Workspace).where(
|
||||
Workspace.owner_id == user_id,
|
||||
Workspace.type == "personal",
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
ws = Workspace(name=name, type="personal", owner_id=user_id)
|
||||
self.db.add(ws)
|
||||
await self.db.flush()
|
||||
|
||||
membership = WorkspaceMembership(
|
||||
workspace_id=ws.id, user_id=user_id, role="owner"
|
||||
)
|
||||
self.db.add(membership)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(ws)
|
||||
return ws
|
||||
|
||||
async def create_team(
|
||||
self, name: str, owner_id: UUID, description: str = ""
|
||||
) -> Workspace:
|
||||
"""Create a new team workspace (tariff-gated)."""
|
||||
await self._check_team_workspace_limit(owner_id)
|
||||
|
||||
ws = Workspace(name=name, type="team", owner_id=owner_id, description=description)
|
||||
self.db.add(ws)
|
||||
await self.db.flush()
|
||||
|
||||
membership = WorkspaceMembership(
|
||||
workspace_id=ws.id, user_id=owner_id, role="owner"
|
||||
)
|
||||
self.db.add(membership)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(ws)
|
||||
return ws
|
||||
|
||||
async def get_by_id(self, workspace_id: UUID) -> Optional[Workspace]:
|
||||
return await self.db.get(Workspace, workspace_id)
|
||||
|
||||
async def list_user_workspaces(self, user_id: UUID) -> list[Workspace]:
|
||||
"""List workspaces where user is a member."""
|
||||
result = await self.db.execute(
|
||||
select(Workspace)
|
||||
.join(WorkspaceMembership)
|
||||
.where(WorkspaceMembership.user_id == user_id)
|
||||
.order_by(Workspace.type, Workspace.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def update_workspace(
|
||||
self, workspace_id: UUID, user_id: UUID, updates: dict[str, Any]
|
||||
) -> Optional[Workspace]:
|
||||
ws = await self.db.get(Workspace, workspace_id)
|
||||
if not ws:
|
||||
return None
|
||||
if ws.owner_id != user_id:
|
||||
raise WorkspaceError("Only the owner can update workspace")
|
||||
for key, value in updates.items():
|
||||
if hasattr(ws, key) and key not in ("id", "owner_id", "type", "created_at"):
|
||||
setattr(ws, key, value)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(ws)
|
||||
return ws
|
||||
|
||||
async def delete_workspace(self, workspace_id: UUID, user_id: UUID) -> bool:
|
||||
ws = await self.db.get(Workspace, workspace_id)
|
||||
if not ws:
|
||||
return False
|
||||
if ws.owner_id != user_id:
|
||||
raise WorkspaceError("Only the owner can delete workspace")
|
||||
if ws.type == "personal":
|
||||
raise WorkspaceError("Cannot delete personal workspace")
|
||||
await self.db.delete(ws)
|
||||
await self.db.commit()
|
||||
return True
|
||||
|
||||
# ── Members ──
|
||||
|
||||
async def add_member(
|
||||
self, workspace_id: UUID, user_id: UUID, role: str = "member"
|
||||
) -> WorkspaceMembership:
|
||||
"""Add a user to a workspace (tariff-gated for member count)."""
|
||||
await self._check_member_limit(workspace_id)
|
||||
|
||||
existing = await self.db.execute(
|
||||
select(WorkspaceMembership).where(
|
||||
WorkspaceMembership.workspace_id == workspace_id,
|
||||
WorkspaceMembership.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise WorkspaceError("User is already a member of this workspace")
|
||||
|
||||
membership = WorkspaceMembership(
|
||||
workspace_id=workspace_id, user_id=user_id, role=role
|
||||
)
|
||||
self.db.add(membership)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(membership)
|
||||
return membership
|
||||
|
||||
async def remove_member(self, workspace_id: UUID, user_id: UUID) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(WorkspaceMembership).where(
|
||||
WorkspaceMembership.workspace_id == workspace_id,
|
||||
WorkspaceMembership.user_id == user_id,
|
||||
)
|
||||
)
|
||||
membership = result.scalar_one_or_none()
|
||||
if not membership:
|
||||
return False
|
||||
await self.db.delete(membership)
|
||||
await self.db.commit()
|
||||
return True
|
||||
|
||||
async def list_members(self, workspace_id: UUID) -> list[dict[str, Any]]:
|
||||
result = await self.db.execute(
|
||||
select(WorkspaceMembership, User)
|
||||
.join(User, WorkspaceMembership.user_id == User.id)
|
||||
.where(WorkspaceMembership.workspace_id == workspace_id)
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"id": str(m.WorkspaceMembership.id),
|
||||
"user_id": str(m.User.id),
|
||||
"workspace_id": str(m.WorkspaceMembership.workspace_id),
|
||||
"role": m.WorkspaceMembership.role,
|
||||
"email": m.User.email,
|
||||
"display_name": m.User.display_name,
|
||||
"joined_at": m.WorkspaceMembership.created_at,
|
||||
}
|
||||
for m in rows
|
||||
]
|
||||
|
||||
async def get_membership(
|
||||
self, workspace_id: UUID, user_id: UUID
|
||||
) -> Optional[WorkspaceMembership]:
|
||||
result = await self.db.execute(
|
||||
select(WorkspaceMembership).where(
|
||||
WorkspaceMembership.workspace_id == workspace_id,
|
||||
WorkspaceMembership.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
JWT_KEY=$(openssl rand -hex 64)
|
||||
JWT_RESET_KEY=$(openssl rand -hex 64)
|
||||
ENC_KEY=$(openssl rand -hex 32)
|
||||
DB_PASS=$(openssl rand -hex 16)
|
||||
|
||||
cat << EOF
|
||||
JWT_SECRET_KEY=$JWT_KEY
|
||||
JWT_RESET_SECRET_KEY=$JWT_RESET_KEY
|
||||
ENCRYPTION_KEY=$ENC_KEY
|
||||
DB_PASS=$DB_PASS
|
||||
EOF
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
TOKEN="2608ab5b7a8965190ab4d8503a8197a47b75b42c"
|
||||
GITEA_URL="http://localhost:3000"
|
||||
|
||||
echo "Creating repository 'voidea' in Gitea..."
|
||||
|
||||
RESPONSE=$(curl -s -X POST "$GITEA_URL/api/v1/user/repos" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-d '{
|
||||
"name": "voidea",
|
||||
"description": "VoIdeaAI - voice-first AI idea assistant",
|
||||
"private": false,
|
||||
"auto_init": false,
|
||||
"default_branch": "master"
|
||||
}')
|
||||
|
||||
echo "Response: $RESPONSE"
|
||||
|
||||
if echo "$RESPONSE" | grep -q '"id"'; then
|
||||
echo "Repository created successfully!"
|
||||
echo "Clone URL: http://localhost:3000/angel/voidea.git"
|
||||
echo "Web URL: http://git.voideaai.ru/angel/voidea"
|
||||
else
|
||||
echo "Failed to create repository."
|
||||
fi
|
||||
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
network_mode: host
|
||||
environment:
|
||||
POSTGRES_DB: gitea
|
||||
POSTGRES_USER: gitea
|
||||
POSTGRES_PASSWORD: ${GITEA_DB_PASS:-gitea_pass}
|
||||
command: -p 5433
|
||||
volumes:
|
||||
- gitea-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U gitea -h localhost -p 5433"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
gitea:
|
||||
image: gitea/gitea:latest-rootless
|
||||
network_mode: host
|
||||
environment:
|
||||
GITEA__database__DB_TYPE: postgres
|
||||
GITEA__database__HOST: localhost:5433
|
||||
GITEA__database__NAME: gitea
|
||||
GITEA__database__USER: gitea
|
||||
GITEA__database__PASSWD: ${GITEA_DB_PASS:-gitea_pass}
|
||||
GITEA__server__DOMAIN: git.voideaai.ru
|
||||
GITEA__server__HTTP_PORT: 3000
|
||||
GITEA__server__ROOT_URL: http://git.voideaai.ru:3000
|
||||
GITEA__server__SSH_DOMAIN: git.voideaai.ru
|
||||
GITEA__server__SSH_PORT: 22
|
||||
volumes:
|
||||
- gitea-data:/var/lib/gitea
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
gitea-db:
|
||||
gitea-data:
|
||||
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name git.voideaai.ru;
|
||||
|
||||
client_max_body_size 512M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name git.voideaai.ru;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/live/git.voideaai.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/live/git.voideaai.ru/privkey.pem;
|
||||
|
||||
client_max_body_size 512M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# git.voideaai.ru -> Gitea (shared instance with git.aegisone.ru)
|
||||
server {
|
||||
listen 80;
|
||||
server_name git.voideaai.ru;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
client_max_body_size 512M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name git.voideaai.ru;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/live/voideaai.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/live/voideaai.ru/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
client_max_body_size 512M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Copying nginx configs..."
|
||||
sudo cp /home/angel/voideaai.ru.conf /opt/projects/nginx-proxy/conf.d/
|
||||
sudo cp /home/angel/git.voideaai.ru.conf /opt/projects/nginx-proxy/conf.d/
|
||||
echo "Configs copied."
|
||||
|
||||
echo "Testing nginx config..."
|
||||
docker compose -p nginx-proxy exec nginx nginx -t
|
||||
|
||||
echo "Reloading nginx..."
|
||||
docker compose -p nginx-proxy exec nginx nginx -s reload
|
||||
echo "Nginx reloaded successfully."
|
||||
@@ -0,0 +1,85 @@
|
||||
# voideaai.ru -> VoIdeaAI FastAPI
|
||||
server {
|
||||
listen 80;
|
||||
server_name voideaai.ru www.voideaai.ru;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8020;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name voideaai.ru www.voideaai.ru;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/live/voideaai.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/live/voideaai.ru/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location /assets/ {
|
||||
proxy_pass http://localhost:8020;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_cache_valid 200 365d;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /icons/ {
|
||||
proxy_pass http://localhost:8020;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_cache_valid 200 365d;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8020;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://localhost:8020;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8020;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name voideaai.ru www.voideaai.ru;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8020;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name voideaai.ru www.voideaai.ru;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/live/voideaai.ru/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/live/voideaai.ru/privkey.pem;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location /assets/ {
|
||||
proxy_pass http://localhost:8020;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_cache_valid 200 365d;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /icons/ {
|
||||
proxy_pass http://localhost:8020;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_cache_valid 200 365d;
|
||||
expires 365d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8020;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://localhost:8020;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8020;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
network: host
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
network: host
|
||||
network_mode: host
|
||||
command: celery -A app.tasks worker -l info --concurrency=2
|
||||
env_file: .env
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
beat:
|
||||
build:
|
||||
context: .
|
||||
network: host
|
||||
network_mode: host
|
||||
command: celery -A app.tasks beat -l info
|
||||
env_file: .env
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
network_mode: host
|
||||
environment:
|
||||
POSTGRES_DB: voidea
|
||||
POSTGRES_USER: voidea
|
||||
POSTGRES_PASSWORD: ${DB_PASS:-voidea_pass}
|
||||
command: -p 5444
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U voidea -h localhost -p 5444"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
network_mode: host
|
||||
command: redis-server --port 6380
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
+4
-1
@@ -19,9 +19,12 @@
|
||||
- Owner защищён от изменений
|
||||
|
||||
### Агенты
|
||||
- Просмотр списка агентов
|
||||
- Просмотр списка агентов (13 + Meta-agent)
|
||||
- Включение/отключение агентов
|
||||
- Редактирование описания
|
||||
- **Meta-agent** — конфигурация JSON (cron_schedule, weights, sources_count, auto_create)
|
||||
- Ручной запуск любого агента кнопкой «Запустить»
|
||||
- Meta-agent по умолчанию запускается по расписанию `0 9 * * 1,4` (пн и чт в 9:00)
|
||||
|
||||
### Журналы
|
||||
- Фильтрация по уровню (DEBUG, INFO, WARNING, ERROR)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# VoIdeaAI — Инструкция для AI-разработчика (локальная разработка + деплой)
|
||||
|
||||
## Назначение
|
||||
Эта инструкция — для AI-агента, который продолжает разработку проекта VoIdeaAI.
|
||||
Работа ведётся **локально**, деплой — отправкой на сервер.
|
||||
|
||||
---
|
||||
|
||||
## 1. Архитектура проекта
|
||||
|
||||
```
|
||||
voidea/
|
||||
├── app/ # Backend (Python FastAPI)
|
||||
│ ├── api/v1/ # HTTP endpoints
|
||||
│ ├── agents/ # Системные агенты
|
||||
│ ├── core/ # Конфиг, БД, security
|
||||
│ ├── integrations/ # AI, OAuth, Telegram, Calendar
|
||||
│ ├── models/ # SQLAlchemy модели
|
||||
│ ├── schemas/ # Pydantic схемы
|
||||
│ ├── services/ # Бизнес-логика
|
||||
│ └── tasks/ # Celery задачи
|
||||
├── webui/ # Frontend (React + Vite + Tailwind)
|
||||
├── flutter/ # Mobile app (Flutter, отдельная разработка)
|
||||
├── deploy/ # Файлы деплоя
|
||||
│ ├── gitea/ # Docker Compose для Gitea
|
||||
│ ├── images/ # Docker образы (tar)
|
||||
│ ├── voideaai.nginx.conf
|
||||
│ └── voidea-api.service / voidea-worker.service / voidea-beat.service (systemd — legacy)
|
||||
├── docs/instructions/ # Инструкции для AI
|
||||
├── Dockerfile # Multi-stage сборка (frontend + backend)
|
||||
├── docker-compose.yml # 4 сервиса: app, worker, db, redis
|
||||
└── .env.production # Шаблон .env для сервера
|
||||
```
|
||||
|
||||
## 2. Технологический стек
|
||||
|
||||
| Компонент | Технология |
|
||||
|-----------|-----------|
|
||||
| Backend | Python 3.12, FastAPI, SQLAlchemy async |
|
||||
| Frontend | React 18, Vite, Tailwind CSS, TypeScript |
|
||||
| Mobile | Flutter (на сервер НЕ деплоится) |
|
||||
| Database | PostgreSQL 16 |
|
||||
| Queue | Celery + Redis 7 |
|
||||
| Auth | JWT (access + refresh) |
|
||||
| AI | Yandex GPT, GigaChat (fallback chain) |
|
||||
| Server | Ubuntu 24.04, Docker Compose, хост-нетворкинг |
|
||||
|
||||
## 3. Локальный запуск для разработки
|
||||
|
||||
### 3.1 Backend
|
||||
```bash
|
||||
# Виртуальное окружение
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
venv\Scripts\activate # Windows
|
||||
|
||||
# Зависимости
|
||||
pip install -r requirements.txt
|
||||
|
||||
# PostgreSQL (локально или через Docker)
|
||||
# БД: voidea, user: voidea, pass: voidea_pass, port: 5432
|
||||
|
||||
# Миграции
|
||||
alembic upgrade head
|
||||
|
||||
# Запуск
|
||||
uvicorn app.main:app --reload --port 8020
|
||||
```
|
||||
|
||||
### 3.2 Frontend
|
||||
```bash
|
||||
cd webui
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3.3 Mobile (Flutter)
|
||||
```bash
|
||||
cd flutter
|
||||
flutter pub get
|
||||
flutter run
|
||||
```
|
||||
|
||||
## 4. Процесс деплоя
|
||||
|
||||
**Важно: на сервере НЕ dev-окружение. Все правки — локально, затем деплой.**
|
||||
|
||||
### Основной сценарий
|
||||
```bash
|
||||
# 1. Внести изменения локально
|
||||
# 2. Закоммитить
|
||||
git add .
|
||||
git commit -m "feat: описание изменения"
|
||||
|
||||
# 3. Запушить в Gitea
|
||||
git push origin master
|
||||
|
||||
# 4. На сервере
|
||||
ssh angel@81.177.141.34
|
||||
cd /opt/projects/voidea
|
||||
|
||||
# 4a. Получить изменения
|
||||
git pull
|
||||
|
||||
# 4b. Пересобрать и перезапустить
|
||||
docker compose up -d --build
|
||||
|
||||
# 4c. Проверить
|
||||
curl -s http://localhost:8020/health
|
||||
```
|
||||
|
||||
### Миграции БД
|
||||
```bash
|
||||
# Накатить
|
||||
docker compose exec app alembic upgrade head
|
||||
|
||||
# Откатить
|
||||
docker compose exec app alembic downgrade -1
|
||||
```
|
||||
|
||||
### Просмотр логов
|
||||
```bash
|
||||
docker compose logs --tail=50 app
|
||||
docker compose logs --tail=50 worker
|
||||
```
|
||||
|
||||
## 5. Структура .env на сервере
|
||||
|
||||
Файл `.env` в `/opt/projects/voidea/.env`.
|
||||
Шаблон — `.env.production` в корне репозитория.
|
||||
|
||||
**Важные отличия от .env.example:**
|
||||
- `DATABASE_URL` использует порт **5444** (а не 5432)
|
||||
- `REDIS_URL` использует порт **6380** (а не 6379)
|
||||
- Порты изменены, чтобы не конфликтовать с проектом aegisone на том же сервере
|
||||
|
||||
## 6. Важные ограничения
|
||||
|
||||
### Серверные (VPS)
|
||||
- **Bridge-сеть не работает** — только `network_mode: host`
|
||||
- **Docker build** всегда с `network: host`
|
||||
- **Docker Hub** может таймаутить на больших образах (>100MB) — используйте `docker save`/`docker load`
|
||||
- Всего 20GB диска, ~13GB свободно. Следите за `df -h /`
|
||||
|
||||
### Кодовые
|
||||
- Не трогать `.env` на сервере (секреты)
|
||||
- `flutter/` — только локальная разработка, в `.dockerignore`, на сервер не деплоится
|
||||
- `webui/node_modules/` — в `.gitignore`, не коммитить
|
||||
- Миграции БД — через `alembic`, не вручную
|
||||
|
||||
## 7. Docker-образы
|
||||
|
||||
Образы хранятся в `deploy/images/` в формате `.tar`.
|
||||
Список используемых образов:
|
||||
- `postgres:16-alpine` — voidea + gitea
|
||||
- `redis:7-alpine` — voidea
|
||||
- `gitea/gitea:latest-rootless` — Gitea
|
||||
- Образ самого приложения собирается из `Dockerfile`
|
||||
|
||||
Для обновления образов на сервере:
|
||||
```bash
|
||||
# Локально: скачать и запаковать
|
||||
docker pull postgres:16-alpine
|
||||
docker save postgres:16-alpine -o deploy/images/postgres.tar
|
||||
scp deploy/images/postgres.tar angel@81.177.141.34:/home/angel/
|
||||
|
||||
# На сервере:
|
||||
docker load -i /home/angel/postgres.tar
|
||||
```
|
||||
|
||||
## 8. Полезные команды на сервере
|
||||
|
||||
```bash
|
||||
# Статус проектов
|
||||
docker compose -p voidea ps
|
||||
docker compose -p gitea ps
|
||||
docker compose -p nginx-proxy ps
|
||||
|
||||
# Логи VoIdea
|
||||
docker compose -p voidea logs --tail=100 -f app
|
||||
|
||||
# Перезапуск конкретного сервиса
|
||||
docker compose -p voidea restart app
|
||||
|
||||
# Полная пересборка
|
||||
docker compose -p voidea up -d --build
|
||||
|
||||
# Очистка кеша Docker
|
||||
docker builder prune -af
|
||||
docker system prune -af
|
||||
|
||||
# Проверка nginx
|
||||
docker compose -p nginx-proxy exec nginx nginx -t
|
||||
docker compose -p nginx-proxy exec nginx nginx -s reload
|
||||
```
|
||||
|
||||
## 9. Порты на сервере
|
||||
|
||||
| Порт | Сервис | Проект |
|
||||
|------|--------|--------|
|
||||
| 8020 | FastAPI | VoIdea |
|
||||
| 5444 | PostgreSQL | VoIdea |
|
||||
| 6380 | Redis | VoIdea |
|
||||
| 3000 | Gitea | Gitea |
|
||||
| 5433 | PostgreSQL | Gitea |
|
||||
| 80/443 | nginx-proxy | Общий |
|
||||
| 8000 | FastAPI | aegisone (чужой) |
|
||||
| 5432 | PostgreSQL | aegisone (чужой) |
|
||||
|
||||
Не занимать порты, помеченные как чужие.
|
||||
|
||||
---
|
||||
|
||||
*Обновлён: 2026-05-19*
|
||||
+42
-1
@@ -53,6 +53,47 @@ VoIdeaAI имеет Telegram бота для быстрого создания
|
||||
- `/idea <текст>` — создать новую идею
|
||||
- `/help` — справка
|
||||
|
||||
## Воронка идей (Idea Funnel)
|
||||
|
||||
Отслеживайте прогресс идей по воронке:
|
||||
|
||||
1. **Сырая (raw)** — только что созданная идея
|
||||
2. **Подтверждённая (validated)** — идея прошла первичную оценку
|
||||
3. **Бэклог (backlog)** — запланирована к реализации
|
||||
4. **В работе (in_progress)** — активная работа
|
||||
5. **Запущена (launched)** — реализована
|
||||
6. **Ретроспектива (retrospective)** — анализ результатов
|
||||
|
||||
Переключайтесь между **Списком** и **Канбан-доской** на дашборде. Перемещайте идеи по воронке кнопками ← → в карточках канбана или через API.
|
||||
|
||||
## Рабочие пространства (Workspaces)
|
||||
|
||||
Личное рабочее пространство создаётся автоматически при регистрации. Командные пространства доступны по тарифу:
|
||||
|
||||
- **Personal** — 1 на пользователя, всегда бесплатно
|
||||
- **Team** — создаются владельцем, участники добавляются по email
|
||||
- Переключайтесь между пространствами через выпадающий список в шапке
|
||||
- Управляйте участниками в настройках пространства
|
||||
|
||||
## Совместная работа (Collaboration)
|
||||
|
||||
На странице идеи доступны:
|
||||
|
||||
- **Голосование** — 👍/👎 оценивайте идеи
|
||||
- **Комментарии** — threaded обсуждения с ответами
|
||||
- **Активность** — лента изменений и действий
|
||||
- **Уведомления** — колокольчик в шапке, отметки о прочитанном
|
||||
|
||||
## Интеграция с календарём
|
||||
|
||||
Добавляйте идеи в календарь одним нажатием кнопки «+ Календарь» на странице идеи:
|
||||
|
||||
- **Google Calendar** — через OAuth (требуется scope calendar.events)
|
||||
- **Yandex Календарь** — через OAuth (требуется scope calendar:event.write)
|
||||
- **Apple Calendar (CalDAV)** — в разработке
|
||||
|
||||
Подключите календарь в Настройки → Интеграции → Календарь.
|
||||
|
||||
## Настройки
|
||||
|
||||
В разделе «Настройки» доступно:
|
||||
@@ -60,5 +101,5 @@ VoIdeaAI имеет Telegram бота для быстрого создания
|
||||
- Безопасность (смена пароля, OAuth привязка)
|
||||
- Голос (настройки микрофона, TTS)
|
||||
- Тема (светлая/тёмная)
|
||||
- Интеграции (Яндекс.Диск)
|
||||
- Интеграции (Яндекс.Диск, Календарь)
|
||||
- Тариф (информация о подписке)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Flutter web plugin registrant file.
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// @dart = 2.13
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
import 'package:connectivity_plus/src/connectivity_plus_web.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash_web.dart';
|
||||
import 'package:flutter_secure_storage_web/flutter_secure_storage_web.dart';
|
||||
import 'package:package_info_plus/src/package_info_plus_web.dart';
|
||||
import 'package:share_plus/src/share_plus_web.dart';
|
||||
import 'package:speech_to_text/speech_to_text_web.dart';
|
||||
import 'package:url_launcher_web/url_launcher_web.dart';
|
||||
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
||||
|
||||
void registerPlugins([final Registrar? pluginRegistrar]) {
|
||||
final Registrar registrar = pluginRegistrar ?? webPluginRegistrar;
|
||||
ConnectivityPlusWebPlugin.registerWith(registrar);
|
||||
FlutterNativeSplashWeb.registerWith(registrar);
|
||||
FlutterSecureStorageWeb.registerWith(registrar);
|
||||
PackageInfoPlusWebPlugin.registerWith(registrar);
|
||||
SharePlusWebPlugin.registerWith(registrar);
|
||||
SpeechToTextPlugin.registerWith(registrar);
|
||||
UrlLauncherPlugin.registerWith(registrar);
|
||||
registrar.registerMessageHandler();
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1 @@
|
||||
D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\dart_build_result.json: C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json D:\\YandexDisk\\voidea\\flutter\\pubspec.yaml d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json
|
||||
@@ -0,0 +1 @@
|
||||
{"inputs":["C:\\Users\\angel\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","D:\\YandexDisk\\voidea\\flutter\\pubspec.yaml","d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\dart_build_result.json","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\dart_build_result.json"]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"build_start":"2026-05-15T12:29:51.171303","build_end":"2026-05-15T12:30:01.542917","dependencies":["file:///C:/Users/angel/flutter/bin/cache/dart-sdk/version","file:///D:/YandexDisk/voidea/flutter/.dart_tool/package_config.json","file:///D:/YandexDisk/voidea/flutter/pubspec.yaml","file:///d:/yandexdisk/voidea/flutter/.dart_tool/package_config.json"],"code_assets":[],"data_assets":[]}
|
||||
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
{"inputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\dart_plugin_registrant.dart"]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"inputs":[],"outputs":[]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\native_assets.json:
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"inputs":["C:\\Users\\angel\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\native_assets.json","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\native_assets.json"]}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"format-version":[1,0,0],"native-assets":{}}
|
||||
@@ -0,0 +1 @@
|
||||
["D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\vm_snapshot_data","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\isolate_snapshot_data","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\kernel_blob.bin","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\fonts\\MaterialIcons-Regular.otf","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\shaders\\ink_sparkle.frag","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\shaders\\stretch_effect.frag","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\AssetManifest.bin","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\FontManifest.json","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\NOTICES.Z","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\NativeAssetsManifest.json"]
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1 @@
|
||||
D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\dart_build_result.json: C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json D:\\YandexDisk\\voidea\\flutter\\pubspec.yaml d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json
|
||||
@@ -0,0 +1 @@
|
||||
{"inputs":["C:\\Users\\angel\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","D:\\YandexDisk\\voidea\\flutter\\pubspec.yaml","d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\dart_build_result.json","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\dart_build_result.json"]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"build_start":"2026-05-15T12:32:59.437301","build_end":"2026-05-15T12:33:16.643706","dependencies":["file:///C:/Users/angel/flutter/bin/cache/dart-sdk/version","file:///D:/YandexDisk/voidea/flutter/.dart_tool/package_config.json","file:///D:/YandexDisk/voidea/flutter/pubspec.yaml","file:///d:/yandexdisk/voidea/flutter/.dart_tool/package_config.json"],"code_assets":[],"data_assets":[]}
|
||||
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
{"inputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\dart_plugin_registrant.dart"]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"inputs":[],"outputs":[]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\native_assets.json:
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"inputs":["C:\\Users\\angel\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\native_assets.json","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\native_assets.json"]}
|
||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"format-version":[1,0,0],"native-assets":{}}
|
||||
@@ -0,0 +1 @@
|
||||
["D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\vm_snapshot_data","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\isolate_snapshot_data","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\kernel_blob.bin","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\fonts\\MaterialIcons-Regular.otf","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\shaders\\ink_sparkle.frag","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\shaders\\stretch_effect.frag","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\AssetManifest.bin","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\FontManifest.json","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\NOTICES.Z","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\NativeAssetsManifest.json"]
|
||||
@@ -0,0 +1,262 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
// This file is generated from template in file `flutter_tools/lib/src/flutter_plugins.dart`.
|
||||
//
|
||||
|
||||
// @dart = 3.4
|
||||
|
||||
import 'dart:io'; // flutter_ignore: dart_io_import.
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart' as flutter_local_notifications;
|
||||
import 'package:path_provider_android/path_provider_android.dart' as path_provider_android;
|
||||
import 'package:sqflite_android/sqflite_android.dart' as sqflite_android;
|
||||
import 'package:url_launcher_android/url_launcher_android.dart' as url_launcher_android;
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart' as flutter_local_notifications;
|
||||
import 'package:path_provider_foundation/path_provider_foundation.dart' as path_provider_foundation;
|
||||
import 'package:sqflite_darwin/sqflite_darwin.dart' as sqflite_darwin;
|
||||
import 'package:url_launcher_ios/url_launcher_ios.dart' as url_launcher_ios;
|
||||
import 'package:connectivity_plus/connectivity_plus.dart' as connectivity_plus;
|
||||
import 'package:flutter_local_notifications_linux/flutter_local_notifications_linux.dart' as flutter_local_notifications_linux;
|
||||
import 'package:package_info_plus/package_info_plus.dart' as package_info_plus;
|
||||
import 'package:path_provider_linux/path_provider_linux.dart' as path_provider_linux;
|
||||
import 'package:share_plus/share_plus.dart' as share_plus;
|
||||
import 'package:url_launcher_linux/url_launcher_linux.dart' as url_launcher_linux;
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart' as flutter_local_notifications;
|
||||
import 'package:path_provider_foundation/path_provider_foundation.dart' as path_provider_foundation;
|
||||
import 'package:sqflite_darwin/sqflite_darwin.dart' as sqflite_darwin;
|
||||
import 'package:url_launcher_macos/url_launcher_macos.dart' as url_launcher_macos;
|
||||
import 'package:flutter_secure_storage_windows/flutter_secure_storage_windows.dart' as flutter_secure_storage_windows;
|
||||
import 'package:package_info_plus/package_info_plus.dart' as package_info_plus;
|
||||
import 'package:path_provider_windows/path_provider_windows.dart' as path_provider_windows;
|
||||
import 'package:share_plus/share_plus.dart' as share_plus;
|
||||
import 'package:speech_to_text_windows/speech_to_text_windows.dart' as speech_to_text_windows;
|
||||
import 'package:url_launcher_windows/url_launcher_windows.dart' as url_launcher_windows;
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
class _PluginRegistrant {
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
static void register() {
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
flutter_local_notifications.AndroidFlutterLocalNotificationsPlugin.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`flutter_local_notifications` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
path_provider_android.PathProviderAndroid.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`path_provider_android` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
sqflite_android.SqfliteAndroid.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`sqflite_android` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
url_launcher_android.UrlLauncherAndroid.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`url_launcher_android` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
} else if (Platform.isIOS) {
|
||||
try {
|
||||
flutter_local_notifications.IOSFlutterLocalNotificationsPlugin.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`flutter_local_notifications` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
path_provider_foundation.PathProviderFoundation.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`path_provider_foundation` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
sqflite_darwin.SqfliteDarwin.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`sqflite_darwin` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
url_launcher_ios.UrlLauncherIOS.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`url_launcher_ios` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
} else if (Platform.isLinux) {
|
||||
try {
|
||||
connectivity_plus.ConnectivityPlusLinuxPlugin.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`connectivity_plus` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
flutter_local_notifications_linux.LinuxFlutterLocalNotificationsPlugin.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`flutter_local_notifications_linux` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
package_info_plus.PackageInfoPlusLinuxPlugin.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`package_info_plus` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
path_provider_linux.PathProviderLinux.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`path_provider_linux` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
share_plus.SharePlusLinuxPlugin.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`share_plus` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
url_launcher_linux.UrlLauncherLinux.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`url_launcher_linux` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
} else if (Platform.isMacOS) {
|
||||
try {
|
||||
flutter_local_notifications.MacOSFlutterLocalNotificationsPlugin.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`flutter_local_notifications` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
path_provider_foundation.PathProviderFoundation.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`path_provider_foundation` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
sqflite_darwin.SqfliteDarwin.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`sqflite_darwin` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
url_launcher_macos.UrlLauncherMacOS.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`url_launcher_macos` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
} else if (Platform.isWindows) {
|
||||
try {
|
||||
flutter_secure_storage_windows.FlutterSecureStorageWindows.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`flutter_secure_storage_windows` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
package_info_plus.PackageInfoPlusWindowsPlugin.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`package_info_plus` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
path_provider_windows.PathProviderWindows.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`path_provider_windows` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
share_plus.SharePlusWindowsPlugin.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`share_plus` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
speech_to_text_windows.SpeechToTextWindows.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`speech_to_text_windows` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
url_launcher_windows.UrlLauncherWindows.registerWith();
|
||||
} catch (err) {
|
||||
print(
|
||||
'`url_launcher_windows` threw an error: $err. '
|
||||
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Last acquired by C:\Users\angel\flutter\bin\cache\dart-sdk\bin\dart.exe (pid 48536) running file:///C:/Users/angel/flutter/bin/cache/flutter_tools.snapshot on 2026-05-15 12:33:08.791541.
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"file_system":[],"environment":[{"key":"PATH","hash":5744524113912922215},{"key":"PROGRAMDATA","hash":-3479985355006727299},{"key":"SYSTEMDRIVE","hash":2892017696301558487},{"key":"SYSTEMROOT","hash":2713241238173206065},{"key":"TEMP","hash":-5046929535283615468},{"key":"TMP","hash":-5046929535283615468},{"key":"USERPROFILE","hash":-6023473899643495512},{"key":"WINDIR","hash":2713241238173206065}]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"file_system":[{"path":"d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json","hash":4956111781633966257},{"path":"D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","hash":4956111781633966257},{"path":"C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version","hash":-1483076285095613998}],"environment":[]}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"assets": {},
|
||||
"config": {
|
||||
"build_asset_types": [
|
||||
"code_assets/code"
|
||||
],
|
||||
"extensions": {
|
||||
"code_assets": {
|
||||
"android": {
|
||||
"target_ndk_api": 24
|
||||
},
|
||||
"c_compiler": {
|
||||
"ar": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\llvm-ar.exe",
|
||||
"cc": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\clang.exe",
|
||||
"ld": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\ld.lld.exe"
|
||||
},
|
||||
"link_mode_preference": "dynamic",
|
||||
"target_architecture": "arm64",
|
||||
"target_os": "android"
|
||||
}
|
||||
},
|
||||
"linking_enabled": false
|
||||
},
|
||||
"out_dir_shared": "D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\hooks_runner\\shared\\objective_c\\build\\",
|
||||
"out_file": "D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\hooks_runner\\objective_c\\193e397ea4\\output.json",
|
||||
"package_name": "objective_c",
|
||||
"package_root": "C:\\Users\\angel\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\objective_c-9.3.0\\",
|
||||
"user_defines": {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"assets_for_linking": {},
|
||||
"status": "success",
|
||||
"timestamp": "2026-05-15 12:30:00.000"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Running `(cd C:\Users\angel\AppData\Local\Pub\Cache\hosted\pub.dev\objective_c-9.3.0\; PATH=C:\Program Files\Android\Android Studio\jbr\bin;C:\Python\Python38-32\Scripts\;C:\Python\Python38-32\;C:\ProgramData\Oracle\Java\javapath;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Program Files (x86)\LonWorks\bin;C:\Users\angel\AppData\Local\Microsoft\WindowsApps;C:\adb;D:\BatchApkTool;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Program Files (x86)\Microsoft SQL Server\110\Tools\Binn\;C:\Program Files (x86)\Microsoft SQL Server\110\DTS\Binn\;C:\Program Files (x86)\Windows Kits\8.1\Windows Performance Toolkit\;C:\Program Files\Intel\WiFi\bin\;C:\Program Files\Common Files\Intel\WirelessCommon\;C:\Program Files (x86)\Microsoft SQL Server\150\DTS\Binn\;C:\Program Files\Git\cmd;C:\Program Files\nodejs\;C:\Users\angel\AppData\Local\Microsoft\WindowsApps;C:\Program Files\Intel\WiFi\bin\;C:\Program Files\Common Files\Intel\WirelessCommon\;C:\Users\angel\AppData\Roaming\npm;C:\Users\angel\AppData\Local\Python\bin;C:\Users\angel\AppData\Local\Programs\Microsoft VS Code\bin;C:\Users\angel\flutter\bin PROGRAMDATA=C:\ProgramData SYSTEMDRIVE=C: SYSTEMROOT=C:\WINDOWS TEMP=C:\Users\angel\AppData\Local\Temp TMP=C:\Users\angel\AppData\Local\Temp USERPROFILE=C:\Users\angel WINDIR=C:\WINDOWS C:\Users\angel\flutter\bin\cache\dart-sdk\bin\dart --packages=D:\YandexDisk\voidea\flutter\.dart_tool\package_config.json D:\YandexDisk\voidea\flutter\.dart_tool\hooks_runner\objective_c\193e397ea4\hook.dill --config=D:\YandexDisk\voidea\flutter\.dart_tool\hooks_runner\objective_c\193e397ea4\input.json )`.
|
||||
@@ -0,0 +1 @@
|
||||
Last acquired by C:\Users\angel\flutter\bin\cache\dart-sdk\bin\dart.exe (pid 48536) running file:///C:/Users/angel/flutter/bin/cache/flutter_tools.snapshot on 2026-05-15 12:32:59.667160.
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"file_system":[],"environment":[{"key":"PATH","hash":5744524113912922215},{"key":"PROGRAMDATA","hash":-3479985355006727299},{"key":"SYSTEMDRIVE","hash":2892017696301558487},{"key":"SYSTEMROOT","hash":2713241238173206065},{"key":"TEMP","hash":-5046929535283615468},{"key":"TMP","hash":-5046929535283615468},{"key":"USERPROFILE","hash":-6023473899643495512},{"key":"WINDIR","hash":2713241238173206065}]}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"file_system":[{"path":"d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json","hash":4956111781633966257},{"path":"D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","hash":4956111781633966257},{"path":"C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version","hash":-1483076285095613998}],"environment":[]}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"assets": {},
|
||||
"config": {
|
||||
"build_asset_types": [
|
||||
"code_assets/code"
|
||||
],
|
||||
"extensions": {
|
||||
"code_assets": {
|
||||
"android": {
|
||||
"target_ndk_api": 24
|
||||
},
|
||||
"c_compiler": {
|
||||
"ar": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\llvm-ar.exe",
|
||||
"cc": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\clang.exe",
|
||||
"ld": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\ld.lld.exe"
|
||||
},
|
||||
"link_mode_preference": "dynamic",
|
||||
"target_architecture": "arm",
|
||||
"target_os": "android"
|
||||
}
|
||||
},
|
||||
"linking_enabled": false
|
||||
},
|
||||
"out_dir_shared": "D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\hooks_runner\\shared\\objective_c\\build\\",
|
||||
"out_file": "D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\hooks_runner\\objective_c\\4d03ae159a\\output.json",
|
||||
"package_name": "objective_c",
|
||||
"package_root": "C:\\Users\\angel\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\objective_c-9.3.0\\",
|
||||
"user_defines": {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user