Отображение юзернеймов
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` (с подсказкой известных пользователей);
|
||||
- в списке показывается `@username`, если он известен, иначе Telegram ID;
|
||||
- фильтр по статусу и режиму (`t2i`/`i2i`);
|
||||
- пагинация;
|
||||
- превью результата в списке (кэшируются в `storage/.thumbnails`);
|
||||
|
|
|
|||
1
bot.py
1
bot.py
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
47
db.py
|
|
@ -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"]),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue