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' ? (
+

+ ) : (
+
+ )}
+
+
+ {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' ? (
+

+ ) : (
+
+ )}
+ {/* 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 */}