# `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 starlette.datastructures import UploadFile as StarletteUploadFile 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 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) def get_delivery_aggregate(_filter: dict = {}, _status: str = None): cursor = delivery_collection.aggregate([ {"$match": { "prod": PROD, **_filter }}, {"$sort": {"_id": -1}}, {"$group": { "_id": "$post_id", "doc": {"$first": "$$ROOT"} }}, {"$replaceRoot": {"newRoot": "$doc"}}, {"$sort": {"_id": -1}}, ]) if _status: return (doc async for doc in cursor if doc.get('status') == _status) else: return cursor 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=85, 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/08", response_model=dict) async def cron_08(background_tasks: BackgroundTasks): async for doc in posts_collection.find({ '_id': {'$gte': ObjectId.from_datetime(datetime.now() - timedelta(days=1))}, '$or':[{'vk':[]},{'tg':[]},{'fb':[]},{'inst':[]},{'max':[]}] }): background_tasks.add_task(publish_social_post, bson_clear(doc)) return {'ok': True} @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} exhibition_finished = await exhibition_collection.find_one_and_update( {**prod_filter, "status": 1, "date_finish": {"$lt": limit_dt}}, {"$set": {"status": 2}}, return_document=ReturnDocument.AFTER, ) if exhibition_finished: exhibition_id_str = str(exhibition_finished["_id"]) data = [doc async for doc in get_delivery_aggregate({"exhibition_id": exhibition_id_str}, 'На выставке')] for doc in data: post_id = doc.get("post_id") exhibition_id = doc.get("exhibition_id") new_doc = { **doc, "delivery_type": "", "track": "", "status": "Возврат художнику", "receiver": doc.get("user"), "sender": doc.get("exhibition", {}).get("user"), } new_doc.pop('_id') new_doc.pop('exhibition') 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( {"status": {'$lte':1}, **prod_filter} ): exhibition_id_str = str(exhibition_to_start["_id"]) exhibition_title = exhibition_to_start.get("title") notified_user_id = None notified_post_user_ids = {} data = [doc async for doc in get_delivery_aggregate({"exhibition_id": exhibition_id_str}, 'Получено')] for doc in data: post_id = doc.get("post_id") exhibition_id = doc.get("exhibition_id") post = await posts_collection.find_one({"_id": ObjectId(post_id)}) if post and post.get("user"): notified_post_user_ids.update({str(post["user"].get("_id")):1}) 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": "На выставке"}} )) for user_id in notified_post_user_ids.keys(): await notify_in_profile( NotifyItem( user_id=user_id, type_item="exhibtion", title=f"Выставка {exhibition_title}", link="/profile/exhibition", text="Ваши картины на выставке", ) ) if tasks: asyncio.gather(*tasks) 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 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) import hashlib def sha256(text: str) -> str: """Server-side SHA-256 (hex)""" return hashlib.sha256(text.encode("utf-8")).hexdigest() @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 Exception: pass users = [doc async for doc in users_collection.find({"$or": or_conditions})] if not users: await increment_failed_attempt(identifier) raise HTTPException(status_code=401, detail="Incorrect credentials") matched_user = None new_hash = None is_hash_login = login_data.client_id == "web_hash" for user in users: stored = user.get("hashed_password") if isinstance(stored, bytes): stored = stored.decode() if not stored: continue # ---------- 1. Client sent SHA-256 hash ---------- if is_hash_login: # Stored value is expected to be Argon2(SHA256(password)) if stored.startswith("$argon2"): if verify_password(login_data.password, stored): matched_user = user break # ---------- 2. Client sent plaintext ---------- else: # Modern: Argon2(SHA256(plain)) if stored.startswith("$argon2"): # Try verifying as if stored is Argon2(SHA256(plain)) hashed_input = sha256(login_data.password) if verify_password(hashed_input, stored): matched_user = user break # Also try classic Argon2(plain) for very old accounts if verify_password(login_data.password, stored): matched_user = user # Force migration to Argon2(SHA256) new_hash = get_password_hash(sha256(login_data.password)) break # Legacy bcrypt(plain) elif stored.startswith(("$2y$", "$2b$", "$2a$")): stored_bcrypt = stored.replace("$2y$", "$2b$", 1) try: if bcrypt.checkpw( login_data.password.encode("utf-8"), stored_bcrypt.encode("utf-8") ): matched_user = user # Migrate to Argon2(SHA256(plain)) new_hash = get_password_hash(sha256(login_data.password)) break except Exception: continue if not matched_user: await increment_failed_attempt(identifier) raise HTTPException(status_code=401, detail="Incorrect credentials") # Apply migration if needed if new_hash: await users_collection.update_one( {"_id": matched_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) data = await cursor.to_list(length=None) return await posts_collection.find({"_id": {"$in":[ ObjectId(doc['_id']) if type(doc['_id']) is str else doc['_id'] for doc in data ]}}).to_list() 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 = [doc async for doc in get_delivery_aggregate({"exhibition_id": exhibition_id}, 'На выставке')] return templates.TemplateResponse( request=request, name="exhibition/index.html", context={"id": 1, "user": user, "exhibition": exhibition, "posts": posts, 'now': datetime.now()} ) 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, 'now': datetime.now()} ) @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: return {'ok': True} # 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( to_email=user['email'], data={ 'link': reset_link, 'email': user['email'] }, subject='Восстановление пароля', template='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( "
Вы больше не будете получать письма от нас.
""" @app.post("/api/admin/delete-user") async def delete_user( payload: DeleteUserRequest, user: Dict = Security(get_current_user, scopes=["admin"]), ): try: result = users_collection.delete_one({"_id": ObjectId(payload._id)}) except Exception: raise HTTPException(status_code=400, detail="Неверный ID пользователя") if result.deleted_count == 0: raise HTTPException(status_code=404, detail="Пользователь не найден") return {"ok": True} @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')} ] } 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')}) posts = [doc async for doc in get_delivery_aggregate(match_)] 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): data = [doc async for doc in get_delivery_aggregate({'post_id': post_id})] 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": safe_strip(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, } ) # TODO BEGIN # @app.get('/profile/delivery/{type_d}/{item_id}') # async def profile_delivery_by_old(request: Request, type_d:str, item_id:str, user: Dict = Security(get_current_user)): # _match = { # "$or": [ # {"sender._id": user.get('_id')}, # {"user._id": user.get('_id')}, # {"receiver._id": user.get('_id')} # ], # f'{type_d}_id': ObjectId(item_id), # } # data = [doc async for doc in get_delivery_aggregate(_match)] # data.sort(key=lambda doc: doc["_id"].generation_time, reverse=True) # return templates.TemplateResponse( # request=request, # name="profile/delivery_old.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)): _match = { "$or": [ {"sender._id": user.get('_id')}, {"user._id": user.get('_id')}, {"receiver._id": user.get('_id')} ] } data = [doc async for doc in get_delivery_aggregate(_match)] data.sort(key=lambda doc: doc["_id"].generation_time, reverse=True) 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)): _match = { "$or": [ {"sender._id": user.get('_id')}, {"user._id": user.get('_id')}, {"receiver._id": user.get('_id')} ], 'exhibition_id': exhibition_id, } data = [doc async for doc in get_delivery_aggregate(_match)] data.sort(key=lambda doc: doc["_id"].generation_time, reverse=True) return templates.TemplateResponse( request=request, name="profile/delivery_old.html", context={ "user": user, "data": data, } ) # TODO END @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 """ _match = { "$or": [ {"sender._id": user.get('_id')}, {"user._id": user.get('_id')}, {"receiver._id": user.get('_id')} ] } data = [doc async for doc in get_delivery_aggregate(_match)] 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)] 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') and post.get('status') not in ['Продано', 'Приглашение отклонено'] and doc.get('status') not in ['Приглашение отклонено'] and doc.get('receiver') and doc.get('receiver').get('_id') == str(user.get('_id')): post.update({'status':doc.get('status'), 'receiver': doc.get('receiver'), 'd_id':doc.get('_id')}) 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') and post.get('status') not in ['Продано', 'Приглашение отклонено'] and doc.get('status') not in ['Приглашение отклонено'] and doc.get('receiver') and doc.get('receiver').get('_id') == str(user.get('_id')): post.update({'status':doc.get('status'), 'receiver': doc.get('receiver'), '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} )