openhands openhands commited on
Commit ·
2299bb4
1
Parent(s): 39d39f4
feat: Add FastAPI backend + React frontend
Browse files- FastAPI app.py with CORS and API routes
- Backend code (models, routes, services, system)
- React frontend (built static files)
- Requirements.txt for dependencies
Co-authored-by: openhands <openhands@all-hands.dev>
- README.md +5 -3
- app.py +88 -0
- backend/app/config.py +51 -0
- backend/app/dependencies.py +49 -0
- backend/app/main.py +83 -0
- backend/app/models/__init__.py +10 -0
- backend/app/models/friend.py +40 -0
- backend/app/models/group.py +58 -0
- backend/app/models/message.py +49 -0
- backend/app/models/reaction.py +26 -0
- backend/app/models/user.py +39 -0
- backend/app/routes/__init__.py +10 -0
- backend/app/routes/auth.py +185 -0
- backend/app/routes/chat.py +255 -0
- backend/app/routes/group.py +399 -0
- backend/app/routes/system.py +119 -0
- backend/app/routes/user.py +183 -0
- backend/app/services/__init__.py +10 -0
- backend/app/services/auth_service.py +70 -0
- backend/app/services/chat_service.py +115 -0
- backend/app/services/file_service.py +137 -0
- backend/app/services/group_service.py +143 -0
- backend/app/services/user_service.py +148 -0
- backend/app/sockets/ws.py +89 -0
- backend/app/system/__init__.py +18 -0
- backend/app/system/batch.py +142 -0
- backend/app/system/controller.py +78 -0
- backend/app/system/monitor.py +74 -0
- backend/app/system/queue.py +93 -0
- backend/app/system/scheduler.py +88 -0
- backend/app/system/worker.py +100 -0
- backend/app/utils/__init__.py +16 -0
- backend/app/utils/avatar.py +54 -0
- backend/app/utils/compression.py +85 -0
- backend/app/utils/formatter.py +57 -0
- backend/app/utils/security.py +57 -0
- backend/app/utils/time.py +79 -0
- backend/requirements.txt +41 -0
- {assets → frontend}/index-CQxv4P1h.css +0 -0
- {assets → frontend}/index-D-0NzOas.js +0 -0
- requirements.txt +12 -0
README.md
CHANGED
|
@@ -3,11 +3,13 @@ title: Chat App
|
|
| 3 |
emoji: 💬
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
-
sdk:
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
pinned: false
|
| 9 |
license: mit
|
| 10 |
-
short_description: 'Modern chat application with
|
| 11 |
---
|
| 12 |
|
| 13 |
# Chat-App 💬
|
|
|
|
| 3 |
emoji: 💬
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: indigo
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.11.0
|
| 8 |
+
python_version: "3.11"
|
| 9 |
+
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
license: mit
|
| 12 |
+
short_description: 'Modern chat application with FastAPI + React'
|
| 13 |
---
|
| 14 |
|
| 15 |
# Chat-App 💬
|
app.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Chat App - FastAPI + React (HuggingFace Space)
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI, Request
|
| 4 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 5 |
+
from fastapi.staticfiles import StaticFiles
|
| 6 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
import uvicorn
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
app = FastAPI(title="Chat App", version="1.0.0")
|
| 11 |
+
|
| 12 |
+
# Enable CORS
|
| 13 |
+
app.add_middleware(
|
| 14 |
+
CORSMiddleware,
|
| 15 |
+
allow_origins=["*"],
|
| 16 |
+
allow_credentials=True,
|
| 17 |
+
allow_methods=["*"],
|
| 18 |
+
allow_headers=["*"],
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# Get the directory where this file is located
|
| 22 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 23 |
+
|
| 24 |
+
# Mount frontend static files
|
| 25 |
+
frontend_path = os.path.join(BASE_DIR, "frontend")
|
| 26 |
+
if os.path.exists(frontend_path):
|
| 27 |
+
app.mount("/frontend", StaticFiles(directory=frontend_path), name="frontend")
|
| 28 |
+
|
| 29 |
+
# API Routes
|
| 30 |
+
@app.get("/")
|
| 31 |
+
async def root(request: Request):
|
| 32 |
+
"""Serve the frontend index.html"""
|
| 33 |
+
index_path = os.path.join(BASE_DIR, "index.html")
|
| 34 |
+
if os.path.exists(index_path):
|
| 35 |
+
with open(index_path, "r") as f:
|
| 36 |
+
content = f.read()
|
| 37 |
+
return HTMLResponse(content=content)
|
| 38 |
+
return {"message": "Chat App API", "status": "running"}
|
| 39 |
+
|
| 40 |
+
@app.get("/api/health")
|
| 41 |
+
async def health():
|
| 42 |
+
"""Health check endpoint"""
|
| 43 |
+
return {"status": "healthy", "service": "chat-app"}
|
| 44 |
+
|
| 45 |
+
@app.get("/api/v1")
|
| 46 |
+
async def api_info():
|
| 47 |
+
"""API info endpoint"""
|
| 48 |
+
return {
|
| 49 |
+
"name": "Chat App API",
|
| 50 |
+
"version": "1.0.0",
|
| 51 |
+
"endpoints": {
|
| 52 |
+
"auth": "/api/v1/auth",
|
| 53 |
+
"users": "/api/v1/users",
|
| 54 |
+
"chat": "/api/v1/chat",
|
| 55 |
+
"groups": "/api/v1/groups"
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
# Auth endpoints
|
| 60 |
+
@app.post("/api/v1/auth/register")
|
| 61 |
+
async def register(data: dict):
|
| 62 |
+
return {"message": "Register endpoint - requires database"}
|
| 63 |
+
|
| 64 |
+
@app.post("/api/v1/auth/login")
|
| 65 |
+
async def login(data: dict):
|
| 66 |
+
return {"message": "Login endpoint - requires database"}
|
| 67 |
+
|
| 68 |
+
# Chat endpoints
|
| 69 |
+
@app.get("/api/v1/chat/conversations")
|
| 70 |
+
async def get_conversations():
|
| 71 |
+
return {"conversations": [], "message": "Requires database setup"}
|
| 72 |
+
|
| 73 |
+
@app.post("/api/v1/chat/send")
|
| 74 |
+
async def send_message(data: dict):
|
| 75 |
+
return {"message": "Send message - requires database"}
|
| 76 |
+
|
| 77 |
+
# Group endpoints
|
| 78 |
+
@app.get("/api/v1/groups")
|
| 79 |
+
async def get_groups():
|
| 80 |
+
return {"groups": [], "message": "Requires database setup"}
|
| 81 |
+
|
| 82 |
+
@app.post("/api/v1/groups/create")
|
| 83 |
+
async def create_group(data: dict):
|
| 84 |
+
return {"message": "Create group - requires database"}
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
port = int(os.environ.get("PORT", 7860))
|
| 88 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
backend/app/config.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration module - loads environment variables and secrets.
|
| 3 |
+
DO NOT commit actual secrets to version control.
|
| 4 |
+
"""
|
| 5 |
+
from pydantic_settings import BaseSettings
|
| 6 |
+
from typing import Optional
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Settings(BaseSettings):
|
| 11 |
+
"""Application settings loaded from environment variables."""
|
| 12 |
+
|
| 13 |
+
# App
|
| 14 |
+
APP_NAME: str = "ChatApp"
|
| 15 |
+
APP_VERSION: str = "1.0.0"
|
| 16 |
+
DEBUG: bool = False
|
| 17 |
+
SECRET_KEY: str = "change-me-in-production" # TODO: Set via env
|
| 18 |
+
|
| 19 |
+
# Database
|
| 20 |
+
DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/chatapp"
|
| 21 |
+
|
| 22 |
+
# JWT
|
| 23 |
+
JWT_SECRET: str = "jwt-secret-change-me"
|
| 24 |
+
JWT_ALGORITHM: str = "HS256"
|
| 25 |
+
JWT_EXPIRATION_MINUTES: int = 60 * 24 * 7 # 7 days
|
| 26 |
+
|
| 27 |
+
# Redis (for async queue)
|
| 28 |
+
REDIS_URL: str = "redis://localhost:6379/0"
|
| 29 |
+
|
| 30 |
+
# GitHub (for storage backup)
|
| 31 |
+
GITHUB_TOKEN: Optional[str] = None
|
| 32 |
+
GITHUB_REPO: Optional[str] = None # e.g., "username/chat-storage"
|
| 33 |
+
|
| 34 |
+
# HuggingFace (optional)
|
| 35 |
+
HF_TOKEN: Optional[str] = None
|
| 36 |
+
HF_SPACE_ID: Optional[str] = None
|
| 37 |
+
|
| 38 |
+
# Compression settings
|
| 39 |
+
COMPRESSION_ENABLED: bool = True
|
| 40 |
+
MAX_BATCH_SIZE_MB: int = 10
|
| 41 |
+
BATCH_INTERVAL_MINUTES: int = 60
|
| 42 |
+
|
| 43 |
+
# Storage
|
| 44 |
+
STORAGE_PATH: str = "./storage/data"
|
| 45 |
+
|
| 46 |
+
class Config:
|
| 47 |
+
env_file = ".env"
|
| 48 |
+
case_sensitive = True
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
settings = Settings()
|
backend/app/dependencies.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dependencies - shared FastAPI dependencies (auth, database, etc).
|
| 3 |
+
"""
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from fastapi import Depends, HTTPException, status
|
| 6 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
from sqlalchemy import create_async_engine, select
|
| 9 |
+
from sqlalchemy.orm import sessionmaker, declarative_base
|
| 10 |
+
|
| 11 |
+
from .config import settings
|
| 12 |
+
|
| 13 |
+
# Security scheme
|
| 14 |
+
security = HTTPBearer()
|
| 15 |
+
|
| 16 |
+
# Database setup
|
| 17 |
+
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
|
| 18 |
+
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
| 19 |
+
|
| 20 |
+
Base = declarative_base()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
async def get_db() -> AsyncSession:
|
| 24 |
+
"""Database session dependency."""
|
| 25 |
+
async with async_session() as session:
|
| 26 |
+
yield session
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
async def get_current_user(
|
| 30 |
+
credentials: HTTPAuthorizationCredentials = Depends(security)
|
| 31 |
+
) -> dict:
|
| 32 |
+
"""
|
| 33 |
+
Validate JWT token and return current user.
|
| 34 |
+
In production, decode JWT and fetch user from DB.
|
| 35 |
+
"""
|
| 36 |
+
# TODO: Implement actual JWT validation
|
| 37 |
+
# For now, return a placeholder user
|
| 38 |
+
token = credentials.credentials
|
| 39 |
+
# This would decode the token and verify it
|
| 40 |
+
return {"id": "user_placeholder", "username": "test_user"}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def get_optional_user(
|
| 44 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
| 45 |
+
) -> Optional[dict]:
|
| 46 |
+
"""Optional auth - returns None if not authenticated."""
|
| 47 |
+
if not credentials:
|
| 48 |
+
return None
|
| 49 |
+
return await get_current_user(credentials)
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main FastAPI application entry point.
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import FastAPI, WebSocket
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
|
| 8 |
+
from .config import settings
|
| 9 |
+
from .routes import auth_router, user_router, chat_router, group_router, system_router
|
| 10 |
+
from .sockets.ws import websocket_endpoint
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@asynccontextmanager
|
| 14 |
+
async def lifespan(app: FastAPI):
|
| 15 |
+
"""Application lifespan - startup and shutdown."""
|
| 16 |
+
# Startup
|
| 17 |
+
print(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
|
| 18 |
+
|
| 19 |
+
# TODO: Start worker and scheduler
|
| 20 |
+
# from .system import worker, batch_scheduler
|
| 21 |
+
# await worker.start()
|
| 22 |
+
# await batch_scheduler.start()
|
| 23 |
+
|
| 24 |
+
yield
|
| 25 |
+
|
| 26 |
+
# Shutdown
|
| 27 |
+
print("Shutting down...")
|
| 28 |
+
# await worker.stop()
|
| 29 |
+
# await batch_scheduler.stop()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# Create FastAPI app
|
| 33 |
+
app = FastAPI(
|
| 34 |
+
title=settings.APP_NAME,
|
| 35 |
+
version=settings.APP_VERSION,
|
| 36 |
+
description="Chat application with async message processing",
|
| 37 |
+
lifespan=lifespan
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# CORS middleware
|
| 41 |
+
app.add_middleware(
|
| 42 |
+
CORSMiddleware,
|
| 43 |
+
allow_origins=["*"], # Configure appropriately for production
|
| 44 |
+
allow_credentials=True,
|
| 45 |
+
allow_methods=["*"],
|
| 46 |
+
allow_headers=["*"],
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# Include routers
|
| 51 |
+
app.include_router(auth_router, prefix="/api")
|
| 52 |
+
app.include_router(user_router, prefix="/api")
|
| 53 |
+
app.include_router(chat_router, prefix="/api")
|
| 54 |
+
app.include_router(group_router, prefix="/api")
|
| 55 |
+
app.include_router(system_router, prefix="/api")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# WebSocket endpoint
|
| 59 |
+
@app.websocket("/ws/{user_id}")
|
| 60 |
+
async def websocket_route(websocket: WebSocket, user_id: str):
|
| 61 |
+
"""WebSocket endpoint for real-time notifications."""
|
| 62 |
+
await websocket_endpoint(websocket, user_id)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# Root endpoint
|
| 66 |
+
@app.get("/")
|
| 67 |
+
async def root():
|
| 68 |
+
"""Root endpoint."""
|
| 69 |
+
return {
|
| 70 |
+
"name": settings.APP_NAME,
|
| 71 |
+
"version": settings.APP_VERSION,
|
| 72 |
+
"status": "running"
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@app.get("/api/health")
|
| 77 |
+
async def health():
|
| 78 |
+
"""Health check endpoint."""
|
| 79 |
+
return {"status": "healthy"}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# Import all routes to ensure they're registered
|
| 83 |
+
__all__ = ["app"]
|
backend/app/models/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Models package - all database models.
|
| 3 |
+
"""
|
| 4 |
+
from .user import User
|
| 5 |
+
from .message import Message
|
| 6 |
+
from .group import Group, GroupMember
|
| 7 |
+
from .friend import Friend
|
| 8 |
+
from .reaction import Reaction
|
| 9 |
+
|
| 10 |
+
__all__ = ["User", "Message", "Group", "GroupMember", "Friend", "Reaction"]
|
backend/app/models/friend.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Friend model - represents friend relationships between users.
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy import Column, String, DateTime, ForeignKey, Boolean, Enum as SQLEnum
|
| 5 |
+
from sqlalchemy.sql import func
|
| 6 |
+
from ..dependencies import Base
|
| 7 |
+
import uuid
|
| 8 |
+
import enum
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class FriendStatus(enum.Enum):
|
| 12 |
+
"""Friend request status."""
|
| 13 |
+
PENDING = "pending"
|
| 14 |
+
ACCEPTED = "accepted"
|
| 15 |
+
BLOCKED = "blocked"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Friend(Base):
|
| 19 |
+
"""Friend relationship model."""
|
| 20 |
+
|
| 21 |
+
__tablename__ = "friends"
|
| 22 |
+
|
| 23 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 24 |
+
|
| 25 |
+
# User who initiated the friend request
|
| 26 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
|
| 27 |
+
|
| 28 |
+
# User who received the request
|
| 29 |
+
friend_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
|
| 30 |
+
|
| 31 |
+
# Status: pending, accepted, blocked
|
| 32 |
+
status = Column(String(20), default="pending")
|
| 33 |
+
|
| 34 |
+
# Timestamps
|
| 35 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 36 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
| 37 |
+
accepted_at = Column(DateTime(timezone=True), nullable=True)
|
| 38 |
+
|
| 39 |
+
def __repr__(self):
|
| 40 |
+
return f"<Friend {self.user_id} <-> {self.friend_id}>"
|
backend/app/models/group.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Group model - represents chat groups.
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean, Integer
|
| 5 |
+
from sqlalchemy.sql import func
|
| 6 |
+
from ..dependencies import Base
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Group(Base):
|
| 11 |
+
"""Chat group model."""
|
| 12 |
+
|
| 13 |
+
__tablename__ = "groups"
|
| 14 |
+
|
| 15 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 16 |
+
name = Column(String(100), nullable=False)
|
| 17 |
+
description = Column(Text, nullable=True)
|
| 18 |
+
avatar = Column(Text, nullable=True)
|
| 19 |
+
|
| 20 |
+
# Admin (owner)
|
| 21 |
+
owner_id = Column(String, ForeignKey("users.id"), nullable=False)
|
| 22 |
+
|
| 23 |
+
# Settings
|
| 24 |
+
is_private = Column(Boolean, default=False)
|
| 25 |
+
max_members = Column(Integer, default=100)
|
| 26 |
+
|
| 27 |
+
# Timestamps
|
| 28 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 29 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
| 30 |
+
|
| 31 |
+
def __repr__(self):
|
| 32 |
+
return f"<Group {self.name}>"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class GroupMember(Base):
|
| 36 |
+
"""Group member with roles."""
|
| 37 |
+
|
| 38 |
+
__tablename__ = "group_members"
|
| 39 |
+
|
| 40 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 41 |
+
group_id = Column(String, ForeignKey("groups.id"), nullable=False, index=True)
|
| 42 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
|
| 43 |
+
|
| 44 |
+
# Role: 'admin', 'co_admin', 'member'
|
| 45 |
+
role = Column(String(20), default="member")
|
| 46 |
+
|
| 47 |
+
# Custom nickname in group
|
| 48 |
+
nickname = Column(String(50), nullable=True)
|
| 49 |
+
|
| 50 |
+
# Mute notifications?
|
| 51 |
+
is_muted = Column(Boolean, default=False)
|
| 52 |
+
is_banned = Column(Boolean, default=False)
|
| 53 |
+
|
| 54 |
+
# Timestamps
|
| 55 |
+
joined_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 56 |
+
|
| 57 |
+
def __repr__(self):
|
| 58 |
+
return f"<GroupMember {self.user_id} in {self.group_id}>"
|
backend/app/models/message.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Message model - represents chat messages.
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean, Integer
|
| 5 |
+
from sqlalchemy.sql import func
|
| 6 |
+
from sqlalchemy.orm import relationship
|
| 7 |
+
from ..dependencies import Base
|
| 8 |
+
import uuid
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Message(Base):
|
| 12 |
+
"""Chat message model."""
|
| 13 |
+
|
| 14 |
+
__tablename__ = "messages"
|
| 15 |
+
|
| 16 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 17 |
+
sender_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
|
| 18 |
+
|
| 19 |
+
# Message type: 'text', 'image', 'file', 'system'
|
| 20 |
+
type = Column(String(20), default="text")
|
| 21 |
+
|
| 22 |
+
# Content (text or file URL)
|
| 23 |
+
content = Column(Text, nullable=False)
|
| 24 |
+
|
| 25 |
+
# For private chat (user-to-user)
|
| 26 |
+
receiver_id = Column(String, ForeignKey("users.id"), nullable=True, index=True)
|
| 27 |
+
|
| 28 |
+
# For group chat
|
| 29 |
+
group_id = Column(String, ForeignKey("groups.id"), nullable=True, index=True)
|
| 30 |
+
|
| 31 |
+
# Message status
|
| 32 |
+
is_deleted = Column(Boolean, default=False)
|
| 33 |
+
is_archived = Column(Boolean, default=False)
|
| 34 |
+
|
| 35 |
+
# Batch processing info
|
| 36 |
+
batch_id = Column(String, nullable=True, index=True)
|
| 37 |
+
compressed = Column(Boolean, default=False)
|
| 38 |
+
|
| 39 |
+
# Timestamps
|
| 40 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 41 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
| 42 |
+
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
| 43 |
+
|
| 44 |
+
# Relationships
|
| 45 |
+
# sender = relationship("User", back_populates="messages")
|
| 46 |
+
# reactions = relationship("Reaction", back_populates="message")
|
| 47 |
+
|
| 48 |
+
def __repr__(self):
|
| 49 |
+
return f"<Message {self.id[:8]} from {self.sender_id[:8]}>"
|
backend/app/models/reaction.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reaction model - emoji/like reactions on messages.
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy import Column, String, DateTime, ForeignKey
|
| 5 |
+
from sqlalchemy.sql import func
|
| 6 |
+
from ..dependencies import Base
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Reaction(Base):
|
| 11 |
+
"""Message reaction (emoji) model."""
|
| 12 |
+
|
| 13 |
+
__tablename__ = "reactions"
|
| 14 |
+
|
| 15 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 16 |
+
message_id = Column(String, ForeignKey("messages.id"), nullable=False, index=True)
|
| 17 |
+
user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
|
| 18 |
+
|
| 19 |
+
# Emoji: '👍', '❤️', '😂', '😢', '😮', '😡', etc.
|
| 20 |
+
emoji = Column(String(10), nullable=False)
|
| 21 |
+
|
| 22 |
+
# Timestamps
|
| 23 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 24 |
+
|
| 25 |
+
def __repr__(self):
|
| 26 |
+
return f"<Reaction {self.emoji} on {self.message_id[:8]}>"
|
backend/app/models/user.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User model - represents a user in the chat system.
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy import Column, String, DateTime, Boolean, Text
|
| 5 |
+
from sqlalchemy.sql import func
|
| 6 |
+
from ..dependencies import Base
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class User(Base):
|
| 11 |
+
"""User account model."""
|
| 12 |
+
|
| 13 |
+
__tablename__ = "users"
|
| 14 |
+
|
| 15 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
| 16 |
+
username = Column(String(50), unique=True, nullable=False, index=True)
|
| 17 |
+
email = Column(String(255), unique=True, nullable=False, index=True)
|
| 18 |
+
password_hash = Column(String(255), nullable=False)
|
| 19 |
+
|
| 20 |
+
# Profile
|
| 21 |
+
display_name = Column(String(100), nullable=True)
|
| 22 |
+
avatar = Column(Text, nullable=True) # URL or base64
|
| 23 |
+
bio = Column(Text, nullable=True)
|
| 24 |
+
status = Column(String(20), default="offline") # online, offline, away
|
| 25 |
+
|
| 26 |
+
# Settings
|
| 27 |
+
is_public = Column(Boolean, default=True) # Public profile?
|
| 28 |
+
allow_friends = Column(Boolean, default=True)
|
| 29 |
+
|
| 30 |
+
# Timestamps
|
| 31 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 32 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
| 33 |
+
last_seen = Column(DateTime(timezone=True), nullable=True)
|
| 34 |
+
|
| 35 |
+
# Blocked users (relationship defined elsewhere)
|
| 36 |
+
# blocked_users = relationship("BlockedUser", back_populates="user")
|
| 37 |
+
|
| 38 |
+
def __repr__(self):
|
| 39 |
+
return f"<User {self.username}>"
|
backend/app/routes/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Routes package - API endpoints.
|
| 3 |
+
"""
|
| 4 |
+
from .auth import router as auth_router
|
| 5 |
+
from .user import router as user_router
|
| 6 |
+
from .chat import router as chat_router
|
| 7 |
+
from .group import router as group_router
|
| 8 |
+
from .system import router as system_router
|
| 9 |
+
|
| 10 |
+
__all__ = ["auth_router", "user_router", "chat_router", "group_router", "system_router"]
|
backend/app/routes/auth.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication routes - register, login, logout, token refresh.
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
+
from fastapi.security import HTTPBearer
|
| 6 |
+
from pydantic import BaseModel, EmailStr
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
from sqlalchemy import select
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
from ..dependencies import get_db, get_current_user
|
| 12 |
+
from ..models import User
|
| 13 |
+
from ..utils.security import hash_password, verify_password, create_access_token
|
| 14 |
+
from ..utils.avatar import generate_avatar
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 17 |
+
security = HTTPBearer()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# --- Schemas ---
|
| 21 |
+
class RegisterRequest(BaseModel):
|
| 22 |
+
username: str
|
| 23 |
+
email: EmailStr
|
| 24 |
+
password: str
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class LoginRequest(BaseModel):
|
| 28 |
+
email: EmailStr
|
| 29 |
+
password: str
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class AuthResponse(BaseModel):
|
| 33 |
+
access_token: str
|
| 34 |
+
token_type: str = "bearer"
|
| 35 |
+
user: dict
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class UserResponse(BaseModel):
|
| 39 |
+
id: str
|
| 40 |
+
username: str
|
| 41 |
+
email: str
|
| 42 |
+
display_name: Optional[str] = None
|
| 43 |
+
avatar: Optional[str] = None
|
| 44 |
+
status: str = "offline"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# --- Routes ---
|
| 48 |
+
@router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
|
| 49 |
+
async def register(
|
| 50 |
+
request: RegisterRequest,
|
| 51 |
+
db: AsyncSession = Depends(get_db)
|
| 52 |
+
):
|
| 53 |
+
"""Register a new user account."""
|
| 54 |
+
# Check if email already exists
|
| 55 |
+
result = await db.execute(select(User).where(User.email == request.email))
|
| 56 |
+
if result.scalar_one_or_none():
|
| 57 |
+
raise HTTPException(
|
| 58 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 59 |
+
detail="Email already registered"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Check if username exists
|
| 63 |
+
result = await db.execute(select(User).where(User.username == request.username))
|
| 64 |
+
if result.scalar_one_or_none():
|
| 65 |
+
raise HTTPException(
|
| 66 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 67 |
+
detail="Username already taken"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Create user
|
| 71 |
+
password_hash = hash_password(request.password)
|
| 72 |
+
avatar_url = await generate_avatar(request.username, request.username)
|
| 73 |
+
|
| 74 |
+
user = User(
|
| 75 |
+
username=request.username,
|
| 76 |
+
email=request.email,
|
| 77 |
+
password_hash=password_hash,
|
| 78 |
+
display_name=request.username,
|
| 79 |
+
avatar=avatar_url,
|
| 80 |
+
status="offline"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
db.add(user)
|
| 84 |
+
await db.commit()
|
| 85 |
+
await db.refresh(user)
|
| 86 |
+
|
| 87 |
+
# Generate token
|
| 88 |
+
access_token = create_access_token({"sub": user.id, "username": user.username})
|
| 89 |
+
|
| 90 |
+
return AuthResponse(
|
| 91 |
+
access_token=access_token,
|
| 92 |
+
user={
|
| 93 |
+
"id": user.id,
|
| 94 |
+
"username": user.username,
|
| 95 |
+
"email": user.email,
|
| 96 |
+
"display_name": user.display_name,
|
| 97 |
+
"avatar": user.avatar,
|
| 98 |
+
"status": user.status
|
| 99 |
+
}
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@router.post("/login", response_model=AuthResponse)
|
| 104 |
+
async def login(
|
| 105 |
+
request: LoginRequest,
|
| 106 |
+
db: AsyncSession = Depends(get_db)
|
| 107 |
+
):
|
| 108 |
+
"""Login with email and password."""
|
| 109 |
+
# Find user
|
| 110 |
+
result = await db.execute(select(User).where(User.email == request.email))
|
| 111 |
+
user = result.scalar_one_or_none()
|
| 112 |
+
|
| 113 |
+
if not user or not verify_password(request.password, user.password_hash):
|
| 114 |
+
raise HTTPException(
|
| 115 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 116 |
+
detail="Invalid email or password"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
# Update status
|
| 120 |
+
user.status = "online"
|
| 121 |
+
await db.commit()
|
| 122 |
+
|
| 123 |
+
# Generate token
|
| 124 |
+
access_token = create_access_token({"sub": user.id, "username": user.username})
|
| 125 |
+
|
| 126 |
+
return AuthResponse(
|
| 127 |
+
access_token=access_token,
|
| 128 |
+
user={
|
| 129 |
+
"id": user.id,
|
| 130 |
+
"username": user.username,
|
| 131 |
+
"email": user.email,
|
| 132 |
+
"display_name": user.display_name,
|
| 133 |
+
"avatar": user.avatar,
|
| 134 |
+
"status": user.status
|
| 135 |
+
}
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@router.post("/logout")
|
| 140 |
+
async def logout(
|
| 141 |
+
current_user: dict = Depends(get_current_user),
|
| 142 |
+
db: AsyncSession = Depends(get_db)
|
| 143 |
+
):
|
| 144 |
+
"""Logout and update user status."""
|
| 145 |
+
result = await db.execute(select(User).where(User.id == current_user["id"]))
|
| 146 |
+
user = result.scalar_one_or_none()
|
| 147 |
+
|
| 148 |
+
if user:
|
| 149 |
+
user.status = "offline"
|
| 150 |
+
await db.commit()
|
| 151 |
+
|
| 152 |
+
return {"message": "Logged out successfully"}
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@router.get("/me", response_model=UserResponse)
|
| 156 |
+
async def get_me(
|
| 157 |
+
current_user: dict = Depends(get_current_user),
|
| 158 |
+
db: AsyncSession = Depends(get_db)
|
| 159 |
+
):
|
| 160 |
+
"""Get current authenticated user profile."""
|
| 161 |
+
result = await db.execute(select(User).where(User.id == current_user["id"]))
|
| 162 |
+
user = result.scalar_one_or_none()
|
| 163 |
+
|
| 164 |
+
if not user:
|
| 165 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 166 |
+
|
| 167 |
+
return UserResponse(
|
| 168 |
+
id=user.id,
|
| 169 |
+
username=user.username,
|
| 170 |
+
email=user.email,
|
| 171 |
+
display_name=user.display_name,
|
| 172 |
+
avatar=user.avatar,
|
| 173 |
+
status=user.status
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@router.post("/refresh")
|
| 178 |
+
async def refresh_token(
|
| 179 |
+
current_user: dict = Depends(get_current_user)
|
| 180 |
+
):
|
| 181 |
+
"""Refresh access token."""
|
| 182 |
+
access_token = create_access_token(
|
| 183 |
+
{"sub": current_user["id"], "username": current_user.get("username", "user")}
|
| 184 |
+
)
|
| 185 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
backend/app/routes/chat.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chat routes - send messages, delete, reactions.
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
from sqlalchemy import select, and_, or_
|
| 8 |
+
from typing import Optional, List
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import uuid
|
| 11 |
+
|
| 12 |
+
from ..dependencies import get_db, get_current_user
|
| 13 |
+
from ..models import Message, Reaction, User
|
| 14 |
+
from ..utils.formatter import clean_message
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/chat", tags=["Chat"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# --- Schemas ---
|
| 20 |
+
class SendMessageRequest(BaseModel):
|
| 21 |
+
receiver_id: Optional[str] = None
|
| 22 |
+
group_id: Optional[str] = None
|
| 23 |
+
content: str
|
| 24 |
+
type: str = "text" # text, image, file
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class MessageResponse(BaseModel):
|
| 28 |
+
id: str
|
| 29 |
+
sender_id: str
|
| 30 |
+
receiver_id: Optional[str] = None
|
| 31 |
+
group_id: Optional[str] = None
|
| 32 |
+
type: str
|
| 33 |
+
content: str
|
| 34 |
+
is_deleted: bool
|
| 35 |
+
created_at: datetime
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ReactionRequest(BaseModel):
|
| 39 |
+
message_id: str
|
| 40 |
+
emoji: str
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ReactionResponse(BaseModel):
|
| 44 |
+
id: str
|
| 45 |
+
message_id: str
|
| 46 |
+
user_id: str
|
| 47 |
+
emoji: str
|
| 48 |
+
created_at: datetime
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# --- Routes ---
|
| 52 |
+
@router.post("/send", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
| 53 |
+
async def send_message(
|
| 54 |
+
request: SendMessageRequest,
|
| 55 |
+
current_user: dict = Depends(get_current_user),
|
| 56 |
+
db: AsyncSession = Depends(get_db)
|
| 57 |
+
):
|
| 58 |
+
"""Send a message to a user or group."""
|
| 59 |
+
# Validate - must have either receiver_id or group_id
|
| 60 |
+
if not request.receiver_id and not request.group_id:
|
| 61 |
+
raise HTTPException(
|
| 62 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 63 |
+
detail="Must specify receiver_id or group_id"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
if request.receiver_id and request.group_id:
|
| 67 |
+
raise HTTPException(
|
| 68 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 69 |
+
detail="Cannot send to both receiver and group"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# Clean message content
|
| 73 |
+
content = clean_message(request.content)
|
| 74 |
+
|
| 75 |
+
# Create message
|
| 76 |
+
message = Message(
|
| 77 |
+
sender_id=current_user["id"],
|
| 78 |
+
receiver_id=request.receiver_id,
|
| 79 |
+
group_id=request.group_id,
|
| 80 |
+
content=content,
|
| 81 |
+
type=request.type,
|
| 82 |
+
compressed=False
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
db.add(message)
|
| 86 |
+
await db.commit()
|
| 87 |
+
await db.refresh(message)
|
| 88 |
+
|
| 89 |
+
# TODO: Push to async queue for batch processing
|
| 90 |
+
# await queue.push_message(message)
|
| 91 |
+
|
| 92 |
+
return MessageResponse(
|
| 93 |
+
id=message.id,
|
| 94 |
+
sender_id=message.sender_id,
|
| 95 |
+
receiver_id=message.receiver_id,
|
| 96 |
+
group_id=message.group_id,
|
| 97 |
+
type=message.type,
|
| 98 |
+
content=message.content,
|
| 99 |
+
is_deleted=message.is_deleted,
|
| 100 |
+
created_at=message.created_at
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.get("/messages/{user_id}")
|
| 105 |
+
async def get_private_messages(
|
| 106 |
+
user_id: str,
|
| 107 |
+
limit: int = 50,
|
| 108 |
+
offset: int = 0,
|
| 109 |
+
current_user: dict = Depends(get_current_user),
|
| 110 |
+
db: AsyncSession = Depends(get_db)
|
| 111 |
+
):
|
| 112 |
+
"""Get private messages between current user and another user."""
|
| 113 |
+
result = await db.execute(
|
| 114 |
+
select(Message).where(
|
| 115 |
+
and_(
|
| 116 |
+
or_(
|
| 117 |
+
and_(Message.sender_id == current_user["id"], Message.receiver_id == user_id),
|
| 118 |
+
and_(Message.sender_id == user_id, Message.receiver_id == current_user["id"])
|
| 119 |
+
),
|
| 120 |
+
Message.group_id.is_(None),
|
| 121 |
+
Message.is_deleted == False
|
| 122 |
+
)
|
| 123 |
+
).order_by(Message.created_at.desc()).limit(limit).offset(offset)
|
| 124 |
+
)
|
| 125 |
+
messages = result.scalars().all()
|
| 126 |
+
|
| 127 |
+
return [
|
| 128 |
+
{
|
| 129 |
+
"id": m.id,
|
| 130 |
+
"sender_id": m.sender_id,
|
| 131 |
+
"receiver_id": m.receiver_id,
|
| 132 |
+
"content": m.content,
|
| 133 |
+
"type": m.type,
|
| 134 |
+
"created_at": m.created_at.isoformat()
|
| 135 |
+
}
|
| 136 |
+
for m in messages
|
| 137 |
+
]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@router.delete("/messages/{message_id}")
|
| 141 |
+
async def delete_message(
|
| 142 |
+
message_id: str,
|
| 143 |
+
current_user: dict = Depends(get_current_user),
|
| 144 |
+
db: AsyncSession = Depends(get_db)
|
| 145 |
+
):
|
| 146 |
+
"""Delete a message (soft delete)."""
|
| 147 |
+
result = await db.execute(
|
| 148 |
+
select(Message).where(
|
| 149 |
+
and_(Message.id == message_id, Message.sender_id == current_user["id"])
|
| 150 |
+
)
|
| 151 |
+
)
|
| 152 |
+
message = result.scalar_one_or_none()
|
| 153 |
+
|
| 154 |
+
if not message:
|
| 155 |
+
raise HTTPException(status_code=404, detail="Message not found or not authorized")
|
| 156 |
+
|
| 157 |
+
message.is_deleted = True
|
| 158 |
+
message.deleted_at = datetime.utcnow()
|
| 159 |
+
await db.commit()
|
| 160 |
+
|
| 161 |
+
return {"message": "Message deleted"}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@router.post("/reactions", response_model=ReactionResponse)
|
| 165 |
+
async def add_reaction(
|
| 166 |
+
request: ReactionRequest,
|
| 167 |
+
current_user: dict = Depends(get_current_user),
|
| 168 |
+
db: AsyncSession = Depends(get_db)
|
| 169 |
+
):
|
| 170 |
+
"""Add an emoji reaction to a message."""
|
| 171 |
+
# Verify message exists
|
| 172 |
+
result = await db.execute(select(Message).where(Message.id == request.message_id))
|
| 173 |
+
message = result.scalar_one_or_none()
|
| 174 |
+
|
| 175 |
+
if not message:
|
| 176 |
+
raise HTTPException(status_code=404, detail="Message not found")
|
| 177 |
+
|
| 178 |
+
# Check if user already reacted with this emoji
|
| 179 |
+
result = await db.execute(
|
| 180 |
+
select(Reaction).where(
|
| 181 |
+
and_(
|
| 182 |
+
Reaction.message_id == request.message_id,
|
| 183 |
+
Reaction.user_id == current_user["id"],
|
| 184 |
+
Reaction.emoji == request.emoji
|
| 185 |
+
)
|
| 186 |
+
)
|
| 187 |
+
)
|
| 188 |
+
existing = result.scalar_one_or_none()
|
| 189 |
+
|
| 190 |
+
if existing:
|
| 191 |
+
raise HTTPException(status_code=400, detail="Reaction already exists")
|
| 192 |
+
|
| 193 |
+
# Create reaction
|
| 194 |
+
reaction = Reaction(
|
| 195 |
+
message_id=request.message_id,
|
| 196 |
+
user_id=current_user["id"],
|
| 197 |
+
emoji=request.emoji
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
db.add(reaction)
|
| 201 |
+
await db.commit()
|
| 202 |
+
await db.refresh(reaction)
|
| 203 |
+
|
| 204 |
+
return ReactionResponse(
|
| 205 |
+
id=reaction.id,
|
| 206 |
+
message_id=reaction.message_id,
|
| 207 |
+
user_id=reaction.user_id,
|
| 208 |
+
emoji=reaction.emoji,
|
| 209 |
+
created_at=reaction.created_at
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
@router.delete("/reactions/{reaction_id}")
|
| 214 |
+
async def remove_reaction(
|
| 215 |
+
reaction_id: str,
|
| 216 |
+
current_user: dict = Depends(get_current_user),
|
| 217 |
+
db: AsyncSession = Depends(get_db)
|
| 218 |
+
):
|
| 219 |
+
"""Remove a reaction."""
|
| 220 |
+
result = await db.execute(
|
| 221 |
+
select(Reaction).where(
|
| 222 |
+
and_(Reaction.id == reaction_id, Reaction.user_id == current_user["id"])
|
| 223 |
+
)
|
| 224 |
+
)
|
| 225 |
+
reaction = result.scalar_one_or_none()
|
| 226 |
+
|
| 227 |
+
if not reaction:
|
| 228 |
+
raise HTTPException(status_code=404, detail="Reaction not found")
|
| 229 |
+
|
| 230 |
+
await db.delete(reaction)
|
| 231 |
+
await db.commit()
|
| 232 |
+
|
| 233 |
+
return {"message": "Reaction removed"}
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
@router.get("/messages/{message_id}/reactions")
|
| 237 |
+
async def get_message_reactions(
|
| 238 |
+
message_id: str,
|
| 239 |
+
db: AsyncSession = Depends(get_db)
|
| 240 |
+
):
|
| 241 |
+
"""Get all reactions on a message."""
|
| 242 |
+
result = await db.execute(
|
| 243 |
+
select(Reaction).where(Reaction.message_id == message_id)
|
| 244 |
+
)
|
| 245 |
+
reactions = result.scalars().all()
|
| 246 |
+
|
| 247 |
+
# Group by emoji
|
| 248 |
+
emoji_counts = {}
|
| 249 |
+
for r in reactions:
|
| 250 |
+
if r.emoji not in emoji_counts:
|
| 251 |
+
emoji_counts[r.emoji] = {"count": 0, "users": []}
|
| 252 |
+
emoji_counts[r.emoji]["count"] += 1
|
| 253 |
+
emoji_counts[r.emoji]["users"].append(r.user_id)
|
| 254 |
+
|
| 255 |
+
return emoji_counts
|
backend/app/routes/group.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Group routes - create groups, manage members, admin controls.
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
from sqlalchemy import select, and_, or_
|
| 8 |
+
from typing import Optional, List
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
import uuid
|
| 11 |
+
|
| 12 |
+
from ..dependencies import get_db, get_current_user
|
| 13 |
+
from ..models import Group, GroupMember, User, Message
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/groups", tags=["Group"])
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# --- Schemas ---
|
| 19 |
+
class CreateGroupRequest(BaseModel):
|
| 20 |
+
name: str
|
| 21 |
+
description: Optional[str] = None
|
| 22 |
+
is_private: bool = False
|
| 23 |
+
max_members: int = 100
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class UpdateGroupRequest(BaseModel):
|
| 27 |
+
name: Optional[str] = None
|
| 28 |
+
description: Optional[str] = None
|
| 29 |
+
avatar: Optional[str] = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class GroupResponse(BaseModel):
|
| 33 |
+
id: str
|
| 34 |
+
name: str
|
| 35 |
+
description: Optional[str]
|
| 36 |
+
avatar: Optional[str]
|
| 37 |
+
owner_id: str
|
| 38 |
+
is_private: bool
|
| 39 |
+
max_members: int
|
| 40 |
+
created_at: datetime
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class AddMemberRequest(BaseModel):
|
| 44 |
+
user_id: str
|
| 45 |
+
role: str = "member" # member, co_admin
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class UpdateMemberRequest(BaseModel):
|
| 49 |
+
nickname: Optional[str] = None
|
| 50 |
+
role: Optional[str] = None
|
| 51 |
+
is_muted: Optional[bool] = None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class MemberResponse(BaseModel):
|
| 55 |
+
id: str
|
| 56 |
+
user_id: str
|
| 57 |
+
group_id: str
|
| 58 |
+
role: str
|
| 59 |
+
nickname: Optional[str]
|
| 60 |
+
is_muted: bool
|
| 61 |
+
joined_at: datetime
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# --- Routes ---
|
| 65 |
+
@router.post("/", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
|
| 66 |
+
async def create_group(
|
| 67 |
+
request: CreateGroupRequest,
|
| 68 |
+
current_user: dict = Depends(get_current_user),
|
| 69 |
+
db: AsyncSession = Depends(get_db)
|
| 70 |
+
):
|
| 71 |
+
"""Create a new chat group."""
|
| 72 |
+
group = Group(
|
| 73 |
+
name=request.name,
|
| 74 |
+
description=request.description,
|
| 75 |
+
owner_id=current_user["id"],
|
| 76 |
+
is_private=request.is_private,
|
| 77 |
+
max_members=request.max_members
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
db.add(group)
|
| 81 |
+
await db.commit()
|
| 82 |
+
await db.refresh(group)
|
| 83 |
+
|
| 84 |
+
# Add creator as admin
|
| 85 |
+
member = GroupMember(
|
| 86 |
+
group_id=group.id,
|
| 87 |
+
user_id=current_user["id"],
|
| 88 |
+
role="admin"
|
| 89 |
+
)
|
| 90 |
+
db.add(member)
|
| 91 |
+
await db.commit()
|
| 92 |
+
|
| 93 |
+
return GroupResponse(
|
| 94 |
+
id=group.id,
|
| 95 |
+
name=group.name,
|
| 96 |
+
description=group.description,
|
| 97 |
+
avatar=group.avatar,
|
| 98 |
+
owner_id=group.owner_id,
|
| 99 |
+
is_private=group.is_private,
|
| 100 |
+
max_members=group.max_members,
|
| 101 |
+
created_at=group.created_at
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@router.get("/{group_id}", response_model=GroupResponse)
|
| 106 |
+
async def get_group(
|
| 107 |
+
group_id: str,
|
| 108 |
+
current_user: dict = Depends(get_current_user),
|
| 109 |
+
db: AsyncSession = Depends(get_db)
|
| 110 |
+
):
|
| 111 |
+
"""Get group details."""
|
| 112 |
+
result = await db.execute(select(Group).where(Group.id == group_id))
|
| 113 |
+
group = result.scalar_one_or_none()
|
| 114 |
+
|
| 115 |
+
if not group:
|
| 116 |
+
raise HTTPException(status_code=404, detail="Group not found")
|
| 117 |
+
|
| 118 |
+
return GroupResponse(
|
| 119 |
+
id=group.id,
|
| 120 |
+
name=group.name,
|
| 121 |
+
description=group.description,
|
| 122 |
+
avatar=group.avatar,
|
| 123 |
+
owner_id=group.owner_id,
|
| 124 |
+
is_private=group.is_private,
|
| 125 |
+
max_members=group.max_members,
|
| 126 |
+
created_at=group.created_at
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@router.put("/{group_id}", response_model=GroupResponse)
|
| 131 |
+
async def update_group(
|
| 132 |
+
group_id: str,
|
| 133 |
+
request: UpdateGroupRequest,
|
| 134 |
+
current_user: dict = Depends(get_current_user),
|
| 135 |
+
db: AsyncSession = Depends(get_db)
|
| 136 |
+
):
|
| 137 |
+
"""Update group (admin only)."""
|
| 138 |
+
# Check if user is admin
|
| 139 |
+
result = await db.execute(
|
| 140 |
+
select(GroupMember).where(
|
| 141 |
+
and_(
|
| 142 |
+
GroupMember.group_id == group_id,
|
| 143 |
+
GroupMember.user_id == current_user["id"],
|
| 144 |
+
GroupMember.role.in_(["admin", "co_admin"])
|
| 145 |
+
)
|
| 146 |
+
)
|
| 147 |
+
)
|
| 148 |
+
member = result.scalar_one_or_none()
|
| 149 |
+
|
| 150 |
+
if not member:
|
| 151 |
+
raise HTTPException(status_code=403, detail="Not authorized")
|
| 152 |
+
|
| 153 |
+
result = await db.execute(select(Group).where(Group.id == group_id))
|
| 154 |
+
group = result.scalar_one_or_none()
|
| 155 |
+
|
| 156 |
+
if request.name:
|
| 157 |
+
group.name = request.name
|
| 158 |
+
if request.description is not None:
|
| 159 |
+
group.description = request.description
|
| 160 |
+
if request.avatar is not None:
|
| 161 |
+
group.avatar = request.avatar
|
| 162 |
+
|
| 163 |
+
await db.commit()
|
| 164 |
+
await db.refresh(group)
|
| 165 |
+
|
| 166 |
+
return GroupResponse(
|
| 167 |
+
id=group.id,
|
| 168 |
+
name=group.name,
|
| 169 |
+
description=group.description,
|
| 170 |
+
avatar=group.avatar,
|
| 171 |
+
owner_id=group.owner_id,
|
| 172 |
+
is_private=group.is_private,
|
| 173 |
+
max_members=group.max_members,
|
| 174 |
+
created_at=group.created_at
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@router.delete("/{group_id}")
|
| 179 |
+
async def delete_group(
|
| 180 |
+
group_id: str,
|
| 181 |
+
current_user: dict = Depends(get_current_user),
|
| 182 |
+
db: AsyncSession = Depends(get_db)
|
| 183 |
+
):
|
| 184 |
+
"""Delete group (owner only)."""
|
| 185 |
+
result = await db.execute(select(Group).where(Group.id == group_id))
|
| 186 |
+
group = result.scalar_one_or_none()
|
| 187 |
+
|
| 188 |
+
if not group:
|
| 189 |
+
raise HTTPException(status_code=404, detail="Group not found")
|
| 190 |
+
|
| 191 |
+
if group.owner_id != current_user["id"]:
|
| 192 |
+
raise HTTPException(status_code=403, detail="Only owner can delete group")
|
| 193 |
+
|
| 194 |
+
await db.delete(group)
|
| 195 |
+
await db.commit()
|
| 196 |
+
|
| 197 |
+
return {"message": "Group deleted"}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@router.get("/{group_id}/members")
|
| 201 |
+
async def get_group_members(
|
| 202 |
+
group_id: str,
|
| 203 |
+
current_user: dict = Depends(get_current_user),
|
| 204 |
+
db: AsyncSession = Depends(get_db)
|
| 205 |
+
):
|
| 206 |
+
"""Get all group members."""
|
| 207 |
+
result = await db.execute(
|
| 208 |
+
select(GroupMember, User).join(User, GroupMember.user_id == User.id).where(
|
| 209 |
+
GroupMember.group_id == group_id,
|
| 210 |
+
GroupMember.is_banned == False
|
| 211 |
+
)
|
| 212 |
+
)
|
| 213 |
+
members = result.all()
|
| 214 |
+
|
| 215 |
+
return [
|
| 216 |
+
{
|
| 217 |
+
"id": m.GroupMember.id,
|
| 218 |
+
"user_id": m.GroupMember.user_id,
|
| 219 |
+
"username": m.User.username,
|
| 220 |
+
"display_name": m.User.display_name,
|
| 221 |
+
"avatar": m.User.avatar,
|
| 222 |
+
"role": m.GroupMember.role,
|
| 223 |
+
"nickname": m.GroupMember.nickname,
|
| 224 |
+
"is_muted": m.GroupMember.is_muted,
|
| 225 |
+
"joined_at": m.GroupMember.joined_at.isoformat()
|
| 226 |
+
}
|
| 227 |
+
for m in members
|
| 228 |
+
]
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@router.post("/{group_id}/members", status_code=status.HTTP_201_CREATED)
|
| 232 |
+
async def add_member(
|
| 233 |
+
group_id: str,
|
| 234 |
+
request: AddMemberRequest,
|
| 235 |
+
current_user: dict = Depends(get_current_user),
|
| 236 |
+
db: AsyncSession = Depends(get_db)
|
| 237 |
+
):
|
| 238 |
+
"""Add a member to group (admin only)."""
|
| 239 |
+
# Check admin
|
| 240 |
+
result = await db.execute(
|
| 241 |
+
select(GroupMember).where(
|
| 242 |
+
and_(
|
| 243 |
+
GroupMember.group_id == group_id,
|
| 244 |
+
GroupMember.user_id == current_user["id"],
|
| 245 |
+
GroupMember.role.in_(["admin", "co_admin"])
|
| 246 |
+
)
|
| 247 |
+
)
|
| 248 |
+
)
|
| 249 |
+
if not result.scalar_one_or_none():
|
| 250 |
+
raise HTTPException(status_code=403, detail="Not authorized")
|
| 251 |
+
|
| 252 |
+
# Check group exists
|
| 253 |
+
result = await db.execute(select(Group).where(Group.id == group_id))
|
| 254 |
+
group = result.scalar_one_or_none()
|
| 255 |
+
if not group:
|
| 256 |
+
raise HTTPException(status_code=404, detail="Group not found")
|
| 257 |
+
|
| 258 |
+
# Check not already member
|
| 259 |
+
result = await db.execute(
|
| 260 |
+
select(GroupMember).where(
|
| 261 |
+
and_(GroupMember.group_id == group_id, GroupMember.user_id == request.user_id)
|
| 262 |
+
)
|
| 263 |
+
)
|
| 264 |
+
if result.scalar_one_or_none():
|
| 265 |
+
raise HTTPException(status_code=400, detail="Already a member")
|
| 266 |
+
|
| 267 |
+
# Add member
|
| 268 |
+
member = GroupMember(
|
| 269 |
+
group_id=group_id,
|
| 270 |
+
user_id=request.user_id,
|
| 271 |
+
role=request.role
|
| 272 |
+
)
|
| 273 |
+
db.add(member)
|
| 274 |
+
await db.commit()
|
| 275 |
+
|
| 276 |
+
return {"message": "Member added"}
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
@router.delete("/{group_id}/members/{user_id}")
|
| 280 |
+
async def remove_member(
|
| 281 |
+
group_id: str,
|
| 282 |
+
user_id: str,
|
| 283 |
+
current_user: dict = Depends(get_current_user),
|
| 284 |
+
db: AsyncSession = Depends(get_db)
|
| 285 |
+
):
|
| 286 |
+
"""Remove a member from group (admin only)."""
|
| 287 |
+
# Check admin or self-removal
|
| 288 |
+
result = await db.execute(
|
| 289 |
+
select(GroupMember).where(
|
| 290 |
+
and_(
|
| 291 |
+
GroupMember.group_id == group_id,
|
| 292 |
+
GroupMember.user_id == current_user["id"],
|
| 293 |
+
GroupMember.role.in_(["admin", "co_admin"])
|
| 294 |
+
)
|
| 295 |
+
)
|
| 296 |
+
)
|
| 297 |
+
is_admin = result.scalar_one_or_none() is not None
|
| 298 |
+
|
| 299 |
+
if not is_admin and user_id != current_user["id"]:
|
| 300 |
+
raise HTTPException(status_code=403, detail="Not authorized")
|
| 301 |
+
|
| 302 |
+
# Find and remove member
|
| 303 |
+
result = await db.execute(
|
| 304 |
+
select(GroupMember).where(
|
| 305 |
+
and_(GroupMember.group_id == group_id, GroupMember.user_id == user_id)
|
| 306 |
+
)
|
| 307 |
+
)
|
| 308 |
+
member = result.scalar_one_or_none()
|
| 309 |
+
|
| 310 |
+
if not member:
|
| 311 |
+
raise HTTPException(status_code=404, detail="Member not found")
|
| 312 |
+
|
| 313 |
+
await db.delete(member)
|
| 314 |
+
await db.commit()
|
| 315 |
+
|
| 316 |
+
return {"message": "Member removed"}
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
@router.put("/{group_id}/members/{user_id}", response_model=MemberResponse)
|
| 320 |
+
async def update_member(
|
| 321 |
+
group_id: str,
|
| 322 |
+
user_id: str,
|
| 323 |
+
request: UpdateMemberRequest,
|
| 324 |
+
current_user: dict = Depends(get_current_user),
|
| 325 |
+
db: AsyncSession = Depends(get_db)
|
| 326 |
+
):
|
| 327 |
+
"""Update member (nickname, role, mute) - admin only."""
|
| 328 |
+
# Check admin
|
| 329 |
+
result = await db.execute(
|
| 330 |
+
select(GroupMember).where(
|
| 331 |
+
and_(
|
| 332 |
+
GroupMember.group_id == group_id,
|
| 333 |
+
GroupMember.user_id == current_user["id"],
|
| 334 |
+
GroupMember.role == "admin"
|
| 335 |
+
)
|
| 336 |
+
)
|
| 337 |
+
)
|
| 338 |
+
if not result.scalar_one_or_none():
|
| 339 |
+
raise HTTPException(status_code=403, detail="Not authorized")
|
| 340 |
+
|
| 341 |
+
result = await db.execute(
|
| 342 |
+
select(GroupMember).where(
|
| 343 |
+
and_(GroupMember.group_id == group_id, GroupMember.user_id == user_id)
|
| 344 |
+
)
|
| 345 |
+
)
|
| 346 |
+
member = result.scalar_one_or_none()
|
| 347 |
+
|
| 348 |
+
if not member:
|
| 349 |
+
raise HTTPException(status_code=404, detail="Member not found")
|
| 350 |
+
|
| 351 |
+
if request.nickname is not None:
|
| 352 |
+
member.nickname = request.nickname
|
| 353 |
+
if request.role is not None:
|
| 354 |
+
member.role = request.role
|
| 355 |
+
if request.is_muted is not None:
|
| 356 |
+
member.is_muted = request.is_muted
|
| 357 |
+
|
| 358 |
+
await db.commit()
|
| 359 |
+
|
| 360 |
+
return MemberResponse(
|
| 361 |
+
id=member.id,
|
| 362 |
+
user_id=member.user_id,
|
| 363 |
+
group_id=member.group_id,
|
| 364 |
+
role=member.role,
|
| 365 |
+
nickname=member.nickname,
|
| 366 |
+
is_muted=member.is_muted,
|
| 367 |
+
joined_at=member.joined_at
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
@router.get("/{group_id}/messages")
|
| 372 |
+
async def get_group_messages(
|
| 373 |
+
group_id: str,
|
| 374 |
+
limit: int = 50,
|
| 375 |
+
offset: int = 0,
|
| 376 |
+
current_user: dict = Depends(get_current_user),
|
| 377 |
+
db: AsyncSession = Depends(get_db)
|
| 378 |
+
):
|
| 379 |
+
"""Get group messages."""
|
| 380 |
+
result = await db.execute(
|
| 381 |
+
select(Message).where(
|
| 382 |
+
and_(
|
| 383 |
+
Message.group_id == group_id,
|
| 384 |
+
Message.is_deleted == False
|
| 385 |
+
)
|
| 386 |
+
).order_by(Message.created_at.desc()).limit(limit).offset(offset)
|
| 387 |
+
)
|
| 388 |
+
messages = result.scalars().all()
|
| 389 |
+
|
| 390 |
+
return [
|
| 391 |
+
{
|
| 392 |
+
"id": m.id,
|
| 393 |
+
"sender_id": m.sender_id,
|
| 394 |
+
"content": m.content,
|
| 395 |
+
"type": m.type,
|
| 396 |
+
"created_at": m.created_at.isoformat()
|
| 397 |
+
}
|
| 398 |
+
for m in messages
|
| 399 |
+
]
|
backend/app/routes/system.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System routes - batch triggers, queue control, monitoring.
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
from typing import Optional, List, Dict
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
|
| 9 |
+
from ..dependencies import get_current_user
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/system", tags=["System"])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# --- Schemas ---
|
| 15 |
+
class BatchStatusResponse(BaseModel):
|
| 16 |
+
last_batch_time: Optional[datetime]
|
| 17 |
+
next_batch_time: Optional[datetime]
|
| 18 |
+
queue_size: int
|
| 19 |
+
processed_today: int
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TriggerBatchRequest(BaseModel):
|
| 23 |
+
user_id: Optional[str] = None
|
| 24 |
+
group_id: Optional[str] = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class QueueStatsResponse(BaseModel):
|
| 28 |
+
size: int
|
| 29 |
+
oldest_message_age_seconds: int
|
| 30 |
+
avg_processing_time_ms: int
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# --- Routes ---
|
| 34 |
+
@router.get("/health")
|
| 35 |
+
async def health_check():
|
| 36 |
+
"""Health check endpoint."""
|
| 37 |
+
return {
|
| 38 |
+
"status": "healthy",
|
| 39 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 40 |
+
"version": "1.0.0"
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@router.get("/batch/status", response_model=BatchStatusResponse)
|
| 45 |
+
async def get_batch_status(current_user: dict = Depends(get_current_user)):
|
| 46 |
+
"""Get batch processing status."""
|
| 47 |
+
# TODO: Pull actual stats from Redis/monitor
|
| 48 |
+
return BatchStatusResponse(
|
| 49 |
+
last_batch_time=datetime.utcnow() - timedelta(hours=1),
|
| 50 |
+
next_batch_time=datetime.utcnow() + timedelta(minutes=30),
|
| 51 |
+
queue_size=42,
|
| 52 |
+
processed_today=156
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.post("/batch/trigger", status_code=status.HTTP_202_ACCEPTED)
|
| 57 |
+
async def trigger_batch(
|
| 58 |
+
request: TriggerBatchRequest,
|
| 59 |
+
current_user: dict = Depends(get_current_user)
|
| 60 |
+
):
|
| 61 |
+
"""
|
| 62 |
+
Manually trigger batch processing.
|
| 63 |
+
Can target specific user or group, or process all.
|
| 64 |
+
"""
|
| 65 |
+
# TODO: Call controller to trigger batch
|
| 66 |
+
target = request.user_id or request.group_id or "all"
|
| 67 |
+
return {
|
| 68 |
+
"message": f"Batch processing triggered for {target}",
|
| 69 |
+
"status": "queued"
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@router.get("/queue/stats", response_model=QueueStatsResponse)
|
| 74 |
+
async def get_queue_stats():
|
| 75 |
+
"""Get queue statistics."""
|
| 76 |
+
# TODO: Pull from Redis
|
| 77 |
+
return QueueStatsResponse(
|
| 78 |
+
size=42,
|
| 79 |
+
oldest_message_age_seconds=300,
|
| 80 |
+
avg_processing_time_ms=15
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.get("/storage/stats")
|
| 85 |
+
async def get_storage_stats():
|
| 86 |
+
"""Get storage statistics."""
|
| 87 |
+
# TODO: Calculate from storage directory
|
| 88 |
+
return {
|
| 89 |
+
"total_files": 1250,
|
| 90 |
+
"total_size_mb": 45.2,
|
| 91 |
+
"compression_ratio": 68.5
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.post("/cleanup/old-messages")
|
| 96 |
+
async def cleanup_old_messages(
|
| 97 |
+
days: int = 30,
|
| 98 |
+
current_user: dict = Depends(get_current_user)
|
| 99 |
+
):
|
| 100 |
+
"""Clean up messages older than specified days."""
|
| 101 |
+
# TODO: Implement cleanup
|
| 102 |
+
return {
|
| 103 |
+
"message": f"Cleanup scheduled for messages older than {days} days"
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@router.get("/logs")
|
| 108 |
+
async def get_logs(
|
| 109 |
+
limit: int = 100,
|
| 110 |
+
level: Optional[str] = None
|
| 111 |
+
):
|
| 112 |
+
"""Get recent system logs."""
|
| 113 |
+
# TODO: Pull from log system
|
| 114 |
+
return {
|
| 115 |
+
"logs": [
|
| 116 |
+
{"timestamp": datetime.utcnow().isoformat(), "level": "INFO", "message": "System running"}
|
| 117 |
+
],
|
| 118 |
+
"count": 1
|
| 119 |
+
}
|
backend/app/routes/user.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User routes - profile, avatar, block user.
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
+
from pydantic import BaseModel, EmailStr
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
from sqlalchemy import select, or_
|
| 8 |
+
from typing import Optional, List
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
from ..dependencies import get_db, get_current_user
|
| 12 |
+
from ..models import User, Friend
|
| 13 |
+
from ..utils.avatar import generate_avatar
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/users", tags=["User"])
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# --- Schemas ---
|
| 19 |
+
class UserProfileResponse(BaseModel):
|
| 20 |
+
id: str
|
| 21 |
+
username: str
|
| 22 |
+
email: str
|
| 23 |
+
display_name: Optional[str] = None
|
| 24 |
+
avatar: Optional[str] = None
|
| 25 |
+
bio: Optional[str] = None
|
| 26 |
+
status: str
|
| 27 |
+
is_public: bool = True
|
| 28 |
+
created_at: datetime
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class UpdateProfileRequest(BaseModel):
|
| 32 |
+
display_name: Optional[str] = None
|
| 33 |
+
bio: Optional[str] = None
|
| 34 |
+
is_public: Optional[bool] = None
|
| 35 |
+
allow_friends: Optional[bool] = None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class BlockUserRequest(BaseModel):
|
| 39 |
+
user_id: str
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class FriendRequest(BaseModel):
|
| 43 |
+
user_id: str
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# --- Routes ---
|
| 47 |
+
@router.get("/{user_id}", response_model=UserProfileResponse)
|
| 48 |
+
async def get_user_profile(
|
| 49 |
+
user_id: str,
|
| 50 |
+
current_user: dict = Depends(get_current_user),
|
| 51 |
+
db: AsyncSession = Depends(get_db)
|
| 52 |
+
):
|
| 53 |
+
"""Get a user's public profile."""
|
| 54 |
+
result = await db.execute(select(User).where(User.id == user_id))
|
| 55 |
+
user = result.scalar_one_or_none()
|
| 56 |
+
|
| 57 |
+
if not user:
|
| 58 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 59 |
+
|
| 60 |
+
# Check if user allows public profile
|
| 61 |
+
if not user.is_public and user.id != current_user["id"]:
|
| 62 |
+
raise HTTPException(status_code=403, detail="This profile is private")
|
| 63 |
+
|
| 64 |
+
return UserProfileResponse(
|
| 65 |
+
id=user.id,
|
| 66 |
+
username=user.username,
|
| 67 |
+
email=user.email,
|
| 68 |
+
display_name=user.display_name,
|
| 69 |
+
avatar=user.avatar,
|
| 70 |
+
bio=user.bio,
|
| 71 |
+
status=user.status,
|
| 72 |
+
is_public=user.is_public,
|
| 73 |
+
created_at=user.created_at
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.put("/profile", response_model=UserProfileResponse)
|
| 78 |
+
async def update_profile(
|
| 79 |
+
request: UpdateProfileRequest,
|
| 80 |
+
current_user: dict = Depends(get_current_user),
|
| 81 |
+
db: AsyncSession = Depends(get_db)
|
| 82 |
+
):
|
| 83 |
+
"""Update current user's profile."""
|
| 84 |
+
result = await db.execute(select(User).where(User.id == current_user["id"]))
|
| 85 |
+
user = result.scalar_one_or_none()
|
| 86 |
+
|
| 87 |
+
if not user:
|
| 88 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 89 |
+
|
| 90 |
+
# Update fields
|
| 91 |
+
if request.display_name is not None:
|
| 92 |
+
user.display_name = request.display_name
|
| 93 |
+
if request.bio is not None:
|
| 94 |
+
user.bio = request.bio
|
| 95 |
+
if request.is_public is not None:
|
| 96 |
+
user.is_public = request.is_public
|
| 97 |
+
if request.allow_friends is not None:
|
| 98 |
+
user.allow_friends = request.allow_friends
|
| 99 |
+
|
| 100 |
+
await db.commit()
|
| 101 |
+
await db.refresh(user)
|
| 102 |
+
|
| 103 |
+
return UserProfileResponse(
|
| 104 |
+
id=user.id,
|
| 105 |
+
username=user.username,
|
| 106 |
+
email=user.email,
|
| 107 |
+
display_name=user.display_name,
|
| 108 |
+
avatar=user.avatar,
|
| 109 |
+
bio=user.bio,
|
| 110 |
+
status=user.status,
|
| 111 |
+
is_public=user.is_public,
|
| 112 |
+
created_at=user.created_at
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@router.put("/avatar")
|
| 117 |
+
async def update_avatar(
|
| 118 |
+
current_user: dict = Depends(get_current_user),
|
| 119 |
+
db: AsyncSession = Depends(get_db)
|
| 120 |
+
):
|
| 121 |
+
"""Update avatar (auto-generate or custom URL)."""
|
| 122 |
+
result = await db.execute(select(User).where(User.id == current_user["id"]))
|
| 123 |
+
user = result.scalar_one_or_none()
|
| 124 |
+
|
| 125 |
+
if not user:
|
| 126 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 127 |
+
|
| 128 |
+
# Generate new avatar based on username
|
| 129 |
+
user.avatar = await generate_avatar(user.id, user.username)
|
| 130 |
+
await db.commit()
|
| 131 |
+
|
| 132 |
+
return {"avatar": user.avatar}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@router.post("/block")
|
| 136 |
+
async def block_user(
|
| 137 |
+
request: BlockUserRequest,
|
| 138 |
+
current_user: dict = Depends(get_current_user),
|
| 139 |
+
db: AsyncSession = Depends(get_db)
|
| 140 |
+
):
|
| 141 |
+
"""Block a user (add to blocked list)."""
|
| 142 |
+
# In a real implementation, this would create a BlockedUser record
|
| 143 |
+
# For now, return success
|
| 144 |
+
return {"message": f"User {request.user_id} blocked"}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@router.post("/unblock")
|
| 148 |
+
async def unblock_user(
|
| 149 |
+
request: BlockUserRequest,
|
| 150 |
+
current_user: dict = Depends(get_current_user),
|
| 151 |
+
db: AsyncSession = Depends(get_db)
|
| 152 |
+
):
|
| 153 |
+
"""Unblock a user."""
|
| 154 |
+
return {"message": f"User {request.user_id} unblocked"}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@router.get("/search/{query}")
|
| 158 |
+
async def search_users(
|
| 159 |
+
query: str,
|
| 160 |
+
current_user: dict = Depends(get_current_user),
|
| 161 |
+
db: AsyncSession = Depends(get_db)
|
| 162 |
+
):
|
| 163 |
+
"""Search users by username or display name."""
|
| 164 |
+
result = await db.execute(
|
| 165 |
+
select(User).where(
|
| 166 |
+
or_(
|
| 167 |
+
User.username.ilike(f"%{query}%"),
|
| 168 |
+
User.display_name.ilike(f"%{query}%")
|
| 169 |
+
)
|
| 170 |
+
).limit(20)
|
| 171 |
+
)
|
| 172 |
+
users = result.scalars().all()
|
| 173 |
+
|
| 174 |
+
return [
|
| 175 |
+
{
|
| 176 |
+
"id": u.id,
|
| 177 |
+
"username": u.username,
|
| 178 |
+
"display_name": u.display_name,
|
| 179 |
+
"avatar": u.avatar,
|
| 180 |
+
"status": u.status
|
| 181 |
+
}
|
| 182 |
+
for u in users
|
| 183 |
+
]
|
backend/app/services/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Services package - business logic layer.
|
| 3 |
+
"""
|
| 4 |
+
from .auth_service import AuthService
|
| 5 |
+
from .chat_service import ChatService
|
| 6 |
+
from .group_service import GroupService
|
| 7 |
+
from .user_service import UserService
|
| 8 |
+
from .file_service import FileService
|
| 9 |
+
|
| 10 |
+
__all__ = ["AuthService", "ChatService", "GroupService", "UserService", "FileService"]
|
backend/app/services/auth_service.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Auth service - authentication business logic.
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from sqlalchemy import select
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from ..models import User
|
| 9 |
+
from ..utils.security import hash_password, verify_password, create_access_token
|
| 10 |
+
from ..utils.avatar import generate_avatar
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class AuthService:
|
| 14 |
+
"""Authentication business logic."""
|
| 15 |
+
|
| 16 |
+
@staticmethod
|
| 17 |
+
async def register_user(db: AsyncSession, username: str, email: str, password: str) -> User:
|
| 18 |
+
"""Register a new user."""
|
| 19 |
+
# Check existing
|
| 20 |
+
result = await db.execute(select(User).where(User.email == email))
|
| 21 |
+
if result.scalar_one_or_none():
|
| 22 |
+
raise ValueError("Email already registered")
|
| 23 |
+
|
| 24 |
+
result = await db.execute(select(User).where(User.username == username))
|
| 25 |
+
if result.scalar_one_or_none():
|
| 26 |
+
raise ValueError("Username taken")
|
| 27 |
+
|
| 28 |
+
# Create
|
| 29 |
+
user = User(
|
| 30 |
+
username=username,
|
| 31 |
+
email=email,
|
| 32 |
+
password_hash=hash_password(password),
|
| 33 |
+
display_name=username,
|
| 34 |
+
avatar=await generate_avatar(username, username),
|
| 35 |
+
status="offline"
|
| 36 |
+
)
|
| 37 |
+
db.add(user)
|
| 38 |
+
await db.commit()
|
| 39 |
+
await db.refresh(user)
|
| 40 |
+
return user
|
| 41 |
+
|
| 42 |
+
@staticmethod
|
| 43 |
+
async def login_user(db: AsyncSession, email: str, password: str) -> Optional[User]:
|
| 44 |
+
"""Login user by email/password."""
|
| 45 |
+
result = await db.execute(select(User).where(User.email == email))
|
| 46 |
+
user = result.scalar_one_or_none()
|
| 47 |
+
|
| 48 |
+
if not user or not verify_password(password, user.password_hash):
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
user.status = "online"
|
| 52 |
+
await db.commit()
|
| 53 |
+
return user
|
| 54 |
+
|
| 55 |
+
@staticmethod
|
| 56 |
+
def generate_token(user: User) -> str:
|
| 57 |
+
"""Generate JWT token for user."""
|
| 58 |
+
return create_access_token({"sub": user.id, "username": user.username})
|
| 59 |
+
|
| 60 |
+
@staticmethod
|
| 61 |
+
async def logout_user(db: AsyncSession, user_id: str) -> bool:
|
| 62 |
+
"""Logout user."""
|
| 63 |
+
result = await db.execute(select(User).where(User.id == user_id))
|
| 64 |
+
user = result.scalar_one_or_none()
|
| 65 |
+
|
| 66 |
+
if user:
|
| 67 |
+
user.status = "offline"
|
| 68 |
+
await db.commit()
|
| 69 |
+
return True
|
| 70 |
+
return False
|
backend/app/services/chat_service.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chat service - message handling and queue push.
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from sqlalchemy import select, and_, or_
|
| 6 |
+
from typing import Optional, List
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
from ..models import Message, User
|
| 10 |
+
from ..utils.formatter import clean_message
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ChatService:
|
| 14 |
+
"""Chat business logic."""
|
| 15 |
+
|
| 16 |
+
@staticmethod
|
| 17 |
+
async def send_message(
|
| 18 |
+
db: AsyncSession,
|
| 19 |
+
sender_id: str,
|
| 20 |
+
content: str,
|
| 21 |
+
receiver_id: Optional[str] = None,
|
| 22 |
+
group_id: Optional[str] = None,
|
| 23 |
+
message_type: str = "text"
|
| 24 |
+
) -> Message:
|
| 25 |
+
"""Send a message (queued for batch processing)."""
|
| 26 |
+
# Clean content
|
| 27 |
+
content = clean_message(content)
|
| 28 |
+
|
| 29 |
+
# Create message (initially uncompressed)
|
| 30 |
+
message = Message(
|
| 31 |
+
sender_id=sender_id,
|
| 32 |
+
receiver_id=receiver_id,
|
| 33 |
+
group_id=group_id,
|
| 34 |
+
content=content,
|
| 35 |
+
type=message_type,
|
| 36 |
+
compressed=False
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
db.add(message)
|
| 40 |
+
await db.commit()
|
| 41 |
+
await db.refresh(message)
|
| 42 |
+
|
| 43 |
+
# TODO: Push to async queue for batch processing
|
| 44 |
+
# from ..system.queue import message_queue
|
| 45 |
+
# await message_queue.push(message)
|
| 46 |
+
|
| 47 |
+
return message
|
| 48 |
+
|
| 49 |
+
@staticmethod
|
| 50 |
+
async def get_private_messages(
|
| 51 |
+
db: AsyncSession,
|
| 52 |
+
user_id: str,
|
| 53 |
+
other_user_id: str,
|
| 54 |
+
limit: int = 50,
|
| 55 |
+
offset: int = 0
|
| 56 |
+
) -> List[Message]:
|
| 57 |
+
"""Get private messages between two users."""
|
| 58 |
+
result = await db.execute(
|
| 59 |
+
select(Message).where(
|
| 60 |
+
and_(
|
| 61 |
+
or_(
|
| 62 |
+
and_(Message.sender_id == user_id, Message.receiver_id == other_user_id),
|
| 63 |
+
and_(Message.sender_id == other_user_id, Message.receiver_id == user_id)
|
| 64 |
+
),
|
| 65 |
+
Message.group_id.is_(None),
|
| 66 |
+
Message.is_deleted == False
|
| 67 |
+
)
|
| 68 |
+
).order_by(Message.created_at.desc()).limit(limit).offset(offset)
|
| 69 |
+
)
|
| 70 |
+
return result.scalars().all()
|
| 71 |
+
|
| 72 |
+
@staticmethod
|
| 73 |
+
async def delete_message(db: AsyncSession, message_id: str, user_id: str) -> bool:
|
| 74 |
+
"""Delete a message (soft delete)."""
|
| 75 |
+
result = await db.execute(
|
| 76 |
+
select(Message).where(
|
| 77 |
+
and_(Message.id == message_id, Message.sender_id == user_id)
|
| 78 |
+
)
|
| 79 |
+
)
|
| 80 |
+
message = result.scalar_one_or_none()
|
| 81 |
+
|
| 82 |
+
if not message:
|
| 83 |
+
return False
|
| 84 |
+
|
| 85 |
+
message.is_deleted = True
|
| 86 |
+
message.deleted_at = datetime.utcnow()
|
| 87 |
+
await db.commit()
|
| 88 |
+
return True
|
| 89 |
+
|
| 90 |
+
@staticmethod
|
| 91 |
+
async def get_conversations(db: AsyncSession, user_id: str) -> List[dict]:
|
| 92 |
+
"""Get list of conversations for a user."""
|
| 93 |
+
# Get recent private conversations
|
| 94 |
+
result = await db.execute(
|
| 95 |
+
select(Message).where(
|
| 96 |
+
or_(
|
| 97 |
+
Message.sender_id == user_id,
|
| 98 |
+
Message.receiver_id == user_id
|
| 99 |
+
)
|
| 100 |
+
).order_by(Message.created_at.desc()).limit(100)
|
| 101 |
+
)
|
| 102 |
+
messages = result.scalars().all()
|
| 103 |
+
|
| 104 |
+
# Group by conversation partner
|
| 105 |
+
conversations = {}
|
| 106 |
+
for msg in messages:
|
| 107 |
+
partner_id = msg.receiver_id if msg.sender_id == user_id else msg.sender_id
|
| 108 |
+
if partner_id and partner_id not in conversations:
|
| 109 |
+
conversations[partner_id] = {
|
| 110 |
+
"user_id": partner_id,
|
| 111 |
+
"last_message": msg.content[:50],
|
| 112 |
+
"last_time": msg.created_at
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
return list(conversations.values())[:20]
|
backend/app/services/file_service.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
File service - GitHub upload logic.
|
| 3 |
+
"""
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import asyncio
|
| 7 |
+
from github import Github
|
| 8 |
+
from ..config import settings
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class FileService:
|
| 12 |
+
"""File storage and GitHub upload logic."""
|
| 13 |
+
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self.github = None
|
| 16 |
+
if settings.GITHUB_TOKEN:
|
| 17 |
+
self.github = Github(settings.GITHUB_TOKEN)
|
| 18 |
+
|
| 19 |
+
async def upload_to_github(
|
| 20 |
+
self,
|
| 21 |
+
file_path: Path,
|
| 22 |
+
repo_path: str,
|
| 23 |
+
branch: str = "main"
|
| 24 |
+
) -> Optional[str]:
|
| 25 |
+
"""
|
| 26 |
+
Upload a file to GitHub repository.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
file_path: Local file path to upload
|
| 30 |
+
repo_path: GitHub repo (e.g., "username/repo")
|
| 31 |
+
branch: Target branch
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
GitHub file URL or None on failure
|
| 35 |
+
"""
|
| 36 |
+
if not self.github:
|
| 37 |
+
print("GitHub token not configured")
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
repo = self.github.get_repo(repo_path)
|
| 42 |
+
|
| 43 |
+
with open(file_path, "rb") as f:
|
| 44 |
+
content = f.read()
|
| 45 |
+
|
| 46 |
+
# Get file path in repo
|
| 47 |
+
repo_path_str = str(file_path).replace("./", "")
|
| 48 |
+
|
| 49 |
+
# Check if file exists
|
| 50 |
+
try:
|
| 51 |
+
existing_file = repo.get_contents(repo_path_str, ref=branch)
|
| 52 |
+
# Update existing file
|
| 53 |
+
repo.update_file(
|
| 54 |
+
path=repo_path_str,
|
| 55 |
+
message=f"Update {file_path.name}",
|
| 56 |
+
content=content,
|
| 57 |
+
sha=existing_file.sha,
|
| 58 |
+
branch=branch
|
| 59 |
+
)
|
| 60 |
+
except Exception:
|
| 61 |
+
# Create new file
|
| 62 |
+
repo.create_file(
|
| 63 |
+
path=repo_path_str,
|
| 64 |
+
message=f"Upload {file_path.name}",
|
| 65 |
+
content=content,
|
| 66 |
+
branch=branch
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
return f"https://github.com/{repo_path}/blob/{branch}/{repo_path_str}"
|
| 70 |
+
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f"GitHub upload error: {e}")
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
async def download_from_github(
|
| 76 |
+
self,
|
| 77 |
+
repo_path: str,
|
| 78 |
+
file_path: str,
|
| 79 |
+
branch: str = "main"
|
| 80 |
+
) -> Optional[bytes]:
|
| 81 |
+
"""Download a file from GitHub."""
|
| 82 |
+
if not self.github:
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
try:
|
| 86 |
+
repo = self.github.get_repo(repo_path)
|
| 87 |
+
contents = repo.get_contents(file_path, ref=branch)
|
| 88 |
+
return contents.decoded_content
|
| 89 |
+
except Exception as e:
|
| 90 |
+
print(f"GitHub download error: {e}")
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
def get_local_storage_path(self, user_id: str, project_id: str, date_path: str) -> Path:
|
| 94 |
+
"""Get local storage path for a user's project data."""
|
| 95 |
+
base = Path(settings.STORAGE_PATH) / "users" / user_id / "projects" / project_id / date_path
|
| 96 |
+
base.mkdir(parents=True, exist_ok=True)
|
| 97 |
+
return base
|
| 98 |
+
|
| 99 |
+
async def save_batch_file(
|
| 100 |
+
self,
|
| 101 |
+
user_id: str,
|
| 102 |
+
project_id: str,
|
| 103 |
+
filename: str,
|
| 104 |
+
content: bytes
|
| 105 |
+
) -> Path:
|
| 106 |
+
"""Save a batch file to local storage."""
|
| 107 |
+
from ..utils.time import get_date_path
|
| 108 |
+
date_path = get_date_path()
|
| 109 |
+
storage_dir = self.get_local_storage_path(user_id, project_id, date_path)
|
| 110 |
+
|
| 111 |
+
file_path = storage_dir / filename
|
| 112 |
+
with open(file_path, "wb") as f:
|
| 113 |
+
f.write(content)
|
| 114 |
+
|
| 115 |
+
return file_path
|
| 116 |
+
|
| 117 |
+
async def upload_batch(
|
| 118 |
+
self,
|
| 119 |
+
user_id: str,
|
| 120 |
+
project_id: str,
|
| 121 |
+
batch_dir: Path
|
| 122 |
+
) -> bool:
|
| 123 |
+
"""Upload all files in a batch directory to GitHub."""
|
| 124 |
+
if not settings.GITHUB_REPO:
|
| 125 |
+
return False
|
| 126 |
+
|
| 127 |
+
success = True
|
| 128 |
+
for file_path in batch_dir.glob("*"):
|
| 129 |
+
if file_path.is_file():
|
| 130 |
+
result = await self.upload_to_github(
|
| 131 |
+
file_path,
|
| 132 |
+
settings.GITHUB_REPO
|
| 133 |
+
)
|
| 134 |
+
if not result:
|
| 135 |
+
success = False
|
| 136 |
+
|
| 137 |
+
return success
|
backend/app/services/group_service.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Group service - group management business logic.
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from sqlalchemy import select, and_
|
| 6 |
+
from typing import Optional, List
|
| 7 |
+
|
| 8 |
+
from ..models import Group, GroupMember, User
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class GroupService:
|
| 12 |
+
"""Group business logic."""
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
async def create_group(
|
| 16 |
+
db: AsyncSession,
|
| 17 |
+
name: str,
|
| 18 |
+
owner_id: str,
|
| 19 |
+
description: Optional[str] = None,
|
| 20 |
+
is_private: bool = False
|
| 21 |
+
) -> Group:
|
| 22 |
+
"""Create a new group."""
|
| 23 |
+
group = Group(
|
| 24 |
+
name=name,
|
| 25 |
+
owner_id=owner_id,
|
| 26 |
+
description=description,
|
| 27 |
+
is_private=is_private
|
| 28 |
+
)
|
| 29 |
+
db.add(group)
|
| 30 |
+
await db.commit()
|
| 31 |
+
await db.refresh(group)
|
| 32 |
+
|
| 33 |
+
# Add owner as admin
|
| 34 |
+
member = GroupMember(
|
| 35 |
+
group_id=group.id,
|
| 36 |
+
user_id=owner_id,
|
| 37 |
+
role="admin"
|
| 38 |
+
)
|
| 39 |
+
db.add(member)
|
| 40 |
+
await db.commit()
|
| 41 |
+
|
| 42 |
+
return group
|
| 43 |
+
|
| 44 |
+
@staticmethod
|
| 45 |
+
async def get_group(db: AsyncSession, group_id: str) -> Optional[Group]:
|
| 46 |
+
"""Get group by ID."""
|
| 47 |
+
result = await db.execute(select(Group).where(Group.id == group_id))
|
| 48 |
+
return result.scalar_one_or_none()
|
| 49 |
+
|
| 50 |
+
@staticmethod
|
| 51 |
+
async def add_member(
|
| 52 |
+
db: AsyncSession,
|
| 53 |
+
group_id: str,
|
| 54 |
+
user_id: str,
|
| 55 |
+
role: str = "member"
|
| 56 |
+
) -> GroupMember:
|
| 57 |
+
"""Add a member to group."""
|
| 58 |
+
# Check not already member
|
| 59 |
+
result = await db.execute(
|
| 60 |
+
select(GroupMember).where(
|
| 61 |
+
and_(GroupMember.group_id == group_id, GroupMember.user_id == user_id)
|
| 62 |
+
)
|
| 63 |
+
)
|
| 64 |
+
if result.scalar_one_or_none():
|
| 65 |
+
raise ValueError("Already a member")
|
| 66 |
+
|
| 67 |
+
member = GroupMember(
|
| 68 |
+
group_id=group_id,
|
| 69 |
+
user_id=user_id,
|
| 70 |
+
role=role
|
| 71 |
+
)
|
| 72 |
+
db.add(member)
|
| 73 |
+
await db.commit()
|
| 74 |
+
await db.refresh(member)
|
| 75 |
+
return member
|
| 76 |
+
|
| 77 |
+
@staticmethod
|
| 78 |
+
async def remove_member(db: AsyncSession, group_id: str, user_id: str) -> bool:
|
| 79 |
+
"""Remove a member from group."""
|
| 80 |
+
result = await db.execute(
|
| 81 |
+
select(GroupMember).where(
|
| 82 |
+
and_(GroupMember.group_id == group_id, GroupMember.user_id == user_id)
|
| 83 |
+
)
|
| 84 |
+
)
|
| 85 |
+
member = result.scalar_one_or_none()
|
| 86 |
+
|
| 87 |
+
if not member:
|
| 88 |
+
return False
|
| 89 |
+
|
| 90 |
+
await db.delete(member)
|
| 91 |
+
await db.commit()
|
| 92 |
+
return True
|
| 93 |
+
|
| 94 |
+
@staticmethod
|
| 95 |
+
async def get_members(db: AsyncSession, group_id: str) -> List[GroupMember]:
|
| 96 |
+
"""Get all group members."""
|
| 97 |
+
result = await db.execute(
|
| 98 |
+
select(GroupMember).where(GroupMember.group_id == group_id)
|
| 99 |
+
)
|
| 100 |
+
return result.scalars().all()
|
| 101 |
+
|
| 102 |
+
@staticmethod
|
| 103 |
+
async def is_admin(db: AsyncSession, group_id: str, user_id: str) -> bool:
|
| 104 |
+
"""Check if user is admin or co_admin."""
|
| 105 |
+
result = await db.execute(
|
| 106 |
+
select(GroupMember).where(
|
| 107 |
+
and_(
|
| 108 |
+
GroupMember.group_id == group_id,
|
| 109 |
+
GroupMember.user_id == user_id,
|
| 110 |
+
GroupMember.role.in_(["admin", "co_admin"])
|
| 111 |
+
)
|
| 112 |
+
)
|
| 113 |
+
)
|
| 114 |
+
return result.scalar_one_or_none() is not None
|
| 115 |
+
|
| 116 |
+
@staticmethod
|
| 117 |
+
async def is_owner(db: AsyncSession, group_id: str, user_id: str) -> bool:
|
| 118 |
+
"""Check if user is owner."""
|
| 119 |
+
result = await db.execute(select(Group).where(Group.id == group_id))
|
| 120 |
+
group = result.scalar_one_or_none()
|
| 121 |
+
return group and group.owner_id == user_id
|
| 122 |
+
|
| 123 |
+
@staticmethod
|
| 124 |
+
async def update_member_role(
|
| 125 |
+
db: AsyncSession,
|
| 126 |
+
group_id: str,
|
| 127 |
+
user_id: str,
|
| 128 |
+
new_role: str
|
| 129 |
+
) -> bool:
|
| 130 |
+
"""Update member role (admin only)."""
|
| 131 |
+
result = await db.execute(
|
| 132 |
+
select(GroupMember).where(
|
| 133 |
+
and_(GroupMember.group_id == group_id, GroupMember.user_id == user_id)
|
| 134 |
+
)
|
| 135 |
+
)
|
| 136 |
+
member = result.scalar_one_or_none()
|
| 137 |
+
|
| 138 |
+
if not member:
|
| 139 |
+
return False
|
| 140 |
+
|
| 141 |
+
member.role = new_role
|
| 142 |
+
await db.commit()
|
| 143 |
+
return True
|
backend/app/services/user_service.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User service - user profile management.
|
| 3 |
+
"""
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from sqlalchemy import select, and_, or_
|
| 6 |
+
from typing import Optional, List
|
| 7 |
+
|
| 8 |
+
from ..models import User, Friend
|
| 9 |
+
from ..utils.avatar import generate_avatar
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class UserService:
|
| 13 |
+
"""User business logic."""
|
| 14 |
+
|
| 15 |
+
@staticmethod
|
| 16 |
+
async def get_user(db: AsyncSession, user_id: str) -> Optional[User]:
|
| 17 |
+
"""Get user by ID."""
|
| 18 |
+
result = await db.execute(select(User).where(User.id == user_id))
|
| 19 |
+
return result.scalar_one_or_none()
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
async def get_by_email(db: AsyncSession, email: str) -> Optional[User]:
|
| 23 |
+
"""Get user by email."""
|
| 24 |
+
result = await db.execute(select(User).where(User.email == email))
|
| 25 |
+
return result.scalar_one_or_none()
|
| 26 |
+
|
| 27 |
+
@staticmethod
|
| 28 |
+
async def get_by_username(db: AsyncSession, username: str) -> Optional[User]:
|
| 29 |
+
"""Get user by username."""
|
| 30 |
+
result = await db.execute(select(User).where(User.username == username))
|
| 31 |
+
return result.scalar_one_or_none()
|
| 32 |
+
|
| 33 |
+
@staticmethod
|
| 34 |
+
async def update_profile(
|
| 35 |
+
db: AsyncSession,
|
| 36 |
+
user_id: str,
|
| 37 |
+
display_name: Optional[str] = None,
|
| 38 |
+
bio: Optional[str] = None,
|
| 39 |
+
is_public: Optional[bool] = None
|
| 40 |
+
) -> Optional[User]:
|
| 41 |
+
"""Update user profile."""
|
| 42 |
+
result = await db.execute(select(User).where(User.id == user_id))
|
| 43 |
+
user = result.scalar_one_or_none()
|
| 44 |
+
|
| 45 |
+
if not user:
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
if display_name is not None:
|
| 49 |
+
user.display_name = display_name
|
| 50 |
+
if bio is not None:
|
| 51 |
+
user.bio = bio
|
| 52 |
+
if is_public is not None:
|
| 53 |
+
user.is_public = is_public
|
| 54 |
+
|
| 55 |
+
await db.commit()
|
| 56 |
+
await db.refresh(user)
|
| 57 |
+
return user
|
| 58 |
+
|
| 59 |
+
@staticmethod
|
| 60 |
+
async def update_avatar(db: AsyncSession, user_id: str) -> Optional[str]:
|
| 61 |
+
"""Generate and update avatar."""
|
| 62 |
+
result = await db.execute(select(User).where(User.id == user_id))
|
| 63 |
+
user = result.scalar_one_or_none()
|
| 64 |
+
|
| 65 |
+
if not user:
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
user.avatar = await generate_avatar(user.id, user.username)
|
| 69 |
+
await db.commit()
|
| 70 |
+
return user.avatar
|
| 71 |
+
|
| 72 |
+
@staticmethod
|
| 73 |
+
async def search_users(db: AsyncSession, query: str, limit: int = 20) -> List[User]:
|
| 74 |
+
"""Search users by username or display name."""
|
| 75 |
+
result = await db.execute(
|
| 76 |
+
select(User).where(
|
| 77 |
+
or_(
|
| 78 |
+
User.username.ilike(f"%{query}%"),
|
| 79 |
+
User.display_name.ilike(f"%{query}%")
|
| 80 |
+
)
|
| 81 |
+
).limit(limit)
|
| 82 |
+
)
|
| 83 |
+
return result.scalars().all()
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
async def add_friend(db: AsyncSession, user_id: str, friend_id: str) -> Friend:
|
| 87 |
+
"""Send friend request."""
|
| 88 |
+
# Check not already friends
|
| 89 |
+
result = await db.execute(
|
| 90 |
+
select(Friend).where(
|
| 91 |
+
or_(
|
| 92 |
+
and_(Friend.user_id == user_id, Friend.friend_id == friend_id),
|
| 93 |
+
and_(Friend.user_id == friend_id, Friend.friend_id == user_id)
|
| 94 |
+
)
|
| 95 |
+
)
|
| 96 |
+
)
|
| 97 |
+
if result.scalar_one_or_none():
|
| 98 |
+
raise ValueError("Already friends or request pending")
|
| 99 |
+
|
| 100 |
+
friend = Friend(
|
| 101 |
+
user_id=user_id,
|
| 102 |
+
friend_id=friend_id,
|
| 103 |
+
status="pending"
|
| 104 |
+
)
|
| 105 |
+
db.add(friend)
|
| 106 |
+
await db.commit()
|
| 107 |
+
await db.refresh(friend)
|
| 108 |
+
return friend
|
| 109 |
+
|
| 110 |
+
@staticmethod
|
| 111 |
+
async def accept_friend(db: AsyncSession, user_id: str, friend_id: str) -> bool:
|
| 112 |
+
"""Accept friend request."""
|
| 113 |
+
result = await db.execute(
|
| 114 |
+
select(Friend).where(
|
| 115 |
+
and_(Friend.user_id == friend_id, Friend.friend_id == user_id, Friend.status == "pending")
|
| 116 |
+
)
|
| 117 |
+
)
|
| 118 |
+
friend = result.scalar_one_or_none()
|
| 119 |
+
|
| 120 |
+
if not friend:
|
| 121 |
+
return False
|
| 122 |
+
|
| 123 |
+
friend.status = "accepted"
|
| 124 |
+
await db.commit()
|
| 125 |
+
return True
|
| 126 |
+
|
| 127 |
+
@staticmethod
|
| 128 |
+
async def get_friends(db: AsyncSession, user_id: str) -> List[User]:
|
| 129 |
+
"""Get user's friends."""
|
| 130 |
+
result = await db.execute(
|
| 131 |
+
select(Friend).where(
|
| 132 |
+
or_(
|
| 133 |
+
and_(Friend.user_id == user_id, Friend.status == "accepted"),
|
| 134 |
+
and_(Friend.friend_id == user_id, Friend.status == "accepted")
|
| 135 |
+
)
|
| 136 |
+
)
|
| 137 |
+
)
|
| 138 |
+
friends = result.scalars().all()
|
| 139 |
+
|
| 140 |
+
friend_ids = []
|
| 141 |
+
for f in friends:
|
| 142 |
+
if f.user_id == user_id:
|
| 143 |
+
friend_ids.append(f.friend_id)
|
| 144 |
+
else:
|
| 145 |
+
friend_ids.append(f.user_id)
|
| 146 |
+
|
| 147 |
+
result = await db.execute(select(User).where(User.id.in_(friend_ids)))
|
| 148 |
+
return result.scalars().all()
|
backend/app/sockets/ws.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WebSocket handler - real-time notifications (optional).
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import WebSocket, WebSocketDisconnect
|
| 5 |
+
from typing import List, Dict
|
| 6 |
+
import json
|
| 7 |
+
import asyncio
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ConnectionManager:
|
| 11 |
+
"""Manage WebSocket connections."""
|
| 12 |
+
|
| 13 |
+
def __init__(self):
|
| 14 |
+
# user_id -> WebSocket
|
| 15 |
+
self.active_connections: Dict[str, WebSocket] = {}
|
| 16 |
+
|
| 17 |
+
async def connect(self, user_id: str, websocket: WebSocket):
|
| 18 |
+
"""Accept and store a WebSocket connection."""
|
| 19 |
+
await websocket.accept()
|
| 20 |
+
self.active_connections[user_id] = websocket
|
| 21 |
+
|
| 22 |
+
def disconnect(self, user_id: str):
|
| 23 |
+
"""Remove a connection."""
|
| 24 |
+
if user_id in self.active_connections:
|
| 25 |
+
del self.active_connections[user_id]
|
| 26 |
+
|
| 27 |
+
async def send_personal(self, user_id: str, message: dict):
|
| 28 |
+
"""Send message to specific user."""
|
| 29 |
+
if user_id in self.active_connections:
|
| 30 |
+
try:
|
| 31 |
+
await self.active_connections[user_id].send_text(json.dumps(message))
|
| 32 |
+
except Exception:
|
| 33 |
+
self.disconnect(user_id)
|
| 34 |
+
|
| 35 |
+
async def broadcast(self, message: dict):
|
| 36 |
+
"""Broadcast to all connected users."""
|
| 37 |
+
for ws in list(self.active_connections.values()):
|
| 38 |
+
try:
|
| 39 |
+
await ws.send_text(json.dumps(message))
|
| 40 |
+
except Exception:
|
| 41 |
+
pass
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
manager = ConnectionManager()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
| 48 |
+
"""
|
| 49 |
+
WebSocket endpoint for real-time notifications.
|
| 50 |
+
|
| 51 |
+
Events:
|
| 52 |
+
- new_message: New message received
|
| 53 |
+
- message_deleted: Message was deleted
|
| 54 |
+
- friend_request: New friend request
|
| 55 |
+
- group_invite: Group invitation
|
| 56 |
+
"""
|
| 57 |
+
await manager.connect(user_id, websocket)
|
| 58 |
+
try:
|
| 59 |
+
while True:
|
| 60 |
+
# Keep connection alive, listen for client messages
|
| 61 |
+
data = await websocket.receive_text()
|
| 62 |
+
# Could handle client acknowledgments here
|
| 63 |
+
pass
|
| 64 |
+
except WebSocketDisconnect:
|
| 65 |
+
manager.disconnect(user_id)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def notify_new_message(receiver_id: str, message: dict):
|
| 69 |
+
"""Notify user of new message."""
|
| 70 |
+
await manager.send_personal(receiver_id, {
|
| 71 |
+
"type": "new_message",
|
| 72 |
+
"data": message
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def notify_friend_request(user_id: str, request: dict):
|
| 77 |
+
"""Notify user of friend request."""
|
| 78 |
+
await manager.send_personal(user_id, {
|
| 79 |
+
"type": "friend_request",
|
| 80 |
+
"data": request
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def notify_group_invite(user_id: str, invite: dict):
|
| 85 |
+
"""Notify user of group invite."""
|
| 86 |
+
await manager.send_personal(user_id, {
|
| 87 |
+
"type": "group_invite",
|
| 88 |
+
"data": invite
|
| 89 |
+
})
|
backend/app/system/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System package - async batch processing core.
|
| 3 |
+
"""
|
| 4 |
+
from .controller import SystemController, controller
|
| 5 |
+
from .queue import MessageQueue, message_queue
|
| 6 |
+
from .worker import Worker, worker
|
| 7 |
+
from .batch import BatchProcessor, batch_processor
|
| 8 |
+
from .scheduler import BatchScheduler, batch_scheduler
|
| 9 |
+
from .monitor import SystemMonitor, system_monitor
|
| 10 |
+
|
| 11 |
+
__all__ = [
|
| 12 |
+
"SystemController", "controller",
|
| 13 |
+
"MessageQueue", "message_queue",
|
| 14 |
+
"Worker", "worker",
|
| 15 |
+
"BatchProcessor", "batch_processor",
|
| 16 |
+
"BatchScheduler", "batch_scheduler",
|
| 17 |
+
"SystemMonitor", "system_monitor"
|
| 18 |
+
]
|
backend/app/system/batch.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Batch builder - groups messages and compresses them.
|
| 3 |
+
"""
|
| 4 |
+
import json
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
import uuid
|
| 9 |
+
|
| 10 |
+
from ..config import settings
|
| 11 |
+
from ..utils.compression import compress_zstd
|
| 12 |
+
from ..utils.time import get_date_path, utc_timestamp
|
| 13 |
+
from ..services.file_service import FileService
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class BatchProcessor:
|
| 17 |
+
"""
|
| 18 |
+
Batch processor - groups messages by user/project/time and compresses.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
def __init__(self, max_size_mb: int = 10):
|
| 22 |
+
self._max_size_bytes = max_size_mb * 1024 * 1024
|
| 23 |
+
self._current_batch: List[dict] = []
|
| 24 |
+
self._current_size = 0
|
| 25 |
+
self._batch_id = str(uuid.uuid4())[:8]
|
| 26 |
+
self._file_service = FileService()
|
| 27 |
+
|
| 28 |
+
async def add_message(self, message: dict) -> bool:
|
| 29 |
+
"""
|
| 30 |
+
Add a message to the current batch.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
message: Cleaned message dict
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
True if added, False if batch is full
|
| 37 |
+
"""
|
| 38 |
+
msg_size = len(json.dumps(message).encode())
|
| 39 |
+
|
| 40 |
+
# Check if adding would exceed limit
|
| 41 |
+
if self._current_size + msg_size > self._max_size_bytes:
|
| 42 |
+
# Compress current batch
|
| 43 |
+
await self.compress_and_save()
|
| 44 |
+
self._start_new_batch()
|
| 45 |
+
|
| 46 |
+
self._current_batch.append(message)
|
| 47 |
+
self._current_size += msg_size
|
| 48 |
+
return True
|
| 49 |
+
|
| 50 |
+
def is_ready(self) -> bool:
|
| 51 |
+
"""Check if batch is ready for compression."""
|
| 52 |
+
# Ready if has messages and reaches size threshold (1MB)
|
| 53 |
+
return len(self._current_batch) > 0 and self._current_size > 1024 * 1024
|
| 54 |
+
|
| 55 |
+
async def compress_and_save(self) -> Optional[Path]:
|
| 56 |
+
"""
|
| 57 |
+
Compress current batch and save to storage.
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
Path to saved file or None on failure
|
| 61 |
+
"""
|
| 62 |
+
if not self._current_batch:
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
# Prepare batch data
|
| 67 |
+
batch_data = {
|
| 68 |
+
"batch_id": self._batch_id,
|
| 69 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 70 |
+
"message_count": len(self._current_batch),
|
| 71 |
+
"messages": self._current_batch
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
# Serialize and compress
|
| 75 |
+
json_data = json.dumps(batch_data, ensure_ascii=False)
|
| 76 |
+
compressed = compress_zstd(json_data.encode())
|
| 77 |
+
|
| 78 |
+
# Generate filename
|
| 79 |
+
timestamp = utc_timestamp()
|
| 80 |
+
filename = f"{timestamp}_{self._batch_id}.zst"
|
| 81 |
+
|
| 82 |
+
# Save to local storage
|
| 83 |
+
# In production, use user_id from messages
|
| 84 |
+
user_id = self._current_batch[0].get("sender_id", "default")
|
| 85 |
+
project_id = "chat_messages"
|
| 86 |
+
date_path = get_date_path()
|
| 87 |
+
|
| 88 |
+
storage_path = self._file_service.get_local_storage_path(user_id, project_id, date_path)
|
| 89 |
+
file_path = storage_path / filename
|
| 90 |
+
|
| 91 |
+
with open(file_path, "wb") as f:
|
| 92 |
+
f.write(compressed)
|
| 93 |
+
|
| 94 |
+
# Create index file
|
| 95 |
+
await self._save_index(storage_path, filename, batch_data)
|
| 96 |
+
|
| 97 |
+
# Optionally upload to GitHub
|
| 98 |
+
if settings.GITHUB_REPO:
|
| 99 |
+
await self._file_service.upload_to_github(file_path, settings.GITHUB_REPO)
|
| 100 |
+
|
| 101 |
+
self._start_new_batch()
|
| 102 |
+
return file_path
|
| 103 |
+
|
| 104 |
+
except Exception as e:
|
| 105 |
+
print(f"Batch save error: {e}")
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
async def _save_index(self, storage_path: Path, filename: str, batch_data: dict):
|
| 109 |
+
"""Save or update index.json for the directory."""
|
| 110 |
+
index_path = storage_path / "index.json"
|
| 111 |
+
|
| 112 |
+
index_data = []
|
| 113 |
+
if index_path.exists():
|
| 114 |
+
with open(index_path, "r") as f:
|
| 115 |
+
index_data = json.load(f)
|
| 116 |
+
|
| 117 |
+
index_data.append({
|
| 118 |
+
"filename": filename,
|
| 119 |
+
"batch_id": self._batch_id,
|
| 120 |
+
"created_at": batch_data["created_at"],
|
| 121 |
+
"message_count": batch_data["message_count"],
|
| 122 |
+
"compressed_size": len(compress_zstd(json.dumps(batch_data).encode()))
|
| 123 |
+
})
|
| 124 |
+
|
| 125 |
+
with open(index_path, "w") as f:
|
| 126 |
+
json.dump(index_data, f, indent=2)
|
| 127 |
+
|
| 128 |
+
def _start_new_batch(self):
|
| 129 |
+
"""Start a new batch."""
|
| 130 |
+
self._current_batch = []
|
| 131 |
+
self._current_size = 0
|
| 132 |
+
self._batch_id = str(uuid.uuid4())[:8]
|
| 133 |
+
|
| 134 |
+
async def force_compress(self) -> Optional[Path]:
|
| 135 |
+
"""Force compress current batch even if not full."""
|
| 136 |
+
if self._current_batch:
|
| 137 |
+
return await self.compress_and_save()
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# Global batch processor
|
| 142 |
+
batch_processor = BatchProcessor()
|
backend/app/system/controller.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
System controller - main entry point for async batch processing.
|
| 3 |
+
Receives messages and routes to appropriate handlers.
|
| 4 |
+
"""
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from ..models import Message
|
| 7 |
+
from .queue import MessageQueue
|
| 8 |
+
from .batch import BatchProcessor
|
| 9 |
+
from .scheduler import BatchScheduler
|
| 10 |
+
from .monitor import SystemMonitor
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class SystemController:
|
| 14 |
+
"""
|
| 15 |
+
Main controller for async message processing.
|
| 16 |
+
Coordinates queue, batch, scheduler, and monitoring.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.queue = MessageQueue()
|
| 21 |
+
self.batch_processor = BatchProcessor()
|
| 22 |
+
self.scheduler = BatchScheduler()
|
| 23 |
+
self.monitor = SystemMonitor()
|
| 24 |
+
|
| 25 |
+
async def receive_message(self, message: Message) -> bool:
|
| 26 |
+
"""
|
| 27 |
+
Receive a message and add it to the processing queue.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
message: Message to process
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
True if queued successfully
|
| 34 |
+
"""
|
| 35 |
+
try:
|
| 36 |
+
await self.queue.enqueue(message)
|
| 37 |
+
self.monitor.increment_queue_size()
|
| 38 |
+
return True
|
| 39 |
+
except Exception as e:
|
| 40 |
+
self.monitor.log_error(f"Failed to queue message: {e}")
|
| 41 |
+
return False
|
| 42 |
+
|
| 43 |
+
async def trigger_batch(
|
| 44 |
+
self,
|
| 45 |
+
user_id: Optional[str] = None,
|
| 46 |
+
group_id: Optional[str] = None
|
| 47 |
+
) -> dict:
|
| 48 |
+
"""
|
| 49 |
+
Manually trigger batch processing.
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
user_id: Optional specific user to process
|
| 53 |
+
group_id: Optional specific group to process
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Status dict with processing results
|
| 57 |
+
"""
|
| 58 |
+
return await self.batch_processor.process_batch(user_id, group_id)
|
| 59 |
+
|
| 60 |
+
async def get_status(self) -> dict:
|
| 61 |
+
"""Get system status."""
|
| 62 |
+
return {
|
| 63 |
+
"queue_size": self.queue.size(),
|
| 64 |
+
"monitor": self.monitor.get_stats(),
|
| 65 |
+
"scheduler_next": self.scheduler.get_next_run()
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
async def start_scheduler(self):
|
| 69 |
+
"""Start the batch scheduler."""
|
| 70 |
+
await self.scheduler.start()
|
| 71 |
+
|
| 72 |
+
async def stop_scheduler(self):
|
| 73 |
+
"""Stop the batch scheduler."""
|
| 74 |
+
await self.scheduler.stop()
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# Global controller instance
|
| 78 |
+
controller = SystemController()
|
backend/app/system/monitor.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Monitor - logs, queue size, errors tracking.
|
| 3 |
+
"""
|
| 4 |
+
from typing import Dict, List
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from collections import deque
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class SystemMonitor:
|
| 10 |
+
"""
|
| 11 |
+
System monitor - tracks metrics, logs, and errors.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
def __init__(self, max_log_entries: int = 1000):
|
| 15 |
+
self._queue_size = 0
|
| 16 |
+
self._processed_count = 0
|
| 17 |
+
self._batch_count = 0
|
| 18 |
+
self._error_count = 0
|
| 19 |
+
self._errors: deque = deque(maxlen=100)
|
| 20 |
+
self._max_log = max_log_entries
|
| 21 |
+
|
| 22 |
+
def increment_queue_size(self, delta: int = 1):
|
| 23 |
+
"""Increment queue size counter."""
|
| 24 |
+
self._queue_size += delta
|
| 25 |
+
|
| 26 |
+
def decrement_queue_size(self, delta: int = 1):
|
| 27 |
+
"""Decrement queue size counter."""
|
| 28 |
+
self._queue_size = max(0, self._queue_size - delta)
|
| 29 |
+
|
| 30 |
+
def increment_processed(self, delta: int = 1):
|
| 31 |
+
"""Increment processed messages counter."""
|
| 32 |
+
self._processed_count += delta
|
| 33 |
+
|
| 34 |
+
def increment_batches(self, delta: int = 1):
|
| 35 |
+
"""Increment batch count."""
|
| 36 |
+
self._batch_count += delta
|
| 37 |
+
|
| 38 |
+
def increment_errors(self, delta: int = 1):
|
| 39 |
+
"""Increment error counter."""
|
| 40 |
+
self._error_count += delta
|
| 41 |
+
|
| 42 |
+
def log_error(self, message: str):
|
| 43 |
+
"""Log an error message."""
|
| 44 |
+
self._errors.append({
|
| 45 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 46 |
+
"message": message
|
| 47 |
+
})
|
| 48 |
+
self.increment_errors()
|
| 49 |
+
|
| 50 |
+
def get_stats(self) -> Dict:
|
| 51 |
+
"""Get current statistics."""
|
| 52 |
+
return {
|
| 53 |
+
"queue_size": self._queue_size,
|
| 54 |
+
"processed_total": self._processed_count,
|
| 55 |
+
"batches_processed": self._batch_count,
|
| 56 |
+
"error_count": self._error_count,
|
| 57 |
+
"error_rate": self._error_count / max(1, self._processed_count)
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
def get_recent_errors(self, limit: int = 10) -> List[Dict]:
|
| 61 |
+
"""Get recent error entries."""
|
| 62 |
+
return list(self._errors)[-limit:]
|
| 63 |
+
|
| 64 |
+
def reset(self):
|
| 65 |
+
"""Reset all counters."""
|
| 66 |
+
self._queue_size = 0
|
| 67 |
+
self._processed_count = 0
|
| 68 |
+
self._batch_count = 0
|
| 69 |
+
self._error_count = 0
|
| 70 |
+
self._errors.clear()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# Global monitor instance
|
| 74 |
+
system_monitor = SystemMonitor()
|
backend/app/system/queue.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Message queue - buffer system for async message processing.
|
| 3 |
+
"""
|
| 4 |
+
from typing import Optional, List
|
| 5 |
+
from collections import deque
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class MessageQueue:
|
| 12 |
+
"""
|
| 13 |
+
In-memory message queue with async support.
|
| 14 |
+
In production, use Redis for distributed processing.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, max_size: int = 10000):
|
| 18 |
+
self._queue = deque()
|
| 19 |
+
self._max_size = max_size
|
| 20 |
+
self._lock = asyncio.Lock()
|
| 21 |
+
|
| 22 |
+
async def enqueue(self, message) -> bool:
|
| 23 |
+
"""
|
| 24 |
+
Add a message to the queue.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
message: Message object to queue
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
True if enqueued successfully
|
| 31 |
+
"""
|
| 32 |
+
async with self._lock:
|
| 33 |
+
if len(self._queue) >= self._max_size:
|
| 34 |
+
return False
|
| 35 |
+
|
| 36 |
+
# Serialize message for storage
|
| 37 |
+
msg_data = {
|
| 38 |
+
"id": message.id,
|
| 39 |
+
"sender_id": message.sender_id,
|
| 40 |
+
"receiver_id": message.receiver_id,
|
| 41 |
+
"group_id": message.group_id,
|
| 42 |
+
"content": message.content,
|
| 43 |
+
"type": message.type,
|
| 44 |
+
"created_at": message.created_at.isoformat() if message.created_at else None
|
| 45 |
+
}
|
| 46 |
+
self._queue.append(msg_data)
|
| 47 |
+
return True
|
| 48 |
+
|
| 49 |
+
async def dequeue(self) -> Optional[dict]:
|
| 50 |
+
"""
|
| 51 |
+
Remove and return the oldest message from queue.
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
Message data dict or None if empty
|
| 55 |
+
"""
|
| 56 |
+
async with self._lock:
|
| 57 |
+
if not self._queue:
|
| 58 |
+
return None
|
| 59 |
+
return self._queue.popleft()
|
| 60 |
+
|
| 61 |
+
async def peek(self) -> Optional[dict]:
|
| 62 |
+
"""View the oldest message without removing it."""
|
| 63 |
+
async with self._lock:
|
| 64 |
+
if not self._queue:
|
| 65 |
+
return None
|
| 66 |
+
return self._queue[0]
|
| 67 |
+
|
| 68 |
+
async def get_batch(self, size: int = 100) -> List[dict]:
|
| 69 |
+
"""Get multiple messages at once for batch processing."""
|
| 70 |
+
async with self._lock:
|
| 71 |
+
batch = []
|
| 72 |
+
for _ in range(min(size, len(self._queue))):
|
| 73 |
+
if self._queue:
|
| 74 |
+
batch.append(self._queue.popleft())
|
| 75 |
+
return batch
|
| 76 |
+
|
| 77 |
+
async def clear(self):
|
| 78 |
+
"""Clear all messages from queue."""
|
| 79 |
+
async with self._lock:
|
| 80 |
+
self._queue.clear()
|
| 81 |
+
|
| 82 |
+
def size(self) -> int:
|
| 83 |
+
"""Get current queue size (non-async)."""
|
| 84 |
+
return len(self._queue)
|
| 85 |
+
|
| 86 |
+
async def is_empty(self) -> bool:
|
| 87 |
+
"""Check if queue is empty."""
|
| 88 |
+
async with self._lock:
|
| 89 |
+
return len(self._queue) == 0
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# Global queue instance
|
| 93 |
+
message_queue = MessageQueue()
|
backend/app/system/scheduler.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Scheduler - time-based trigger for batch processing (daily/hourly).
|
| 3 |
+
"""
|
| 4 |
+
import asyncio
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from typing import Optional, Callable
|
| 7 |
+
from ..config import settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class BatchScheduler:
|
| 11 |
+
"""
|
| 12 |
+
Scheduler for automatic batch processing.
|
| 13 |
+
Triggers compression at configured intervals.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self._running = False
|
| 18 |
+
self._task: Optional[asyncio.Task] = None
|
| 19 |
+
self._interval_minutes = settings.BATCH_INTERVAL_MINUTES
|
| 20 |
+
self._next_run: Optional[datetime] = None
|
| 21 |
+
self._last_run: Optional[datetime] = None
|
| 22 |
+
|
| 23 |
+
async def start(self):
|
| 24 |
+
"""Start the scheduler."""
|
| 25 |
+
self._running = True
|
| 26 |
+
self._calculate_next_run()
|
| 27 |
+
self._task = asyncio.create_task(self._run_loop())
|
| 28 |
+
|
| 29 |
+
async def stop(self):
|
| 30 |
+
"""Stop the scheduler."""
|
| 31 |
+
self._running = False
|
| 32 |
+
if self._task:
|
| 33 |
+
self._task.cancel()
|
| 34 |
+
try:
|
| 35 |
+
await self._task
|
| 36 |
+
except asyncio.CancelledError:
|
| 37 |
+
pass
|
| 38 |
+
|
| 39 |
+
async def _run_loop(self):
|
| 40 |
+
"""Main scheduler loop."""
|
| 41 |
+
while self._running:
|
| 42 |
+
try:
|
| 43 |
+
if self._should_run():
|
| 44 |
+
await self._trigger_batch()
|
| 45 |
+
except Exception as e:
|
| 46 |
+
print(f"Scheduler error: {e}")
|
| 47 |
+
|
| 48 |
+
# Check every minute
|
| 49 |
+
await asyncio.sleep(60)
|
| 50 |
+
|
| 51 |
+
def _should_run(self) -> bool:
|
| 52 |
+
"""Check if it's time to run."""
|
| 53 |
+
if not self._next_run:
|
| 54 |
+
return False
|
| 55 |
+
return datetime.utcnow() >= self._next_run
|
| 56 |
+
|
| 57 |
+
def _calculate_next_run(self):
|
| 58 |
+
"""Calculate next run time."""
|
| 59 |
+
self._next_run = datetime.utcnow() + timedelta(minutes=self._interval_minutes)
|
| 60 |
+
|
| 61 |
+
async def _trigger_batch(self):
|
| 62 |
+
"""Trigger batch processing."""
|
| 63 |
+
from .batch import batch_processor
|
| 64 |
+
from .monitor import system_monitor
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
await batch_processor.force_compress()
|
| 68 |
+
self._last_run = datetime.utcnow()
|
| 69 |
+
self._calculate_next_run()
|
| 70 |
+
system_monitor.increment_batches()
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f"Scheduled batch error: {e}")
|
| 73 |
+
|
| 74 |
+
def get_next_run(self) -> Optional[datetime]:
|
| 75 |
+
"""Get next scheduled run time."""
|
| 76 |
+
return self._next_run
|
| 77 |
+
|
| 78 |
+
def get_last_run(self) -> Optional[datetime]:
|
| 79 |
+
"""Get last run time."""
|
| 80 |
+
return self._last_run
|
| 81 |
+
|
| 82 |
+
async def run_now(self):
|
| 83 |
+
"""Manually trigger a batch immediately."""
|
| 84 |
+
await self._trigger_batch()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# Global scheduler
|
| 88 |
+
batch_scheduler = BatchScheduler()
|
backend/app/system/worker.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Worker - process messages from queue (clean, group, compress).
|
| 3 |
+
"""
|
| 4 |
+
import asyncio
|
| 5 |
+
from typing import Optional, List
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
|
| 8 |
+
from .queue import message_queue
|
| 9 |
+
from .batch import BatchProcessor
|
| 10 |
+
from .monitor import SystemMonitor
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Worker:
|
| 14 |
+
"""
|
| 15 |
+
Background worker that processes messages from queue.
|
| 16 |
+
Handles cleaning, grouping, and preparing for compression.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.batch_processor = BatchProcessor()
|
| 21 |
+
self.monitor = SystemMonitor()
|
| 22 |
+
self._running = False
|
| 23 |
+
self._task: Optional[asyncio.Task] = None
|
| 24 |
+
|
| 25 |
+
async def start(self, interval_seconds: int = 5):
|
| 26 |
+
"""
|
| 27 |
+
Start the worker process.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
interval_seconds: How often to check queue
|
| 31 |
+
"""
|
| 32 |
+
self._running = True
|
| 33 |
+
self._task = asyncio.create_task(self._run_loop(interval_seconds))
|
| 34 |
+
|
| 35 |
+
async def stop(self):
|
| 36 |
+
"""Stop the worker."""
|
| 37 |
+
self._running = False
|
| 38 |
+
if self._task:
|
| 39 |
+
self._task.cancel()
|
| 40 |
+
try:
|
| 41 |
+
await self._task
|
| 42 |
+
except asyncio.CancelledError:
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
async def _run_loop(self, interval: int):
|
| 46 |
+
"""Main worker loop."""
|
| 47 |
+
while self._running:
|
| 48 |
+
try:
|
| 49 |
+
await self._process_queue()
|
| 50 |
+
except Exception as e:
|
| 51 |
+
self.monitor.log_error(f"Worker error: {e}")
|
| 52 |
+
|
| 53 |
+
await asyncio.sleep(interval)
|
| 54 |
+
|
| 55 |
+
async def _process_queue(self):
|
| 56 |
+
"""Process pending messages in queue."""
|
| 57 |
+
# Get batch of messages
|
| 58 |
+
messages = await message_queue.get_batch(size=100)
|
| 59 |
+
|
| 60 |
+
if not messages:
|
| 61 |
+
return
|
| 62 |
+
|
| 63 |
+
# Process each message
|
| 64 |
+
for msg_data in messages:
|
| 65 |
+
try:
|
| 66 |
+
# Clean and normalize message
|
| 67 |
+
cleaned = await self._clean_message(msg_data)
|
| 68 |
+
|
| 69 |
+
# Add to batch processor
|
| 70 |
+
await self.batch_processor.add_message(cleaned)
|
| 71 |
+
|
| 72 |
+
self.monitor.increment_processed()
|
| 73 |
+
except Exception as e:
|
| 74 |
+
self.monitor.log_error(f"Message processing error: {e}")
|
| 75 |
+
|
| 76 |
+
# Check if batch is ready to compress
|
| 77 |
+
await self._check_batch_ready()
|
| 78 |
+
|
| 79 |
+
async def _clean_message(self, msg_data: dict) -> dict:
|
| 80 |
+
"""Clean and normalize message data."""
|
| 81 |
+
# In production, use formatter.clean_message
|
| 82 |
+
content = msg_data.get("content", "")
|
| 83 |
+
|
| 84 |
+
# Basic cleaning
|
| 85 |
+
cleaned_content = content.strip()
|
| 86 |
+
|
| 87 |
+
return {
|
| 88 |
+
**msg_data,
|
| 89 |
+
"content": cleaned_content,
|
| 90 |
+
"processed_at": datetime.utcnow().isoformat()
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
async def _check_batch_ready(self):
|
| 94 |
+
"""Check if batch is ready for compression."""
|
| 95 |
+
if self.batch_processor.is_ready():
|
| 96 |
+
await self.batch_processor.compress_and_save()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# Global worker instance
|
| 100 |
+
worker = Worker()
|
backend/app/utils/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utils package - helper utilities.
|
| 3 |
+
"""
|
| 4 |
+
from .security import hash_password, verify_password, create_access_token, decode_access_token
|
| 5 |
+
from .avatar import generate_avatar, get_avatar_initials
|
| 6 |
+
from .formatter import clean_message, normalize_content
|
| 7 |
+
from .time import utc_now, utc_timestamp, format_date
|
| 8 |
+
from .compression import compress_zstd, decompress_zstd
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"hash_password", "verify_password", "create_access_token", "decode_access_token",
|
| 12 |
+
"generate_avatar", "get_avatar_initials",
|
| 13 |
+
"clean_message", "normalize_content",
|
| 14 |
+
"utc_now", "utc_timestamp", "format_date",
|
| 15 |
+
"compress_zstd", "decompress_zstd"
|
| 16 |
+
]
|
backend/app/utils/avatar.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Avatar utilities - auto-generate avatars for users.
|
| 3 |
+
"""
|
| 4 |
+
import hashlib
|
| 5 |
+
import base64
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from io import BytesIO
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def generate_avatar_seed(user_id: str) -> str:
|
| 12 |
+
"""Generate a deterministic seed from user ID for avatar."""
|
| 13 |
+
return hashlib.md5(user_id.encode()).hexdigest()[:16]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_avatar_color(user_id: str) -> tuple:
|
| 17 |
+
"""Generate a consistent color from user ID (RGB)."""
|
| 18 |
+
seed = generate_avatar_seed(user_id)
|
| 19 |
+
# Generate consistent color based on seed
|
| 20 |
+
r = int(seed[0:2], 16) % 200 + 30 # Avoid too dark
|
| 21 |
+
g = int(seed[2:4], 16) % 200 + 30
|
| 22 |
+
b = int(seed[4:6], 16) % 200 + 30
|
| 23 |
+
return (r, g, b)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_avatar_initials(username: str) -> str:
|
| 27 |
+
"""Get initials from username for avatar display."""
|
| 28 |
+
if not username:
|
| 29 |
+
return "?"
|
| 30 |
+
parts = username.split()
|
| 31 |
+
if len(parts) >= 2:
|
| 32 |
+
return (parts[0][0] + parts[1][0]).upper()
|
| 33 |
+
return username[:2].upper()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def generate_identicon(user_id: str) -> str:
|
| 37 |
+
"""
|
| 38 |
+
Generate a simple identicon (base64 encoded).
|
| 39 |
+
In production, use a proper library or service.
|
| 40 |
+
"""
|
| 41 |
+
# Simplified placeholder - returns a colored background URL
|
| 42 |
+
color = get_avatar_color(user_id)
|
| 43 |
+
# In production: generate actual identicon or use a service like DiceBear
|
| 44 |
+
return f"https://api.dicebear.com/7.x/initials/svg?seed={user_id}"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def generate_avatar(user_id: str, username: str) -> str:
|
| 48 |
+
"""
|
| 49 |
+
Generate an avatar URL for a user.
|
| 50 |
+
Can be customized to use different styles or services.
|
| 51 |
+
"""
|
| 52 |
+
# Using DiceBear API for automatic avatar generation
|
| 53 |
+
# Options: 'initials', 'identicon', 'bottts', 'avataaars', etc.
|
| 54 |
+
return f"https://api.dicebear.com/7.x/initials/svg?seed={username}&backgroundColor={get_avatar_color(user_id)}"
|
backend/app/utils/compression.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Compression utilities - gzip and zstd compression for batch storage.
|
| 3 |
+
"""
|
| 4 |
+
import gzip
|
| 5 |
+
import zstandard as zstd
|
| 6 |
+
from typing import bytes
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def compress_gzip(data: bytes) -> bytes:
|
| 12 |
+
"""Compress data using gzip."""
|
| 13 |
+
return gzip.compress(data, compresslevel=6)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def decompress_gzip(data: bytes) -> bytes:
|
| 17 |
+
"""Decompress gzip data."""
|
| 18 |
+
return gzip.decompress(data)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def compress_zstd(data: bytes, level: int = 3) -> bytes:
|
| 22 |
+
"""Compress data using zstd (better compression ratio)."""
|
| 23 |
+
cctx = zstd.ZstdCompressor(level=level)
|
| 24 |
+
return cctx.compress(data)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def decompress_zstd(data: bytes) -> bytes:
|
| 28 |
+
"""Decompress zstd data."""
|
| 29 |
+
dctx = zstd.ZstdDecompressor()
|
| 30 |
+
return dctx.decompress(data)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def compress_file(input_path: Path, output_path: Path, method: str = "zstd") -> bool:
|
| 34 |
+
"""
|
| 35 |
+
Compress a file and save to output path.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
input_path: Source file path
|
| 39 |
+
output_path: Destination file path
|
| 40 |
+
method: 'zstd' or 'gzip'
|
| 41 |
+
"""
|
| 42 |
+
try:
|
| 43 |
+
with open(input_path, "rb") as f:
|
| 44 |
+
data = f.read()
|
| 45 |
+
|
| 46 |
+
if method == "zstd":
|
| 47 |
+
compressed = compress_zstd(data)
|
| 48 |
+
else:
|
| 49 |
+
compressed = compress_gzip(data)
|
| 50 |
+
|
| 51 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
with open(output_path, "wb") as f:
|
| 53 |
+
f.write(compressed)
|
| 54 |
+
|
| 55 |
+
return True
|
| 56 |
+
except Exception as e:
|
| 57 |
+
print(f"Compression error: {e}")
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def decompress_file(input_path: Path, output_path: Path, method: str = "zstd") -> bool:
|
| 62 |
+
"""Decompress a file."""
|
| 63 |
+
try:
|
| 64 |
+
with open(input_path, "rb") as f:
|
| 65 |
+
data = f.read()
|
| 66 |
+
|
| 67 |
+
if method == "zstd":
|
| 68 |
+
decompressed = decompress_zstd(data)
|
| 69 |
+
else:
|
| 70 |
+
decompressed = decompress_gzip(data)
|
| 71 |
+
|
| 72 |
+
with open(output_path, "wb") as f:
|
| 73 |
+
f.write(decompressed)
|
| 74 |
+
|
| 75 |
+
return True
|
| 76 |
+
except Exception as e:
|
| 77 |
+
print(f"Decompression error: {e}")
|
| 78 |
+
return False
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def get_compression_ratio(original_size: int, compressed_size: int) -> float:
|
| 82 |
+
"""Calculate compression ratio."""
|
| 83 |
+
if original_size == 0:
|
| 84 |
+
return 0.0
|
| 85 |
+
return round((1 - compressed_size / original_size) * 100, 2)
|
backend/app/utils/formatter.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Formatter utilities - clean and normalize messages.
|
| 3 |
+
"""
|
| 4 |
+
import re
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from html import escape
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def clean_message(text: str, max_length: int = 4000) -> str:
|
| 10 |
+
"""
|
| 11 |
+
Clean and sanitize message text.
|
| 12 |
+
- Remove excessive whitespace
|
| 13 |
+
- Limit length
|
| 14 |
+
- Escape HTML
|
| 15 |
+
"""
|
| 16 |
+
if not text:
|
| 17 |
+
return ""
|
| 18 |
+
|
| 19 |
+
# Strip leading/trailing whitespace
|
| 20 |
+
text = text.strip()
|
| 21 |
+
|
| 22 |
+
# Remove excessive internal whitespace (more than 2 spaces)
|
| 23 |
+
text = re.sub(r' {2,}', ' ', text)
|
| 24 |
+
|
| 25 |
+
# Remove excessive newlines (more than 2 consecutive)
|
| 26 |
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
| 27 |
+
|
| 28 |
+
# Limit length
|
| 29 |
+
if len(text) > max_length:
|
| 30 |
+
text = text[:max_length]
|
| 31 |
+
|
| 32 |
+
# Escape HTML to prevent XSS
|
| 33 |
+
text = escape(text)
|
| 34 |
+
|
| 35 |
+
return text
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def normalize_content(content: str) -> str:
|
| 39 |
+
"""Normalize message content for storage/search."""
|
| 40 |
+
# Lowercase for search indexing (optional)
|
| 41 |
+
# Remove special characters for search (optional)
|
| 42 |
+
return content.strip()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def format_timestamp(timestamp) -> str:
|
| 46 |
+
"""Format a timestamp for display."""
|
| 47 |
+
from datetime import datetime
|
| 48 |
+
if isinstance(timestamp, datetime):
|
| 49 |
+
return timestamp.strftime("%Y-%m-%d %H:%M")
|
| 50 |
+
return str(timestamp)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def truncate_text(text: str, max_len: int = 50) -> str:
|
| 54 |
+
"""Truncate text for previews."""
|
| 55 |
+
if len(text) <= max_len:
|
| 56 |
+
return text
|
| 57 |
+
return text[:max_len - 3] + "..."
|
backend/app/utils/security.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Security utilities - password hashing, JWT token handling.
|
| 3 |
+
"""
|
| 4 |
+
from passlib.context import CryptContext
|
| 5 |
+
from jose import JWTError, jwt
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
from typing import Optional
|
| 8 |
+
from ..config import settings
|
| 9 |
+
|
| 10 |
+
# Password hashing
|
| 11 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def hash_password(password: str) -> str:
|
| 15 |
+
"""Hash a plain text password."""
|
| 16 |
+
return pwd_context.hash(password)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 20 |
+
"""Verify a password against its hash."""
|
| 21 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
| 25 |
+
"""Create a JWT access token."""
|
| 26 |
+
to_encode = data.copy()
|
| 27 |
+
if expires_delta:
|
| 28 |
+
expire = datetime.utcnow() + expires_delta
|
| 29 |
+
else:
|
| 30 |
+
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRATION_MINUTES)
|
| 31 |
+
|
| 32 |
+
to_encode.update({"exp": expire})
|
| 33 |
+
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
| 34 |
+
return encoded_jwt
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def decode_access_token(token: str) -> Optional[dict]:
|
| 38 |
+
"""Decode and verify a JWT token."""
|
| 39 |
+
try:
|
| 40 |
+
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
|
| 41 |
+
return payload
|
| 42 |
+
except JWTError:
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def create_password_reset_token(email: str) -> str:
|
| 47 |
+
"""Create a password reset token (time-limited)."""
|
| 48 |
+
delta = timedelta(hours=1)
|
| 49 |
+
return create_access_token({"sub": email, "type": "password_reset"}, delta)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def verify_password_reset_token(token: str) -> Optional[str]:
|
| 53 |
+
"""Verify password reset token and return email."""
|
| 54 |
+
payload = decode_access_token(token)
|
| 55 |
+
if payload and payload.get("type") == "password_reset":
|
| 56 |
+
return payload.get("sub")
|
| 57 |
+
return None
|
backend/app/utils/time.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Time utilities - timestamp helpers.
|
| 3 |
+
"""
|
| 4 |
+
from datetime import datetime, timezone, timedelta
|
| 5 |
+
from typing import Optional
|
| 6 |
+
import time
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def utc_now() -> datetime:
|
| 10 |
+
"""Get current UTC time as timezone-aware datetime."""
|
| 11 |
+
return datetime.now(timezone.utc)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def utc_timestamp() -> int:
|
| 15 |
+
"""Get current UTC timestamp in seconds."""
|
| 16 |
+
return int(time.time())
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def format_date(dt: datetime, fmt: str = "%Y-%m-%d") -> str:
|
| 20 |
+
"""Format datetime to string."""
|
| 21 |
+
if dt is None:
|
| 22 |
+
return ""
|
| 23 |
+
return dt.strftime(fmt)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def format_datetime(dt: datetime) -> str:
|
| 27 |
+
"""Format datetime for display."""
|
| 28 |
+
if dt is None:
|
| 29 |
+
return ""
|
| 30 |
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def parse_date(date_str: str) -> Optional[datetime]:
|
| 34 |
+
"""Parse date string to datetime."""
|
| 35 |
+
try:
|
| 36 |
+
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
| 37 |
+
except (ValueError, AttributeError):
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def time_ago(dt: datetime) -> str:
|
| 42 |
+
"""Get human-readable time ago string."""
|
| 43 |
+
if dt is None:
|
| 44 |
+
return "unknown"
|
| 45 |
+
|
| 46 |
+
now = utc_now()
|
| 47 |
+
if dt.tzinfo is None:
|
| 48 |
+
dt = dt.replace(tzinfo=timezone.utc)
|
| 49 |
+
|
| 50 |
+
diff = now - dt
|
| 51 |
+
|
| 52 |
+
seconds = diff.total_seconds()
|
| 53 |
+
if seconds < 60:
|
| 54 |
+
return "just now"
|
| 55 |
+
elif seconds < 3600:
|
| 56 |
+
minutes = int(seconds / 60)
|
| 57 |
+
return f"{minutes}m ago"
|
| 58 |
+
elif seconds < 86400:
|
| 59 |
+
hours = int(seconds / 3600)
|
| 60 |
+
return f"{hours}h ago"
|
| 61 |
+
elif seconds < 604800:
|
| 62 |
+
days = int(seconds / 86400)
|
| 63 |
+
return f"{days}d ago"
|
| 64 |
+
else:
|
| 65 |
+
return format_date(dt)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def get_date_path(dt: Optional[datetime] = None) -> str:
|
| 69 |
+
"""Get date path for storage (YYYY/MM/DD)."""
|
| 70 |
+
if dt is None:
|
| 71 |
+
dt = utc_now()
|
| 72 |
+
return f"{dt.year}/{dt.month:02d}/{dt.day:02d}"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def get_hour_bucket(dt: Optional[datetime] = None) -> int:
|
| 76 |
+
"""Get hour bucket (0-23) for batching."""
|
| 77 |
+
if dt is None:
|
| 78 |
+
dt = utc_now()
|
| 79 |
+
return dt.hour
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FastAPI and async
|
| 2 |
+
fastapi==0.109.0
|
| 3 |
+
uvicorn[standard]==0.27.0
|
| 4 |
+
python-multipart==0.0.6
|
| 5 |
+
pydantic==2.5.3
|
| 6 |
+
pydantic-settings==2.1.0
|
| 7 |
+
|
| 8 |
+
# Database
|
| 9 |
+
sqlalchemy==2.0.25
|
| 10 |
+
asyncpg==0.29.0
|
| 11 |
+
alembic==1.13.1
|
| 12 |
+
|
| 13 |
+
# Auth
|
| 14 |
+
python-jose[cryptography]==3.3.0
|
| 15 |
+
passlib[bcrypt]==1.7.4
|
| 16 |
+
bcrypt==4.1.2
|
| 17 |
+
|
| 18 |
+
# Async queue and workers
|
| 19 |
+
aioredis==2.0.1
|
| 20 |
+
celery==5.3.6
|
| 21 |
+
httpx==0.26.0
|
| 22 |
+
|
| 23 |
+
# Compression
|
| 24 |
+
zstandard==0.22.0
|
| 25 |
+
|
| 26 |
+
# Utils
|
| 27 |
+
python-dotenv==1.0.0
|
| 28 |
+
aiofiles==23.2.1
|
| 29 |
+
python-dateutil==2.8.2
|
| 30 |
+
|
| 31 |
+
# WebSocket
|
| 32 |
+
websockets==12.0
|
| 33 |
+
|
| 34 |
+
# GitHub API
|
| 35 |
+
pygithub==2.2.0
|
| 36 |
+
|
| 37 |
+
# Logging
|
| 38 |
+
loguru==0.7.2
|
| 39 |
+
|
| 40 |
+
# Optional: HuggingFace dataset upload
|
| 41 |
+
# huggingface-hub==0.20.0
|
{assets → frontend}/index-CQxv4P1h.css
RENAMED
|
File without changes
|
{assets → frontend}/index-D-0NzOas.js
RENAMED
|
File without changes
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HuggingFace Space Dependencies
|
| 2 |
+
fastapi>=0.109.0
|
| 3 |
+
uvicorn[standard]>=0.27.0
|
| 4 |
+
python-multipart>=0.0.6
|
| 5 |
+
python-jose[cryptography]>=3.3.0
|
| 6 |
+
passlib[bcrypt]>=1.7.4
|
| 7 |
+
sqlalchemy>=2.0.0
|
| 8 |
+
asyncpg>=0.29.0
|
| 9 |
+
redis>=5.0.0
|
| 10 |
+
python-dotenv>=1.0.0
|
| 11 |
+
pydantic>=2.5.0
|
| 12 |
+
pydantic-settings>=2.1.0
|