avtopost.kultmost.ru/main.py
2026-08-17 13:48:37 -04:00

3723 lines
135 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `main.py` generate from `main.ipynb` by `build.ipynb`
# FastAPI
## App
from fastapi import FastAPI, APIRouter, UploadFile, File, Depends, HTTPException, Body, Security, Form, Request, Response, BackgroundTasks, Query
from motor.motor_asyncio import AsyncIOMotorCollection
import jwt
from datetime import datetime, timedelta, timezone, date
import io
import asyncio
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, StreamingResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
import mimetypes
from urllib.parse import quote
from starlette.middleware.gzip import GZipMiddleware
import uuid
import bcrypt
from typing import List, Any, Dict, Optional, Union
from datetime import timedelta
from urllib.parse import urlencode
from bson import ObjectId
from PIL import Image
from io import BytesIO
from dateutil.parser import isoparse
import httpx
import string
from db import *
import vk as vk
import tg as tg
from delivery_router import router as delivery_router
from auth import get_user, get_current_user, get_bearer_token, get_access_token, get_token
from roles import *
from utils import *
from tasks import *
from models import *
from config import *
import logging
import json
from templates import templates
import email_task as email_task
from pydantic import EmailStr
import secrets
from pymongo import ReturnDocument
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
### Конфигурация
app = FastAPI(title="KULTMOST")
app.mount("/static", StaticFiles(directory="static"), name="static")
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(
CORSMiddleware,
allow_origins=['*'],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(delivery_router)
app.include_router(vk.router, prefix='/api/v1/social', tags=["Auth"])
### Хелперы
RUS_TO_LAT = {
'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'yo','ж':'zh','з':'z',
'и':'i','й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r',
'с':'s','т':'t','у':'u','ф':'f','х':'kh','ц':'ts','ч':'ch','ш':'sh','щ':'shch',
'ъ':'"','ы':'y','ь':"'",'э':'e','ю':'yu','я':'ya',
' ':'-',
'А':'A','Б':'B','В':'V','Г':'G','Д':'D','Е':'E','Ё':'Yo','Ж':'Zh','З':'Z',
'И':'I','Й':'Y','К':'K','Л':'L','М':'M','Н':'N','О':'O','П':'P','Р':'R',
'С':'S','Т':'T','У':'U','Ф':'F','Х':'Kh','Ц':'Ts','Ч':'Ch','Ш':'Sh','Щ':'Shch',
'Ъ':'"','Ы':'Y','Ь':"'",'Э':'E','Ю':'Yu','Я':'Ya'
}
def transliterate(text: str) -> str:
return ''.join(RUS_TO_LAT.get(ch, '' if ch not in string.ascii_letters else ch) for ch in text)
async def get_latest_news() -> List[dict]:
cursor = news_collection.find({'publish_on_platform': True}).sort("_id", -1).limit(30)
docs: List[dict] = []
async for doc in cursor:
published = doc.get("published_at")
if published is not None and hasattr(published, "isoformat"):
doc["published_at"] = published.isoformat()
docs.append(doc)
return docs
async def logo_user_task(user: dict):
img_id = user.get('img') if user else None
new_id = None
if img_id:
# download original image bytes
file_data = await gridfs_map['users'].open_download_stream(ObjectId(img_id))
original_image_data = await file_data.read()
# store original in GridFS (keep a copy)
original_img_id = await gridfs_map['users'].upload_from_stream(f'original_{img_id}', BytesIO(original_image_data))
# open image with PIL
img = Image.open(BytesIO(original_image_data))
w, h = img.size
# resize if needed
if w > 500:
factor = 500 / w
new_size = (500, int(h * factor))
img = img.resize(new_size, resample=Image.LANCZOS)
# ensure image mode is compatible with JPEG
if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info):
# composite onto white background to remove alpha
background = Image.new('RGB', img.size, (255, 255, 255))
alpha = img.split()[-1] if img.mode != 'P' else img.convert('RGBA').split()[-1]
background.paste(img.convert('RGBA'), mask=alpha)
img = background
else:
img = img.convert('RGB')
# save to buffer as JPEG
buf = BytesIO()
# img.save(buf, format='JPEG', quality=85)
img.save(buf, format="WEBP", quality=quality, method=6)
buf.seek(0)
# upload processed avatar to GridFS
new_id = await gridfs_map['users'].upload_from_stream('500_avatar.jpg', buf)
# update user doc with new image ids (store as strings to match your original code)
await users_collection.update_one(
{'_id': ObjectId(user.get('_id'))},
{'$set': {'img': str(new_id), 'original_img': str(original_img_id)}}
)
async def poster_competition(competition):
img_id = competition.get('poster_orig_id')
try:
stream = await gridfs_map['competition'].open_download_stream(ObjectId(img_id))
data = await stream.read()
buf = await build_composite(data, 0.75, 800)
new_id = await gridfs_map['competition'].upload_from_stream(f"500_{stream.filename}", buf)
except Exception:
new_id = img_id
print(f'Ошибка обработки id {img_id} в выставке {competition["_id"]}')
if new_id:
await competitions_col.update_one(
{'_id': ObjectId(competition.get('_id'))},
{'$set': {
'poster_file_id': new_id
}}
)
async def poster_exhibition(exhibition):
img_id = exhibition.get('poster_orig_id')
try:
stream = await gridfs_map['exhibition'].open_download_stream(ObjectId(img_id))
data = await stream.read()
buf = await build_composite(data, 0.73, 800)
new_id = await gridfs_map['exhibition'].upload_from_stream(f"500_{stream.filename}", buf)
except Exception:
new_id = img_id
print(f'Ошибка обработки id {img_id} в выставке {exhibition["_id"]}')
if new_id:
await exhibition_collection.update_one(
{'_id': ObjectId(exhibition.get('_id'))},
{'$set': {
'poster_file_id': new_id
}}
)
async def replace_new_medias(post, links_media):
img_ids = post.get('medias_original', [])
if not isinstance(img_ids, list):
img_ids = [img_ids]
new_ids = []
for img_id in img_ids:
if img_id in links_media:
new_ids.append(links_media['img_id'])
continue
try:
stream = await gridfs_map['posts'].open_download_stream(ObjectId(img_id))
data = await stream.read()
buf = await build_composite(data)
new_id = await gridfs_map['posts'].upload_from_stream(f"500_{stream.filename}", buf)
new_ids.append(str(new_id))
except Exception:
new_ids.append(str(img_id))
print(f'Ошибка обработки id {img_id} в посте {post["_id"]}')
if new_ids:
await posts_collection.update_one(
{'_id': ObjectId(post.get('_id'))},
{'$set': {
'medias': new_ids
}}
)
async def replace_post_medias(post):
img_ids = post.get('medias_original', [])
if not isinstance(img_ids, list):
img_ids = [img_ids]
new_ids = []
for img_id in img_ids:
try:
stream = await gridfs_map['posts'].open_download_stream(ObjectId(img_id))
data = await stream.read()
buf = await build_composite(data)
new_id = await gridfs_map['posts'].upload_from_stream(f"500_{stream.filename}", buf)
new_ids.append(str(new_id))
except Exception:
new_ids.append(str(img_id))
print(f'Ошибка обработки id {img_id} в посте {post["_id"]}')
if new_ids:
await posts_collection.update_one(
{'_id': ObjectId(post.get('_id'))},
{'$set': {
'medias': new_ids
}}
)
async def replace_news_medias(news):
img_ids = news.get('orig_media', [])
if not isinstance(img_ids, list):
img_ids = [img_ids]
new_ids = []
for img_id in img_ids:
try:
stream = await gridfs_map['news'].open_download_stream(ObjectId(img_id))
data = await stream.read()
buf = await build_composite(data)
new_id = await gridfs_map['news'].upload_from_stream(f"500_{stream.filename}", buf)
new_ids.append(str(new_id))
except Exception:
new_ids.append(str(img_id))
print(f'Ошибка обработки id {img_id} в посте {news["_id"]}')
if new_ids:
await news_collection.update_one(
{'_id': ObjectId(news.get('_id'))},
{'$set': {
'media': new_ids
}}
)
async def save_to_gridfs(upload: UploadFile, bucket_name: str):
if upload is None:
return None
grid = gridfs_map.get(bucket_name)
if grid is None:
return None
filename = upload.filename or str(uuid.uuid4())
contents = await upload.read()
stream = io.BytesIO(contents)
try:
file_id = await grid.upload_from_stream(filename, stream)
return str(file_id)
finally:
await upload.close()
async def posts_temp(user,request):
data=[]
async for i in posts_collection.find({'user._id': user.get('_id')}).sort('_id',-1).limit(1000):
data.append(i)
return templates.TemplateResponse(
request=request, name="profile/posts.html", context={ "user": user, 'cards': data}
)
@app.get("/cron/day")
async def cron_every_day(token: str = Security(get_token)):
res = await competitions_col.update_many(
{
"date_finish": {"$lt": datetime.now(timezone.utc) + timedelta(hours=3)},
"status": {"$lt": 2},
},
{"$set": {"status": 2}},
)
return {}
# await notify_collection.delete_many({'title':'Доставка картины'})
# await notify_collection.update_many({'title':'Доставка картины'},{'$set':{'type':'delivery'}})
# async for existing in delivery_collection.find({
# 'exhibition_id':
# '6a55f0e90154435ba37a7c25'
# ,
# 'status':
# 'Ожидает получения'
# }):
# print(str(existing['receiver'].get('_id')))
# notify = await notify_in_profile(NotifyItem(
# user_id=str(existing['receiver'].get('_id')),
# type_item='delivery',
# title='Доставка картины',
# link=f'/profile/delivery',
# text=f'{str(existing['sender']['username'])} прислал вам картину — {existing['title']}'
# ))
# print(notify)
# await delivery_collection.update_one({'_id': existing.get('_id')},{'$set':{'notify': notify.upserted_id}})
@app.get("/cron/5")
async def cron_every_five_min(token: str = Security(get_token)):
now = datetime.now(timezone.utc)
limit_dt = now + timedelta(hours=3)
prod_filter = {"prod": PROD}
# 1) If there's an exhibition that is going to be finished soon -> move deliveries to "Возврат художнику"
exhibition_finished = await exhibition_collection.find_one_and_update(
{**prod_filter, "date_finish": {"$lt": limit_dt}},
{"$set": {"status": 2}},
return_document=ReturnDocument.AFTER,
)
if exhibition_finished:
exhibition_id_str = str(exhibition_finished["_id"])
async for doc in delivery_collection.find(
{"exhibition_id": exhibition_id_str, "status": "Получено", **prod_filter},
{"_id": 0},
):
sender = doc.get("sender")
post_id = doc.get("post_id")
exhibition_id = doc.get("exhibition_id")
new_doc = {
**doc,
"delivery_type": "",
"track": "",
"status": "Возврат художнику",
"receiver": sender,
"sender": doc.get("receiver"),
}
await delivery_collection.update_one(
{**prod_filter, "status": "Возврат художнику", "post_id": post_id, "exhibition_id": exhibition_id},
{"$set": new_doc},
upsert=True,
)
return {}
tasks = []
async for exhibition_to_start in exhibition_collection.find(
{**prod_filter}
):
exhibition_id_str = str(exhibition_to_start["_id"])
exhibition_title = exhibition_to_start.get("title")
notified_user_id = None
notified_post_user_id = None
async for doc in delivery_collection.find(
{"exhibition_id": exhibition_id_str, "status": "Получено", **prod_filter},
{"_id": 0},
):
post_id = doc.get("post_id")
exhibition_id = doc.get("exhibition_id")
post = await posts_collection.find_one({"_id": ObjectId(post_id)})
# Use user from post; keep them even if multiple deliveries exist
if post and post.get("user") and not notified_post_user_id:
notified_post_user_id = str(post["user"].get("_id"))
new_doc = {
**doc,
"exhibition": exhibition_to_start,
"status": "На выставке",
"user": post.get("user") if post else None,
"post": post,
**prod_filter
}
tasks.append(delivery_collection.update_one(
{**prod_filter, "status": "На выставке", "post_id": post_id, "exhibition_id": exhibition_id},
{"$set": new_doc},
upsert=True,
))
tasks.append(posts_collection.update_one(
{"_id": ObjectId(post_id)},
{"$set": {"status": "На выставке"}}
))
# NOTE: your original code used `post` from inside the loop (could be last item / even undefined).
# This version notifies once using the first found post user.
if notified_post_user_id:
await notify_in_profile(
NotifyItem(
user_id=notified_post_user_id,
type_item="exhibtion",
title=f"Выставка {exhibition_title}",
link="/profile/exhibition",
text="Ваши картины на выставке",
)
)
if tasks:
asyncio.gather(*tasks)
# 2) Otherwise find an exhibition that should start soon -> move deliveries to "На выставке"
exhibition_to_start = await exhibition_collection.find_one_and_update(
{**prod_filter, "status": 0, "datetime_start": {"$lt": limit_dt}},
{"$set": {"status": 1}},
return_document=ReturnDocument.AFTER,
)
if not exhibition_to_start:
return {}
# If vk/tg arrays are empty -> send social invitation
if exhibition_to_start.get("vk") == [] or exhibition_to_start.get("tg") == []:
await service_social_exhibition(bson_clear(exhibition_to_start))
return {}
### Эндпоинты
@app.post("/api/register", response_model=dict)
async def register(
background_tasks: BackgroundTasks,
username: Optional[str] = Form(None),
phone: Optional[str] = Form(None),
email: Optional[str] = Form(None),
password: str = Form(...),
client_id: str = Form(...),
roles: List[str] = Form(...),
birthday: Optional[date] = Form(None),
grade: Optional[str] = Form(None),
Female_Name_Lastame: Optional[str] = Form(None),
living_place: Optional[str] = Form(None),
offer_accepted: Optional[bool] = Form(False),
img: Optional[UploadFile] = File(None),
request: Request = None,
response: Response = None
):
user = UserRegister(
username=username, phone=phone, email=email, password=password,
client_id=client_id, roles=roles, birthday=birthday, grade=grade,
Female_Name_Lastame=Female_Name_Lastame, living_place=living_place,
offer_accepted=offer_accepted
)
# if normalize_phone(user.phone) and await users_collection.find_one({"phone": normalize_phone(user.phone)}):
# raise HTTPException(400, "Телефон уже зарегестрирован")
if user.email and await users_collection.find_one({"email": user.email}):
raise HTTPException(400, "Email уже зарегестрирован")
hashed_password = get_password_hash(user.password)
allowed_roles = [r for r in user.roles if r not in BLOCK_ROLES]
if any(r in REQUIRES_OFFER for r in user.roles):
if not user.offer_accepted:
raise HTTPException(status_code=400, detail="Оферта должна быть принята")
if not user.offer_accepted_at:
user.offer_accepted_at = datetime.utcnow()
if img:
img_id = await save_to_gridfs(img, 'users')
else:
img_id = '/static/painter.svg'
doc = {
"username": user.username,
"phone": normalize_phone(user.phone) if user.phone else None,
"email": user.email,
"img": img_id,
"hashed_password": hashed_password,
"roles": allowed_roles,
"birthday": user.birthday.isoformat() if user.birthday else None,
"grade": user.grade,
"female_name_lastname": user.female_name_lastname,
"living_place": user.living_place,
"client_id": user.client_id,
"extra_scopes": user.extra_scopes,
"offer_accepted": bool(user.offer_accepted),
"offer_accepted_at": user.offer_accepted_at.isoformat() if user.offer_accepted_at else None,
}
result = await users_collection.insert_one(doc)
user_id_str = str(result.inserted_id)
doc_out = {
"_id": user_id_str,
"id": user_id_str,
**{k: v for k, v in doc.items()}
}
background_tasks.add_task(email_new_user, bson_clear(doc_out))
background_tasks.add_task(logo_user_task, bson_clear(doc_out))
scopes = get_all_user_scopes(allowed_roles, [])
client_id = user.client_id
client_ip = request.headers.get('X-Real-IP') or request.client.host
access_exp_delta = timedelta(days=ACCESS_TOKEN_EXPIRE_MINUTES)
refresh_exp_delta = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
access_token = create_jwt_token(
{"sub": user_id_str, "scopes": scopes, "client_id": client_id, "ip": client_ip},
access_exp_delta
)
refresh_token = create_jwt_token(
{"sub": user_id_str, "type": "refresh", "client_id": client_id, "ip": client_ip},
refresh_exp_delta
)
await redis_client.setex(f"access:{access_token}", int(access_exp_delta.total_seconds()), user_id_str)
await redis_client.setex(f"refresh:{hash_token(refresh_token)}", int(refresh_exp_delta.total_seconds()), user_id_str)
session_key = f"user_sessions:{user_id_str}"
await redis_client.sadd(session_key, f"access:{access_token}")
await redis_client.sadd(session_key, f"refresh:{hash_token(refresh_token)}")
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(access_exp_delta.total_seconds())
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(refresh_exp_delta.total_seconds())
)
return {
"ok": True,
"access_token": access_token,
"refresh_token": refresh_token,
"user_id": user_id_str,
"token_type": "bearer"
}
@app.post("/api/admin/login")
async def login_admin(
request: Request,
response: Response,
login_data: AdminLoginRequest = Body(...),
current_user: Dict = Security(get_current_user, scopes=["admin"])
):
user = await users_collection.find_one({"_id": ObjectId(login_data.user_id)})
user_id_str = str(user["_id"])
scopes = get_all_user_scopes(user.get("roles", []), user.get("extra_scopes", []))
client_ip = request.headers.get('X-Real-IP') or request.headers.get('X-Real-IP') or request.client.host
access_exp_delta = timedelta(days=ACCESS_TOKEN_EXPIRE_MINUTES)
refresh_exp_delta = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
access_token = create_jwt_token(
{"sub": user_id_str, "scopes": scopes, "client_id": 'admin', "ip": client_ip},
access_exp_delta
)
refresh_token = create_jwt_token(
{"sub": user_id_str, "type": "refresh", "client_id": 'admin', "ip": client_ip},
refresh_exp_delta
)
await redis_client.setex(f"access:{access_token}", int(access_exp_delta.total_seconds()), user_id_str)
await redis_client.setex(f"refresh:{hash_token(refresh_token)}", int(refresh_exp_delta.total_seconds()), user_id_str)
session_key = f"user_sessions:{user_id_str}"
await redis_client.sadd(session_key, f"access:{access_token}")
await redis_client.sadd(session_key, f"refresh:{hash_token(refresh_token)}")
admin_access_token = request.cookies.get("access_token")
admin_refresh_token = request.cookies.get("refresh_token")
response.set_cookie(
key="admin_access_token",
value=admin_access_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(access_exp_delta.total_seconds())
)
response.set_cookie(
key="admin_refresh_token",
value=admin_refresh_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(refresh_exp_delta.total_seconds())
)
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(access_exp_delta.total_seconds())
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(refresh_exp_delta.total_seconds())
)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"user_id": user_id_str,
"token_type": "bearer"
}
@app.post("/api/admin/back")
async def login_admin_back(
request: Request,
response: Response,
):
access_exp_delta = timedelta(days=ACCESS_TOKEN_EXPIRE_MINUTES)
refresh_exp_delta = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
admin_access_token = request.cookies.get("admin_access_token")
admin_refresh_token = request.cookies.get("refreadmin_refresh_tokensh_token")
response.set_cookie(
key="access_token",
value=admin_access_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(access_exp_delta.total_seconds())
)
response.set_cookie(
key="refresh_token",
value=admin_refresh_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(refresh_exp_delta.total_seconds())
)
return {'ok': True}
@app.get("/login/{token}")
async def login_token(token: str, request: Request, response: Response):
token_hash = hash_token(token)
log = await users_token_collection.find_one(
{'token_hash': token_hash}
)
if not log:
return RedirectResponse("/login?" + urlencode({"next": '/profile'}))
next_url = log.get('url', '/')
created = log.get("created_at")
if not created:
try:
created = isoparse(ObjectId(log.get("_id")).generation_time.replace(tzinfo=timezone.utc))
except Exception:
return RedirectResponse("/login?" + urlencode({"next": next_url}))
created = created.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
if created < now - timedelta(days=TOKEN_TTL_DAYS):
await users_token_collection.delete_one({'token_hash': token_hash})
return RedirectResponse("/login?" + urlencode({"next": log.get('url', next_url)}))
user_id = log.get("user_id")
if not user_id:
return RedirectResponse("/login?" + urlencode({"next": log.get('url', next_url)}))
user = await users_collection.find_one({"_id": ObjectId(user_id)})
if not user:
return RedirectResponse("/login?" + urlencode({"next": log.get('url', next_url)}))
user_id_str = str(user["_id"])
scopes = get_all_user_scopes(user.get("roles", []), user.get("extra_scopes", []))
client_ip = request.headers.get('X-Real-IP') or request.client.host
access_exp_delta = timedelta(days=ACCESS_TOKEN_EXPIRE_MINUTES)
refresh_exp_delta = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
access_token = create_jwt_token(
{"sub": user_id_str, "scopes": scopes, "client_id": 'token', "ip": client_ip},
access_exp_delta
)
refresh_token = create_jwt_token(
{"sub": user_id_str, "type": "refresh", "client_id": 'token', "ip": client_ip},
refresh_exp_delta
)
await redis_client.setex(f"access:{access_token}", int(access_exp_delta.total_seconds()), user_id_str)
await redis_client.setex(f"refresh:{hash_token(refresh_token)}", int(refresh_exp_delta.total_seconds()), user_id_str)
session_key = f"user_sessions:{user_id_str}"
await redis_client.sadd(session_key, f"access:{access_token}")
await redis_client.sadd(session_key, f"refresh:{hash_token(refresh_token)}")
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(access_exp_delta.total_seconds())
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(refresh_exp_delta.total_seconds())
)
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=PROD,
domain='.kultmost.ru',
samesite="Strict",
max_age=int(access_exp_delta.total_seconds())
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=PROD,
domain='.kultmost.ru',
samesite="Strict",
max_age=int(refresh_exp_delta.total_seconds())
)
# Validate and sanitize redirect
if not is_safe_redirect(next_url):
next_url = '/'
return RedirectResponse(next_url,headers=response.headers)
@app.post("/api/login")
async def login(
request: Request,
response: Response,
login_data: LoginRequest = Body(...),
):
identifier = safe_strip(login_data.identifier)
await check_bruteforce(identifier)
or_conditions = [
{"email": identifier},
{"email": identifier.lower()},
]
try:
normalized_phone = normalize_phone(identifier)
if normalized_phone:
or_conditions.append({"phone": normalized_phone})
except:
pass
users = [doc async for doc in users_collection.find({"$or": or_conditions})]
if not users:
await increment_failed_attempt(identifier)
raise HTTPException(401, "Incorrect credentials")
# Track if a valid password was found
valid_user_found = False
for user in users:
stored = user.get("hashed_password")
if isinstance(stored, bytes):
stored = stored.decode()
# Check if the password is hashed with Argon2
if stored.startswith("$argon2"):
ok = verify_password(login_data.password, stored)
if ok:
valid_user_found = True
break # Password is valid, exit the loop
# Check if the password is hashed with bcrypt
elif stored.startswith("$2y$"):
stored = stored.replace("$2y$", "$2b$", 1)
if bcrypt.checkpw(login_data.password.encode(), stored.encode()):
valid_user_found = True
new_hash = get_password_hash(login_data.password)
break # Password is valid, exit the loop
# If no valid user was found, increment attempts and raise error
if not valid_user_found:
await increment_failed_attempt(identifier)
raise HTTPException(401, "Incorrect credentials")
# If we reach this point, authentication succeeded
# Proceed with further logic such as generating tokens or returning user details
# await users_collection.update_one({"_id": user["_id"]}, {"$set": {"hashed_password": new_hash}})
await reset_failed_attempts(identifier)
user_id_str = str(user["_id"])
scopes = get_all_user_scopes(user.get("roles", []), user.get("extra_scopes", []))
client_ip = request.headers.get('X-Real-IP') or request.client.host
access_exp_delta = timedelta(days=ACCESS_TOKEN_EXPIRE_MINUTES)
refresh_exp_delta = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
access_token = create_jwt_token(
{"sub": user_id_str, "scopes": scopes, "client_id": login_data.client_id, "ip": client_ip},
access_exp_delta
)
refresh_token = create_jwt_token(
{"sub": user_id_str, "type": "refresh", "client_id": login_data.client_id, "ip": client_ip},
refresh_exp_delta
)
await redis_client.setex(f"access:{access_token}", int(access_exp_delta.total_seconds()), user_id_str)
await redis_client.setex(f"refresh:{hash_token(refresh_token)}", int(refresh_exp_delta.total_seconds()), user_id_str)
session_key = f"user_sessions:{user_id_str}"
await redis_client.sadd(session_key, f"access:{access_token}")
await redis_client.sadd(session_key, f"refresh:{hash_token(refresh_token)}")
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(access_exp_delta.total_seconds())
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(refresh_exp_delta.total_seconds())
)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"user_id": user_id_str,
"token_type": "bearer"
}
@app.post("/api/old/login")
async def old_login(
request: Request,
response: Response,
):
token_old = request.cookies.get('token') or request.cookies.get('v2token')
access = await get_access_token(request, response)
if token_old and not access:
try:
headers = {'Authorization': f"Bearer {token_old}"}
async with httpx.AsyncClient(http2=True, verify=ctx) as client:
res = await client.get('https://avtopost.kultmost.ru/api/v1/auth/me', headers=headers)
email = res.json()['data']['email']
print(email)
user = await users_collection.find_one({'email': email})
except:
return {'ok': False}
user_id_str = str(user["_id"])
scopes = get_all_user_scopes(user.get("roles", []), user.get("extra_scopes", []))
client_ip = request.headers.get('X-Real-IP') or request.client.host
access_exp_delta = timedelta(days=ACCESS_TOKEN_EXPIRE_MINUTES)
refresh_exp_delta = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
access_token = create_jwt_token(
{"sub": user_id_str, "scopes": scopes, "client_id": 'old_token', "ip": client_ip},
access_exp_delta
)
refresh_token = create_jwt_token(
{"sub": user_id_str, "type": "refresh", "client_id": 'old_token', "ip": client_ip},
refresh_exp_delta
)
await redis_client.setex(f"access:{access_token}", int(access_exp_delta.total_seconds()), user_id_str)
await redis_client.setex(f"refresh:{hash_token(refresh_token)}", int(refresh_exp_delta.total_seconds()), user_id_str)
session_key = f"user_sessions:{user_id_str}"
await redis_client.sadd(session_key, f"access:{access_token}")
await redis_client.sadd(session_key, f"refresh:{hash_token(refresh_token)}")
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(access_exp_delta.total_seconds())
)
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=PROD,
samesite="Strict",
max_age=int(refresh_exp_delta.total_seconds())
)
return {'ok': True}
@app.post("/refresh", response_model=TokenResponse)
async def refresh_token(
request: Request,
response: Response,
req: RefreshRequest
):
refresh_token = (
request.cookies.get("refresh_token") or
get_bearer_token(request) or
(req.refresh_token if req else None)
)
if not refresh_token:
raise HTTPException(401, "Refresh token required")
client_id = None
if req:
client_id = req.client_id
refresh_hash = hash_token(refresh_token)
user_id_str = await redis_client.get(f"refresh:{refresh_hash}")
if not user_id_str:
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
try:
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Not a refresh token")
if payload.get("sub") != user_id_str:
raise HTTPException(status_code=401, detail="Token mismatch")
except jwt.InvalidTokenError as e:
raise HTTPException(status_code=401, detail=f"Invalid refresh token: {str(e)}")
await redis_client.delete(f"refresh:{refresh_hash}")
user = await users_collection.find_one({"_id": ObjectId(user_id_str)})
scopes = get_all_user_scopes(user.get("roles", []), user.get("extra_scopes", []))
client_ip = request.headers.get('X-Real-IP') or request.client.host
access_exp = timedelta(days=ACCESS_TOKEN_EXPIRE_MINUTES)
refresh_exp = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
new_access = create_jwt_token(
{"sub": user_id_str, "scopes": scopes, "client_id": client_id, "ip": client_ip},
access_exp
)
new_refresh = create_jwt_token(
{"sub": user_id_str, "type": "refresh", "client_id": client_id, "ip": client_ip},
refresh_exp
)
new_refresh_hash = hash_token(new_refresh)
await redis_client.setex(f"access:{new_access}", int(access_exp.total_seconds()), user_id_str)
await redis_client.setex(f"refresh:{new_refresh_hash}", int(refresh_exp.total_seconds()), user_id_str)
session_key = f"user_sessions:{user_id_str}"
await redis_client.sadd(session_key, f"access:{new_access}")
await redis_client.sadd(session_key, f"refresh:{new_refresh_hash}")
response.set_cookie(
key="access_token",
value=new_access,
httponly=True,
secure=PROD,
samesite="strict",
max_age=int(access_exp.total_seconds())
)
response.set_cookie(
key="refresh_token",
value=new_refresh,
httponly=True,
secure=PROD,
samesite="strict",
max_age=int(refresh_exp.total_seconds())
)
return {
"access_token": new_access,
"token_type": "bearer",
"refresh_token": new_refresh
}
@app.post("/logout")
async def logout(
request: Request,
response: Response,
current_user: Dict = Depends(get_current_user)
):
user_id_str = current_user["id"]
session_key = f"user_sessions:{user_id_str}"
tokens = await redis_client.smembers(session_key)
for t in tokens:
await redis_client.delete(t)
await redis_client.delete(session_key)
for cookie_name in request.cookies.keys():
if cookie_name in ['zoom']:
continue
for dom in [None,'.kultmost.ru','www.kultmost.ru','avtopost.kultmost.ru','avtopost.kultmost.ru','kultmost.ru']:
response.delete_cookie(
key=cookie_name,
domain=dom,
path="/",
secure=False,
httponly=False,
)
return {"message": "Logged out from all sessions"}
@app.get("/api/me", response_model=dict)
async def get_me(response: Response, current_user: Dict = Security(get_current_user, scopes=["read:self"])):
user = display_user(current_user)
return bson_clear(user)
@app.get("/healthz")
async def healthz():
try:
await mongo_client.admin.command('ping')
except Exception as e:
logging.error(f"MongoDB is unhealthy: {str(e)}")
raise HTTPException(status_code=500, detail=f"MongoDB is unhealthy: {str(e)}")
try:
await redis_client.ping()
except Exception as e:
logging.error(f"Redis is unhealthy: {str(e)}")
raise HTTPException(status_code=500, detail=f"Redis is unhealthy: {str(e)}")
return {}
@app.get("/registration", response_class=HTMLResponse)
async def index(
request: Request,
next: str | None = None,
):
return templates.TemplateResponse(
request=request, name="registration.html", context={'next': next}
)
async def get_unique_users(competition_request_col: AsyncIOMotorCollection, competition_id: str, finish: bool = False) -> List[Dict[str, Any]]:
# Determine match criteria based on finish parameter
status_match = {"$match": {"posts.status": "Проходит"}} if finish else {"$match": {}}
pipeline = [
{"$match": {"competition_id": competition_id}},
{"$unwind": "$user"},
{"$project": {
"user": 1,
"posts": 1,
"status": 1
}},
{"$unwind": "$posts"},
status_match, # Use the conditional match here
{"$group": {
"_id": "$user._id",
"user": {"$first": "$user"},
"unique_posts": {"$addToSet": "$posts._id"},
"status": {"$first": "$status"}
}},
{"$project": {
"user": 1,
"post_count": {"$size": "$unique_posts"},
"status": { "$cond": {"if": {"$ne": ["$status", None]}, "then": "$status", "else": False } }
}}
]
cursor = competition_request_col.aggregate(pipeline)
results = []
async for doc in cursor:
results.append(doc)
return results
from typing import Any, Dict, List
from bson import ObjectId
from motor.motor_asyncio import AsyncIOMotorCollection
async def get_unique_posts_by_user(
competition_request_col: AsyncIOMotorCollection,
competition_id: str,
user_id: str,
) -> list[dict[str, Any]]:
pipeline = [
{"$match": {"competition_id": competition_id, "user._id": user_id}},
{"$unwind": "$posts"},
{
"$addFields": {
"status_priority": {
"$switch": {
"branches": [
{"case": {"$eq": ["$posts.status", "Проходит"]}, "then": 1},
{"case": {"$eq": ["$posts.status", "На рассмотрении"]}, "then": 2},
],
"default": 3,
}
}
}
},
{"$sort": {"status_priority": 1, "posts._id": 1}}, # tie-breaker if needed
{"$group": {"_id": "$posts._id", "post": {"$first": "$posts"}}},
{"$replaceRoot": {"newRoot": "$post"}},
]
cursor = competition_request_col.aggregate(pipeline)
return await cursor.to_list(length=None)
async def get_unique_posts_с(competition_request_col: AsyncIOMotorCollection, competition_id: str, finish: bool = False) -> List[Any]:
match_criteria = {
"competition_id": competition_id,
}
if finish:
match_criteria['posts.status'] = 'Проходит'
pipeline = [
{"$match": match_criteria},
{"$unwind": "$posts"},
{"$match": {"posts.status": "Проходит"} if finish else {}},
{
"$group": {
"_id": "$posts._id", # Group by unique post ID
"post": {"$first": "$posts"} # Retrieve the first occurrence
}
},
{"$project": {"_id": 0, "post": 1}} # Include the post details
]
cursor = competition_request_col.aggregate(pipeline)
unique_posts = await cursor.to_list(length=None) # Fetch all unique posts
return [unique_post["post"] for unique_post in unique_posts] if unique_posts else []
@app.get("/exhibition/{exhibition_id}", response_class=HTMLResponse)
async def exhibition_members(
request: Request,
exhibition_id: str,
user: Dict = Security(get_user)
):
exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)})
if exhibition:
posts = await delivery_collection.find({'status': 'На выставке', "exhibition_id": exhibition_id}).to_list()
return templates.TemplateResponse(
request=request, name="exhibition/index.html", context={"id": 1, "user": user, "exhibition": exhibition, "posts": posts}
)
else:
raise HTTPException(status_code=307, headers={"Location": '/competitions'}, detail="Конкурс не найден")
@app.get("/competition/{competition_id}", response_class=HTMLResponse)
async def competition_members(
request: Request,
competition_id: str,
user: Dict = Security(get_user)
):
competition = await competitions_col.find_one({'_id': ObjectId(competition_id)})
if competition:
members = await get_unique_users(competition_request_col, competition_id, competition['status']==2)
posts = await get_unique_posts_с(competition_request_col, competition_id, competition['status']==2)
return templates.TemplateResponse(
request=request, name="competition/index.html", context={"id": 1, "user": user, "competition": competition, "members": members, "posts": posts}
)
else:
raise HTTPException(status_code=307, headers={"Location": '/competitions'}, detail="Конкурс не найден")
@app.get("/competition/{competition_id}/{member_id}", response_class=HTMLResponse)
async def competition_member(
request: Request,
competition_id: str,
member_id: str,
user: Dict = Security(get_user)
):
competition = await competitions_col.find_one({'_id': ObjectId(competition_id)})
if competition:
member = await users_collection.find_one({'_id':ObjectId(member_id)})
req = await competition_request_col.find_one({'competition_id': competition_id,'user._id': member_id})
member['status'] = req.get('status', False)
if member:
posts = await get_unique_posts_by_user(competition_request_col, competition_id, member_id)
return templates.TemplateResponse(
request=request, name="competition/member/index.html", context={"id": 1, "user": user, "competition": competition, 'member':member, 'posts': posts}
)
else:
raise HTTPException(status_code=307, headers={"Location": f'/competition/{competition._id}'}, detail="Участник не найден")
else:
raise HTTPException(status_code=307, headers={"Location": '/competitions'}, detail="Конкурс не найден")
async def _serialize(doc):
doc = doc.copy()
if "_id" in doc:
doc["_id"] = str(doc["_id"])
if "user" in doc:
doc["user"] = {'img': doc["user"].get('img'), 'username': doc["user"].get('username')}
# if "_id" in doc["user"]:
# doc["user"]["_id"] = str(doc["user"]["_id"])
# doc["user"]["notify"] = []
return doc
@app.get("/api/competitions", response_model=List)
async def get_competition():
competitions = []
current_time = datetime.now(timezone.utc) + timedelta(hours=3)
async for doc in competitions_col.find({
'datetime_start': {'$lt': current_time}
},{'fb':0,'inst':0,'max':0,'tg':0,'vk':0}).sort('_id',-1).limit(32):
competitions.append(bson_clear(doc))
return competitions
@app.get("/exhibitions", response_class=HTMLResponse)
async def exhibitions_all(
request: Request,
user: Dict = Security(get_user)
):
current_time = datetime.now(timezone.utc) + timedelta(hours=3)
find_list = [
{'datetime_start': {'$lt': current_time}},
{'user._id': user.get('id','') if user else ''}
]
if user and 'admin' in user.get('roles',[]):
find_list.append({'datetime_start': {'$gte': current_time}})
exhibitions = await exhibition_collection.find({
'$or': find_list,
'prod': PROD
}).sort({'_id':-1}).limit(1000).to_list()
return templates.TemplateResponse(
request=request, name="exhibitions.html", context={"id": 1, "user": user, "exhibitions": exhibitions}
)
@app.get("/competitions", response_class=HTMLResponse)
async def competition(
request: Request,
user: Dict = Security(get_user)
):
competitions = []
current_time = datetime.now(timezone.utc) + timedelta(hours=3)
find_list = [
{'datetime_start': {'$lt': current_time}},
{'user._id': user.get('id','') if user else ''}
]
if user and 'admin' in user.get('roles',[]):
find_list.append({'datetime_start': {'$gte': current_time}})
async for doc in competitions_col.find({
'$or': find_list
} if PROD else {}).sort({'_id':-1}).limit(1000):
competitions.append(doc)
return templates.TemplateResponse(
request=request, name="competitions.html", context={"id": 1, "user": user, "competitions": competitions}
)
router = APIRouter()
@router.post("/api/restore")
async def restore_api(
request: Request,
restore_data: RestoreRequest = Body(...),
):
# 1. Валидация и очистка входных данных
identifier = restore_data.identifier.strip()
if not identifier or not is_valid_email(identifier):
raise HTTPException(status_code=400, detail="Некорректный формат email")
# 2. Проверка анти-спама / ограничения частоты запросов
client_ip = request.client.host
rate_limit_key = f"rate_limit:restore:{identifier}:{client_ip}"
# Проверяем, запрашивал ли пользователь сброс недавно (например, за последние 60 секунд)
existing_request = await redis_client.get(rate_limit_key)
if existing_request:
# Опционально: получаем TTL, чтобы сообщить пользователю, когда можно повторить попытку
ttl = await redis_client.ttl(rate_limit_key)
raise HTTPException(
status_code=429,
detail=f"Слишком много запросов. Попробуйте через {ttl} секунд."
)
# Устанавливаем флаг ограничения частоты (истекает через 60 секунд)
await redis_client.set(rate_limit_key, "1", ex=60)
# 3. Поиск пользователя
user = await users_collection.find_one({'$or': [
{'email': identifier},
{'email': identifier.lower()}
]})
if not user:
# Безопасность: всегда возвращать одинаковое время ответа/структуру сообщения, если возможно
# Но для удобства использования мы можем сказать "не найден".
# Чтобы предотвратить перечисление, некоторые приложения всегда возвращают "OK", но отправляют письмо только если найдено.
# Здесь мы придерживаемся вашей оригинальной логики, но помните о риске перечисления.
raise HTTPException(status_code=404, detail="Пользователь с таким email не найден")
# 4. Генерация токена
token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
token_data = {
"user_id": str(user["_id"]),
"email": user["email"],
"next": restore_data.next,
"expires_at": expires_at.isoformat()
}
# 5. Сохранение токена сброса в Redis
reset_token_key = f"reset_token:{token}"
await redis_client.set(reset_token_key, json.dumps(token_data), ex=3600)
# 6. Генерация ссылки для сброса
base_url = str(request.base_url).rstrip('/')
reset_link = f"{base_url}/restore/{token}"
# 7. Асинхронная отправка email
try:
email_task.notify.delay(
recipient=user['email'],
context={
'link': reset_link,
'email': user['email']
},
subject='Восстановление пароля',
template_name='restore.html'
)
except Exception as e:
# Логирование ошибки
print(f"Не удалось отправить письмо: {e}")
# В зависимости от политики, вы можете захотеть удалить токен, если письмо не отправлено
await redis_client.delete(reset_token_key)
raise HTTPException(status_code=500, detail="Ошибка отправки письма")
return {'ok': True}
@router.get("/restore/{token}", response_class=HTMLResponse)
async def restore_page(request: Request, token: str):
token_key = f"reset_token:{token}"
token_data = await redis_client.get(token_key)
if not token_data:
return HTMLResponse(
"<h2 style='text-align:center;margin-top:100px;'>Ссылка недействительна или истекла</h2>",
status_code=400
)
return templates.TemplateResponse(
request=request, name="restore_token.html", context={"token": token}
)
@router.post("/api/reset-password")
async def reset_password(
data: ResetPasswordRequest = Body(...),
):
token_key = f"reset_token:{data.token}"
token_data_str = await redis_client.get(token_key)
if not token_data_str:
raise HTTPException(status_code=400, detail="Токен недействителен или истёк")
token_data = json.loads(token_data_str)
hashed_password = get_password_hash(data.new_password)
await users_collection.update_one(
{"_id": ObjectId(token_data["user_id"])},
{"$set": {"hashed_password": hashed_password}}
)
await redis_client.delete(token_key)
return {"ok": True, "message": "Пароль успешно изменён", "next": token_data.get('next', '/profile')}
@router.get("/restore", response_class=HTMLResponse)
async def restore_init(
request: Request,
next: str | None = None,
):
return templates.TemplateResponse(
request=request, name="restore.html", context={'next': next}
)
app.include_router(router, tags=['Восстановление пароля'])
@app.get("/login", response_class=HTMLResponse)
async def login_(
request: Request,
next_url: str | None = None,
):
if not next_url:
next_url = request.headers.get("Referer")
context={"next": next_url}
print(next_url)
if next_url and 'profile/competition' in next_url:
context.update({'notify':{'text':'Участвовать в конкурсе могут только пользователи сервиса! Пожалуйста, войдите или зарегистрируйтесь.', 'status':'info','duration':8000}})
return templates.TemplateResponse(
request=request,
name="login.html",
context=context,
)
@app.post("/api/switch-profile")
async def switch_profile(request: Request,
response: Response,user: Dict = Security(get_current_user)):
await users_collection.update_one({'_id':ObjectId(user.get('_id'))},{'$set':{'profile':'' if user.get('profile',False) else 'partner'}})
return {'ok': True}
@app.post("/api/post/description")
async def post_edit_description(
data: PostEditDescription = Body(...),
current_user: Dict = Security(get_current_user)
):
await posts_collection.update_one(
{'_id':ObjectId(data.post_id), 'user._id': current_user.get('_id')},
{'$set': {'description': data.description}}
)
return {'ok': True}
@app.delete("/api/partner/exhibition/delete")
async def partner_exhibition_delete(
data: PartnerExhibitionDelete = Body(...),
current_user: Dict = Security(get_current_user, scopes=["create:super_items"])
):
exhibition = await exhibition_collection.find_one_and_delete(
{'_id':ObjectId(data.exhibition_id), 'user._id': current_user.get('_id')},
)
for i in exhibition.get('tg',[]):
tg.delete_from_tg.delay(i.get('result',[{}])[0].get('message_id'), i.get('doc'))
for i in exhibition.get('vk',[]):
vk.delete_wall.delay(i.get('response',{}).get('post_id'), i.get('doc'))
return {'ok': True}
@app.delete("/api/partner/competition/delete")
async def partner_competition_delete(
data: Partnercompetitionstop = Body(...),
current_user: Dict = Security(get_current_user, scopes=["create:super_items"])
):
competition = await competitions_col.delete_one(
{'_id':ObjectId(data.competition_id), 'user._id': current_user.get('_id')},
)
for i in competition.get('tg',[]):
tg.delete_from_tg.delay(i.get('result',[{}])[0].get('message_id'), i.get('doc'))
for i in competition.get('vk',[]):
vk.delete_wall.delay(i.get('response',{}).get('post_id'), i.get('doc'))
return {'ok': True}
@app.post("/api/partner/competition/finish")
async def partner_competition_finish(
data: Partnercompetitionstop = Body(...),
current_user: Dict = Security(get_current_user, scopes=["create:super_items"])
):
await competitions_col.update_one(
{'_id':ObjectId(data.competition_id), 'user._id': current_user.get('_id')},
{'$set': {'status': 2}},
)
return {'ok': True}
@app.post("/api/partner/competition/stop")
async def partner_competition_stop(
data: Partnercompetitionstop = Body(...),
current_user: Dict = Security(get_current_user, scopes=["create:super_items"])
):
await competitions_col.update_one(
{'_id':ObjectId(data.competition_id), 'user._id': current_user.get('_id')},
{'$set': {'status': 1}},
)
return {'ok': True}
@app.post("/api/partner/competition/select")
async def partner_competition_request_artist(
background_tasks: BackgroundTasks,
data: CompetitionArtistRequest = Body(...),
current_user: Dict = Security(get_current_user, scopes=["create:super_items"])
):
print(data)
competition = await competitions_col.find_one({'_id':ObjectId(data.competition_id), 'user._id': current_user.get('_id')})
if competition:
await competition_request_col.update_many(
{'user._id': data.user_id, 'competition_id': data.competition_id},
{'$set': {'status': True,"posts.$[].status": 'Проходит' }},
)
background_tasks.add_task(accept_competition_req, competition, data.user_id)
social_vk = await social_collection.find_one({'user_id':current_user.get('_id'), 'group': {"$exists": False}, 'type':'vk'})
user = display_user(current_user)
user['social_vk'] = social_vk
await invitations_collection.update_one({
"user_id": data.user_id,
"item": competition.get('title'),
"title": "Вы прошли отбор",
"type_": competition.get("type_"),
"user": user,
"prod": PROD,
"type": "Конкурс"
},{'$set':{
"user_id": data.user_id,
"item": competition.get('title'),
"title": "Вы прошли отбор",
"type_": competition.get("type_"),
"user": user,
"prod": PROD,
"type": "Конкурс"
}},upsert=True)
return {'ok': True}
else:
return {'ok': False}
from fastapi import HTTPException, BackgroundTasks, Body, Security
from bson import ObjectId
from typing import Dict
@app.post(
"/api/partner/competition/request",
response_model=dict,
summary="Обновление статуса заявки участника в конкурсе (для партнёра)",
description="""
Позволяет владельцу конкурса обновить статус конкретного поста в заявке.
- Проверяет права владельца конкурса.
- Обновляет статус поста в массиве `posts`.
- Если после обновления все посты в заявке имеют статус "Проходит" или "На рассмотрении" —
заявка помечается `notify: True` и запускается фоновая обработка.
""",
status_code=200,
tags=["Partner", "Competition", "Requests"],
)
async def partner_competition_request(
background_tasks: BackgroundTasks,
data: PartnercompetitionRequest = Body(...),
current_user: Dict = Security(get_current_user, scopes=["create:super_items"])
):
"""
Обработчик обновления статуса поста в заявке на конкурс.
Flow:
1. Проверка владения конкурсом.
2. Массовое обновление статуса поста в массиве заявок.
3. Проверка полностью одобренных заявок → установка `notify` + фоновая задача.
"""
# Проверка прав владельца конкурса
competition = await competitions_col.find_one({
'_id': ObjectId(data.competition_id),
'user._id': current_user.get('_id')
})
if not competition:
raise HTTPException(
status_code=403,
detail="Конкурс не найден или у вас нет прав на его редактирование"
)
# Обновляем статус конкретного поста
await competition_request_col.update_many(
{
'posts._id': ObjectId(data.post_id),
'competition_id': data.competition_id
},
{'$set': {'posts.$[p].status': data.status}},
array_filters=[{'p._id': ObjectId(data.post_id)}]
)
posts_list = []
user = None
async for req in competition_request_col.find({
'posts._id': ObjectId(data.post_id),
'competition_id': data.competition_id,
}):
if not user:
user = req.get('user')
posts_list.extend(req.get('posts', []))
unique_posts = {str(p.get('_id')): p for p in posts_list}.values()
background_tasks.add_task(update_competition_req, competition, user, unique_posts)
return {'ok': True}
async def _update_post_description(post):
if post.get("description"):
await posts_collection.update_one(
{"_id": ObjectId(post["postId"])},
{"$set": {"description": post["description"]}}
)
async def background_request_competition(posts: List[dict]):
coros = [_update_post_description(p) for p in posts]
if coros:
await asyncio.gather(*coros)
@app.post("/api/send_competition_request/{competition_id}")
async def send_competition_request(
request: Request,
competition_id: str,
background_tasks: BackgroundTasks,
user: Dict = Security(get_current_user),
):
try:
data = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON")
posts_input = data.get("posts") or []
if not posts_input:
raise HTTPException(status_code=400, detail="Поле 'posts' обязательно")
# Build mapping: postId -> nomination (if provided)
nomination_map: dict[str, str | None] = {}
post_ids: list[str] = []
for p in posts_input:
post_id = p.get("postId")
if post_id:
post_ids.append(post_id)
nomination_map[post_id] = p.get("nomination") # can be str or None
unique_post_ids = list(dict.fromkeys(post_ids))
if not unique_post_ids:
raise HTTPException(status_code=400, detail="Нет валидных postId")
# Validate ObjectIds
try:
obj_ids = [ObjectId(pid) for pid in unique_post_ids]
except Exception:
raise HTTPException(status_code=400, detail="Некорректный формат postId")
# Check already used posts
used_post_ids: set[str] = set()
async for req in competition_request_col.find({
"user._id": user["_id"],
"competition_id": competition_id
}):
for post in req.get("posts", []):
if post_id := post.get("_id"):
used_post_ids.add(str(post_id))
new_obj_ids = [oid for oid in obj_ids if str(oid) not in used_post_ids]
excluded_count = len(obj_ids) - len(new_obj_ids)
if not new_obj_ids:
return {"ok": True, "message": "Все посты уже были отправлены ранее"}
# Fetch full posts
cursor = posts_collection.find({"_id": {"$in": new_obj_ids}})
fetched_posts = [doc async for doc in cursor]
# Enrich posts with nomination from input
for post in fetched_posts:
post_id_str = str(post.get("_id"))
if nomination := nomination_map.get(post_id_str):
post["nomination"] = nomination
doc = {
"user": display_user(user),
"competition_id": competition_id,
"posts": fetched_posts,
"created_at": datetime.utcnow(),
}
result = await competition_request_col.insert_one(doc)
# Background tasks
background_tasks.add_task(background_request_competition, posts_input) # pass original input
background_tasks.add_task(new_competiton_req, bson_clear(doc))
response = {
"ok": True,
"inserted_id": str(result.inserted_id)
}
if excluded_count > 0:
response["excluded"] = excluded_count
response["message"] = f"Исключено {excluded_count} уже использованных постов"
return response
@app.get("/profile/news", response_class=HTMLResponse)
async def profile_news(
request: Request,
user: Dict = Security(get_current_user)
):
news = [doc async for doc in news_collection.find({'user._id': user.get('_id')}).sort('_id',-1).limit(1000)]
return templates.TemplateResponse(
request=request, name="profile/news.html", context={ "user": user, 'news': news}
)
@app.get("/profile/form", response_class=HTMLResponse)
async def profile_form(
request: Request,
user: Dict = Security(get_current_user)
):
return templates.TemplateResponse(
request=request, name="profile/form.html", context={ "user": user}
)
async def competitions(user,request):
data=[]
async for i in competitions_col.find({'user._id': user.get('_id')}).sort('_id',-1).limit(1000):
data.append(i)
return templates.TemplateResponse(
request=request, name="profile/partner/competitions.html", context={ "user": user, 'cards': data}
)
@app.get("/profile/partner/competitions", response_class=HTMLResponse)
async def profile_partner_competitions(
request: Request,
user: Dict = Security(get_current_user, scopes=["create:super_items"])
):
await users_collection.update_one({'_id':ObjectId(user.get('_id'))},{'$set':{'profile':'partner'}})
user['profile'] = 'partner'
return await competitions(user,request)
async def get_unique_posts(competition_request_col: AsyncIOMotorCollection, competition_id: str) -> List[Dict[str, Any]]:
pipeline = [
{"$match": {"competition_id": competition_id}},
{"$project": {"posts": 1, "_id": 0}},
{"$unwind": "$posts"},
{"$group": {
"_id": "$posts._id", # group by post _id for uniqueness
"post": {"$first": "$posts"}
}},
{"$addFields": {
"status_order": {
"$switch": {
"branches": [
{"case": {"$eq": ["$post.status", 'Проходит']}, "then": 2},
{"case": {"$eq": ["$post.status", 'На рассмотрении']}, "then": 3}
],
"default": 1
}
}
}},
{"$sort": {"status_order": 1}},
{"$project": {"post": 1, "_id": 0}}
]
cursor = competition_request_col.aggregate(pipeline)
results = []
async for doc in cursor:
results.append(doc['post']) # Adjust based on the added $project stage
return results
async def add_competition_id_to_posts(posts, competition_id):
oid = ObjectId(competition_id)
for post in posts:
post['competition'] = oid
return posts
async def get_unique_users_p(competition_request_col: AsyncIOMotorCollection, competition_id: str) -> List[Dict[str, Any]]:
pipeline = [
{"$match": {"competition_id": competition_id}},
{"$unwind": "$user"},
{"$project": {
"user": 1,
"posts": 1,
"status": 1
}},
{"$unwind": "$posts"},
{"$group": {
"_id": "$user._id",
"user": {"$first": "$user"},
"unique_posts": {"$addToSet": "$posts._id"},
"status": {"$first": "$status"}
}},
{"$project": {
"user": 1,
"post_count": {"$size": "$unique_posts"},
"status": { "$cond": { "if": {"$ne": ["$status", None]}, "then": "$status", "else": False}}
}},
{"$sort": {"status": 1, "_id": 1}}
]
cursor = competition_request_col.aggregate(pipeline)
results = []
async for doc in cursor:
results.append(doc)
return results
@app.get("/profile/partner/competition/{competition_id}", response_class=HTMLResponse)
async def profile_partner_competition(
request: Request,
competition_id: str,
user: Dict = Security(get_current_user)
):
competition = await competitions_col.find_one({'_id': ObjectId(competition_id), 'user._id': user.get('_id')})
if not competition:
raise HTTPException(status_code=403, detail="Not enough permissions")
members = await get_unique_users_p(competition_request_col, competition_id)
posts = await get_unique_posts(competition_request_col, competition_id)
posts = await add_competition_id_to_posts(posts, competition_id)
return templates.TemplateResponse(
request=request, name="profile/partner/competition.html", context={ "user": user, "competition": competition, 'posts': posts, "members": members}
)
@app.get("/profile/partner/competition/{competition_id}/{member_id}", response_class=HTMLResponse)
async def profile_partner_competition_member(
request: Request,
competition_id: str,
member_id: str,
user: Dict = Security(get_current_user)
):
competition = await competitions_col.find_one({'_id': ObjectId(competition_id), 'user._id': user.get('_id')})
if not competition:
raise HTTPException(status_code=403, detail="Not enough permissions")
social_vk = await social_collection.find_one({'user_id':member_id,'type':'vk','group':{"$exists": False}})
member = await users_collection.find_one({'_id':ObjectId(member_id)})
req = await competition_request_col.find_one({'competition_id': competition_id,'user._id': member_id})
member['status'] = req.get('status', False)
posts = await get_unique_posts_by_user(competition_request_col, competition_id, member_id)
posts = await add_competition_id_to_posts(posts, competition_id)
return templates.TemplateResponse(
request=request, name="profile/partner/competition_member.html", context={ "user": user, "social_vk":social_vk, "competition": competition, 'posts': posts, "member": member}
)
@app.get("/profile/competition", response_class=HTMLResponse)
async def profile_competitions(
request: Request,
user: Dict = Security(get_current_user)
):
competitions = []
current_time = datetime.now(timezone.utc) + timedelta(hours=3)
async for i in competitions_col.find({
'$or': [
{'datetime_start': {'$lt': current_time}},
{'user._id': user.get('_id')}
]
} if PROD else {}).sort('_id', -1).limit(1000):
competitions.append(i)
return templates.TemplateResponse(
request=request, name="profile/competition.html", context={ "user": user, 'competitions': competitions}
)
@app.get("/profile/competition/{competition_id}", response_class=HTMLResponse)
async def profile_competition(
request: Request,
competition_id: str,
user: Dict = Security(get_current_user)
):
# required_keys = [
# "biography",
# "messengers",
# "style",
# "material_manufacturer",
# "genres",
# "education", "material_manufacturer",
# "art_types", "material_use",
# "unions", "master_classes",
# "style", "workshop_tour",
# "specialization",
# ]
# if all(user.get(key) for key in required_keys):
competition = await competitions_col.find_one({'_id':ObjectId(competition_id)})
posts=[]
async for i in posts_collection.find({'user._id': user.get('_id')}).sort('_id',-1).limit(1000):
posts.append(i)
return templates.TemplateResponse(
request=request, name="profile/competition/index.html", context={
"user": user, 'competition': competition, 'posts': posts,
'notify':{'text':'Выберите картины на конкурс','status':'info','duration':3000}
}
)
# else:
# return templates.TemplateResponse(
# request=request, name="profile/form.html", context={ "user": user, 'notify':{'text':'В начале заполните анкету под звездочками','status':'error','duration':8000}}
# )
@app.get("/profile", response_class=HTMLResponse)
async def profile(
request: Request,
response: Response,
user: Dict = Security(get_current_user)
):
if user.get('profile',False):
return templates.TemplateResponse(
request=request, name="profile/index.html", context={ "user": user, 'notify': {'text': 'Кабинет партнера', 'status': 'info', 'duration': 1500}}
)
else:
return templates.TemplateResponse(
request=request, name="profile/index.html", context={ "user": user}
)
@app.get("/profile/partner", response_class=HTMLResponse)
async def profile(
request: Request,
response: Response,
user: Dict = Security(get_current_user)
):
if user.get('profile',False):
return await competitions(user,request)
else:
return await posts_temp(user,request)
@app.get("/profile/posts", response_class=HTMLResponse)
async def profile_posts(
request: Request,
user: Dict = Security(get_current_user)
):
return await posts_temp(user,request)
@app.get("/profile/password", response_class=HTMLResponse)
async def profile_password(
request: Request,
user: Dict = Security(get_current_user)
):
return templates.TemplateResponse(
request=request, name="profile/password.html", context={"user": user}
)
@app.get("/profile/exhibition", response_class=HTMLResponse)
async def profile_exhibition(
request: Request,
user: Dict = Security(get_current_user)
):
# data = exhibition_collection.
data = await exhibition_collection.find({}).sort('_id', -1).to_list()
return templates.TemplateResponse(
request=request, name="profile/exhibition.html", context={"user": user, "data": data, "now": datetime.now()}
)
@app.get("/")
async def index(request: Request, user: Dict = Security(get_user)):
news = await get_latest_news()
return templates.TemplateResponse(
request=request, name="index.html", context={"user": user, "news": news}
)
@app.get("/about")
async def about(request: Request, user: Dict = Security(get_user)):
news = await get_latest_news()
return templates.TemplateResponse(
request=request, name="about.html", context={"user": user}
)
@app.get("/news")
async def news(request: Request, user: Dict = Security(get_user)):
news = [doc async for doc in news_collection.find({}).limit(1000)]
return templates.TemplateResponse(
request=request, name="news.html", context={"user": user, "news": news}
)
@app.get("/pwa")
async def pwa(request: Request, user: Dict = Security(get_user)):
news = await get_latest_news()
return templates.TemplateResponse(
request=request, name="index.html", context={"user": user, "news": news, 'pwa':True}
)
@app.get("/pwa2")
async def pwa(request: Request, user: Dict = Security(get_user)):
news = await get_latest_news()
return templates.TemplateResponse(
request=request, name="index.html", context={"user": user, "news": news, 'pwa':True}
)
@app.get("/offline.html")
async def offline_html(request: Request, user: Dict = Security(get_user)):
news = await get_latest_news()
return templates.TemplateResponse(
request=request, name="index.html", context={"user": user, "news": news, 'pwa':True}
)
@app.get("/partner/galerist", response_class=HTMLResponse)
async def partner_galerist(
request: Request,
user: Dict = Security(get_user)
):
partner = [doc async for doc in users_collection.find({'roles': 'partner'},{'username': 1, 'description': 1})]
return templates.TemplateResponse(
request=request, name="partner/galerist.html", context={"id": 1, "user": user, "partner": partner}
)
@app.get("/partner", response_class=HTMLResponse)
async def partner(
request: Request,
user: Dict = Security(get_user)
):
return templates.TemplateResponse(
request=request, name="partner/index.html", context={"id": 1, "user": user}
)
@app.get("/artists", response_class=HTMLResponse)
async def artists(
request: Request,
user: Dict = Security(get_current_user, scopes=["users:read"])
):
users = [doc async for doc in users_collection.find({'roles':'user'}).limit(1000)]
return templates.TemplateResponse(
request=request, name="users/index.html", context={"id": 1, "user":user, "users": users}
)
@app.get("/my-paints", response_class=HTMLResponse)
async def My_paints(
request: Request,
user: Dict = Security(get_current_user, scopes=["read:self"])
):
user_id = user.get('_id')
if isinstance(user_id, int) or (is_str:=len(user_id) < 5):
doc = await users_collection.find_one({"id": int(user_id) if is_str else user_id}, {"_id": 1})
if not doc:
raise HTTPException(status_code=404, detail="Пользователь не найден")
return RedirectResponse(url=f"/artists/{doc.get('_id')}", status_code=301)
doc = users_collection.find_one({"_id": user_id})
if not doc:
raise HTTPException(status_code=404, detail="Пользователь не найден")
total_key = f"total_w_c_artists:{user_id}"
today_key = f"today_w_c_artists:{user_id}"
total_watch_count = await redis_client.incr(total_key)
today_date = datetime.now().strftime("%Y-%m-%d")
daily_key = f"{today_key}:{today_date}"
today_watch_count = await redis_client.incr(daily_key)
find = await users_collection.find_one({'_id':ObjectId(user_id)})
cards = [i async for i in posts_collection.find({'user._id': user_id}).sort('_id',-1).limit(1000)]
return templates.TemplateResponse(
request=request, name="users/user.html", context={"id": 1, "user":user, "find": find, "cards": cards,"total_watch_count": total_watch_count,"today_watch_count": today_watch_count,}
)
@app.get("/artists/{user_id}", response_class=HTMLResponse)
async def artists_id(
request: Request,
user_id: Union[int, str],
user: Dict = Security(get_current_user, scopes=["users:read"])
):
if isinstance(user_id, int) or (is_str:=len(user_id) < 5):
doc = await users_collection.find_one({"id": int(user_id) if is_str else user_id}, {"_id": 1})
if not doc:
raise HTTPException(status_code=404, detail="Пользователь не найден")
return RedirectResponse(url=f"/artists/{doc.get('_id')}", status_code=301)
doc = users_collection.find_one({"_id": user_id})
if not doc:
raise HTTPException(status_code=404, detail="Пользователь не найден")
total_key = f"total_w_c_artists:{user_id}"
today_key = f"today_w_c_artists:{user_id}"
total_watch_count = await redis_client.incr(total_key)
today_date = datetime.now().strftime("%Y-%m-%d")
daily_key = f"{today_key}:{today_date}"
today_watch_count = await redis_client.incr(daily_key)
find = await users_collection.find_one({'_id':ObjectId(user_id)})
cards = [i async for i in posts_collection.find({'user._id': user_id}).sort('_id',-1).limit(1000)]
return templates.TemplateResponse(
request=request, name="users/user.html", context={"id": 1, "user":user, "find": find, "cards": cards,"total_watch_count": total_watch_count,"today_watch_count": today_watch_count,}
)
@app.get("/form/competition", response_class=HTMLResponse)
async def form_(
request: Request,
user: Dict = Security(get_current_user, scopes=["create:super_items"])
):
return templates.TemplateResponse(
request=request, name="form/competition.html", context={"id": 1, "user": user}
)
@app.get("/form/competition/{competition_id}", response_class=HTMLResponse)
async def form_(
request: Request,
competition_id: str,
user: Dict = Security(get_current_user, scopes=["create:super_items"])
):
competition = await competitions_col.find_one({'_id': ObjectId(competition_id)})
data = json.dumps(competition, cls=MongoJSONEncoder, ensure_ascii=False)
return templates.TemplateResponse(
request=request, name="form/competition_edit.html", context={"competition": str(data), "user": user}
)
@app.get('/news/{news_id}')
async def article_page(
news_id: Union[int, str],
requset: Request,
user: Dict = Security(get_user)
):
if isinstance(news_id, int) or (is_str:=len(news_id) < 5):
doc = await news_collection.find_one({"id": int(news_id) if is_str else news_id}, {"_id": 1})
if not doc:
raise HTTPException(status_code=404, detail="новость не найдена")
return RedirectResponse(url=f"/news/{doc.get('_id')}", status_code=301)
news = await news_collection.find_one({'_id':ObjectId(news_id)})
if not news:
raise HTTPException(status_code=404, detail="Новость не найдена")
return templates.TemplateResponse(
request=requset, name='news/index.html', context={"user":user, "news": news}
)
@app.get("/form/post", response_class=HTMLResponse)
async def form_post(
request: Request,
user: Dict = Security(get_current_user)
):
partner = [doc async for doc in users_collection.find({'roles': 'partner'},{'username': 1})]
service_social = [doc async for doc in social_collection.find({'service': True})]
user_social = [doc async for doc in social_collection.find({'active': True,'service': {'$exists': False},'user_id': str(user.get('_id'))})]
return templates.TemplateResponse(
request=request, name="posts/form.html", context={"id": 1, "user": user, 'user_social': user_social, 'partner': partner, 'service_social': service_social}
)
@app.get("/form/post/{post_id}", response_class=HTMLResponse)
async def form_post_edit(
request: Request,
post_id:str,
user: Dict = Security(get_current_user)
):
post = await posts_collection.find_one({'_id':ObjectId(post_id)})
data = json.dumps(post, cls=MongoJSONEncoder, ensure_ascii=False)
return templates.TemplateResponse(
request=request, name="form/post_edit.html", context={"post": data, "user": user}
)
@app.get("/form/news", response_class=HTMLResponse)
async def form_news(
request: Request,
user: Dict = Security(get_current_user)
):
return templates.TemplateResponse(
request=request, name="form/news.html", context={"id": 1, "user": user}
)
@app.get("/form/news/{news_id}", response_class=HTMLResponse)
async def form_news_edit(
request: Request,
news_id:str,
user: Dict = Security(get_current_user)
):
news = await news_collection.find_one({'_id':ObjectId(news_id)})
return templates.TemplateResponse(
request=request, name="form/news_edit.html", context={"news": news, "user": user}
)
@app.get("/form/exhibition", response_class=HTMLResponse)
async def form_exhibition(
request: Request,
user: Dict = Security(get_current_user)
):
return templates.TemplateResponse(
request=request, name="form/exhibition.html", context={"id": 1, "user": user}
)
@app.get("/form/exhibition/{exhibition_id}", response_class=HTMLResponse)
async def form_exhibition(
request: Request,
exhibition_id: str,
user: Dict = Security(get_current_user)
):
exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)},{'posts':0,'vk':0,'tg':0,'fb':0,'inst':0,'max':0})
data = json.dumps(exhibition, cls=MongoJSONEncoder, ensure_ascii=False)
return templates.TemplateResponse(
request=request, name="form/exhibition_edit.html", context={"id": 1, "user": user, "exhibition": str(data)}
)
@app.post("/api/avatar", response_model=dict)
async def avatar_change(
background_tasks: BackgroundTasks,
avatar: Optional[UploadFile] = File(...),
current_user: Dict = Security(get_current_user)
):
try:
avatar_id = await save_to_gridfs(avatar, 'users')
await users_collection.update_one({'_id': ObjectId(current_user.get('_id'))}, {'$set': {'img': avatar_id}})
background_tasks.add_task(logo_user_task, bson_clear(current_user))
return {'ok': True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/user/form", response_model=dict)
async def user_form(
request: Request,
current_user: Dict = Security(get_current_user)
):
try:
doc = await request.json()
if not isinstance(doc, dict):
raise HTTPException(status_code=400, detail="Invalid JSON body")
# Remove any protected fields from incoming update
update_doc = {k: v for k, v in doc.items() if k not in PROTECTED_FIELDS_USER}
if not update_doc:
return {"ok": True, "updated": False, "reason": "no allowed fields"}
result = users_collection.update_one(
{"_id": ObjectId(current_user["id"])},
{"$set": update_doc}
)
return {"ok": True, "updated": True}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get('/paintings/{paint_id}')
async def paintings(
request: Request,
paint_id: Union[int, str],
user: Dict = Security(get_user)
):
if isinstance(paint_id, int) or (is_str:=len(paint_id) < 5):
doc = await posts_collection.find_one({"id": int(paint_id) if is_str else paint_id}, {"_id": 1})
if not doc:
raise HTTPException(status_code=404, detail="Картина не найдена")
return RedirectResponse(url=f"/paintings/{doc.get('_id')}", status_code=301)
paint = await posts_collection.find_one({'_id': ObjectId(paint_id)})
if not paint:
raise HTTPException(status_code=404, detail="Картина не найдена")
total_key = f"total_watch_count_paint:{paint_id}"
today_key = f"today_watch_count_paint:{paint_id}"
total_watch_count = await redis_client.incr(total_key)
today_date = datetime.now().strftime("%Y-%m-%d")
daily_key = f"{today_key}:{today_date}"
today_watch_count = await redis_client.incr(daily_key)
description = f"{paint['author']}\n"
description += f"Размер: {paint['size']}\n"
description += f"Материал: {paint['material']}\n"
description += f"Стоимость: {paint['price']}\n"
description += f"{paint['description']}"
return templates.TemplateResponse(
request=request, name="paintings/paint.html", context={"paint": paint, "user": user, "description": description,'today_watch_count':today_watch_count,'total_watch_count':total_watch_count}
)
@app.get('/post/{paint_id}')
async def old_post(
request: Request,
paint_id: int,
user: Dict = Security(get_user)
):
if isinstance(paint_id, int):
doc = await posts_collection.find_one({"id": paint_id}, {"_id": 1})
if not doc:
raise HTTPException(status_code=404, detail="Картина не найдена")
return RedirectResponse(url=f"/paintings/{doc.get('_id')}", status_code=301)
@app.delete("/api/post/{post_id}", response_model=dict)
async def post_delete(
post_id: Optional[str],
current_user: Dict = Security(get_current_user, scopes=["create:posts"])
):
await posts_collection.delete_one({'_id': ObjectId(post_id), 'user._id': current_user.get('_id')})
return {'ok': True}
@app.post("/api/post", response_model=dict)
async def post_painting(
background_tasks: BackgroundTasks,
author: str = Form(None),
grade: str = Form(None),
city: str = Form(None),
title: str = Form(...),
size: str = Form(None),
material: str = Form(None),
genre: str = Form(None),
price: str = Form(None),
description: str = Form(None),
post_medias: Optional[List[str]] = Form(...),
current_user: Dict = Security(get_current_user, scopes=["create:posts"])
):
try:
doc = {
"title": safe_strip(title),
"url": transliterate(f"{safe_strip(title)} {safe_strip(author, current_user['username'])} {safe_strip(city)}"),
"author": safe_strip(author, current_user['username']),
"grade": safe_strip(grade),
"city": safe_strip(city) or current_user.get('living_place'),
"size": safe_strip(size),
"status": "В каталоге",
"material": safe_strip(material),
"genre": safe_strip(genre),
"price": safe_strip(price),
"description": safe_strip(description),
"medias": post_medias or [],
"medias_original": post_medias or [],
"user": display_user(current_user),
**{i:[] for i in SOCIALS},
}
res = await posts_collection.insert_one(doc)
doc_out = {
"id": str(res.inserted_id),
"_id": str(res.inserted_id),
**{k: v for k, v in doc.items()}
}
doc_out = bson_clear(doc_out)
background_tasks.add_task(replace_post_medias, doc_out)
if PROD:
background_tasks.add_task(email_new_post, doc_out)
background_tasks.add_task(publish_social_post, doc_out)
if 'user' in current_user.get('roles',[]) and safe_strip(grade) != current_user.get('grade'):
await users_collection.update_one({'_id':ObjectId(current_user.get('_id'))},{"$set": {'grade': safe_strip(grade)}})
return doc_out
except Exception as e:
print(e)
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/post/{post_id}", response_model=dict)
async def put_painting(
background_tasks: BackgroundTasks,
post_id: str,
author: str = Form(None),
grade: str = Form(None),
city: str = Form(None),
title: str = Form(...),
size: str = Form(None),
material: str = Form(None),
genre: str = Form(None),
price: str = Form(None),
description: str = Form(None),
post_medias: Optional[List[str]] = Form(...),
current_user: Dict = Security(get_current_user, scopes=["create:posts"])
):
update_doc = {}
if title is not None:
update_doc["title"] = safe_strip(title)
update_doc["url"] = transliterate(f"{safe_strip(title)} {safe_strip(author)} {safe_strip(city)}")
if author is not None:
update_doc["author"] = safe_strip(author)
if grade is not None:
update_doc["grade"] = safe_strip(grade)
if city is not None:
update_doc["city"] = safe_strip(city)
if size is not None:
update_doc["size"] = safe_strip(size)
if material is not None:
update_doc["material"] = safe_strip(material)
if genre is not None:
update_doc["genre"] = safe_strip(genre)
if price is not None:
update_doc["price"] = safe_strip(price)
if description is not None:
update_doc["description"] = safe_strip(description)
if post_medias:
update_doc["medias_original"] = post_medias
res = await posts_collection.find_one_and_update(
{'_id': ObjectId(post_id)},
{'$set': update_doc},
return_document=False
)
links_media = list(zip(res.get('medias_original',[]), res.get('medias',[])))
if res is None:
raise HTTPException(status_code=404, detail="Картина не найдена")
doc_out = {
"_id":post_id,
"id":post_id,
**{k: v for k, v in update_doc.items()}
}
doc_out = bson_clear(doc_out)
background_tasks.add_task(update_social_post, doc_out)
background_tasks.add_task(replace_new_medias, bson_clear(doc_out), links_media)
return doc_out
@app.post("/api/posts/media", response_model=dict)
async def post_media(
medias: Optional[List[UploadFile]] = File(None),
current_user: Dict = Security(get_current_user, scopes=["create:posts"])
):
try:
post_medias = await asyncio.gather(*[save_to_gridfs(media, 'posts') for media in medias])
return {'post_medias': post_medias}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/news", response_model=dict)
async def post_news(
background_tasks: BackgroundTasks,
title: str = Form(...),
description: str = Form(None),
short_description: str = Form(None),
publish_on_platform: bool = Form(False),
post_medias: Optional[List[str]] = Form(...),
current_user: Dict = Security(get_current_user, scopes=["create:news"])
):
try:
doc = {
"title": safe_strip(title),
"short_description": safe_strip(short_description),
"publish_on_platform": publish_on_platform,
"description": safe_strip(description),
"media": post_medias,
"media_original": post_medias,
"user": display_user(current_user),
**{i:[] for i in SOCIALS},
}
res = await news_collection.insert_one(doc)
doc_out = {
"id": str(res.inserted_id),
**{k: v for k, v in doc.items()}
}
doc_out = bson_clear(doc_out)
# background_tasks.add_task(email_new_post, doc_out)
background_tasks.add_task(replace_news_medias, doc_out)
return doc_out
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/news/{news_id}", response_model=dict)
async def put_news(
background_tasks: BackgroundTasks,
news_id: str,
title: str = Form(...),
description: str = Form(None),
short_description: str = Form(None),
publish_on_platform: bool = Form(False),
post_medias: Optional[List[str]] = Form(...),
current_user: Dict = Security(get_current_user, scopes=["create:news"])
):
update_doc = {}
if title is not None:
update_doc["title"] = safe_strip(title)
if description is not None:
update_doc["description"] = safe_strip(description)
if short_description is not None:
update_doc["short_description"] = safe_strip(short_description)
if publish_on_platform is not None:
update_doc["publish_on_platform"] = publish_on_platform
if post_medias:
update_doc["media"] = post_medias
update_doc["media_original"] = post_medias
res = await news_collection.update_one(
{'_id': ObjectId(news_id)},
{'$set': update_doc}
)
if res.modified_count == 0:
raise HTTPException(status_code=404, detail="Картина не найдена")
doc_out = {
"id":news_id,
**{k: v for k, v in update_doc.items()}
}
doc_out = bson_clear(doc_out)
return doc_out
@app.post("/api/news/media", response_model=dict)
async def news_media(
medias: Optional[List[UploadFile]] = File(None),
current_user: Dict = Security(get_current_user, scopes=["create:news"])
):
try:
media = await asyncio.gather(*[save_to_gridfs(media, 'news') for media in medias])
return {'media': media}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/read/notify", response_model=Dict)
async def read_notify(
user: Dict = Security(get_user)
):
await notify_collection.update_many({'user_id':user.get('_id')},{'$set':{'read':False}})
return {}
@app.post("/api/competition", response_model=Dict, tags=["Competition", "Partner"])
async def post_competition(
background_tasks: BackgroundTasks,
payload: CompetitionCreate = Depends(build_payload),
current_user: Dict = Security(get_current_user, scopes=["create:super_items"]),
):
try:
poster_id = await save_to_gridfs(payload.poster, "competition")
if payload.rules:
rules_id = await save_to_gridfs(payload.rules, "competition")
else:
rules_id = None
await users_collection.update_one(
{"_id": ObjectId(current_user["_id"])},
{"$addToSet": {"send_address": safe_strip(payload.address)}},
upsert=True,
)
doc = {
"url": transliterate(safe_strip(payload.title)),
"title": safe_strip(payload.title),
"topic": safe_strip(payload.topic) if payload.topic else None,
"type_": payload.type_,
"datetime_start": payload.datetime_start,
"date_finish": payload.date_finish,
"nominations": payload.nominations,
"address": safe_strip(payload.address),
"emailsNotify": payload.emailsNotify,
"serviceSocial": payload.serviceSocial,
"userSocial": payload.userSocial,
"short_desc": safe_strip(payload.short_desc) if payload.short_desc else None,
"poster_file_id": poster_id,
"poster_orig_id": poster_id,
"rules_file_id": rules_id,
"rules_link": payload.rules_link,
"status": 0,
"user": display_user(current_user),
"min_paintings": payload.min_paintings,
"max_paintings": payload.max_paintings,
**{i:[] for i in SOCIALS},
}
res = await competitions_col.insert_one(doc)
background_tasks.add_task(
poster_competition, bson_clear({**doc, "_id": res.inserted_id})
)
return {"ok": True, "competition_id": str(res.inserted_id)}
except Exception as exc:
logging.error(str(exc))
raise HTTPException(status_code=500, detail=str(exc))
@app.post("/api/competition/publish", response_model=Dict, tags=["Competition", "Partner"])
async def publish_competition(
background_tasks: BackgroundTasks,
payload: CompetitionPublishParams = Depends(build_publish_payload),
current_user: Dict = Security(get_current_user, scopes=["create:super_items"]),
):
try:
logging.info(
{
"current_user": current_user.get("_id"),
"competition_id": payload.competition_id,
"emailsNotify": payload.emailsNotify,
"serviceSocial": payload.serviceSocial,
"userSocial": payload.userSocial,
"usersSocial": payload.usersSocial,
}
)
competition = await competitions_col.find_one(
{
"user._id": current_user.get("_id"),
"_id": ObjectId(payload.competition_id),
}
)
if not competition:
raise HTTPException(status_code=404, detail="Конкурс не найден")
competition = bson_clear(competition)
if competition["datetime_start"] > datetime.now():
await competitions_col.update_one(
{"_id": ObjectId(payload.competition_id)},
{"$set": {"datetime_start": datetime.now()}},
)
if payload.emailsNotify:
background_tasks.add_task(invite_users_to_competitions, competition)
if any([payload.serviceSocial, payload.userSocial, payload.usersSocial]):
background_tasks.add_task(
service_social_competition,
competition,
payload.serviceSocial,
payload.userSocial,
payload.usersSocial,
)
return {"ok": True, "competition_id": payload.competition_id}
except Exception as exc:
logging.error(str(exc))
raise HTTPException(status_code=500, detail=str(exc))
@app.put("/api/competition/{competition_id}", response_model=Dict, tags=["Competition", "Partner"])
async def put_competition(
background_tasks: BackgroundTasks,
competition_id: str,
payload: CompetitionUpdate = Depends(build_update_payload),
current_user: Dict = Security(get_current_user, scopes=["create:super_items"]),
):
try:
update_doc = {
"user": display_user(current_user),
"rules_link": payload.rules_link,
"type_": payload.type_,
}
if payload.nominations is not None:
update_doc["nominations"] = payload.nominations
if payload.title is not None:
update_doc["title"] = safe_strip(payload.title)
if payload.topic is not None:
update_doc["topic"] = safe_strip(payload.topic)
if payload.datetime_start is not None:
update_doc["datetime_start"] = payload.datetime_start
if datetime.now(timezone.utc) + timedelta(hours=3) < payload.datetime_start:
update_doc["status"] = -1
if payload.date_finish is not None:
update_doc["date_finish"] = payload.date_finish
if payload.address is not None:
await users_collection.update_one(
{ "_id": ObjectId(current_user["_id"]) },
{ "$addToSet": { "send_address": safe_strip(payload.address) } },
upsert=True
)
update_doc["address"] = safe_strip(payload.address)
if payload.short_desc is not None:
update_doc["short_desc"] = safe_strip(payload.short_desc)
if payload.emailsNotify is not None:
update_doc["emailsNotify"] = payload.emailsNotify
if payload.serviceSocial is not None:
update_doc["serviceSocial"] = payload.serviceSocial
if payload.userSocial is not None:
update_doc["userSocial"] = payload.userSocial
if payload.min_paintings is not None:
update_doc["min_paintings"] = int(payload.min_paintings)
if payload.max_paintings is not None:
update_doc["max_paintings"] = int(payload.max_paintings)
if payload.poster:
poster_id = await save_to_gridfs(payload.poster, 'competition')
update_doc["poster_file_id"] = poster_id
background_tasks.add_task(poster_competition, bson_clear({**update_doc, '_id': competition_id}))
if payload.rules:
rules_id = await save_to_gridfs(payload.rules, 'competition')
update_doc["rules_file_id"] = rules_id
res = await competitions_col.update_one(
{'_id': ObjectId(competition_id)},
{'$set': update_doc}
)
return {'ok': True}
except Exception as exc:
logging.error(str(exc))
raise HTTPException(status_code=500, detail=str(exc))
@app.get("/api/exhibitions", response_model=List)
async def get_exhibitions():
exhibitions = []
current_time = datetime.now(timezone.utc) + timedelta(hours=3)
async for doc in exhibition_collection.find({
'$or': [
{'datetime_start': {'$lt': current_time}},
],
'prod': True
},{'vk': 0,'max': 0,'tg': 0,'fb': 0,'inst': 0}).sort('_id',-1).limit(1000):
exhibitions.append(bson_clear(doc))
return exhibitions
@app.post("/api/exhibition", response_model=Dict)
async def post_exhibition(
background_tasks: BackgroundTasks,
payload: ExhibitionCreate = Depends(build_exhibition_payload),
current_user: Dict = Security(get_current_user, scopes=["create:super_items"])
):
try:
poster_id = await save_to_gridfs(payload.poster, 'exhibition')
rules_id = await save_to_gridfs(payload.rules, 'exhibition') if payload.rules else None
tizer_id = await save_to_gridfs(payload.tizer, 'exhibition') if payload.tizer else None
await users_collection.update_one(
{"_id": ObjectId(current_user["_id"])},
{"$addToSet": {
"send_address": safe_strip(payload.address),
"places": safe_strip(payload.place)
}},
upsert=True
)
doc = {
"url": transliterate(safe_strip(payload.title)),
"title": safe_strip(payload.title),
"topic": safe_strip(payload.topic) if payload.topic else None,
"datetime_start": payload.datetime_start,
"date_finish": payload.date_finish,
"address": safe_strip(payload.address) if payload.address else None,
"place": safe_strip(payload.place) if payload.place else None,
"rules_file_id": rules_id,
"rules_link": payload.rules_link,
"tizer_file_id": tizer_id,
"tizer_link": payload.tizer_link,
"poster_orig_id": poster_id,
"poster_file_id": poster_id,
"status": 0,
"prod": PROD,
"user": display_user(current_user),
"posts": [],
**{i:[] for i in SOCIALS},
}
res = await exhibition_collection.insert_one(doc)
exhibition_id = res.inserted_id
background_tasks.add_task(
poster_exhibition,
bson_clear({**doc, '_id': exhibition_id})
)
return {'_id': str(exhibition_id)}
except Exception as e:
logging.error(str(e))
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/exhibition/{exhibition_id}", response_model=Dict)
async def put_exhibition(
background_tasks: BackgroundTasks,
exhibition_id: str,
payload: ExhibitionUpdate = Depends(build_update_exhibition_payload),
current_user: Dict = Security(get_current_user, scopes=["create:super_items"])
):
try:
update_doc = {
"user": display_user(current_user),
}
if payload.title is not None:
update_doc["title"] = safe_strip(payload.title)
if payload.topic is not None:
update_doc["topic"] = safe_strip(payload.topic)
if payload.place is not None:
await users_collection.update_one(
{"_id": ObjectId(current_user["_id"])},
{"$addToSet": {"places": safe_strip(payload.place)}},
upsert=True
)
update_doc["place"] = safe_strip(payload.place)
if payload.datetime_start is not None:
update_doc["datetime_start"] = payload.datetime_start
if payload.date_finish is not None:
update_doc["date_finish"] = payload.date_finish
if payload.address is not None:
await users_collection.update_one(
{"_id": ObjectId(current_user["_id"])},
{"$addToSet": {"send_address": safe_strip(payload.address)}},
upsert=True
)
update_doc["address"] = safe_strip(payload.address)
if payload.short_desc is not None:
update_doc["short_desc"] = safe_strip(payload.short_desc)
update_doc["rules_link"] = payload.rules_link
update_doc["tizer_link"] = payload.tizer_link
# Обработка файлов
if payload.poster:
poster_id = await save_to_gridfs(payload.poster, 'exhibition')
update_doc["poster_file_id"] = poster_id
update_doc["poster_orig_id"] = poster_id
background_tasks.add_task(
poster_exhibition,
bson_clear({**update_doc, '_id': exhibition_id})
)
if payload.tizer:
tizer_id = await save_to_gridfs(payload.tizer, 'exhibition')
update_doc["tizer_file_id"] = tizer_id
update_doc["tizer_link"] = None
if payload.rules:
rules_id = await save_to_gridfs(payload.rules, 'exhibition')
update_doc["rules_file_id"] = rules_id
update_doc["rules_link"] = None
# Если нужно добавить nominations / min_paintings и т.д. — расширь модель и этот блок
res = await exhibition_collection.update_one(
{'_id': ObjectId(exhibition_id)},
{'$set': update_doc}
)
return {'_id': exhibition_id}
except Exception as e:
logging.error(str(e))
raise HTTPException(status_code=500, detail=str(e))
async def stream_gridfs_file(file_id: str, bucket_name: str):
if not file_id:
return None
grid = gridfs_map.get(bucket_name)
if grid is None:
return None
try:
oid = ObjectId(file_id)
except Exception:
return None
stream = None
try:
stream = await grid.open_download_stream(oid)
filename = stream.filename or "file"
content_type = (
stream.content_type
or mimetypes.guess_type(filename)[0]
or "application/octet-stream"
)
file_length = getattr(stream, "length", None)
async def file_iterator(chunk_size: int = 64 * 1024):
try:
while True:
chunk = await stream.read(chunk_size)
if not chunk:
break
yield chunk
finally:
if stream is not None:
close = getattr(stream, "close", None)
if asyncio.iscoroutinefunction(close) or asyncio.iscoroutine(close):
await close()
elif callable(close):
close()
return filename, content_type, file_length, file_iterator
except Exception:
if stream is not None:
close = getattr(stream, "close", None)
if asyncio.iscoroutinefunction(close) or asyncio.iscoroutine(close):
await close()
elif callable(close):
close()
return None
def content_disposition_header(filename: str, inline: bool = True) -> str:
dispo_type = "inline" if inline else "attachment"
fallback = filename.encode("latin-1", errors="ignore").decode("latin-1")
fallback = fallback.replace('"', '\\"').replace("\\", "\\\\") or "file"
try:
filename.encode("latin-1")
return f'{dispo_type}; filename="{fallback}"'
except UnicodeEncodeError:
filename_quoted = quote(filename, safe="")
return f'{dispo_type}; filename="{fallback}"; filename*=UTF-8\'\'{filename_quoted}'
@app.get("/b/{bucket_name}/{file_id}")
async def get_file(bucket_name: str, file_id: str):
res = await stream_gridfs_file(file_id, bucket_name)
if res is None:
raise HTTPException(status_code=404, detail="File not found")
filename, content_type, file_length, iterator = res
headers = {
"Content-Disposition": content_disposition_header(filename, inline=True),
"Cache-Control": "public, max-age=3600",
"ETag": f'"{bucket_name}:{file_id}"',
"Vary": "Accept-Encoding",
}
if file_length is not None:
headers["Content-Length"] = str(file_length)
return StreamingResponse(iterator(), media_type=content_type, headers=headers)
@app.get("/a/{user_id}")
async def get_avatar(user_id: str):
user = await users_collection.find_one({'_id': ObjectId(user_id)},{'img':1})
file_id = user['img']
res = await stream_gridfs_file(file_id, 'users')
if res is None:
raise HTTPException(status_code=404, detail="File not found")
filename, content_type, file_length, iterator = res
headers = {
"Content-Disposition": content_disposition_header(filename, inline=True),
"Cache-Control": "public, max-age=3600",
"ETag": f'"users:{file_id}"',
"Vary": "Accept-Encoding",
}
if file_length is not None:
headers["Content-Length"] = str(file_length)
return StreamingResponse(iterator(), media_type=content_type, headers=headers)
async def unsubscribe_user(
email: str,
token: Optional[str] = None
) -> bool:
try:
if token:
stored_email = await redis_client.get(f"unsub:{token}")
if not stored_email or stored_email != email:
return False
await redis_client.delete(f"unsub:{token}")
result = await mongo_client['email']['tokens'].update_one(
{"token": token, "email": email, "used": False},
{"$set": {"used": True, "used_at": datetime.utcnow()}}
)
if result.modified_count == 0:
return False
await users_collection.update_one(
{"email": email},
{"$set": {
"email_subscribed": False,
"email_unsubscribed_at": datetime.utcnow(),
"email_unsubscribe_reason": "user_request"
}}
)
logging.info(f"✅ Пользователь отписался: {email}")
return True
except Exception as e:
logging.error(f"❌ Ошибка отписки {email}: {e}")
return False
@app.get("/api/v1/email/unsubscribe", response_class=HTMLResponse)
async def unsubscribe(
request: Request,
email: EmailStr,
token: Optional[str] = None,
):
logging.info(f"Отписка {token} {email}")
await unsubscribe_user(email)
return """
<html>
<head><title>Отписка</title></head>
<body style="font-family:Arial;text-align:center;margin-top:50px;">
<h2>✅ Вы успешно отписались</h2>
<p>Вы больше не будете получать письма от нас.</p>
<p><a href="/">Вернуться на сайт</a></p>
</body>
</html>
"""
@app.get("/profile/admin/users", response_class=HTMLResponse)
async def profile_admin_statistics(
request: Request,
user: Dict = Security(get_current_user, scopes=["admin"])
):
users = [doc async for doc in users_collection.find().sort({'_id':-1})]
return templates.TemplateResponse(
request=request, name="profile/admin/users.html", context={"user": user,
'users': users
}
)
from collections import defaultdict
@app.get("/profile/admin/statistics", response_class=HTMLResponse)
async def profile_admin_statistics(
request: Request,
user: Dict = Security(get_current_user, scopes=["admin"])
):
pipeline1 = [
{"$match": {"roles": "user"}},
{
"$group": {
"_id": "$living_place",
"user_count": {"$sum": 1},
"user_ids": {"$push": "$_id"}
}
},
{"$sort": {"user_count": -1}}
]
living_stats = await users_collection.aggregate(pipeline1).to_list(length=None)
for stat in living_stats:
if stat["_id"]:
user_ids = stat["user_ids"] # list of user _id values (ObjectId or str)
# If posts store user._id as string, convert ObjectId -> str
user_id_strings = [str(u) for u in user_ids]
# post_count (use count_documents as you had)
stat["post_count"] = await posts_collection.count_documents(
{"user._id": {"$in": user_id_strings}}
)
# usernames
cursor = users_collection.find(
{"_id": {"$in": user_ids}},
{"username": 1, "_id": 1}
).limit(1000)
stat["usernames"] = await cursor.to_list(length=None)
# fetch posts for these users (limit/fields optional)
posts_cursor = posts_collection.find(
{"user._id": {"$in": user_id_strings}},
{"_id": 1, "title": 1, "created_at": 1, "user._id": 1}
).sort("created_at", -1)
stat["posts"] = await posts_cursor.to_list(length=None)
pipeline2 = [
{"$match": {"roles": "user"}},
{
"$project": {
"year_month": {
"$dateToString": {
"format": "%Y-%m",
"date": {"$toDate": "$_id"}
}
}
}
},
{
"$group": {
"_id": "$year_month",
"user_count": {"$sum": 1},
"user_ids": {"$push": "$_id"}
}
},
{"$sort": {"_id": -1}}
]
reg_stats = await users_collection.aggregate(pipeline2).to_list(length=None)
for stat in reg_stats:
user_ids = stat["user_ids"] # list of user _id values (ObjectId or str)
user_id_strings = [str(u) for u in user_ids] # use this if posts.user._id is a str
stat["post_count"] = await posts_collection.count_documents(
{"user._id": {"$in": user_id_strings}}
)
cursor = users_collection.find(
{"_id": {"$in": user_ids}},
{"username": 1, "_id": 0}
).limit(1000)
stat["usernames"] = await cursor.to_list(length=None)
# fetch posts (flat list)
posts_cursor = posts_collection.find(
{"user._id": {"$in": user_id_strings}},
{"_id": 1, "title": 1, "created_at": 1, "user._id": 1}
).sort("created_at", -1)
stat["posts"] = await posts_cursor.to_list(length=None)
# OR: group posts per user
posts = stat["posts"]
grouped = defaultdict(list)
for p in posts:
grouped[p["user"]["_id"]].append(p)
stat["posts_by_user"] = dict(grouped)
return templates.TemplateResponse(
request=request, name="profile/admin/statistics.html", context={"user": user, "living_stats": living_stats,
"reg_stats": reg_stats,
"all_users": await users_collection.count_documents({ 'roles': "user" }),
"all_city": len(await users_collection.distinct("living_place", filter={ 'roles': "user" }))
}
)
@app.post("/tg")
async def tg_result_save(data:dict, token: str = Security(get_token)):
item = data.get('item')
result = data.get('result', {})
if item:
update_spec = {"$push": {"tg": result}}
await mongo_client[item.get('db')][item.get('col')].update_one({'_id': ObjectId(item.get('_id'))}, update_spec)
@app.get("/profile/social", response_class=HTMLResponse)
async def profile_social(
request: Request,
user: Dict = Security(get_current_user)
):
socials = [doc async for doc in social_collection.find({'user_id': str(user.get('_id'))})]
return templates.TemplateResponse(
request=request, name="profile/social.html", context={"user": user, 'socials': socials}
)
@app.get("/profile/invitation/{invitation_id}", response_class=HTMLResponse)
async def profile_invitation(
request: Request,
invitation_id: str,
user: Dict = Security(get_current_user)
):
data = await invitations_collection.find_one({'_id': ObjectId(invitation_id)})
match_ = {
"$or": [
{"sender._id": user.get('_id')},
{"user._id": user.get('_id')},
{"receiver._id": user.get('_id')}
],
'prod': PROD,
}
if data.get('competition'):
match_.update({'competition_id': data.get('competition').get('_id')})
if data.get('exhibition_id'):
match_.update({'exhibition_id': data.get('exhibition_id')})
cursor = delivery_collection.aggregate([
{"$match": match_},
{"$sort": {"_id": -1}},
{"$group": {
"_id": "$post_id",
"doc": {"$first": "$$ROOT"}
}},
{"$replaceRoot": {"newRoot": "$doc"}},
{"$sort": {"_id": -1}},
])
posts = [doc async for doc in cursor]
return templates.TemplateResponse(
request=request, name="profile/invitation.html", context={ "user": user, "doc": data, "data": posts}
)
@app.get("/profile/invitation", response_class=HTMLResponse)
async def profile_invitation(
request: Request,
user: Dict = Security(get_current_user)
):
data = await invitations_collection.find({'prod': PROD,'user_id': user.get('_id')}).sort('_id', -1).to_list()
await notify_collection.update_many({'user_id':user.get('_id'),'type':'invitation'},{"$set":{'read':False}})
return templates.TemplateResponse(
request=request, name="profile/invitations.html", context={ "user": user, "data": data, "now": datetime.now()}
)
@app.post('/api/social/settings', response_model=dict)
async def social_setting(
id: Optional[str] = Body(None, embed=True),
key: Optional[str] = Body(None, embed=True),
check: Optional[bool] = Body(None, embed=True),
user: Dict = Security(get_current_user)
):
await social_collection.update_one({'_id': ObjectId(id), 'user_id': user.get('_id')},{'$set': {key:check}})
return {'ok': True}
async def get_delivery_post(post_id):
cursor = delivery_collection.aggregate([
{"$match": {
'post_id': post_id,
'prod': PROD,
}},
{"$sort": {"_id": -1}},
{"$group": {
"_id": "$post_id",
"doc": {"$first": "$$ROOT"}
}},
{"$replaceRoot": {"newRoot": "$doc"}},
{"$sort": {"_id": -1}},
])
data = [doc async for doc in cursor]
return data[0] if data else False
@app.post('/api/v1/views')
async def get_views_count(request: Request):
url = (await request.json())['url']
if 'artist' in url:
user_id = url.split('/')[-1]
total_key = f"total_w_c_artists:{user_id}"
today_key = f"today_w_c_artists:{user_id}"
total_watch_count = await redis_client.incr(total_key)
today_date = datetime.now().strftime("%Y-%m-%d")
daily_key = f"{today_key}:{today_date}"
today_watch_count = await redis_client.incr(daily_key)
return {"totalViews":total_watch_count,"todayViews":today_watch_count}
if 'post' in url:
paint_id = url.split('/')[-1]
total_key = f"total_watch_count_paint:{paint_id}"
today_key = f"today_watch_count_paint:{paint_id}"
total_watch_count = await redis_client.incr(total_key)
today_date = datetime.now().strftime("%Y-%m-%d")
daily_key = f"{today_key}:{today_date}"
today_watch_count = await redis_client.incr(daily_key)
return {"totalViews":total_watch_count,"todayViews":today_watch_count}
if 'news' in url:
news_id = url.split('/')[-1]
total_key = f"total_watch_count_news:{news_id}"
today_key = f"today_watch_count_news:{news_id}"
total_watch_count = await redis_client.incr(total_key)
today_date = datetime.now().strftime("%Y-%m-%d")
daily_key = f"{today_key}:{today_date}"
today_watch_count = await redis_client.incr(daily_key)
return {"totalViews":total_watch_count,"todayViews":today_watch_count}
return {"totalViews":0,"todayViews":0}
@app.get('/api/v1/generic/post', response_model=dict)
async def get_posts(
per_page: Optional[int] = Query(4),
page: Optional[int] = Query(1),
user_id: Optional[str] = Query(None),
filter: Optional[str] = Query(None, description="JSON filter string or omitted")
):
posts = []
if filter:
try:
filt = json.loads(filter)
mql = convert_to_mql(filt)
def mql_to_mongo(m):
if "and" in m:
return {"$and": [mql_to_mongo(x) for x in m["and"]]}
if "or" in m:
return {"$or": [mql_to_mongo(x) for x in m["or"]]}
field, op, value = m["field"], m["op"], m["value"]
if field == 'author_name':
field = 'author'
if field == 'user.gallery_name':
field = 'user.username'
if field == 'name':
field = 'title'
if op == "like":
return {field: {"$regex": value, "$options": "i"}}
return {field: value}
mongo_query = mql_to_mongo(mql)
for post in [doc async for doc in posts_collection.find(mongo_query).sort({"_id":-1}).limit(per_page * page + 8)][per_page * page - per_page:per_page * page]:
post['delivery'] = await get_delivery_post(str(post.get('_id')))
post['user']['avatar_url'] = f"/a/{post['user'].get('_id')}"
post['user']['name'] = post['user']['username']
photos = [{'url':f"/b/posts/{i}", 'type':'photo', 'sort_order':x+1} for x,i in enumerate(post['medias'])]
post['id'] = post.get('_id')
post['photos'] = photos
post['name'] = post['title']
post['author_name'] = post['author']
post['media'] = photos
posts.append(bson_clear(post))
post_count = await posts_collection.count_documents(mongo_query)
return {
'current_page': page,
'data':posts,
'total': post_count,
'to': 16,
'last_page': post_count / per_page,
'per_page': per_page
}
except json.JSONDecodeError:
pass
q = {}
if user_id:
try:
if len(str(user_id)) < 6:
user_id = int(user_id)
user = await users_collection.find_one({'id':user_id})
q['user._id'] = str(user["_id"])
else:
q['user._id'] = user_id
except:
q['user._id'] = user_id
for post in [doc async for doc in posts_collection.find(q).sort({"_id":-1}).limit(per_page * page + 8)][per_page * page - per_page:per_page * page]:
post['delivery'] = await get_delivery_post(str(post.get('_id')))
post['user']['avatar_url'] = f"/a/{post['user'].get('_id')}"
post['user']['name'] = post['user']['username']
post['author_name'] = post['author']
photos = [{'url':f"/b/posts/{i}", 'type':'photo', 'sort_order':x+1} for x,i in enumerate(post['medias'])]
post['id'] = post.get('_id')
post['photos'] = photos
post['media'] = photos
post['name'] = post['title']
posts.append(bson_clear(post))
post_count = await posts_collection.count_documents(q)
return {
'current_page': page,
'data':posts,
'total': post_count,
'to': 16,
'last_page': post_count / per_page,
'per_page': per_page
}
@app.get('/api/v2/statistics', response_model=dict)
async def statistics_v2(
user: Optional[str] = Query(None),
post: Optional[str] = Query(None),
):
if user:
return {"statistics":await users_collection.count_documents({ 'roles': "user" }),"additional_stats":len(await users_collection.distinct("living_place", filter={ 'roles': "user" }))}
else:
return {"statistics":await posts_collection.count_documents({}),"additional_stats":await users_collection.count_documents({ 'roles': "user" })}
@app.get('/api/v2/user/{user_id}', response_model=dict)
async def get_user_by_id(
user_id: Optional[str]
):
try:
user = await users_collection.find_one({'id': int(user_id)})
except:
user = await users_collection.find_one({'_id': ObjectId(user_id)})
timestamp = user.get('_id').generation_time
user['posts_count'] = await posts_collection.count_documents({'user._id':str(user.get('_id'))})
timestamp_str = timestamp.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
user['created_at'] = timestamp_str
user['avatar_url'] = f"https://avtopost.kultmost.ru/a/{user.get('_id')}"
user['name'] = user['username']
user.pop('hashed_password')
user.pop('phone')
user.pop('email')
return {'data':bson_clear(user)}
@app.get('/api/v1/generic/post/{post_id}', response_model=dict)
async def get_post_by_id(
post_id: Optional[str]
):
try:
post = await posts_collection.find_one({'id': int(post_id)})
except:
post = await posts_collection.find_one({'_id': ObjectId(post_id)})
post['user']['avatar_url'] = f"/a/{post['user'].get('_id')}"
post['user']['name'] = post['user']['username']
photos = [{'url':f"/b/posts/{i}", 'type':'photo', 'sort_order':x+1} for x,i in enumerate(post['medias_original'])]
post['photos'] = photos
post['id'] = post.get('_id')
post['name'] = post['title']
post['media'] = photos
return bson_clear(post)
## Work
@app.get("/profile/invitation/competition/{competition_id}", response_class=HTMLResponse)
async def profile_invitation(
request: Request,
competition_id: str,
user: Dict = Security(get_current_user)
):
competition = await competitions_col.find_one({'_id': ObjectId(competition_id)})
req = await competition_request_col.find_one({'competition_id': competition_id, 'user._id': user.get('_id')})
return templates.TemplateResponse(
request=request, name="profile/invitation/competition.html", context={ "user": user, "competition": competition, "req": req}
)
@app.post('/api/invitation/accept/{invitations_id}')
async def invitation_accept(invitations_id: str, request: Request):
payload = await request.json()
posts_ids: List[str] = payload.get("posts", [])
# optional: валидация
if not isinstance(posts_ids, list):
posts_ids = []
await accept_invitations(invitations_id, posts_ids)
return {'ok': True}
@app.get('/profile/delivery/{post_id}')
async def profile_delivery_history_post(request: Request, post_id:str, user: Dict = Security(get_current_user)):
data = await delivery_collection.find({'post_id': post_id, "$or": [
{"sender._id": user.get('_id')},
{"user._id": user.get('_id')},
{"receiver._id": user.get('_id')}
]}).sort('_id', -1).to_list()
return templates.TemplateResponse(
request=request,
name="profile/delivery_history.html",
context={
"user": user,
"data": data,
}
)
@app.get('/profile/delivery/competition/{competition_id}')
async def profile_delivery_by_competition(request: Request, competition_id:str, user: Dict = Security(get_current_user)):
cursor = delivery_collection.aggregate([
{"$match": {
"$or": [
{"sender._id": user.get('_id')},
{"user._id": user.get('_id')},
{"receiver._id": user.get('_id')}
],
'competition_id': ObjectId(competition_id),
'prod': PROD,
}},
{"$sort": {"_id": -1}},
{"$group": {
"_id": "$post_id",
"doc": {"$first": "$$ROOT"}
}},
{"$replaceRoot": {"newRoot": "$doc"}},
{"$sort": {"_id": -1}},
])
data = [doc async for doc in cursor]
return templates.TemplateResponse(
request=request,
name="profile/delivery_old.html",
context={
"user": user,
"data": data,
}
)
@app.get('/profile/delivery/exhibition/{exhibition_id}')
async def profile_delivery_by_exhibition(request: Request, exhibition_id:str, user: Dict = Security(get_current_user)):
cursor = delivery_collection.aggregate([
{"$match": {
"$or": [
{"sender._id": user.get('_id')},
{"user._id": user.get('_id')},
{"receiver._id": user.get('_id')}
],
'exhibition_id': exhibition_id,
'prod': PROD,
}},
{"$sort": {"_id": -1}},
{"$group": {
"_id": "$post_id",
"doc": {"$first": "$$ROOT"}
}},
{"$replaceRoot": {"newRoot": "$doc"}},
{"$sort": {"_id": -1}},
])
data = [doc async for doc in cursor]
return templates.TemplateResponse(
request=request,
name="profile/delivery_old.html",
context={
"user": user,
"data": data,
}
)
@app.get(
"/profile/delivery",
response_class=HTMLResponse,
summary="Личный кабинет → Доставка Картин",
description="""
Отображает страницу с историей доставок пользователя.
Показывает **все** доставки, где пользователь является либо отправителем (`sender`),
либо получателем (`receiver`).
""",
tags=["Profile", "Delivery"],
)
async def profile_delivery(
request: Request,
user: Dict = Security(get_current_user)
):
"""
Рендерит HTML-страницу с доставками текущего пользователя.
Запрашивает все документы из коллекции `delivery`, где пользователь
участвует как отправитель или получатель.
Args:
request: FastAPI Request (для шаблонизатора)
user: Текущий авторизованный пользователь (из Security)
Returns:
HTMLResponse с шаблоном profile/delivery.html
"""
# Получаем все доставки пользователя (отправитель ИЛИ получатель)
cursor = delivery_collection.aggregate([
{"$match": {
"$or": [
{"sender._id": user.get('_id')},
{"user._id": user.get('_id')},
{"receiver._id": user.get('_id')}
],
'prod': PROD,
}},
{"$sort": {"_id": -1}}, # why not work
{"$group": {
"_id": "$post_id",
"doc": {"$first": "$$ROOT"}
}},
{"$replaceRoot": {"newRoot": "$doc"}},
{"$sort": {"_id": -1}},
])
data = [doc async for doc in cursor]
exhibitions = await exhibition_collection.find({'_id': {'$in': [ObjectId(i.get('exhibition_id')) for i in data if i.get('exhibition_id')]}}).to_list()
competitions = await competitions_col.find({'_id': {'$in': [i.get('competition_id') for i in data if i.get('competition_id')]}}).to_list()
return templates.TemplateResponse(
request=request,
name="profile/delivery.html",
context={
"user": user,
"exhibitions": exhibitions,
"competitions": competitions,
"data": data,
"now": datetime.now(),
"page_title": "Доставка",
"active_section": "delivery"
}
)
@app.post("/api/exhibition/send/invitations")
async def api_exhibition_send_invitations(
background_tasks: BackgroundTasks,
exhibition_id: Optional[str] = Form(...),
user: Dict = Security(get_current_user)
):
background_tasks.add_task(exhibition_send_invitations, exhibition_id)
logging.info(f"send exh inv {exhibition_id}")
return {"ok": True}
@app.post("/api/exhibition/post/select")
async def api_exhibition_post_select(
post_id: Optional[str] = Form(...),
exhibition_id: Optional[str] = Form(...),
user: Dict = Security(get_current_user)
):
await exhibition_collection.update_one(
{
"_id": ObjectId(exhibition_id),
"user._id": user.get("_id")
},
[
{
"$set": {
"posts": {
"$cond": [
{"$in": [post_id, {"$ifNull": ["$posts", []]}]},
{"$setDifference": ["$posts", [post_id]]},
{"$setUnion": ["$posts", [post_id]]}
]
}
}
}
]
)
return {"ok": True}
@app.get("/profile/partner/exhibition/{exhibition_id}/catalog", response_class=HTMLResponse)
async def profile_partner_exhibitions_catalog(
request: Request,
exhibition_id: str,
user: Dict = Security(get_current_user)
):
exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)})
cursor = posts_collection.aggregate([
{
"$match": {
"city": {"$nin": [None, ""]}
}
},
{ '$group': { '_id': "$city", 'count': { '$sum': 1 } } },
{ '$sort': { '_id': 1 } }
])
living_places = [doc async for doc in cursor]
cursor = posts_collection.aggregate([
{
"$match": {
"genre": {"$nin": [None, ""]}
}
},
{
"$project": {
"genres": {"$split": ["$genre", ","]}
}
},
{"$unwind": "$genres"},
{
"$match": {
"genres": {"$nin": ["", None]}
}
},
{
"$group": {
"_id": "$genres",
"count": {"$sum": 1}
}
},
{"$sort": {"_id": 1}}
])
genres = [{'_id': doc["_id"].strip(), 'count': doc["count"]} async for doc in cursor]
users = await users_collection.find(
{"roles": {"$in": ["partner", "galerist"]}},
{"username": 1, "_id": 0}
).to_list()
usernames = [u["username"] for u in users]
pipeline = [
{"$match": {"user.roles": {"$in": ["partner", "galerist"]}}},
{"$group": {"_id": "$user.username", "count": {"$sum": 1}}},
]
cursor = posts_collection.aggregate(pipeline)
post_counts = await cursor.to_list(length=None)
counts_by_username = {d["_id"]: d["count"] for d in post_counts}
galerists = [{"_id": uname, "count": counts_by_username.get(uname, '')} for uname in usernames]
posts = [doc async for doc in posts_collection.find().sort('_id', -1).limit(32)]
return templates.TemplateResponse(
request=request, name="profile/partner/exhibition catalog.html", context={"now": datetime.now(), "user": user, "galerists": galerists, "genres": genres, "living_places": living_places, "exhibition": exhibition, "posts": posts}
)
@app.get("/profile/partner/exhibition/{exhibition_id}", response_class=HTMLResponse)
async def profile_partner_exhibitions(
request: Request,
exhibition_id: str,
user: Dict = Security(get_current_user)
):
exhibition = await exhibition_collection.find_one({'_id': ObjectId(exhibition_id)})
obj_ids = [ObjectId(x) for x in exhibition.get('posts', [])]
posts = await posts_collection.find({"_id": {"$in": obj_ids}}).to_list() if obj_ids else []
d_posts = await delivery_collection.find({'post_id':{'$in':[str(i.get('_id')) for i in posts]}}).to_list()
for post in posts:
for doc in d_posts:
if str(post.get('_id')) == doc.get('post_id'):
post.update({'status':doc.get('status'), 'd_id':doc.get('_id')})
return templates.TemplateResponse(
request=request, name="profile/partner/exhibition.html", context={"now": datetime.now(), "user": user, "exhibition": exhibition, "posts": posts}
)
@app.get("/profile/partner/exhibitions", response_class=HTMLResponse)
async def profile_exhibitions(
request: Request,
user: Dict = Security(get_current_user)
):
data = await exhibition_collection.find({'user._id': user.get('_id')}).sort('_id', -1).to_list()
return templates.TemplateResponse(
request=request, name="profile/partner/exhibitions.html", context={"now": datetime.now(), "user": user, "data": data}
)