diff --git a/main.py b/main.py new file mode 100644 index 0000000..155b052 --- /dev/null +++ b/main.py @@ -0,0 +1,3470 @@ +# 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 +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)): + exhibition = await exhibition_collection.find_one_and_update( + {"prod": PROD, "status": 0, "date_finish": {"$lt": datetime.now(timezone.utc) + timedelta(hours=3)}}, + {"$set": {"status": 2}}, return_document=False) + if exhibition: + tasks = [] + async for doc in delivery_collection.find({'exhibition_id': str(exhibition.get('_id')), 'status': 'Получено'},{'_id':0}): + new_doc = {**doc, 'delivery_type':'', 'track':'', 'status': 'Возврат художнику', 'receiver': doc.get('sender'), 'sender': doc.get('receiver')} + tasks.append(delivery_collection.update_one({"prod": PROD, 'status': 'Возврат художнику', 'post_id': doc.get('post_id'),'exhibition_id': doc.get('exhibition_id')},{'$set':new_doc},upsert=True)) + asyncio.gather(*tasks) + else: + exhibition = await exhibition_collection.find_one_and_update( + {"prod": PROD, "datetime_start": {"$lt": datetime.now(timezone.utc) + timedelta(hours=3)}}, + {"$set": {"status": 1}}, return_document=False) + if exhibition: + if exhibition.get('vk') == [] or exhibition.get('tg') == []: + await service_social_exhibition(exhibition) + tasks = [] + async for doc in delivery_collection.find({'exhibition_id': str(exhibition.get('_id')), 'status': 'Получено'},{'_id':0}): + post = await posts_collection.find_one({'_id': ObjectId(doc.get('post_id'))}) + new_doc = {**doc, 'exhibition': exhibition, 'status': 'На выставке', 'user': post.get('user'), 'post': post} + tasks.append(delivery_collection.update_one({"prod": PROD, 'status': 'На выставке', 'post_id': doc.get('post_id'),'exhibition_id': doc.get('exhibition_id')},{'$set':new_doc},upsert=True)) + tasks.append(notify_in_profile(NotifyItem( + user_id=str(post.get('user',{}).get('_id')), + type_item='exhibtion', + title=f'Выставка {doc.get('exhibition',{}).get('title')}', + link=f'/profile/exhibition', + text=f'Ваши картины на выставке' + ))) + asyncio.gather(*tasks) + 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) + + users = [doc async for doc in users_collection.find({ + "$or": [ + {"email": identifier}, + {"email": identifier.lower()} + ] + })] + + + 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) + refresh_exp = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + if current_user['email'] == 'kultmost@yandex.ru': + response.set_cookie( + domain='.kultmost.ru', + key="token", + value='2124|bhcbiP7XEgSe6ooTAoPf6mklBWNExG6a5CmmwTvTfafc5d5b', + httponly=True, + secure=PROD, + samesite="strict", + max_age=int(refresh_exp.total_seconds()) + ) + 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(...), +): + email = restore_data.identifier.strip().lower() + next = restore_data.next + + user = await users_collection.find_one({'$or': [ + {'email': email}, + {'email': email.lower()} + ]}) + + if not user: + raise HTTPException(status_code=404, detail='Email не найден') + + token = secrets.token_urlsafe(32) + expires_at = datetime.utcnow() + timedelta(hours=1) + + token_data = { + "user_id": str(user["_id"]), + "email": user["email"], + "next": next, + "expires_at": expires_at.isoformat() + } + + await redis_client.set( + f"reset_token:{token}", + json.dumps(token_data), + ex=3600 + ) + + reset_link = f"{str(request.base_url).rstrip('/')}/restore/{token}" + + + email_task.notify.delay( + user['email'], + {'link': reset_link, 'email': user['email']}, + 'Восстановление пароля', + 'restore.html' + ) + + 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( + "
Вы больше не будете получать письма от нас.
+ + + + """ + + +@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", response_class=HTMLResponse) +async def profile_invitation( + request: Request, + user: Dict = Security(get_current_user) +): + data = [doc async for doc in invitations_collection.find({'prod': PROD,'user_id': user.get('_id')})] + + return templates.TemplateResponse( + request=request, name="profile/invitation.html", context={ "user": user, "data": data} + ) + +@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.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: + user_id = int(user_id) + user = await users_collection.find_one({'id':user_id}) + q['user._id'] = str(user["_id"]) + except: + q['user._id'] = user_id + print(q) + 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): + await accept_invitations(invitations_id) + 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", + 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] + + return templates.TemplateResponse( + request=request, + name="profile/delivery.html", + context={ + "user": user, + "data": data, + "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 [] + 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} + ) \ No newline at end of file