From 65b2512d8c1b9ebef40ada3b7ab6f89a8a6c70cb Mon Sep 17 00:00:00 2001 From: Oronemu Date: Sun, 4 Jan 2026 04:58:41 +0700 Subject: [PATCH] Add upload images --- backend/app/api/v1/admin.py | 74 +++++- backend/app/services/telegram_notifier.py | 203 ++++++++++++++++ frontend/src/api/admin.ts | 26 +- .../src/pages/admin/AdminBroadcastPage.tsx | 230 ++++++++++++++++-- 4 files changed, 502 insertions(+), 31 deletions(-) diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index 97a096f..167b817 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -1,8 +1,9 @@ from datetime import datetime -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Form from sqlalchemy import select, func from sqlalchemy.orm import selectinload from pydantic import BaseModel, Field +from typing import Optional from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa from app.models import ( @@ -531,9 +532,10 @@ async def get_logs( @limiter.limit("1/minute") async def broadcast_to_all( request: Request, - data: BroadcastRequest, current_user: CurrentUser, db: DbSession, + message: str = Form(""), + media: list[UploadFile] = File(default=[]), ): """Send broadcast message to all users with Telegram linked. Admin only.""" require_admin_with_2fa(current_user) @@ -547,15 +549,40 @@ async def broadcast_to_all( total_count = len(users) sent_count = 0 + # Read media files if provided (up to 10 files, Telegram limit) + media_items = [] + for file in media[:10]: + if file and file.filename: + file_data = await file.read() + content_type = file.content_type or "" + if content_type.startswith("image/"): + media_items.append({ + "type": "photo", + "data": file_data, + "filename": file.filename, + "content_type": content_type + }) + elif content_type.startswith("video/"): + media_items.append({ + "type": "video", + "data": file_data, + "filename": file.filename, + "content_type": content_type + }) + for user in users: - if await telegram_notifier.send_message(user.telegram_id, data.message): + if await telegram_notifier.send_media_message( + user.telegram_id, + text=message if message.strip() else None, + media_items=media_items if media_items else None + ): sent_count += 1 # Log action await log_admin_action( db, current_user.id, AdminActionType.BROADCAST_ALL.value, "broadcast", 0, - {"message": data.message[:100], "sent": sent_count, "total": total_count}, + {"message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)}, request.client.host if request.client else None ) @@ -567,9 +594,10 @@ async def broadcast_to_all( async def broadcast_to_marathon( request: Request, marathon_id: int, - data: BroadcastRequest, current_user: CurrentUser, db: DbSession, + message: str = Form(""), + media: list[UploadFile] = File(default=[]), ): """Send broadcast message to marathon participants. Admin only.""" require_admin_with_2fa(current_user) @@ -580,7 +608,7 @@ async def broadcast_to_marathon( if not marathon: raise HTTPException(status_code=404, detail="Marathon not found") - # Get participants count + # Get participants with telegram total_result = await db.execute( select(User) .join(Participant, Participant.user_id == User.id) @@ -592,15 +620,41 @@ async def broadcast_to_marathon( users = total_result.scalars().all() total_count = len(users) - sent_count = await telegram_notifier.notify_marathon_participants( - db, marathon_id, data.message - ) + # Read media files if provided (up to 10 files, Telegram limit) + media_items = [] + for file in media[:10]: + if file and file.filename: + file_data = await file.read() + content_type = file.content_type or "" + if content_type.startswith("image/"): + media_items.append({ + "type": "photo", + "data": file_data, + "filename": file.filename, + "content_type": content_type + }) + elif content_type.startswith("video/"): + media_items.append({ + "type": "video", + "data": file_data, + "filename": file.filename, + "content_type": content_type + }) + + sent_count = 0 + for user in users: + if await telegram_notifier.send_media_message( + user.telegram_id, + text=message if message.strip() else None, + media_items=media_items if media_items else None + ): + sent_count += 1 # Log action await log_admin_action( db, current_user.id, AdminActionType.BROADCAST_MARATHON.value, "marathon", marathon_id, - {"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count}, + {"title": marathon.title, "message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)}, request.client.host if request.client else None ) diff --git a/backend/app/services/telegram_notifier.py b/backend/app/services/telegram_notifier.py index 7790b05..f98de46 100644 --- a/backend/app/services/telegram_notifier.py +++ b/backend/app/services/telegram_notifier.py @@ -54,6 +54,209 @@ class TelegramNotifier: logger.error(f"Error sending Telegram message: {e}") return False + async def send_photo( + self, + chat_id: int, + photo: bytes, + caption: str | None = None, + parse_mode: str = "HTML", + filename: str = "photo.jpg", + content_type: str = "image/jpeg" + ) -> bool: + """Send a photo to a Telegram chat.""" + if not self.bot_token: + logger.warning("Telegram bot token not configured") + return False + + try: + timeout = httpx.Timeout(connect=30.0, read=60.0, write=120.0, pool=30.0) + async with httpx.AsyncClient(timeout=timeout) as client: + data = {"chat_id": str(chat_id)} + if caption: + data["caption"] = caption + data["parse_mode"] = parse_mode + + files = {"photo": (filename, photo, content_type)} + + response = await client.post( + f"{self.api_url}/sendPhoto", + data=data, + files=files, + ) + if response.status_code == 200: + return True + else: + logger.error(f"Failed to send photo to {chat_id}: {response.status_code} - {response.text}") + return False + except Exception as e: + logger.error(f"Error sending Telegram photo to {chat_id}: {type(e).__name__}: {e}") + return False + + async def send_video( + self, + chat_id: int, + video: bytes, + caption: str | None = None, + parse_mode: str = "HTML", + filename: str = "video.mp4", + content_type: str = "video/mp4" + ) -> bool: + """Send a video to a Telegram chat.""" + if not self.bot_token: + logger.warning("Telegram bot token not configured") + return False + + try: + timeout = httpx.Timeout(connect=30.0, read=120.0, write=300.0, pool=30.0) + async with httpx.AsyncClient(timeout=timeout) as client: + data = {"chat_id": str(chat_id)} + if caption: + data["caption"] = caption + data["parse_mode"] = parse_mode + + files = {"video": (filename, video, content_type)} + + response = await client.post( + f"{self.api_url}/sendVideo", + data=data, + files=files, + ) + if response.status_code == 200: + return True + else: + logger.error(f"Failed to send video to {chat_id}: {response.status_code} - {response.text}") + return False + except Exception as e: + logger.error(f"Error sending Telegram video to {chat_id}: {type(e).__name__}: {e}") + return False + + async def send_media_group( + self, + chat_id: int, + media_items: list[dict], + caption: str | None = None, + parse_mode: str = "HTML" + ) -> bool: + """ + Send a media group (multiple photos/videos) to a Telegram chat. + + media_items: list of dicts with keys: + - type: "photo" or "video" + - data: bytes + - filename: str + - content_type: str + """ + if not self.bot_token: + logger.warning("Telegram bot token not configured") + return False + + if not media_items: + return False + + try: + import json + # Use longer timeouts for file uploads + timeout = httpx.Timeout( + connect=30.0, + read=120.0, + write=300.0, # 5 minutes for uploading files + pool=30.0 + ) + async with httpx.AsyncClient(timeout=timeout) as client: + # Build media array and files dict + media_array = [] + files_dict = {} + + for i, item in enumerate(media_items): + attach_name = f"media{i}" + media_obj = { + "type": item["type"], + "media": f"attach://{attach_name}" + } + # Only first item gets the caption + if i == 0 and caption: + media_obj["caption"] = caption + media_obj["parse_mode"] = parse_mode + + media_array.append(media_obj) + files_dict[attach_name] = ( + item.get("filename", f"file{i}"), + item["data"], + item.get("content_type", "application/octet-stream") + ) + + data = { + "chat_id": str(chat_id), + "media": json.dumps(media_array) + } + + logger.info(f"Sending media group to {chat_id}: {len(media_items)} files") + response = await client.post( + f"{self.api_url}/sendMediaGroup", + data=data, + files=files_dict, + ) + if response.status_code == 200: + logger.info(f"Successfully sent media group to {chat_id}") + return True + else: + logger.error(f"Failed to send media group to {chat_id}: {response.status_code} - {response.text}") + return False + except Exception as e: + logger.error(f"Error sending Telegram media group to {chat_id}: {type(e).__name__}: {e}") + return False + + async def send_media_message( + self, + chat_id: int, + text: str | None = None, + media_type: str | None = None, + media_data: bytes | None = None, + media_items: list[dict] | None = None, + parse_mode: str = "HTML" + ) -> bool: + """ + Send a message with optional media. + + For single media: use media_type and media_data + For multiple media: use media_items list with dicts containing: + - type: "photo" or "video" + - data: bytes + - filename: str (optional) + - content_type: str (optional) + """ + # Multiple media - use media group + if media_items and len(media_items) > 1: + return await self.send_media_group(chat_id, media_items, text, parse_mode) + + # Single media from media_items + if media_items and len(media_items) == 1: + item = media_items[0] + if item["type"] == "photo": + return await self.send_photo( + chat_id, item["data"], text, parse_mode, + item.get("filename", "photo.jpg"), + item.get("content_type", "image/jpeg") + ) + elif item["type"] == "video": + return await self.send_video( + chat_id, item["data"], text, parse_mode, + item.get("filename", "video.mp4"), + item.get("content_type", "video/mp4") + ) + + # Legacy single media support + if media_data and media_type: + if media_type == "photo": + return await self.send_photo(chat_id, media_data, text, parse_mode) + elif media_type == "video": + return await self.send_video(chat_id, media_data, text, parse_mode) + + if text: + return await self.send_message(chat_id, text, parse_mode) + + return False + async def notify_user( self, db: AsyncSession, diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 962a2b7..8e938a2 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -92,13 +92,31 @@ export const adminApi = { }, // Broadcast - broadcastToAll: async (message: string): Promise => { - const response = await client.post('/admin/broadcast/all', { message }) + broadcastToAll: async (message: string, media?: File[]): Promise => { + const formData = new FormData() + formData.append('message', message) + if (media && media.length > 0) { + media.forEach(file => { + formData.append('media', file) + }) + } + const response = await client.post('/admin/broadcast/all', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) return response.data }, - broadcastToMarathon: async (marathonId: number, message: string): Promise => { - const response = await client.post(`/admin/broadcast/marathon/${marathonId}`, { message }) + broadcastToMarathon: async (marathonId: number, message: string, media?: File[]): Promise => { + const formData = new FormData() + formData.append('message', message) + if (media && media.length > 0) { + media.forEach(file => { + formData.append('media', file) + }) + } + const response = await client.post(`/admin/broadcast/marathon/${marathonId}`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) return response.data }, diff --git a/frontend/src/pages/admin/AdminBroadcastPage.tsx b/frontend/src/pages/admin/AdminBroadcastPage.tsx index 4e323ce..baad002 100644 --- a/frontend/src/pages/admin/AdminBroadcastPage.tsx +++ b/frontend/src/pages/admin/AdminBroadcastPage.tsx @@ -3,7 +3,13 @@ import { adminApi } from '@/api' import type { AdminMarathon } from '@/types' import { useToast } from '@/store/toast' import { NeonButton } from '@/components/ui' -import { Send, Users, Trophy, AlertTriangle, Search, Eye, MessageSquare, ChevronDown, X } from 'lucide-react' +import { Send, Users, Trophy, AlertTriangle, Search, Eye, MessageSquare, ChevronDown, X, Image, Film, Trash2, Plus } from 'lucide-react' + +interface MediaItem { + file: File + preview: string + type: 'photo' | 'video' +} // Telegram supported tags for reference const TELEGRAM_TAGS = [ @@ -40,6 +46,8 @@ export function AdminBroadcastPage() { const [marathonSearch, setMarathonSearch] = useState('') const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'preparing' | 'finished'>('all') const [showTagsHelp, setShowTagsHelp] = useState(false) + const [mediaItems, setMediaItems] = useState([]) + const fileInputRef = useRef(null) // Undo/Redo history const [history, setHistory] = useState(['']) @@ -132,6 +140,72 @@ export function AdminBroadcastPage() { } }, [undo, redo]) + // Handle media file selection (multiple) + const handleMediaSelect = (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0) return + + // Check max limit (10 files for Telegram media group) + if (mediaItems.length + files.length > 10) { + toast.error('Максимум 10 файлов') + return + } + + const newItems: MediaItem[] = [] + + for (const file of Array.from(files)) { + // Validate file type + const isImage = file.type.startsWith('image/') + const isVideo = file.type.startsWith('video/') + if (!isImage && !isVideo) { + toast.error(`${file.name}: поддерживаются только изображения и видео`) + continue + } + + // Validate file size (20MB for images, 50MB for videos) + const maxSize = isImage ? 20 * 1024 * 1024 : 50 * 1024 * 1024 + if (file.size > maxSize) { + toast.error(`${file.name}: файл слишком большой. Максимум: ${isImage ? '20MB' : '50MB'}`) + continue + } + + newItems.push({ + file, + preview: URL.createObjectURL(file), + type: isImage ? 'photo' : 'video' + }) + } + + if (newItems.length > 0) { + setMediaItems(prev => [...prev, ...newItems]) + } + + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + // Remove single media item + const removeMediaItem = (index: number) => { + setMediaItems(prev => { + const item = prev[index] + if (item) { + URL.revokeObjectURL(item.preview) + } + return prev.filter((_, i) => i !== index) + }) + } + + // Clear all media + const clearAllMedia = () => { + mediaItems.forEach(item => URL.revokeObjectURL(item.preview)) + setMediaItems([]) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + // Filter marathons based on search and status const filteredMarathons = useMemo(() => { return marathons.filter(m => { @@ -145,8 +219,8 @@ export function AdminBroadcastPage() { const selectedMarathon = marathons.find(m => m.id === marathonId) const handleSend = async () => { - if (!message.trim()) { - toast.error('Введите сообщение') + if (!message.trim() && mediaItems.length === 0) { + toast.error('Введите сообщение или прикрепите медиа') return } @@ -157,15 +231,17 @@ export function AdminBroadcastPage() { setSending(true) try { + const files = mediaItems.map(item => item.file) let result if (targetType === 'all') { - result = await adminApi.broadcastToAll(message) + result = await adminApi.broadcastToAll(message, files.length > 0 ? files : undefined) } else { - result = await adminApi.broadcastToMarathon(marathonId!, message) + result = await adminApi.broadcastToMarathon(marathonId!, message, files.length > 0 ? files : undefined) } toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`) setMessage('') + clearAllMedia() } catch (err) { console.error('Failed to send broadcast:', err) toast.error('Ошибка отправки') @@ -469,6 +545,90 @@ export function AdminBroadcastPage() { {message.length} / 2000

+ + {/* Media Attachment */} +
+
+ + + {mediaItems.length > 0 && ( + <> + + {mediaItems.length}/10 файлов + + + + )} +
+ + {/* Media Grid Preview */} + {mediaItems.length > 0 && ( +
+ {mediaItems.map((item, index) => ( +
+ {item.type === 'photo' ? ( + {`Preview + ) : ( +
+
+ )} + +
+ {item.type === 'photo' ? 'IMG' : 'VID'} +
+
+ ))} + {mediaItems.length < 10 && ( + + )} +
+ )} +
{/* Send Button */} @@ -476,7 +636,7 @@ export function AdminBroadcastPage() { size="lg" color="purple" onClick={handleSend} - disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)} + disabled={sending || (!message.trim() && mediaItems.length === 0) || message.length > 2000 || (targetType === 'marathon' && !marathonId)} isLoading={sending} icon={} className="w-full" @@ -521,18 +681,54 @@ export function AdminBroadcastPage() { {/* Message Bubble */} -
- {message.trim() ? ( -
- ) : ( -

Введите сообщение...

+
+ {/* Media Preview in Telegram style */} + {mediaItems.length > 0 && ( +
1 ? 'grid grid-cols-2 gap-0.5' : ''}`}> + {mediaItems.slice(0, 4).map((item, index) => ( +
+ {item.type === 'photo' ? ( + {`Preview + ) : ( +
+
+ )} + {/* Show +N indicator on last visible item if there are more */} + {index === 3 && mediaItems.length > 4 && ( +
+ +{mediaItems.length - 4} +
+ )} +
+ ))} +
)} -

- {new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} -

+
+ {message.trim() ? ( +
+ ) : mediaItems.length === 0 ? ( +

Введите сообщение...

+ ) : null} +

+ {new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} +

+
{/* Info */}