Отображение юзернеймов
Build and push Docker image / docker (push) Successful in 10s Details

This commit is contained in:
MpakobecEx 2026-10-18 19:10:32 +05:00
parent 134a47dfc5
commit 067f9f31c7
9 changed files with 109 additions and 11 deletions

View File

@ -89,6 +89,7 @@ http://192.168.31.158:8099
Возможности:
- фильтр по Telegram `user_id` (с подсказкой известных пользователей);
- в списке показывается `@username`, если он известен, иначе Telegram ID;
- фильтр по статусу и режиму (`t2i`/`i2i`);
- пагинация;
- превью результата в списке (кэшируются в `storage/.thumbnails`);

1
bot.py
View File

@ -290,6 +290,7 @@ class MpakoBot:
try:
request, position = await self._queue.enqueue(
user_id=user.id,
username=user.username,
mode=mode,
prompt=prompt,
input_file_path=input_path,

View File

@ -297,6 +297,7 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
- [x] `web.py`: FastAPI + Jinja2, отдельный контейнер `web`.
- [x] Список запросов с фильтрами `user_id`, `status`, `mode` и пагинацией.
- [x] Отображение `@username` с fallback на Telegram ID; миграция старых БД.
- [x] Превью результата в списке, генерация и кэш в `storage/.thumbnails`.
- [x] Карточка запроса: промпт, seed, ошибка, `comfy_prompt_id`, вход/результат.
- [x] Отдача изображений только из `STORAGE_DIR` (защита от path traversal).

47
db.py
View File

@ -29,6 +29,7 @@ class UserAlreadyQueued(RuntimeError):
class Request:
id: int
user_id: int
username: str | None
timestamp: str
prompt_text: str
input_file_path: str | None
@ -40,11 +41,16 @@ class Request:
error_text: str | None
finished_at: str | None
@property
def display_user(self) -> str:
return f"@{self.username}" if self.username else str(self.user_id)
@classmethod
def from_row(cls, row: aiosqlite.Row) -> Request:
return cls(
id=row["id"],
user_id=row["user_id"],
username=row["username"],
timestamp=row["timestamp"],
prompt_text=row["prompt_text"],
input_file_path=row["input_file_path"],
@ -61,14 +67,20 @@ class Request:
@dataclass(frozen=True)
class UserStat:
user_id: int
username: str | None
total: int
last_seen: str
@property
def display_user(self) -> str:
return f"@{self.username}" if self.username else str(self.user_id)
SCHEMA = """
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
username TEXT,
timestamp TEXT NOT NULL,
prompt_text TEXT NOT NULL,
input_file_path TEXT,
@ -111,8 +123,16 @@ class Database:
await self._conn.execute("PRAGMA busy_timeout=5000")
await self._conn.execute("PRAGMA foreign_keys=ON")
await self._conn.executescript(SCHEMA)
await self._migrate()
await self._conn.commit()
async def _migrate(self) -> None:
conn = self._connection()
async with conn.execute("PRAGMA table_info(requests)") as cursor:
columns = {row["name"] async for row in cursor}
if "username" not in columns:
await conn.execute("ALTER TABLE requests ADD COLUMN username TEXT")
async def close(self) -> None:
if self._conn is not None:
await self._conn.close()
@ -127,6 +147,7 @@ class Database:
self,
*,
user_id: int,
username: str | None = None,
mode: str,
prompt_text: str,
input_file_path: str | None,
@ -138,10 +159,19 @@ class Database:
cursor = await conn.execute(
"""
INSERT INTO requests
(user_id, timestamp, prompt_text, input_file_path, status, mode, seed)
VALUES (?, ?, ?, ?, 'queued', ?, ?)
(user_id, username, timestamp, prompt_text,
input_file_path, status, mode, seed)
VALUES (?, ?, ?, ?, ?, 'queued', ?, ?)
""",
(user_id, utc_now(), prompt_text, input_file_path, mode, seed),
(
user_id,
username,
utc_now(),
prompt_text,
input_file_path,
mode,
seed,
),
)
request_id = cursor.lastrowid
await conn.commit()
@ -255,7 +285,15 @@ class Database:
conn = self._connection()
async with conn.execute(
"""
SELECT user_id, COUNT(*) AS total, MAX(timestamp) AS last_seen
SELECT
user_id,
COUNT(*) AS total,
MAX(timestamp) AS last_seen,
(
SELECT username FROM requests AS r2
WHERE r2.user_id = requests.user_id AND r2.username IS NOT NULL
ORDER BY r2.id DESC LIMIT 1
) AS username
FROM requests
GROUP BY user_id
ORDER BY last_seen DESC
@ -267,6 +305,7 @@ class Database:
return [
UserStat(
user_id=int(row["user_id"]),
username=row["username"],
total=int(row["total"]),
last_seen=str(row["last_seen"]),
)

View File

@ -100,6 +100,7 @@ class QueueManager:
mode: str,
prompt: str,
input_file_path: str | None = None,
username: str | None = None,
) -> tuple[Request, int]:
async with self._lock:
if self._stopping:
@ -113,6 +114,7 @@ class QueueManager:
seed = secrets.randbelow(2**32)
request = await self._db.create_request(
user_id=user_id,
username=username,
mode=mode,
prompt_text=prompt,
input_file_path=input_file_path,

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import sqlite3
import pytest
import pytest_asyncio
@ -113,6 +115,7 @@ async def test_recover_marks_running_failed_and_returns_queued(db: Database) ->
async def test_history_filters_and_stats(db: Database) -> None:
first = await db.create_request(
user_id=1,
username="alice",
mode="t2i",
prompt_text="apple",
input_file_path=None,
@ -121,6 +124,7 @@ async def test_history_filters_and_stats(db: Database) -> None:
await db.set_done(first.id, "/tmp/1.png")
second = await db.create_request(
user_id=1,
username="alice",
mode="i2i",
prompt_text="banana",
input_file_path="/tmp/in.png",
@ -156,3 +160,47 @@ async def test_history_filters_and_stats(db: Database) -> None:
users = await db.list_users()
assert {user.user_id for user in users} == {1, 2}
alice = next(user for user in users if user.user_id == 1)
assert alice.username == "alice"
assert alice.display_user == "@alice"
async def test_migrates_legacy_schema_without_username(tmp_path) -> None:
path = tmp_path / "legacy.db"
connection = sqlite3.connect(path)
connection.execute(
"""
CREATE TABLE requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
timestamp TEXT NOT NULL,
prompt_text TEXT NOT NULL,
input_file_path TEXT,
output_file_path TEXT,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'done', 'failed')),
comfy_prompt_id TEXT,
mode TEXT NOT NULL CHECK (mode IN ('t2i', 'i2i')),
seed INTEGER,
error_text TEXT,
finished_at TEXT
)
"""
)
connection.commit()
connection.close()
database = Database(path)
await database.connect()
try:
request = await database.create_request(
user_id=1,
username="alice",
mode="t2i",
prompt_text="apple",
input_file_path=None,
seed=1,
)
assert request.username == "alice"
finally:
await database.close()

View File

@ -28,16 +28,18 @@ def insert_request(
status: str = "done",
mode: str = "t2i",
output: str | None = None,
username: str | None = None,
) -> int:
connection = sqlite3.connect(path)
try:
cursor = connection.execute(
"""
INSERT INTO requests
(user_id, timestamp, prompt_text, status, mode, seed, output_file_path)
VALUES (?, '2026-10-18T12:00:00+00:00', ?, ?, ?, 1, ?)
(user_id, username, timestamp, prompt_text, status, mode, seed,
output_file_path)
VALUES (?, ?, '2026-10-18T12:00:00+00:00', ?, ?, ?, 1, ?)
""",
(user_id, prompt, status, mode, output),
(user_id, username, prompt, status, mode, output),
)
connection.commit()
return int(cursor.lastrowid)
@ -49,13 +51,17 @@ def test_history_page_and_user_filter(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
app = create_app(settings)
with TestClient(app) as client:
insert_request(settings.db_path, user_id=1, prompt="apple prompt")
insert_request(
settings.db_path, user_id=1, username="alice", prompt="apple prompt"
)
insert_request(settings.db_path, user_id=2, prompt="banana prompt")
response = client.get("/")
assert response.status_code == 200
assert "apple prompt" in response.text
assert "banana prompt" in response.text
assert "@alice" in response.text
assert 'title="ID: 2">2</a>' in response.text
response = client.get("/?user_id=1")
assert response.status_code == 200

View File

@ -6,7 +6,7 @@
<dl class="meta">
<dt>Пользователь</dt>
<dd><a href="/?user_id={{ item.user_id }}">{{ item.user_id }}</a></dd>
<dd><a href="/?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></dd>
<dt>Статус</dt>
<dd><span class="badge {{ item.status }}">{{ item.status }}</span></dd>
<dt>Режим</dt>

View File

@ -15,7 +15,7 @@
</label>
<datalist id="known-users">
{% for user in users %}
<option value="{{ user.user_id }}">{{ user.total }} запросов</option>
<option value="{{ user.user_id }}">{{ user.display_user }} · {{ user.total }} запросов</option>
{% endfor %}
</datalist>
@ -76,7 +76,7 @@
{% endif %}
</td>
<td class="nowrap">{{ item.timestamp|fmt_dt }}</td>
<td><a href="/?user_id={{ item.user_id }}">{{ item.user_id }}</a></td>
<td><a href="/?user_id={{ item.user_id }}" title="ID: {{ item.user_id }}">{{ item.display_user }}</a></td>
<td class="mono">{{ item.mode }}</td>
<td><span class="badge {{ item.status }}">{{ item.status }}</span></td>
<td class="prompt" title="{{ item.prompt_text }}">{{ item.prompt_text|truncate(90, True) }}</td>