Отображение юзернеймов
Build and push Docker image / docker (push) Successful in 10s
Details
Build and push Docker image / docker (push) Successful in 10s
Details
This commit is contained in:
parent
134a47dfc5
commit
067f9f31c7
|
|
@ -89,6 +89,7 @@ http://192.168.31.158:8099
|
||||||
Возможности:
|
Возможности:
|
||||||
|
|
||||||
- фильтр по Telegram `user_id` (с подсказкой известных пользователей);
|
- фильтр по Telegram `user_id` (с подсказкой известных пользователей);
|
||||||
|
- в списке показывается `@username`, если он известен, иначе Telegram ID;
|
||||||
- фильтр по статусу и режиму (`t2i`/`i2i`);
|
- фильтр по статусу и режиму (`t2i`/`i2i`);
|
||||||
- пагинация;
|
- пагинация;
|
||||||
- превью результата в списке (кэшируются в `storage/.thumbnails`);
|
- превью результата в списке (кэшируются в `storage/.thumbnails`);
|
||||||
|
|
|
||||||
1
bot.py
1
bot.py
|
|
@ -290,6 +290,7 @@ class MpakoBot:
|
||||||
try:
|
try:
|
||||||
request, position = await self._queue.enqueue(
|
request, position = await self._queue.enqueue(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
|
username=user.username,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
input_file_path=input_path,
|
input_file_path=input_path,
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,7 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
|
||||||
|
|
||||||
- [x] `web.py`: FastAPI + Jinja2, отдельный контейнер `web`.
|
- [x] `web.py`: FastAPI + Jinja2, отдельный контейнер `web`.
|
||||||
- [x] Список запросов с фильтрами `user_id`, `status`, `mode` и пагинацией.
|
- [x] Список запросов с фильтрами `user_id`, `status`, `mode` и пагинацией.
|
||||||
|
- [x] Отображение `@username` с fallback на Telegram ID; миграция старых БД.
|
||||||
- [x] Превью результата в списке, генерация и кэш в `storage/.thumbnails`.
|
- [x] Превью результата в списке, генерация и кэш в `storage/.thumbnails`.
|
||||||
- [x] Карточка запроса: промпт, seed, ошибка, `comfy_prompt_id`, вход/результат.
|
- [x] Карточка запроса: промпт, seed, ошибка, `comfy_prompt_id`, вход/результат.
|
||||||
- [x] Отдача изображений только из `STORAGE_DIR` (защита от path traversal).
|
- [x] Отдача изображений только из `STORAGE_DIR` (защита от path traversal).
|
||||||
|
|
|
||||||
47
db.py
47
db.py
|
|
@ -29,6 +29,7 @@ class UserAlreadyQueued(RuntimeError):
|
||||||
class Request:
|
class Request:
|
||||||
id: int
|
id: int
|
||||||
user_id: int
|
user_id: int
|
||||||
|
username: str | None
|
||||||
timestamp: str
|
timestamp: str
|
||||||
prompt_text: str
|
prompt_text: str
|
||||||
input_file_path: str | None
|
input_file_path: str | None
|
||||||
|
|
@ -40,11 +41,16 @@ class Request:
|
||||||
error_text: str | None
|
error_text: str | None
|
||||||
finished_at: 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
|
@classmethod
|
||||||
def from_row(cls, row: aiosqlite.Row) -> Request:
|
def from_row(cls, row: aiosqlite.Row) -> Request:
|
||||||
return cls(
|
return cls(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
user_id=row["user_id"],
|
user_id=row["user_id"],
|
||||||
|
username=row["username"],
|
||||||
timestamp=row["timestamp"],
|
timestamp=row["timestamp"],
|
||||||
prompt_text=row["prompt_text"],
|
prompt_text=row["prompt_text"],
|
||||||
input_file_path=row["input_file_path"],
|
input_file_path=row["input_file_path"],
|
||||||
|
|
@ -61,14 +67,20 @@ class Request:
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class UserStat:
|
class UserStat:
|
||||||
user_id: int
|
user_id: int
|
||||||
|
username: str | None
|
||||||
total: int
|
total: int
|
||||||
last_seen: str
|
last_seen: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_user(self) -> str:
|
||||||
|
return f"@{self.username}" if self.username else str(self.user_id)
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS requests (
|
CREATE TABLE IF NOT EXISTS requests (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT,
|
||||||
timestamp TEXT NOT NULL,
|
timestamp TEXT NOT NULL,
|
||||||
prompt_text TEXT NOT NULL,
|
prompt_text TEXT NOT NULL,
|
||||||
input_file_path TEXT,
|
input_file_path TEXT,
|
||||||
|
|
@ -111,8 +123,16 @@ class Database:
|
||||||
await self._conn.execute("PRAGMA busy_timeout=5000")
|
await self._conn.execute("PRAGMA busy_timeout=5000")
|
||||||
await self._conn.execute("PRAGMA foreign_keys=ON")
|
await self._conn.execute("PRAGMA foreign_keys=ON")
|
||||||
await self._conn.executescript(SCHEMA)
|
await self._conn.executescript(SCHEMA)
|
||||||
|
await self._migrate()
|
||||||
await self._conn.commit()
|
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:
|
async def close(self) -> None:
|
||||||
if self._conn is not None:
|
if self._conn is not None:
|
||||||
await self._conn.close()
|
await self._conn.close()
|
||||||
|
|
@ -127,6 +147,7 @@ class Database:
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
username: str | None = None,
|
||||||
mode: str,
|
mode: str,
|
||||||
prompt_text: str,
|
prompt_text: str,
|
||||||
input_file_path: str | None,
|
input_file_path: str | None,
|
||||||
|
|
@ -138,10 +159,19 @@ class Database:
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO requests
|
INSERT INTO requests
|
||||||
(user_id, timestamp, prompt_text, input_file_path, status, mode, seed)
|
(user_id, username, timestamp, prompt_text,
|
||||||
VALUES (?, ?, ?, ?, 'queued', ?, ?)
|
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
|
request_id = cursor.lastrowid
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
|
|
@ -255,7 +285,15 @@ class Database:
|
||||||
conn = self._connection()
|
conn = self._connection()
|
||||||
async with conn.execute(
|
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
|
FROM requests
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
ORDER BY last_seen DESC
|
ORDER BY last_seen DESC
|
||||||
|
|
@ -267,6 +305,7 @@ class Database:
|
||||||
return [
|
return [
|
||||||
UserStat(
|
UserStat(
|
||||||
user_id=int(row["user_id"]),
|
user_id=int(row["user_id"]),
|
||||||
|
username=row["username"],
|
||||||
total=int(row["total"]),
|
total=int(row["total"]),
|
||||||
last_seen=str(row["last_seen"]),
|
last_seen=str(row["last_seen"]),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ class QueueManager:
|
||||||
mode: str,
|
mode: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
input_file_path: str | None = None,
|
input_file_path: str | None = None,
|
||||||
|
username: str | None = None,
|
||||||
) -> tuple[Request, int]:
|
) -> tuple[Request, int]:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if self._stopping:
|
if self._stopping:
|
||||||
|
|
@ -113,6 +114,7 @@ class QueueManager:
|
||||||
seed = secrets.randbelow(2**32)
|
seed = secrets.randbelow(2**32)
|
||||||
request = await self._db.create_request(
|
request = await self._db.create_request(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
username=username,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
prompt_text=prompt,
|
prompt_text=prompt,
|
||||||
input_file_path=input_file_path,
|
input_file_path=input_file_path,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
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:
|
async def test_history_filters_and_stats(db: Database) -> None:
|
||||||
first = await db.create_request(
|
first = await db.create_request(
|
||||||
user_id=1,
|
user_id=1,
|
||||||
|
username="alice",
|
||||||
mode="t2i",
|
mode="t2i",
|
||||||
prompt_text="apple",
|
prompt_text="apple",
|
||||||
input_file_path=None,
|
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")
|
await db.set_done(first.id, "/tmp/1.png")
|
||||||
second = await db.create_request(
|
second = await db.create_request(
|
||||||
user_id=1,
|
user_id=1,
|
||||||
|
username="alice",
|
||||||
mode="i2i",
|
mode="i2i",
|
||||||
prompt_text="banana",
|
prompt_text="banana",
|
||||||
input_file_path="/tmp/in.png",
|
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()
|
users = await db.list_users()
|
||||||
assert {user.user_id for user in users} == {1, 2}
|
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()
|
||||||
|
|
|
||||||
|
|
@ -28,16 +28,18 @@ def insert_request(
|
||||||
status: str = "done",
|
status: str = "done",
|
||||||
mode: str = "t2i",
|
mode: str = "t2i",
|
||||||
output: str | None = None,
|
output: str | None = None,
|
||||||
|
username: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
connection = sqlite3.connect(path)
|
connection = sqlite3.connect(path)
|
||||||
try:
|
try:
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO requests
|
INSERT INTO requests
|
||||||
(user_id, timestamp, prompt_text, status, mode, seed, output_file_path)
|
(user_id, username, timestamp, prompt_text, status, mode, seed,
|
||||||
VALUES (?, '2026-10-18T12:00:00+00:00', ?, ?, ?, 1, ?)
|
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()
|
connection.commit()
|
||||||
return int(cursor.lastrowid)
|
return int(cursor.lastrowid)
|
||||||
|
|
@ -49,13 +51,17 @@ def test_history_page_and_user_filter(tmp_path: Path) -> None:
|
||||||
settings = make_settings(tmp_path)
|
settings = make_settings(tmp_path)
|
||||||
app = create_app(settings)
|
app = create_app(settings)
|
||||||
with TestClient(app) as client:
|
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")
|
insert_request(settings.db_path, user_id=2, prompt="banana prompt")
|
||||||
|
|
||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "apple prompt" in response.text
|
assert "apple prompt" in response.text
|
||||||
assert "banana 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")
|
response = client.get("/?user_id=1")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
<dl class="meta">
|
<dl class="meta">
|
||||||
<dt>Пользователь</dt>
|
<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>
|
<dt>Статус</dt>
|
||||||
<dd><span class="badge {{ item.status }}">{{ item.status }}</span></dd>
|
<dd><span class="badge {{ item.status }}">{{ item.status }}</span></dd>
|
||||||
<dt>Режим</dt>
|
<dt>Режим</dt>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
</label>
|
</label>
|
||||||
<datalist id="known-users">
|
<datalist id="known-users">
|
||||||
{% for user in 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 %}
|
{% endfor %}
|
||||||
</datalist>
|
</datalist>
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="nowrap">{{ item.timestamp|fmt_dt }}</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 class="mono">{{ item.mode }}</td>
|
||||||
<td><span class="badge {{ item.status }}">{{ item.status }}</span></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>
|
<td class="prompt" title="{{ item.prompt_text }}">{{ item.prompt_text|truncate(90, True) }}</td>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue