Add upload images

This commit is contained in:
2026-01-04 04:58:41 +07:00
parent 81d992abe6
commit 65b2512d8c
4 changed files with 502 additions and 31 deletions

View File

@@ -1,8 +1,9 @@
from datetime import datetime 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 import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
from app.models import ( from app.models import (
@@ -531,9 +532,10 @@ async def get_logs(
@limiter.limit("1/minute") @limiter.limit("1/minute")
async def broadcast_to_all( async def broadcast_to_all(
request: Request, request: Request,
data: BroadcastRequest,
current_user: CurrentUser, current_user: CurrentUser,
db: DbSession, db: DbSession,
message: str = Form(""),
media: list[UploadFile] = File(default=[]),
): ):
"""Send broadcast message to all users with Telegram linked. Admin only.""" """Send broadcast message to all users with Telegram linked. Admin only."""
require_admin_with_2fa(current_user) require_admin_with_2fa(current_user)
@@ -547,15 +549,40 @@ async def broadcast_to_all(
total_count = len(users) total_count = len(users)
sent_count = 0 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: 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 sent_count += 1
# Log action # Log action
await log_admin_action( await log_admin_action(
db, current_user.id, AdminActionType.BROADCAST_ALL.value, db, current_user.id, AdminActionType.BROADCAST_ALL.value,
"broadcast", 0, "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 request.client.host if request.client else None
) )
@@ -567,9 +594,10 @@ async def broadcast_to_all(
async def broadcast_to_marathon( async def broadcast_to_marathon(
request: Request, request: Request,
marathon_id: int, marathon_id: int,
data: BroadcastRequest,
current_user: CurrentUser, current_user: CurrentUser,
db: DbSession, db: DbSession,
message: str = Form(""),
media: list[UploadFile] = File(default=[]),
): ):
"""Send broadcast message to marathon participants. Admin only.""" """Send broadcast message to marathon participants. Admin only."""
require_admin_with_2fa(current_user) require_admin_with_2fa(current_user)
@@ -580,7 +608,7 @@ async def broadcast_to_marathon(
if not marathon: if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found") raise HTTPException(status_code=404, detail="Marathon not found")
# Get participants count # Get participants with telegram
total_result = await db.execute( total_result = await db.execute(
select(User) select(User)
.join(Participant, Participant.user_id == User.id) .join(Participant, Participant.user_id == User.id)
@@ -592,15 +620,41 @@ async def broadcast_to_marathon(
users = total_result.scalars().all() users = total_result.scalars().all()
total_count = len(users) total_count = len(users)
sent_count = await telegram_notifier.notify_marathon_participants( # Read media files if provided (up to 10 files, Telegram limit)
db, marathon_id, data.message 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 # Log action
await log_admin_action( await log_admin_action(
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value, db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
"marathon", marathon_id, "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 request.client.host if request.client else None
) )

View File

@@ -54,6 +54,209 @@ class TelegramNotifier:
logger.error(f"Error sending Telegram message: {e}") logger.error(f"Error sending Telegram message: {e}")
return False 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( async def notify_user(
self, self,
db: AsyncSession, db: AsyncSession,

View File

@@ -92,13 +92,31 @@ export const adminApi = {
}, },
// Broadcast // Broadcast
broadcastToAll: async (message: string): Promise<BroadcastResponse> => { broadcastToAll: async (message: string, media?: File[]): Promise<BroadcastResponse> => {
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', { message }) 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<BroadcastResponse>('/admin/broadcast/all', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return response.data return response.data
}, },
broadcastToMarathon: async (marathonId: number, message: string): Promise<BroadcastResponse> => { broadcastToMarathon: async (marathonId: number, message: string, media?: File[]): Promise<BroadcastResponse> => {
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, { message }) 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<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return response.data return response.data
}, },

View File

@@ -3,7 +3,13 @@ import { adminApi } from '@/api'
import type { AdminMarathon } from '@/types' import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { NeonButton } from '@/components/ui' 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 // Telegram supported tags for reference
const TELEGRAM_TAGS = [ const TELEGRAM_TAGS = [
@@ -40,6 +46,8 @@ export function AdminBroadcastPage() {
const [marathonSearch, setMarathonSearch] = useState('') const [marathonSearch, setMarathonSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'preparing' | 'finished'>('all') const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'preparing' | 'finished'>('all')
const [showTagsHelp, setShowTagsHelp] = useState(false) const [showTagsHelp, setShowTagsHelp] = useState(false)
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// Undo/Redo history // Undo/Redo history
const [history, setHistory] = useState<string[]>(['']) const [history, setHistory] = useState<string[]>([''])
@@ -132,6 +140,72 @@ export function AdminBroadcastPage() {
} }
}, [undo, redo]) }, [undo, redo])
// Handle media file selection (multiple)
const handleMediaSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
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 // Filter marathons based on search and status
const filteredMarathons = useMemo(() => { const filteredMarathons = useMemo(() => {
return marathons.filter(m => { return marathons.filter(m => {
@@ -145,8 +219,8 @@ export function AdminBroadcastPage() {
const selectedMarathon = marathons.find(m => m.id === marathonId) const selectedMarathon = marathons.find(m => m.id === marathonId)
const handleSend = async () => { const handleSend = async () => {
if (!message.trim()) { if (!message.trim() && mediaItems.length === 0) {
toast.error('Введите сообщение') toast.error('Введите сообщение или прикрепите медиа')
return return
} }
@@ -157,15 +231,17 @@ export function AdminBroadcastPage() {
setSending(true) setSending(true)
try { try {
const files = mediaItems.map(item => item.file)
let result let result
if (targetType === 'all') { if (targetType === 'all') {
result = await adminApi.broadcastToAll(message) result = await adminApi.broadcastToAll(message, files.length > 0 ? files : undefined)
} else { } 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} сообщений`) toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
setMessage('') setMessage('')
clearAllMedia()
} catch (err) { } catch (err) {
console.error('Failed to send broadcast:', err) console.error('Failed to send broadcast:', err)
toast.error('Ошибка отправки') toast.error('Ошибка отправки')
@@ -469,6 +545,90 @@ export function AdminBroadcastPage() {
{message.length} / 2000 {message.length} / 2000
</p> </p>
</div> </div>
{/* Media Attachment */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleMediaSelect}
accept="image/*,video/*"
multiple
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={mediaItems.length >= 10}
className="flex items-center gap-2 px-3 py-2 text-sm bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Image className="w-4 h-4" />
<Film className="w-4 h-4" />
<span>Прикрепить медиа</span>
</button>
{mediaItems.length > 0 && (
<>
<span className="text-sm text-gray-500">
{mediaItems.length}/10 файлов
</span>
<button
onClick={clearAllMedia}
className="flex items-center gap-1 px-2 py-1 text-xs text-red-400 hover:text-red-300 transition-colors"
>
<Trash2 className="w-3 h-3" />
Удалить все
</button>
</>
)}
</div>
{/* Media Grid Preview */}
{mediaItems.length > 0 && (
<div className="grid grid-cols-5 gap-2">
{mediaItems.map((item, index) => (
<div
key={index}
className="relative aspect-square rounded-lg overflow-hidden border border-dark-600 bg-dark-800 group"
>
{item.type === 'photo' ? (
<img
src={item.preview}
alt={`Preview ${index + 1}`}
className="w-full h-full object-cover"
/>
) : (
<div className="relative w-full h-full">
<video
src={item.preview}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<Film className="w-6 h-6 text-white" />
</div>
</div>
)}
<button
onClick={() => removeMediaItem(index)}
className="absolute top-1 right-1 p-1 bg-red-500/80 hover:bg-red-500 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
<div className="absolute bottom-1 left-1 px-1.5 py-0.5 bg-black/60 rounded text-xs text-white">
{item.type === 'photo' ? 'IMG' : 'VID'}
</div>
</div>
))}
{mediaItems.length < 10 && (
<button
onClick={() => fileInputRef.current?.click()}
className="aspect-square rounded-lg border-2 border-dashed border-dark-500 hover:border-accent-500/50 flex items-center justify-center text-gray-500 hover:text-accent-400 transition-colors"
>
<Plus className="w-6 h-6" />
</button>
)}
</div>
)}
</div>
</div> </div>
{/* Send Button */} {/* Send Button */}
@@ -476,7 +636,7 @@ export function AdminBroadcastPage() {
size="lg" size="lg"
color="purple" color="purple"
onClick={handleSend} 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} isLoading={sending}
icon={<Send className="w-5 h-5" />} icon={<Send className="w-5 h-5" />}
className="w-full" className="w-full"
@@ -521,18 +681,54 @@ export function AdminBroadcastPage() {
</div> </div>
{/* Message Bubble */} {/* Message Bubble */}
<div className="bg-[#182533] rounded-2xl rounded-tl-md p-3 max-w-full"> <div className="bg-[#182533] rounded-2xl rounded-tl-md overflow-hidden max-w-full">
{message.trim() ? ( {/* Media Preview in Telegram style */}
<div {mediaItems.length > 0 && (
className="text-white text-sm break-words telegram-preview" <div className={`${mediaItems.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''}`}>
dangerouslySetInnerHTML={{ __html: telegramToHtml(message) }} {mediaItems.slice(0, 4).map((item, index) => (
/> <div key={index} className="relative">
) : ( {item.type === 'photo' ? (
<p className="text-gray-500 text-sm italic">Введите сообщение...</p> <img
src={item.preview}
alt={`Preview ${index + 1}`}
className={`w-full object-cover ${mediaItems.length === 1 ? 'max-h-64' : 'h-24'}`}
/>
) : (
<div className="relative bg-black">
<video
src={item.preview}
className={`w-full object-cover ${mediaItems.length === 1 ? 'max-h-64' : 'h-24'}`}
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-white/30 backdrop-blur flex items-center justify-center">
<Film className="w-4 h-4 text-white" />
</div>
</div>
</div>
)}
{/* Show +N indicator on last visible item if there are more */}
{index === 3 && mediaItems.length > 4 && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<span className="text-white text-lg font-bold">+{mediaItems.length - 4}</span>
</div>
)}
</div>
))}
</div>
)} )}
<p className="text-gray-500 text-xs text-right mt-2"> <div className="p-3">
{new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} {message.trim() ? (
</p> <div
className="text-white text-sm break-words telegram-preview"
dangerouslySetInnerHTML={{ __html: telegramToHtml(message) }}
/>
) : mediaItems.length === 0 ? (
<p className="text-gray-500 text-sm italic">Введите сообщение...</p>
) : null}
<p className="text-gray-500 text-xs text-right mt-2">
{new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div> </div>
{/* Info */} {/* Info */}