new web ract

This commit is contained in:
jze9
2026-05-15 03:31:28 +05:00
parent de78624495
commit 2335497226
58 changed files with 7397 additions and 406 deletions

View File

@@ -21,6 +21,7 @@ class ArticleIn(BaseModel):
content: str = ""
excerpt: str = ""
cover_url: Optional[str] = None
source_url: Optional[str] = None
font_family: str = "Merriweather"
category_id: Optional[str] = None
tag_names: list[str] = []
@@ -35,6 +36,7 @@ def _article_dict(a: Article) -> dict:
"content": a.content,
"excerpt": a.excerpt,
"cover_url": a.cover_url,
"source_url": a.source_url,
"font_family": a.font_family,
"status": a.status.value,
"view_count": a.view_count,
@@ -73,14 +75,19 @@ async def list_articles(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
status: Optional[ArticleStatus] = Query(None),
q: Optional[str] = Query(None, max_length=200),
db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin),
):
q = select(Article).options(selectinload(Article.category), selectinload(Article.tags))
from sqlalchemy import or_
stmt = select(Article).options(selectinload(Article.category), selectinload(Article.tags))
if status:
q = q.where(Article.status == status)
total = (await db.execute(select(func.count()).select_from(q.subquery()))).scalar_one()
articles = (await db.execute(q.order_by(Article.created_at.desc()).offset(offset).limit(limit))).scalars().all()
stmt = stmt.where(Article.status == status)
if q:
pattern = f"%{q}%"
stmt = stmt.where(or_(Article.title.ilike(pattern), Article.excerpt.ilike(pattern)))
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
articles = (await db.execute(stmt.order_by(Article.created_at.desc()).offset(offset).limit(limit))).scalars().all()
return {
"total": total,
"offset": offset,
@@ -115,6 +122,7 @@ async def create_article(
content=data.content,
excerpt=data.excerpt,
cover_url=data.cover_url,
source_url=data.source_url,
font_family=data.font_family,
status=data.status,
category=cat,
@@ -173,7 +181,8 @@ async def update_article(
article.slug = slug
article.content = data.content
article.excerpt = data.excerpt
article.cover_url = data.cover_url
article.cover_url = data.cover_url
article.source_url = data.source_url
article.font_family = data.font_family
article.status = data.status
article.category = cat

View File

@@ -1,6 +1,6 @@
import os
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -9,17 +9,24 @@ from passlib.context import CryptContext
from bd.database import get_db, AsyncSessionLocal
from bd.models import Admin
from rate_limiter import limiter
router = APIRouter()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
JWT_SECRET = os.getenv("JWT_SECRET", "changeme")
JWT_ALGO = "HS256"
JWT_EXP_HOURS = 24
_raw_secret = os.getenv("JWT_SECRET", "")
if not _raw_secret:
import secrets as _s
_raw_secret = _s.token_hex(32)
print("[WARN] JWT_SECRET не задан — сгенерирован временный секрет. Установи JWT_SECRET в .env!")
JWT_SECRET = _raw_secret
JWT_ALGO = "HS256"
JWT_EXP_HOURS = 8
def _make_token(username: str) -> str:
exp = datetime.utcnow() + timedelta(hours=JWT_EXP_HOURS)
exp = datetime.now(timezone.utc) + timedelta(hours=JWT_EXP_HOURS)
return jwt.encode({"sub": username, "exp": exp}, JWT_SECRET, algorithm=JWT_ALGO)
@@ -40,7 +47,8 @@ class LoginRequest(BaseModel):
@router.post("/login")
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
@limiter.limit("5/minute")
async def login(request: Request, data: LoginRequest, db: AsyncSession = Depends(get_db)):
admin = (await db.execute(select(Admin).where(Admin.username == data.username))).scalar_one_or_none()
if not admin or not pwd_context.verify(data.password, admin.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный логин или пароль")
@@ -48,12 +56,7 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
@router.post("/change-password")
async def change_password(
data: dict,
db: AsyncSession = Depends(get_db),
):
from route.deps import require_admin
# handled with deps in main
async def change_password(data: dict, db: AsyncSession = Depends(get_db)):
admin = (await db.execute(select(Admin).where(Admin.username == data["username"]))).scalar_one_or_none()
if not admin or not pwd_context.verify(data["current_password"], admin.password_hash):
raise HTTPException(status_code=400, detail="Неверный текущий пароль")

View File

@@ -99,20 +99,25 @@ async def upload_media(
@router.get("")
async def list_media(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
limit: int = Query(40, ge=1, le=200),
media_type: str | None = Query(None),
db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin),
):
q = select(Media)
from sqlalchemy import func
base = select(Media)
if media_type:
q = q.where(Media.media_type == media_type)
items = (await db.execute(q.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all()
return [
{"id": str(m.id), "url": m.url, "filename": m.filename, "media_type": m.media_type,
"size_bytes": m.size_bytes, "created_at": m.created_at.isoformat()}
for m in items
]
base = base.where(Media.media_type == media_type)
total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
items = (await db.execute(base.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all()
return {
"total": total,
"items": [
{"id": str(m.id), "url": m.url, "filename": m.filename, "media_type": m.media_type,
"size_bytes": m.size_bytes, "created_at": m.created_at.isoformat()}
for m in items
],
}
@router.delete("/{media_id}")

113
api/route/admin_vk.py Normal file
View File

@@ -0,0 +1,113 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bd.database import get_db
from bd.models import VkSource
from route.deps import require_admin
from vk_parser import run_import, run_history_import, VK_TOKEN
router = APIRouter()
class SourceIn(BaseModel):
group_id: str
group_name: str
enabled: bool = True
def _source_dict(s: VkSource) -> dict:
return {
"id": str(s.id),
"group_id": s.group_id,
"group_name": s.group_name,
"enabled": s.enabled,
"last_run": s.last_run.isoformat() if s.last_run else None,
"created_at": s.created_at.isoformat(),
}
@router.get("/status")
async def vk_status(_: str = Depends(require_admin)):
return {"configured": bool(VK_TOKEN)}
# ── CRUD источников ───────────────────────────────────────────────────────────
@router.get("/sources")
async def list_sources(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
rows = (await db.execute(select(VkSource).order_by(VkSource.created_at))).scalars().all()
return [_source_dict(r) for r in rows]
def _clean_group_id(raw: str) -> str:
"""Из строки вида '-95503797', '95503797_6512', 'wall-95503797_12' вытащить только числовой ID группы."""
import re
raw = raw.strip().lstrip("-")
# Берём только первое число (до '_' если есть)
m = re.search(r"(\d+)", raw)
return m.group(1) if m else raw
@router.post("/sources", status_code=201)
async def add_source(data: SourceIn, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
group_id = _clean_group_id(data.group_id)
exists = (await db.execute(select(VkSource).where(VkSource.group_id == group_id))).scalar_one_or_none()
if exists:
raise HTTPException(status_code=409, detail="Источник с таким group_id уже есть")
source = VkSource(group_id=group_id, group_name=data.group_name, enabled=data.enabled)
db.add(source)
await db.commit()
await db.refresh(source)
return _source_dict(source)
@router.patch("/sources/{source_id}")
async def update_source(source_id: str, data: SourceIn, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Источник не найден")
source.group_id = _clean_group_id(data.group_id)
source.group_name = data.group_name
source.enabled = data.enabled
await db.commit()
await db.refresh(source)
return _source_dict(source)
@router.delete("/sources/{source_id}", status_code=204)
async def delete_source(source_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Источник не найден")
await db.delete(source)
await db.commit()
# ── Ручной запуск импорта ─────────────────────────────────────────────────────
@router.post("/import")
async def vk_manual_import(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
background_tasks.add_task(run_import)
return {"ok": True, "message": "Импорт последних постов запущен в фоне"}
@router.post("/import/history")
async def vk_history_import_all(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
background_tasks.add_task(run_history_import)
return {"ok": True, "message": "Исторический импорт всех групп запущен в фоне (до 5 мин)"}
@router.post("/import/history/{source_id}")
async def vk_history_import_one(
source_id: str,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin),
):
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Источник не найден")
background_tasks.add_task(run_history_import, group_id=source.group_id)
return {"ok": True, "message": f"Исторический импорт группы «{source.group_name}» запущен в фоне"}

View File

@@ -1,12 +1,13 @@
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy import select, func, or_
from sqlalchemy.orm import selectinload
import redis.asyncio as aioredis
from bd.database import get_db, get_redis
from bd.models import Article, Category, Tag, ArticleStatus
from bd.models import Article, Category, Tag, ArticleStatus, article_tag
router = APIRouter()
@@ -22,6 +23,7 @@ def _article_card(a: Article) -> dict:
"slug": a.slug,
"excerpt": a.excerpt,
"cover_url": a.cover_url,
"source_url": a.source_url,
"published_at": a.published_at.isoformat() if a.published_at else None,
"view_count": a.view_count,
"category": _cat_dict(a.category) if a.category else None,
@@ -42,10 +44,13 @@ async def list_news(
limit: int = Query(20, ge=1, le=100),
category: str | None = Query(None),
tag: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
q: str | None = Query(None, max_length=200),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
cache_key = f"news:list:{offset}:{limit}:{category or ''}:{tag or ''}"
cache_key = f"news:list:{offset}:{limit}:{category or ''}:{tag or ''}:{date_from or ''}:{date_to or ''}:{q or ''}"
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
@@ -68,6 +73,29 @@ async def list_news(
if t:
base = base.where(Article.tags.any(Tag.id == t.id))
if date_from:
try:
base = base.where(Article.published_at >= datetime.fromisoformat(date_from))
except ValueError:
pass
if date_to:
try:
base = base.where(Article.published_at <= datetime.fromisoformat(date_to))
except ValueError:
pass
if q:
pattern = f"%{q}%"
base = base.where(
or_(
Article.title.ilike(pattern),
Article.excerpt.ilike(pattern),
Article.content.ilike(pattern),
Article.tags.any(Tag.name.ilike(pattern)),
)
)
count_q = select(func.count()).select_from(base.subquery())
total: int = (await db.execute(count_q)).scalar_one()
@@ -82,7 +110,7 @@ async def list_news(
"has_more": (offset + limit) < total,
"items": [_article_card(a) for a in articles],
}
await redis.setex(cache_key, 30, json.dumps(result, default=str))
await redis.setex(cache_key, 30 if q else 120, json.dumps(result, default=str))
return result
@@ -96,7 +124,36 @@ async def list_categories(
return json.loads(cached)
cats = (await db.execute(select(Category).order_by(Category.name))).scalars().all()
result = [_cat_dict(c) for c in cats]
await redis.setex("news:categories", 60, json.dumps(result))
await redis.setex("news:categories", 300, json.dumps(result))
return result
@router.get("/tags")
async def list_tags(
limit: int = Query(30, ge=1, le=100),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
):
cache_key = f"news:tags:{limit}"
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
q = (
select(Tag, func.count(article_tag.c.article_id).label("cnt"))
.join(article_tag, Tag.id == article_tag.c.tag_id)
.join(Article, article_tag.c.article_id == Article.id)
.where(Article.status == ArticleStatus.published)
.group_by(Tag.id)
.order_by(func.count(article_tag.c.article_id).desc())
.limit(limit)
)
rows = (await db.execute(q)).all()
result = [
{"id": str(r.Tag.id), "name": r.Tag.name, "slug": r.Tag.slug, "count": r.cnt}
for r in rows
]
await redis.setex(cache_key, 300, json.dumps(result))
return result