Spaces:
Running on Zero
Running on Zero
Add HF auth QR dashboard and saved generation backend
Browse files- analytics_caller_app_patch.sql +138 -0
- analytics_view_recreate.sql +124 -0
- app.py +376 -5
- hf_dashboard.py +503 -0
- hf_space_dashboard_bootstrap.sql +30 -0
- tests-unit/hf_dashboard_test.py +165 -0
analytics_caller_app_patch.sql
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
alter table public.analytics_generation_events
|
| 2 |
+
add column if not exists caller_app text;
|
| 3 |
+
|
| 4 |
+
alter table public.analytics_download_events
|
| 5 |
+
add column if not exists caller_app text;
|
| 6 |
+
|
| 7 |
+
alter table public.analytics_validation_events
|
| 8 |
+
add column if not exists caller_app text;
|
| 9 |
+
|
| 10 |
+
create index if not exists analytics_generation_events_caller_app_idx
|
| 11 |
+
on public.analytics_generation_events (caller_app, timestamp desc);
|
| 12 |
+
|
| 13 |
+
create index if not exists analytics_download_events_caller_app_idx
|
| 14 |
+
on public.analytics_download_events (caller_app, timestamp desc);
|
| 15 |
+
|
| 16 |
+
create index if not exists analytics_validation_events_caller_app_idx
|
| 17 |
+
on public.analytics_validation_events (caller_app, timestamp desc);
|
| 18 |
+
|
| 19 |
+
create or replace view public.analytics_generation_outcomes as
|
| 20 |
+
select
|
| 21 |
+
g.generation_id,
|
| 22 |
+
g.timestamp as generation_timestamp,
|
| 23 |
+
g.source,
|
| 24 |
+
g.caller_app,
|
| 25 |
+
g.pipeline,
|
| 26 |
+
g.analytics_opt_in,
|
| 27 |
+
g.status,
|
| 28 |
+
g.error_bucket,
|
| 29 |
+
exists (
|
| 30 |
+
select 1
|
| 31 |
+
from public.analytics_download_events d
|
| 32 |
+
where d.generation_id = g.generation_id
|
| 33 |
+
) as has_download
|
| 34 |
+
from public.analytics_generation_events g;
|
| 35 |
+
|
| 36 |
+
create or replace view public.analytics_download_events_inferred as
|
| 37 |
+
select
|
| 38 |
+
d.id,
|
| 39 |
+
d.generation_id,
|
| 40 |
+
d.timestamp,
|
| 41 |
+
d.product,
|
| 42 |
+
d.caller_app,
|
| 43 |
+
d.source,
|
| 44 |
+
d.pipeline,
|
| 45 |
+
d.tool_name,
|
| 46 |
+
d.analytics_opt_in,
|
| 47 |
+
d.format,
|
| 48 |
+
d.anonymous_id,
|
| 49 |
+
d.qr_payload_full,
|
| 50 |
+
d.seed,
|
| 51 |
+
d.created_at,
|
| 52 |
+
case
|
| 53 |
+
when d.tool_name like '%_standard' then 'standard'
|
| 54 |
+
when d.tool_name like '%_artistic' then 'artistic'
|
| 55 |
+
when d.source = 'mcp' and d.tool_name like '%_1' then 'standard'
|
| 56 |
+
when d.source = 'mcp' and d.tool_name not like '%_1' then 'artistic'
|
| 57 |
+
else d.pipeline
|
| 58 |
+
end as pipeline_inferred
|
| 59 |
+
from public.analytics_download_events d;
|
| 60 |
+
|
| 61 |
+
create or replace view public.analytics_generation_signals as
|
| 62 |
+
with ordered_generations as (
|
| 63 |
+
select
|
| 64 |
+
g.id,
|
| 65 |
+
g.generation_id,
|
| 66 |
+
g.timestamp,
|
| 67 |
+
g.product,
|
| 68 |
+
g.caller_app,
|
| 69 |
+
g.source,
|
| 70 |
+
g.pipeline,
|
| 71 |
+
g.tool_name,
|
| 72 |
+
g.analytics_opt_in,
|
| 73 |
+
g.status,
|
| 74 |
+
g.error_bucket,
|
| 75 |
+
g.anonymous_id,
|
| 76 |
+
g.prompt_full,
|
| 77 |
+
g.qr_payload_full,
|
| 78 |
+
g.settings_full,
|
| 79 |
+
g.created_at,
|
| 80 |
+
lead(g.timestamp) over (
|
| 81 |
+
partition by g.source, g.anonymous_id, g.pipeline, coalesce(g.caller_app, '')
|
| 82 |
+
order by g.timestamp
|
| 83 |
+
) as next_generation_timestamp
|
| 84 |
+
from public.analytics_generation_events g
|
| 85 |
+
), generation_with_downloads as (
|
| 86 |
+
select
|
| 87 |
+
g.generation_id,
|
| 88 |
+
g.timestamp,
|
| 89 |
+
g.product,
|
| 90 |
+
g.caller_app,
|
| 91 |
+
g.source,
|
| 92 |
+
g.pipeline,
|
| 93 |
+
g.analytics_opt_in,
|
| 94 |
+
g.status,
|
| 95 |
+
g.error_bucket,
|
| 96 |
+
g.anonymous_id,
|
| 97 |
+
g.prompt_full,
|
| 98 |
+
g.qr_payload_full,
|
| 99 |
+
g.settings_full,
|
| 100 |
+
g.next_generation_timestamp,
|
| 101 |
+
exists (
|
| 102 |
+
select 1
|
| 103 |
+
from public.analytics_download_events d
|
| 104 |
+
where d.source = g.source
|
| 105 |
+
and coalesce(d.caller_app, '') = coalesce(g.caller_app, '')
|
| 106 |
+
and d.anonymous_id = g.anonymous_id
|
| 107 |
+
and d.timestamp >= g.timestamp
|
| 108 |
+
and d.timestamp <= g.timestamp + interval '10 minutes'
|
| 109 |
+
) as has_download_within_10m
|
| 110 |
+
from ordered_generations g
|
| 111 |
+
)
|
| 112 |
+
select
|
| 113 |
+
generation_id,
|
| 114 |
+
timestamp,
|
| 115 |
+
product,
|
| 116 |
+
caller_app,
|
| 117 |
+
source,
|
| 118 |
+
pipeline,
|
| 119 |
+
analytics_opt_in,
|
| 120 |
+
status,
|
| 121 |
+
error_bucket,
|
| 122 |
+
anonymous_id,
|
| 123 |
+
prompt_full,
|
| 124 |
+
qr_payload_full,
|
| 125 |
+
settings_full,
|
| 126 |
+
has_download_within_10m,
|
| 127 |
+
next_generation_timestamp,
|
| 128 |
+
case
|
| 129 |
+
when error_bucket = 'infra_limited' then 'infra_limited'
|
| 130 |
+
when status = 'success' and has_download_within_10m then 'happy'
|
| 131 |
+
when status = 'success'
|
| 132 |
+
and next_generation_timestamp is not null
|
| 133 |
+
and next_generation_timestamp <= timestamp + interval '10 minutes'
|
| 134 |
+
and not has_download_within_10m then 'unhappy'
|
| 135 |
+
when status = 'error' then 'error'
|
| 136 |
+
else 'neutral'
|
| 137 |
+
end as outcome_signal
|
| 138 |
+
from generation_with_downloads;
|
analytics_view_recreate.sql
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
drop view if exists public.analytics_generation_signals;
|
| 2 |
+
drop view if exists public.analytics_download_events_inferred;
|
| 3 |
+
drop view if exists public.analytics_generation_outcomes;
|
| 4 |
+
|
| 5 |
+
create view public.analytics_generation_outcomes as
|
| 6 |
+
select
|
| 7 |
+
g.generation_id,
|
| 8 |
+
g.timestamp as generation_timestamp,
|
| 9 |
+
g.source,
|
| 10 |
+
g.caller_app,
|
| 11 |
+
g.pipeline,
|
| 12 |
+
g.analytics_opt_in,
|
| 13 |
+
g.status,
|
| 14 |
+
g.error_bucket,
|
| 15 |
+
exists (
|
| 16 |
+
select 1
|
| 17 |
+
from public.analytics_download_events d
|
| 18 |
+
where d.generation_id = g.generation_id
|
| 19 |
+
) as has_download
|
| 20 |
+
from public.analytics_generation_events g;
|
| 21 |
+
|
| 22 |
+
create view public.analytics_download_events_inferred as
|
| 23 |
+
select
|
| 24 |
+
d.id,
|
| 25 |
+
d.generation_id,
|
| 26 |
+
d.timestamp,
|
| 27 |
+
d.product,
|
| 28 |
+
d.caller_app,
|
| 29 |
+
d.source,
|
| 30 |
+
d.pipeline,
|
| 31 |
+
d.tool_name,
|
| 32 |
+
d.analytics_opt_in,
|
| 33 |
+
d.format,
|
| 34 |
+
d.anonymous_id,
|
| 35 |
+
d.qr_payload_full,
|
| 36 |
+
d.seed,
|
| 37 |
+
d.created_at,
|
| 38 |
+
case
|
| 39 |
+
when d.tool_name like '%_standard' then 'standard'
|
| 40 |
+
when d.tool_name like '%_artistic' then 'artistic'
|
| 41 |
+
when d.source = 'mcp' and d.tool_name like '%_1' then 'standard'
|
| 42 |
+
when d.source = 'mcp' and d.tool_name not like '%_1' then 'artistic'
|
| 43 |
+
else d.pipeline
|
| 44 |
+
end as pipeline_inferred
|
| 45 |
+
from public.analytics_download_events d;
|
| 46 |
+
|
| 47 |
+
create view public.analytics_generation_signals as
|
| 48 |
+
with ordered_generations as (
|
| 49 |
+
select
|
| 50 |
+
g.id,
|
| 51 |
+
g.generation_id,
|
| 52 |
+
g.timestamp,
|
| 53 |
+
g.product,
|
| 54 |
+
g.caller_app,
|
| 55 |
+
g.source,
|
| 56 |
+
g.pipeline,
|
| 57 |
+
g.tool_name,
|
| 58 |
+
g.analytics_opt_in,
|
| 59 |
+
g.status,
|
| 60 |
+
g.error_bucket,
|
| 61 |
+
g.anonymous_id,
|
| 62 |
+
g.prompt_full,
|
| 63 |
+
g.qr_payload_full,
|
| 64 |
+
g.settings_full,
|
| 65 |
+
g.created_at,
|
| 66 |
+
lead(g.timestamp) over (
|
| 67 |
+
partition by g.source, g.anonymous_id, g.pipeline, coalesce(g.caller_app, '')
|
| 68 |
+
order by g.timestamp
|
| 69 |
+
) as next_generation_timestamp
|
| 70 |
+
from public.analytics_generation_events g
|
| 71 |
+
), generation_with_downloads as (
|
| 72 |
+
select
|
| 73 |
+
g.generation_id,
|
| 74 |
+
g.timestamp,
|
| 75 |
+
g.product,
|
| 76 |
+
g.caller_app,
|
| 77 |
+
g.source,
|
| 78 |
+
g.pipeline,
|
| 79 |
+
g.analytics_opt_in,
|
| 80 |
+
g.status,
|
| 81 |
+
g.error_bucket,
|
| 82 |
+
g.anonymous_id,
|
| 83 |
+
g.prompt_full,
|
| 84 |
+
g.qr_payload_full,
|
| 85 |
+
g.settings_full,
|
| 86 |
+
g.next_generation_timestamp,
|
| 87 |
+
exists (
|
| 88 |
+
select 1
|
| 89 |
+
from public.analytics_download_events d
|
| 90 |
+
where d.source = g.source
|
| 91 |
+
and coalesce(d.caller_app, '') = coalesce(g.caller_app, '')
|
| 92 |
+
and d.anonymous_id = g.anonymous_id
|
| 93 |
+
and d.timestamp >= g.timestamp
|
| 94 |
+
and d.timestamp <= g.timestamp + interval '10 minutes'
|
| 95 |
+
) as has_download_within_10m
|
| 96 |
+
from ordered_generations g
|
| 97 |
+
)
|
| 98 |
+
select
|
| 99 |
+
generation_id,
|
| 100 |
+
timestamp,
|
| 101 |
+
product,
|
| 102 |
+
caller_app,
|
| 103 |
+
source,
|
| 104 |
+
pipeline,
|
| 105 |
+
analytics_opt_in,
|
| 106 |
+
status,
|
| 107 |
+
error_bucket,
|
| 108 |
+
anonymous_id,
|
| 109 |
+
prompt_full,
|
| 110 |
+
qr_payload_full,
|
| 111 |
+
settings_full,
|
| 112 |
+
has_download_within_10m,
|
| 113 |
+
next_generation_timestamp,
|
| 114 |
+
case
|
| 115 |
+
when error_bucket = 'infra_limited' then 'infra_limited'
|
| 116 |
+
when status = 'success' and has_download_within_10m then 'happy'
|
| 117 |
+
when status = 'success'
|
| 118 |
+
and next_generation_timestamp is not null
|
| 119 |
+
and next_generation_timestamp <= timestamp + interval '10 minutes'
|
| 120 |
+
and not has_download_within_10m then 'unhappy'
|
| 121 |
+
when status = 'error' then 'error'
|
| 122 |
+
else 'neutral'
|
| 123 |
+
end as outcome_signal
|
| 124 |
+
from generation_with_downloads;
|
app.py
CHANGED
|
@@ -30,6 +30,12 @@ from huggingface_hub import hf_hub_download
|
|
| 30 |
from PIL import Image
|
| 31 |
import kornia.color # For RGB→HSV conversion in Stable Cascade filter
|
| 32 |
from runtime_config import get_analytics_product
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
# ── Export helpers (PNG + embedded SVG download) ──────────────────────────────
|
| 35 |
import base64
|
|
@@ -319,9 +325,135 @@ def _append_status_note(status: str | None, note: str | None) -> str | None:
|
|
| 319 |
return status
|
| 320 |
if not status:
|
| 321 |
return note
|
|
|
|
|
|
|
| 322 |
return f"{status}\n\n{note}"
|
| 323 |
|
| 324 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
def _build_shortener_note(shortener_result: Mapping[str, Any] | None) -> str | None:
|
| 326 |
if not shortener_result:
|
| 327 |
return None
|
|
@@ -366,6 +498,8 @@ def _maybe_shorten_url_for_qr(
|
|
| 366 |
result = {
|
| 367 |
"applied": False,
|
| 368 |
"effective_qr_text": fallback_qr_text,
|
|
|
|
|
|
|
| 369 |
"short_url": None,
|
| 370 |
"expires_at": None,
|
| 371 |
"existed": False,
|
|
@@ -416,6 +550,8 @@ def _maybe_shorten_url_for_qr(
|
|
| 416 |
return {
|
| 417 |
"applied": True,
|
| 418 |
"effective_qr_text": _normalize_qr_text_for_validation(short_url, "URL"),
|
|
|
|
|
|
|
| 419 |
"short_url": short_url,
|
| 420 |
"expires_at": payload.get("expires_at"),
|
| 421 |
"existed": bool(payload.get("existed")),
|
|
@@ -2732,10 +2868,29 @@ def generate_standard_qr(
|
|
| 2732 |
request=request,
|
| 2733 |
)
|
| 2734 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2735 |
yield (
|
| 2736 |
final_image,
|
| 2737 |
_append_status_note(
|
| 2738 |
-
_append_status_note(
|
| 2739 |
shortener_note,
|
| 2740 |
),
|
| 2741 |
gr.update(value=settings_json),
|
|
@@ -3060,10 +3215,29 @@ def generate_artistic_qr(
|
|
| 3060 |
request=request,
|
| 3061 |
)
|
| 3062 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3063 |
yield (
|
| 3064 |
final_image,
|
| 3065 |
_append_status_note(
|
| 3066 |
-
_append_status_note(
|
| 3067 |
shortener_note,
|
| 3068 |
),
|
| 3069 |
gr.update(value=settings_json),
|
|
@@ -4766,9 +4940,9 @@ with gr.Blocks(delete_cache=(3600, 3600)) as demo:
|
|
| 4766 |
)
|
| 4767 |
|
| 4768 |
# Add tabs for different generation methods
|
| 4769 |
-
with gr.Tabs():
|
| 4770 |
# ARTISTIC QR TAB
|
| 4771 |
-
with gr.TabItem("Artistic QR"):
|
| 4772 |
# Short description
|
| 4773 |
gr.Markdown("""
|
| 4774 |
🎨 **Create artistic QR codes that blend seamlessly with your creative vision**
|
|
@@ -5802,7 +5976,7 @@ with gr.Blocks(delete_cache=(3600, 3600)) as demo:
|
|
| 5802 |
)
|
| 5803 |
|
| 5804 |
# STANDARD QR TAB
|
| 5805 |
-
with gr.TabItem("Standard QR"):
|
| 5806 |
# Short description
|
| 5807 |
gr.Markdown("""
|
| 5808 |
⚡ **2x faster than Artistic pipeline** - perfect for quota management
|
|
@@ -6429,6 +6603,203 @@ with gr.Blocks(delete_cache=(3600, 3600)) as demo:
|
|
| 6429 |
label="Example Presets (Click to Load)",
|
| 6430 |
)
|
| 6431 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6432 |
# ARTISTIC QR TAB
|
| 6433 |
|
| 6434 |
# Queue is required for gr.Progress() to work!
|
|
|
|
| 30 |
from PIL import Image
|
| 31 |
import kornia.color # For RGB→HSV conversion in Stable Cascade filter
|
| 32 |
from runtime_config import get_analytics_product
|
| 33 |
+
from hf_dashboard import (
|
| 34 |
+
DashboardError,
|
| 35 |
+
get_generation_detail,
|
| 36 |
+
list_my_generations,
|
| 37 |
+
persist_generation,
|
| 38 |
+
)
|
| 39 |
|
| 40 |
# ── Export helpers (PNG + embedded SVG download) ──────────────────────────────
|
| 41 |
import base64
|
|
|
|
| 325 |
return status
|
| 326 |
if not status:
|
| 327 |
return note
|
| 328 |
+
if note in status:
|
| 329 |
+
return status
|
| 330 |
return f"{status}\n\n{note}"
|
| 331 |
|
| 332 |
|
| 333 |
+
def _coerce_pil_image(image: Any) -> Image.Image:
|
| 334 |
+
if isinstance(image, Image.Image):
|
| 335 |
+
return image
|
| 336 |
+
return Image.fromarray(np.asarray(image))
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def _dashboard_empty_payload(message: str):
|
| 340 |
+
return (
|
| 341 |
+
message,
|
| 342 |
+
gr.update(value=[]),
|
| 343 |
+
[],
|
| 344 |
+
"Select a saved QR to inspect analytics and reload settings.",
|
| 345 |
+
None,
|
| 346 |
+
[],
|
| 347 |
+
[],
|
| 348 |
+
"{}",
|
| 349 |
+
gr.update(visible=False),
|
| 350 |
+
gr.update(visible=False),
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _load_my_qr_dashboard(request: Union[gr.Request, None] = None):
|
| 355 |
+
try:
|
| 356 |
+
payload = list_my_generations(request)
|
| 357 |
+
except DashboardError as exc:
|
| 358 |
+
return _dashboard_empty_payload(f"My QR Codes unavailable: {exc}")
|
| 359 |
+
except Exception as exc:
|
| 360 |
+
print("[dashboard-load]", traceback.format_exc())
|
| 361 |
+
return _dashboard_empty_payload(
|
| 362 |
+
f"My QR Codes unavailable: {_format_exception_message(exc)}"
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
if not payload.get("authenticated"):
|
| 366 |
+
return _dashboard_empty_payload(str(payload.get("status") or "Sign in required."))
|
| 367 |
+
|
| 368 |
+
records = payload.get("records") or []
|
| 369 |
+
gallery = payload.get("gallery") or []
|
| 370 |
+
if not records:
|
| 371 |
+
return (
|
| 372 |
+
str(payload.get("status") or "No saved QR codes yet."),
|
| 373 |
+
gr.update(value=[]),
|
| 374 |
+
records,
|
| 375 |
+
"No saved QR codes yet. Generate one while signed in, then come back here.",
|
| 376 |
+
None,
|
| 377 |
+
[],
|
| 378 |
+
[],
|
| 379 |
+
"{}",
|
| 380 |
+
gr.update(visible=False),
|
| 381 |
+
gr.update(visible=False),
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
return (
|
| 385 |
+
str(payload.get("status") or "Loaded."),
|
| 386 |
+
gr.update(value=gallery),
|
| 387 |
+
records,
|
| 388 |
+
"Select a saved QR to inspect analytics and reload settings.",
|
| 389 |
+
None,
|
| 390 |
+
[],
|
| 391 |
+
[],
|
| 392 |
+
"{}",
|
| 393 |
+
gr.update(visible=False),
|
| 394 |
+
gr.update(visible=False),
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def _select_my_qr_generation(
|
| 399 |
+
records: list[dict[str, Any]],
|
| 400 |
+
evt: gr.SelectData,
|
| 401 |
+
request: Union[gr.Request, None] = None,
|
| 402 |
+
):
|
| 403 |
+
if not records:
|
| 404 |
+
return (
|
| 405 |
+
"No saved QR codes are loaded yet.",
|
| 406 |
+
None,
|
| 407 |
+
[],
|
| 408 |
+
[],
|
| 409 |
+
"{}",
|
| 410 |
+
gr.update(visible=False),
|
| 411 |
+
gr.update(visible=False),
|
| 412 |
+
)
|
| 413 |
+
try:
|
| 414 |
+
detail = get_generation_detail(request, records, int(evt.index))
|
| 415 |
+
except DashboardError as exc:
|
| 416 |
+
return (
|
| 417 |
+
f"Could not load QR details: {exc}",
|
| 418 |
+
None,
|
| 419 |
+
[],
|
| 420 |
+
[],
|
| 421 |
+
"{}",
|
| 422 |
+
gr.update(visible=False),
|
| 423 |
+
gr.update(visible=False),
|
| 424 |
+
)
|
| 425 |
+
except Exception as exc:
|
| 426 |
+
print("[dashboard-select]", traceback.format_exc())
|
| 427 |
+
return (
|
| 428 |
+
f"Could not load QR details: {_format_exception_message(exc)}",
|
| 429 |
+
None,
|
| 430 |
+
[],
|
| 431 |
+
[],
|
| 432 |
+
"{}",
|
| 433 |
+
gr.update(visible=False),
|
| 434 |
+
gr.update(visible=False),
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
return (
|
| 438 |
+
detail["detail_markdown"],
|
| 439 |
+
detail["image"],
|
| 440 |
+
detail["top_countries_rows"],
|
| 441 |
+
detail["scans_by_day_rows"],
|
| 442 |
+
detail["settings_json"],
|
| 443 |
+
gr.update(visible=detail["is_standard"]),
|
| 444 |
+
gr.update(visible=detail["is_artistic"]),
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def _load_standard_settings_from_dashboard(settings_json: str):
|
| 449 |
+
return (*load_settings_from_json_standard(settings_json), gr.update(selected="standard-tab"))
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def _load_artistic_settings_from_dashboard(settings_json: str):
|
| 454 |
+
return (*load_settings_from_json_artistic(settings_json), gr.update(selected="artistic-tab"))
|
| 455 |
+
|
| 456 |
+
|
| 457 |
def _build_shortener_note(shortener_result: Mapping[str, Any] | None) -> str | None:
|
| 458 |
if not shortener_result:
|
| 459 |
return None
|
|
|
|
| 498 |
result = {
|
| 499 |
"applied": False,
|
| 500 |
"effective_qr_text": fallback_qr_text,
|
| 501 |
+
"short_link_id": None,
|
| 502 |
+
"short_code": None,
|
| 503 |
"short_url": None,
|
| 504 |
"expires_at": None,
|
| 505 |
"existed": False,
|
|
|
|
| 550 |
return {
|
| 551 |
"applied": True,
|
| 552 |
"effective_qr_text": _normalize_qr_text_for_validation(short_url, "URL"),
|
| 553 |
+
"short_link_id": payload.get("short_link_id"),
|
| 554 |
+
"short_code": payload.get("code"),
|
| 555 |
"short_url": short_url,
|
| 556 |
"expires_at": payload.get("expires_at"),
|
| 557 |
"existed": bool(payload.get("existed")),
|
|
|
|
| 2868 |
request=request,
|
| 2869 |
)
|
| 2870 |
)
|
| 2871 |
+
dashboard_note = None
|
| 2872 |
+
try:
|
| 2873 |
+
dashboard_note = persist_generation(
|
| 2874 |
+
request=request,
|
| 2875 |
+
generation_id=generation_id,
|
| 2876 |
+
qr_mode="standard",
|
| 2877 |
+
prompt=prompt,
|
| 2878 |
+
text_input=text_input,
|
| 2879 |
+
input_type=input_type,
|
| 2880 |
+
status="completed",
|
| 2881 |
+
settings_dict=settings_dict,
|
| 2882 |
+
url_normalization=url_normalization,
|
| 2883 |
+
shortener_result=shortener_result,
|
| 2884 |
+
final_image=_coerce_pil_image(final_image),
|
| 2885 |
+
)
|
| 2886 |
+
except Exception as exc:
|
| 2887 |
+
print("[dashboard-save][standard]", traceback.format_exc())
|
| 2888 |
+
dashboard_note = f"Could not save to My QR Codes: {_format_exception_message(exc)}"
|
| 2889 |
+
final_status_with_dashboard = _append_status_note(final_status, dashboard_note)
|
| 2890 |
yield (
|
| 2891 |
final_image,
|
| 2892 |
_append_status_note(
|
| 2893 |
+
_append_status_note(final_status_with_dashboard, url_normalization_note),
|
| 2894 |
shortener_note,
|
| 2895 |
),
|
| 2896 |
gr.update(value=settings_json),
|
|
|
|
| 3215 |
request=request,
|
| 3216 |
)
|
| 3217 |
)
|
| 3218 |
+
dashboard_note = None
|
| 3219 |
+
try:
|
| 3220 |
+
dashboard_note = persist_generation(
|
| 3221 |
+
request=request,
|
| 3222 |
+
generation_id=generation_id,
|
| 3223 |
+
qr_mode="artistic",
|
| 3224 |
+
prompt=prompt,
|
| 3225 |
+
text_input=text_input,
|
| 3226 |
+
input_type=input_type,
|
| 3227 |
+
status="completed",
|
| 3228 |
+
settings_dict=settings_dict,
|
| 3229 |
+
url_normalization=url_normalization,
|
| 3230 |
+
shortener_result=shortener_result,
|
| 3231 |
+
final_image=_coerce_pil_image(final_image),
|
| 3232 |
+
)
|
| 3233 |
+
except Exception as exc:
|
| 3234 |
+
print("[dashboard-save][artistic]", traceback.format_exc())
|
| 3235 |
+
dashboard_note = f"Could not save to My QR Codes: {_format_exception_message(exc)}"
|
| 3236 |
+
final_status_with_dashboard = _append_status_note(final_status, dashboard_note)
|
| 3237 |
yield (
|
| 3238 |
final_image,
|
| 3239 |
_append_status_note(
|
| 3240 |
+
_append_status_note(final_status_with_dashboard, url_normalization_note),
|
| 3241 |
shortener_note,
|
| 3242 |
),
|
| 3243 |
gr.update(value=settings_json),
|
|
|
|
| 4940 |
)
|
| 4941 |
|
| 4942 |
# Add tabs for different generation methods
|
| 4943 |
+
with gr.Tabs() as qr_tabs:
|
| 4944 |
# ARTISTIC QR TAB
|
| 4945 |
+
with gr.TabItem("Artistic QR", id="artistic-tab"):
|
| 4946 |
# Short description
|
| 4947 |
gr.Markdown("""
|
| 4948 |
🎨 **Create artistic QR codes that blend seamlessly with your creative vision**
|
|
|
|
| 5976 |
)
|
| 5977 |
|
| 5978 |
# STANDARD QR TAB
|
| 5979 |
+
with gr.TabItem("Standard QR", id="standard-tab"):
|
| 5980 |
# Short description
|
| 5981 |
gr.Markdown("""
|
| 5982 |
⚡ **2x faster than Artistic pipeline** - perfect for quota management
|
|
|
|
| 6603 |
label="Example Presets (Click to Load)",
|
| 6604 |
)
|
| 6605 |
|
| 6606 |
+
with gr.TabItem("My QR Codes", id="dashboard-tab"):
|
| 6607 |
+
gr.Markdown(
|
| 6608 |
+
"""
|
| 6609 |
+
Sign in with Hugging Face, then refresh to see your saved QR generations.
|
| 6610 |
+
Click any saved QR to inspect scan analytics and load its exact settings back into the generator tabs.
|
| 6611 |
+
"""
|
| 6612 |
+
)
|
| 6613 |
+
with gr.Row():
|
| 6614 |
+
dashboard_login_btn = gr.LoginButton("Sign in with Hugging Face")
|
| 6615 |
+
dashboard_refresh_btn = gr.Button("Refresh My QR Codes", variant="primary")
|
| 6616 |
+
dashboard_status = gr.Markdown(
|
| 6617 |
+
"Sign in with Hugging Face to see your saved QR codes and analytics."
|
| 6618 |
+
)
|
| 6619 |
+
my_qr_records_state = gr.State([])
|
| 6620 |
+
my_qr_gallery = gr.Gallery(
|
| 6621 |
+
label="My QR Generations",
|
| 6622 |
+
value=[],
|
| 6623 |
+
columns=3,
|
| 6624 |
+
rows=2,
|
| 6625 |
+
height="auto",
|
| 6626 |
+
allow_preview=True,
|
| 6627 |
+
object_fit="cover",
|
| 6628 |
+
show_download_button=False,
|
| 6629 |
+
)
|
| 6630 |
+
with gr.Row():
|
| 6631 |
+
with gr.Column(scale=1):
|
| 6632 |
+
my_qr_detail_image = gr.Image(
|
| 6633 |
+
label="Selected QR",
|
| 6634 |
+
visible=True,
|
| 6635 |
+
interactive=False,
|
| 6636 |
+
)
|
| 6637 |
+
with gr.Column(scale=1):
|
| 6638 |
+
my_qr_detail_markdown = gr.Markdown(
|
| 6639 |
+
"Select a saved QR to inspect analytics and reload settings."
|
| 6640 |
+
)
|
| 6641 |
+
with gr.Row():
|
| 6642 |
+
my_qr_top_countries = gr.Dataframe(
|
| 6643 |
+
headers=["Country", "Scans"],
|
| 6644 |
+
datatype=["str", "number"],
|
| 6645 |
+
row_count=10,
|
| 6646 |
+
col_count=(2, "fixed"),
|
| 6647 |
+
label="Top countries",
|
| 6648 |
+
interactive=False,
|
| 6649 |
+
value=[],
|
| 6650 |
+
)
|
| 6651 |
+
my_qr_scans_by_day = gr.Dataframe(
|
| 6652 |
+
headers=["Day", "Scans"],
|
| 6653 |
+
datatype=["str", "number"],
|
| 6654 |
+
row_count=10,
|
| 6655 |
+
col_count=(2, "fixed"),
|
| 6656 |
+
label="Scans by day",
|
| 6657 |
+
interactive=False,
|
| 6658 |
+
value=[],
|
| 6659 |
+
)
|
| 6660 |
+
my_qr_settings_json = gr.Textbox(
|
| 6661 |
+
label="Saved settings JSON",
|
| 6662 |
+
lines=10,
|
| 6663 |
+
interactive=True,
|
| 6664 |
+
show_copy_button=True,
|
| 6665 |
+
value="{}",
|
| 6666 |
+
)
|
| 6667 |
+
with gr.Row():
|
| 6668 |
+
load_standard_from_dashboard_btn = gr.Button(
|
| 6669 |
+
"Load into Standard QR",
|
| 6670 |
+
variant="secondary",
|
| 6671 |
+
visible=False,
|
| 6672 |
+
)
|
| 6673 |
+
load_artistic_from_dashboard_btn = gr.Button(
|
| 6674 |
+
"Load into Artistic QR",
|
| 6675 |
+
variant="secondary",
|
| 6676 |
+
visible=False,
|
| 6677 |
+
)
|
| 6678 |
+
|
| 6679 |
+
dashboard_refresh_btn.click(
|
| 6680 |
+
fn=_load_my_qr_dashboard,
|
| 6681 |
+
inputs=[],
|
| 6682 |
+
outputs=[
|
| 6683 |
+
dashboard_status,
|
| 6684 |
+
my_qr_gallery,
|
| 6685 |
+
my_qr_records_state,
|
| 6686 |
+
my_qr_detail_markdown,
|
| 6687 |
+
my_qr_detail_image,
|
| 6688 |
+
my_qr_top_countries,
|
| 6689 |
+
my_qr_scans_by_day,
|
| 6690 |
+
my_qr_settings_json,
|
| 6691 |
+
load_standard_from_dashboard_btn,
|
| 6692 |
+
load_artistic_from_dashboard_btn,
|
| 6693 |
+
],
|
| 6694 |
+
)
|
| 6695 |
+
|
| 6696 |
+
demo.load(
|
| 6697 |
+
fn=_load_my_qr_dashboard,
|
| 6698 |
+
inputs=[],
|
| 6699 |
+
outputs=[
|
| 6700 |
+
dashboard_status,
|
| 6701 |
+
my_qr_gallery,
|
| 6702 |
+
my_qr_records_state,
|
| 6703 |
+
my_qr_detail_markdown,
|
| 6704 |
+
my_qr_detail_image,
|
| 6705 |
+
my_qr_top_countries,
|
| 6706 |
+
my_qr_scans_by_day,
|
| 6707 |
+
my_qr_settings_json,
|
| 6708 |
+
load_standard_from_dashboard_btn,
|
| 6709 |
+
load_artistic_from_dashboard_btn,
|
| 6710 |
+
],
|
| 6711 |
+
)
|
| 6712 |
+
|
| 6713 |
+
my_qr_gallery.select(
|
| 6714 |
+
fn=_select_my_qr_generation,
|
| 6715 |
+
inputs=[my_qr_records_state],
|
| 6716 |
+
outputs=[
|
| 6717 |
+
my_qr_detail_markdown,
|
| 6718 |
+
my_qr_detail_image,
|
| 6719 |
+
my_qr_top_countries,
|
| 6720 |
+
my_qr_scans_by_day,
|
| 6721 |
+
my_qr_settings_json,
|
| 6722 |
+
load_standard_from_dashboard_btn,
|
| 6723 |
+
load_artistic_from_dashboard_btn,
|
| 6724 |
+
],
|
| 6725 |
+
)
|
| 6726 |
+
|
| 6727 |
+
load_standard_from_dashboard_btn.click(
|
| 6728 |
+
fn=_load_standard_settings_from_dashboard,
|
| 6729 |
+
inputs=[my_qr_settings_json],
|
| 6730 |
+
outputs=[
|
| 6731 |
+
prompt_input,
|
| 6732 |
+
negative_prompt_standard,
|
| 6733 |
+
text_input,
|
| 6734 |
+
input_type,
|
| 6735 |
+
use_temporary_short_link,
|
| 6736 |
+
image_size,
|
| 6737 |
+
border_size,
|
| 6738 |
+
error_correction,
|
| 6739 |
+
module_size,
|
| 6740 |
+
module_drawer,
|
| 6741 |
+
use_custom_seed,
|
| 6742 |
+
seed,
|
| 6743 |
+
enable_upscale,
|
| 6744 |
+
enable_animation,
|
| 6745 |
+
controlnet_strength_standard_first,
|
| 6746 |
+
controlnet_strength_standard_final,
|
| 6747 |
+
enable_color_quantization,
|
| 6748 |
+
num_colors,
|
| 6749 |
+
color_1,
|
| 6750 |
+
color_2,
|
| 6751 |
+
color_3,
|
| 6752 |
+
color_4,
|
| 6753 |
+
apply_gradient_filter,
|
| 6754 |
+
gradient_strength,
|
| 6755 |
+
variation_steps,
|
| 6756 |
+
import_status_standard,
|
| 6757 |
+
qr_tabs,
|
| 6758 |
+
],
|
| 6759 |
+
)
|
| 6760 |
+
|
| 6761 |
+
load_artistic_from_dashboard_btn.click(
|
| 6762 |
+
fn=_load_artistic_settings_from_dashboard,
|
| 6763 |
+
inputs=[my_qr_settings_json],
|
| 6764 |
+
outputs=[
|
| 6765 |
+
artistic_prompt_input,
|
| 6766 |
+
negative_prompt_artistic,
|
| 6767 |
+
artistic_text_input,
|
| 6768 |
+
artistic_input_type,
|
| 6769 |
+
artistic_use_temporary_short_link,
|
| 6770 |
+
artistic_image_size,
|
| 6771 |
+
artistic_border_size,
|
| 6772 |
+
artistic_error_correction,
|
| 6773 |
+
artistic_module_size,
|
| 6774 |
+
artistic_module_drawer,
|
| 6775 |
+
artistic_use_custom_seed,
|
| 6776 |
+
artistic_seed,
|
| 6777 |
+
artistic_enable_upscale,
|
| 6778 |
+
artistic_enable_animation,
|
| 6779 |
+
enable_freeu_artistic,
|
| 6780 |
+
freeu_b1,
|
| 6781 |
+
freeu_b2,
|
| 6782 |
+
freeu_s1,
|
| 6783 |
+
freeu_s2,
|
| 6784 |
+
enable_sag,
|
| 6785 |
+
sag_scale,
|
| 6786 |
+
sag_blur_sigma,
|
| 6787 |
+
controlnet_strength_first,
|
| 6788 |
+
controlnet_strength_final,
|
| 6789 |
+
artistic_enable_color_quantization,
|
| 6790 |
+
artistic_num_colors,
|
| 6791 |
+
artistic_color_1,
|
| 6792 |
+
artistic_color_2,
|
| 6793 |
+
artistic_color_3,
|
| 6794 |
+
artistic_color_4,
|
| 6795 |
+
artistic_apply_gradient_filter,
|
| 6796 |
+
artistic_gradient_strength,
|
| 6797 |
+
artistic_variation_steps,
|
| 6798 |
+
import_status_artistic,
|
| 6799 |
+
qr_tabs,
|
| 6800 |
+
],
|
| 6801 |
+
)
|
| 6802 |
+
|
| 6803 |
# ARTISTIC QR TAB
|
| 6804 |
|
| 6805 |
# Queue is required for gr.Progress() to work!
|
hf_dashboard.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
from collections import Counter
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from typing import Any, Mapping
|
| 9 |
+
from urllib import error as urllib_error
|
| 10 |
+
from urllib import parse as urllib_parse
|
| 11 |
+
from urllib import request as urllib_request
|
| 12 |
+
|
| 13 |
+
from PIL import Image
|
| 14 |
+
|
| 15 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL", "").rstrip("/")
|
| 16 |
+
SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
|
| 17 |
+
GENERATIONS_BUCKET = os.getenv("SUPABASE_GENERATIONS_BUCKET", "qr-generations")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class DashboardError(RuntimeError):
|
| 21 |
+
pass
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
_BUCKET_READY = False
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _require_supabase() -> None:
|
| 28 |
+
if not SUPABASE_URL or not SUPABASE_SERVICE_ROLE_KEY:
|
| 29 |
+
raise DashboardError(
|
| 30 |
+
"Supabase is not configured. Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY."
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _quote_filter(value: str) -> str:
|
| 35 |
+
return urllib_parse.quote(str(value), safe="")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _headers(*, json_body: bool = True, extra: Mapping[str, str] | None = None) -> dict[str, str]:
|
| 39 |
+
headers = {
|
| 40 |
+
"apikey": SUPABASE_SERVICE_ROLE_KEY,
|
| 41 |
+
"Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}",
|
| 42 |
+
"User-Agent": "Mozilla/5.0 Hermes/1.0",
|
| 43 |
+
}
|
| 44 |
+
if json_body:
|
| 45 |
+
headers["Content-Type"] = "application/json"
|
| 46 |
+
if extra:
|
| 47 |
+
headers.update(dict(extra))
|
| 48 |
+
return headers
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _request_json(
|
| 52 |
+
method: str,
|
| 53 |
+
path: str,
|
| 54 |
+
*,
|
| 55 |
+
payload: Any | None = None,
|
| 56 |
+
headers: Mapping[str, str] | None = None,
|
| 57 |
+
timeout: float = 20,
|
| 58 |
+
) -> Any:
|
| 59 |
+
_require_supabase()
|
| 60 |
+
data = None
|
| 61 |
+
body_headers = _headers(json_body=True, extra=headers)
|
| 62 |
+
if payload is not None:
|
| 63 |
+
data = json.dumps(payload).encode("utf-8")
|
| 64 |
+
req = urllib_request.Request(
|
| 65 |
+
f"{SUPABASE_URL}{path}",
|
| 66 |
+
data=data,
|
| 67 |
+
headers=body_headers,
|
| 68 |
+
method=method,
|
| 69 |
+
)
|
| 70 |
+
try:
|
| 71 |
+
with urllib_request.urlopen(req, timeout=timeout) as response:
|
| 72 |
+
raw = response.read()
|
| 73 |
+
if not raw:
|
| 74 |
+
return None
|
| 75 |
+
return json.loads(raw.decode("utf-8"))
|
| 76 |
+
except urllib_error.HTTPError as exc:
|
| 77 |
+
detail = exc.read().decode("utf-8", "ignore")
|
| 78 |
+
raise DashboardError(f"Supabase request failed ({exc.code} {method} {path}): {detail}") from exc
|
| 79 |
+
except urllib_error.URLError as exc:
|
| 80 |
+
raise DashboardError(f"Supabase request failed ({method} {path}): {exc}") from exc
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _request_bytes(
|
| 84 |
+
method: str,
|
| 85 |
+
path: str,
|
| 86 |
+
*,
|
| 87 |
+
data: bytes,
|
| 88 |
+
content_type: str,
|
| 89 |
+
headers: Mapping[str, str] | None = None,
|
| 90 |
+
timeout: float = 30,
|
| 91 |
+
) -> None:
|
| 92 |
+
_require_supabase()
|
| 93 |
+
body_headers = _headers(
|
| 94 |
+
json_body=False,
|
| 95 |
+
extra={"Content-Type": content_type, **(dict(headers or {}))},
|
| 96 |
+
)
|
| 97 |
+
req = urllib_request.Request(
|
| 98 |
+
f"{SUPABASE_URL}{path}",
|
| 99 |
+
data=data,
|
| 100 |
+
headers=body_headers,
|
| 101 |
+
method=method,
|
| 102 |
+
)
|
| 103 |
+
try:
|
| 104 |
+
with urllib_request.urlopen(req, timeout=timeout):
|
| 105 |
+
return None
|
| 106 |
+
except urllib_error.HTTPError as exc:
|
| 107 |
+
detail = exc.read().decode("utf-8", "ignore")
|
| 108 |
+
raise DashboardError(f"Supabase upload failed ({exc.code} {method} {path}): {detail}") from exc
|
| 109 |
+
except urllib_error.URLError as exc:
|
| 110 |
+
raise DashboardError(f"Supabase upload failed ({method} {path}): {exc}") from exc
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _table_select(table: str, *, query: str) -> list[dict[str, Any]]:
|
| 114 |
+
result = _request_json("GET", f"/rest/v1/{table}?{query}")
|
| 115 |
+
if isinstance(result, list):
|
| 116 |
+
return result
|
| 117 |
+
return []
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _insert_row(table: str, row: Mapping[str, Any], *, upsert: bool = False) -> list[dict[str, Any]]:
|
| 121 |
+
prefer = "return=representation"
|
| 122 |
+
if upsert:
|
| 123 |
+
prefer += ",resolution=merge-duplicates"
|
| 124 |
+
result = _request_json(
|
| 125 |
+
"POST",
|
| 126 |
+
f"/rest/v1/{table}",
|
| 127 |
+
payload=row,
|
| 128 |
+
headers={"Prefer": prefer},
|
| 129 |
+
)
|
| 130 |
+
if isinstance(result, list):
|
| 131 |
+
return result
|
| 132 |
+
return []
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _patch_rows(table: str, *, filters: str, values: Mapping[str, Any]) -> list[dict[str, Any]]:
|
| 136 |
+
result = _request_json(
|
| 137 |
+
"PATCH",
|
| 138 |
+
f"/rest/v1/{table}?{filters}",
|
| 139 |
+
payload=values,
|
| 140 |
+
headers={"Prefer": "return=representation"},
|
| 141 |
+
)
|
| 142 |
+
if isinstance(result, list):
|
| 143 |
+
return result
|
| 144 |
+
return []
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _ensure_bucket() -> None:
|
| 148 |
+
global _BUCKET_READY
|
| 149 |
+
if _BUCKET_READY:
|
| 150 |
+
return
|
| 151 |
+
try:
|
| 152 |
+
_request_json(
|
| 153 |
+
"POST",
|
| 154 |
+
"/storage/v1/bucket",
|
| 155 |
+
payload={
|
| 156 |
+
"id": GENERATIONS_BUCKET,
|
| 157 |
+
"name": GENERATIONS_BUCKET,
|
| 158 |
+
"public": True,
|
| 159 |
+
},
|
| 160 |
+
)
|
| 161 |
+
except DashboardError as exc:
|
| 162 |
+
message = str(exc)
|
| 163 |
+
if "Duplicate" not in message and "already exists" not in message:
|
| 164 |
+
raise
|
| 165 |
+
_BUCKET_READY = True
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _png_bytes(image: Image.Image, *, max_size: int | None = None) -> bytes:
|
| 169 |
+
image = image.convert("RGB")
|
| 170 |
+
if max_size is not None:
|
| 171 |
+
image = image.copy()
|
| 172 |
+
image.thumbnail((max_size, max_size))
|
| 173 |
+
buf = io.BytesIO()
|
| 174 |
+
image.save(buf, format="PNG", optimize=True)
|
| 175 |
+
return buf.getvalue()
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _upload_public_png(*, image: Image.Image, object_path: str, max_size: int | None = None) -> str:
|
| 179 |
+
_ensure_bucket()
|
| 180 |
+
_request_bytes(
|
| 181 |
+
"POST",
|
| 182 |
+
f"/storage/v1/object/{GENERATIONS_BUCKET}/{urllib_parse.quote(object_path, safe='/')}",
|
| 183 |
+
data=_png_bytes(image, max_size=max_size),
|
| 184 |
+
content_type="image/png",
|
| 185 |
+
headers={"x-upsert": "true"},
|
| 186 |
+
)
|
| 187 |
+
return f"{SUPABASE_URL}/storage/v1/object/public/{GENERATIONS_BUCKET}/{object_path}"
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _session_oauth_info(request: Any | None) -> dict[str, Any]:
|
| 191 |
+
if request is None:
|
| 192 |
+
return {}
|
| 193 |
+
session = getattr(request, "session", None) or {}
|
| 194 |
+
oauth_info = session.get("oauth_info") if isinstance(session, Mapping) else None
|
| 195 |
+
if isinstance(oauth_info, Mapping):
|
| 196 |
+
return dict(oauth_info)
|
| 197 |
+
return {}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def get_current_user_context(request: Any | None) -> dict[str, Any] | None:
|
| 201 |
+
oauth_info = _session_oauth_info(request)
|
| 202 |
+
raw_userinfo = oauth_info.get("userinfo")
|
| 203 |
+
userinfo: Mapping[str, Any] = raw_userinfo if isinstance(raw_userinfo, Mapping) else {}
|
| 204 |
+
provider_subject = str(userinfo.get("sub") or "").strip()
|
| 205 |
+
username = str(userinfo.get("preferred_username") or getattr(request, "username", "") or "").strip()
|
| 206 |
+
display_name = str(userinfo.get("name") or username or "").strip()
|
| 207 |
+
avatar_url = str(userinfo.get("picture") or "").strip()
|
| 208 |
+
profile_url = str(userinfo.get("profile") or (f"https://huggingface.co/{username}" if username else "")).strip()
|
| 209 |
+
email = str(userinfo.get("email") or "").strip() or None
|
| 210 |
+
if not provider_subject or not username:
|
| 211 |
+
return None
|
| 212 |
+
return {
|
| 213 |
+
"provider": "huggingface",
|
| 214 |
+
"provider_subject": provider_subject,
|
| 215 |
+
"username": username,
|
| 216 |
+
"display_name": display_name or username,
|
| 217 |
+
"avatar_url": avatar_url or None,
|
| 218 |
+
"profile_url": profile_url or None,
|
| 219 |
+
"email": email,
|
| 220 |
+
"raw_profile": {"userinfo": userinfo, "oauth_info": {k: v for k, v in oauth_info.items() if k != "access_token"}},
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def ensure_app_user_for_request(request: Any | None) -> dict[str, Any] | None:
|
| 225 |
+
user = get_current_user_context(request)
|
| 226 |
+
if user is None:
|
| 227 |
+
return None
|
| 228 |
+
|
| 229 |
+
filters = (
|
| 230 |
+
f"provider=eq.{_quote_filter(user['provider'])}"
|
| 231 |
+
f"&provider_subject=eq.{_quote_filter(user['provider_subject'])}"
|
| 232 |
+
"&select=id,app_user_id"
|
| 233 |
+
"&limit=1"
|
| 234 |
+
)
|
| 235 |
+
existing = _table_select("user_identities", query=filters)
|
| 236 |
+
app_user_id = str(existing[0].get("app_user_id") or "").strip() if existing else ""
|
| 237 |
+
|
| 238 |
+
now_iso = datetime.now(timezone.utc).isoformat()
|
| 239 |
+
if not app_user_id:
|
| 240 |
+
inserted_users = _insert_row(
|
| 241 |
+
"app_users",
|
| 242 |
+
{
|
| 243 |
+
"display_name": user["display_name"],
|
| 244 |
+
"avatar_url": user["avatar_url"],
|
| 245 |
+
"created_at": now_iso,
|
| 246 |
+
"updated_at": now_iso,
|
| 247 |
+
"last_login_at": now_iso,
|
| 248 |
+
},
|
| 249 |
+
)
|
| 250 |
+
if not inserted_users:
|
| 251 |
+
raise DashboardError("Failed to create app user row.")
|
| 252 |
+
app_user_id = str(inserted_users[0]["id"])
|
| 253 |
+
_insert_row(
|
| 254 |
+
"user_identities",
|
| 255 |
+
{
|
| 256 |
+
"app_user_id": app_user_id,
|
| 257 |
+
"provider": user["provider"],
|
| 258 |
+
"provider_subject": user["provider_subject"],
|
| 259 |
+
"username": user["username"],
|
| 260 |
+
"email": user["email"],
|
| 261 |
+
"raw_profile": user["raw_profile"],
|
| 262 |
+
"created_at": now_iso,
|
| 263 |
+
"updated_at": now_iso,
|
| 264 |
+
},
|
| 265 |
+
upsert=True,
|
| 266 |
+
)
|
| 267 |
+
else:
|
| 268 |
+
_patch_rows(
|
| 269 |
+
"app_users",
|
| 270 |
+
filters=f"id=eq.{_quote_filter(app_user_id)}",
|
| 271 |
+
values={
|
| 272 |
+
"display_name": user["display_name"],
|
| 273 |
+
"avatar_url": user["avatar_url"],
|
| 274 |
+
"updated_at": now_iso,
|
| 275 |
+
"last_login_at": now_iso,
|
| 276 |
+
},
|
| 277 |
+
)
|
| 278 |
+
identity_filters = (
|
| 279 |
+
f"provider=eq.{_quote_filter(user['provider'])}"
|
| 280 |
+
f"&provider_subject=eq.{_quote_filter(user['provider_subject'])}"
|
| 281 |
+
)
|
| 282 |
+
_patch_rows(
|
| 283 |
+
"user_identities",
|
| 284 |
+
filters=identity_filters,
|
| 285 |
+
values={
|
| 286 |
+
"username": user["username"],
|
| 287 |
+
"email": user["email"],
|
| 288 |
+
"raw_profile": user["raw_profile"],
|
| 289 |
+
"updated_at": now_iso,
|
| 290 |
+
},
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
return {
|
| 294 |
+
**user,
|
| 295 |
+
"app_user_id": app_user_id,
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def persist_generation(
|
| 300 |
+
*,
|
| 301 |
+
request: Any | None,
|
| 302 |
+
generation_id: str,
|
| 303 |
+
qr_mode: str,
|
| 304 |
+
prompt: str,
|
| 305 |
+
text_input: str,
|
| 306 |
+
input_type: str,
|
| 307 |
+
status: str,
|
| 308 |
+
settings_dict: Mapping[str, Any],
|
| 309 |
+
url_normalization: Mapping[str, Any] | None,
|
| 310 |
+
shortener_result: Mapping[str, Any] | None,
|
| 311 |
+
final_image: Image.Image,
|
| 312 |
+
) -> str | None:
|
| 313 |
+
user = ensure_app_user_for_request(request)
|
| 314 |
+
if user is None:
|
| 315 |
+
return None
|
| 316 |
+
|
| 317 |
+
timestamp = datetime.now(timezone.utc)
|
| 318 |
+
object_prefix = f"users/{user['app_user_id']}/{timestamp.strftime('%Y/%m/%d')}/{generation_id}"
|
| 319 |
+
completed_image_url = _upload_public_png(
|
| 320 |
+
image=final_image,
|
| 321 |
+
object_path=f"{object_prefix}.png",
|
| 322 |
+
)
|
| 323 |
+
thumbnail_url = _upload_public_png(
|
| 324 |
+
image=final_image,
|
| 325 |
+
object_path=f"{object_prefix}_thumb.png",
|
| 326 |
+
max_size=320,
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
normalized_destination_url = None
|
| 330 |
+
if input_type == "URL" and url_normalization:
|
| 331 |
+
normalized_destination_url = str(url_normalization.get("normalized_url") or "").strip() or None
|
| 332 |
+
|
| 333 |
+
short_link_id = None
|
| 334 |
+
short_code = None
|
| 335 |
+
short_url = None
|
| 336 |
+
shortener_expires_at = None
|
| 337 |
+
shortener_applied = False
|
| 338 |
+
if shortener_result:
|
| 339 |
+
short_link_id = str(shortener_result.get("short_link_id") or "").strip() or None
|
| 340 |
+
short_code = str(shortener_result.get("code") or "").strip() or None
|
| 341 |
+
short_url = str(shortener_result.get("short_url") or "").strip() or None
|
| 342 |
+
shortener_expires_at = str(shortener_result.get("expires_at") or "").strip() or None
|
| 343 |
+
shortener_applied = bool(shortener_result.get("applied"))
|
| 344 |
+
|
| 345 |
+
credit_cost = 4 if str(qr_mode).lower() == "artistic" else 1
|
| 346 |
+
row = {
|
| 347 |
+
"id": generation_id,
|
| 348 |
+
"user_id": user["app_user_id"],
|
| 349 |
+
"app_user_id": user["app_user_id"],
|
| 350 |
+
"destination_url": str(text_input or ""),
|
| 351 |
+
"qr_mode": str(qr_mode or "standard"),
|
| 352 |
+
"preset_id": None,
|
| 353 |
+
"prompt": prompt,
|
| 354 |
+
"status": status,
|
| 355 |
+
"credit_cost": credit_cost,
|
| 356 |
+
"input_type": input_type,
|
| 357 |
+
"destination_url_normalized": normalized_destination_url,
|
| 358 |
+
"effective_qr_text": str(settings_dict.get("effective_qr_text") or text_input or ""),
|
| 359 |
+
"shortener_applied": shortener_applied,
|
| 360 |
+
"short_link_id": short_link_id,
|
| 361 |
+
"short_code": short_code,
|
| 362 |
+
"short_url": short_url,
|
| 363 |
+
"shortener_expires_at": shortener_expires_at,
|
| 364 |
+
"completed_image_url": completed_image_url,
|
| 365 |
+
"thumbnail_url": thumbnail_url,
|
| 366 |
+
"metadata": {
|
| 367 |
+
"settings": dict(settings_dict),
|
| 368 |
+
"auth": {
|
| 369 |
+
"provider": user["provider"],
|
| 370 |
+
"provider_subject": user["provider_subject"],
|
| 371 |
+
"username": user["username"],
|
| 372 |
+
"display_name": user["display_name"],
|
| 373 |
+
"profile_url": user["profile_url"],
|
| 374 |
+
},
|
| 375 |
+
},
|
| 376 |
+
"updated_at": timestamp.isoformat(),
|
| 377 |
+
}
|
| 378 |
+
_insert_row("generations", row)
|
| 379 |
+
return f"Saved to My QR Codes for @{user['username']}."
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def list_my_generations(request: Any | None) -> dict[str, Any]:
|
| 383 |
+
user = ensure_app_user_for_request(request)
|
| 384 |
+
if user is None:
|
| 385 |
+
return {
|
| 386 |
+
"authenticated": False,
|
| 387 |
+
"status": "Sign in with Hugging Face to see your saved QR codes and analytics.",
|
| 388 |
+
"gallery": [],
|
| 389 |
+
"records": [],
|
| 390 |
+
"user": None,
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
query = (
|
| 394 |
+
"select=id,created_at,updated_at,status,input_type,qr_mode,prompt,destination_url_original,destination_url_normalized,"
|
| 395 |
+
"effective_qr_text,shortener_applied,short_link_id,short_code,short_url,shortener_expires_at,completed_image_url,thumbnail_url,metadata"
|
| 396 |
+
f"&app_user_id=eq.{_quote_filter(user['app_user_id'])}"
|
| 397 |
+
"&order=created_at.desc"
|
| 398 |
+
)
|
| 399 |
+
rows = _table_select("dashboard_generation_summaries", query=query)
|
| 400 |
+
gallery: list[tuple[str, str]] = []
|
| 401 |
+
for row in rows:
|
| 402 |
+
image = str(row.get("thumbnail_url") or row.get("completed_image_url") or "").strip()
|
| 403 |
+
created = str(row.get("created_at") or "")[:16].replace("T", " ")
|
| 404 |
+
label = f"{str(row.get('qr_mode') or 'qr').title()} · {created}"
|
| 405 |
+
gallery.append((image, label))
|
| 406 |
+
|
| 407 |
+
display_name = user.get("display_name") or user.get("username")
|
| 408 |
+
return {
|
| 409 |
+
"authenticated": True,
|
| 410 |
+
"status": f"Signed in as {display_name} (@{user['username']}). Found {len(rows)} saved QR code(s).",
|
| 411 |
+
"gallery": gallery,
|
| 412 |
+
"records": rows,
|
| 413 |
+
"user": user,
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def _extract_short_link_id(record: Mapping[str, Any]) -> str | None:
|
| 418 |
+
short_link_id = str(record.get("short_link_id") or "").strip()
|
| 419 |
+
if short_link_id:
|
| 420 |
+
return short_link_id
|
| 421 |
+
|
| 422 |
+
short_code = str(record.get("short_code") or "").strip()
|
| 423 |
+
if not short_code:
|
| 424 |
+
short_url = str(record.get("short_url") or "").strip()
|
| 425 |
+
if short_url:
|
| 426 |
+
short_code = short_url.rstrip("/").rsplit("/", 1)[-1]
|
| 427 |
+
if not short_code:
|
| 428 |
+
return None
|
| 429 |
+
|
| 430 |
+
rows = _table_select(
|
| 431 |
+
"short_links",
|
| 432 |
+
query=f"select=id&code=eq.{_quote_filter(short_code)}&limit=1",
|
| 433 |
+
)
|
| 434 |
+
if not rows:
|
| 435 |
+
return None
|
| 436 |
+
return str(rows[0].get("id") or "").strip() or None
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def get_generation_detail(request: Any | None, records: list[dict[str, Any]], index: int) -> dict[str, Any]:
|
| 440 |
+
user = ensure_app_user_for_request(request)
|
| 441 |
+
if user is None:
|
| 442 |
+
raise DashboardError("Please sign in first.")
|
| 443 |
+
if index < 0 or index >= len(records):
|
| 444 |
+
raise DashboardError("Selected QR code is out of range.")
|
| 445 |
+
|
| 446 |
+
record = records[index]
|
| 447 |
+
if str(record.get("app_user_id") or "") != str(user.get("app_user_id") or ""):
|
| 448 |
+
raise DashboardError("That QR code does not belong to the current user.")
|
| 449 |
+
|
| 450 |
+
short_link_id = _extract_short_link_id(record)
|
| 451 |
+
scan_rows: list[dict[str, Any]] = []
|
| 452 |
+
if short_link_id:
|
| 453 |
+
scan_rows = _table_select(
|
| 454 |
+
"short_link_scan_events",
|
| 455 |
+
query=(
|
| 456 |
+
"select=scanned_at,country_code,visitor_hash,is_bot,is_prefetch"
|
| 457 |
+
f"&short_link_id=eq.{_quote_filter(short_link_id)}"
|
| 458 |
+
"&order=scanned_at.desc"
|
| 459 |
+
"&limit=1000"
|
| 460 |
+
),
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
total_scans = len(scan_rows)
|
| 464 |
+
last_scanned_at = str(scan_rows[0].get("scanned_at") or "").replace("T", " ")[:19] if scan_rows else "Never"
|
| 465 |
+
unique_scans = len({str(row.get('visitor_hash') or '') for row in scan_rows if str(row.get('visitor_hash') or '').strip()})
|
| 466 |
+
|
| 467 |
+
countries = Counter(str(row.get("country_code") or "Unknown") for row in scan_rows)
|
| 468 |
+
scans_by_day = Counter(str(row.get("scanned_at") or "")[:10] for row in scan_rows if str(row.get("scanned_at") or ""))
|
| 469 |
+
|
| 470 |
+
top_countries_rows = [[country, count] for country, count in countries.most_common(10)]
|
| 471 |
+
scans_by_day_rows = [[day, scans_by_day[day]] for day in sorted(scans_by_day.keys())]
|
| 472 |
+
|
| 473 |
+
metadata = record.get("metadata")
|
| 474 |
+
metadata_map: Mapping[str, Any] = metadata if isinstance(metadata, Mapping) else {}
|
| 475 |
+
raw_settings = metadata_map.get("settings")
|
| 476 |
+
settings: Mapping[str, Any] = raw_settings if isinstance(raw_settings, Mapping) else {}
|
| 477 |
+
settings_json = json.dumps(settings, indent=2, ensure_ascii=False) if settings else "{}"
|
| 478 |
+
|
| 479 |
+
detail_markdown = "\n".join(
|
| 480 |
+
[
|
| 481 |
+
f"### {str(record.get('qr_mode') or 'QR').title()} QR",
|
| 482 |
+
f"- Created: {str(record.get('created_at') or '').replace('T', ' ')[:19]}",
|
| 483 |
+
f"- Status: {record.get('status') or 'unknown'}",
|
| 484 |
+
f"- Input type: {record.get('input_type') or 'unknown'}",
|
| 485 |
+
f"- Destination: {record.get('destination_url_original') or '—'}",
|
| 486 |
+
f"- Short URL: {record.get('short_url') or '—'}",
|
| 487 |
+
f"- Effective QR text: {record.get('effective_qr_text') or '—'}",
|
| 488 |
+
f"- Total scans: {total_scans}",
|
| 489 |
+
f"- Approx. unique scans: {unique_scans}",
|
| 490 |
+
f"- Last scanned at: {last_scanned_at}",
|
| 491 |
+
]
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
return {
|
| 495 |
+
"record": record,
|
| 496 |
+
"detail_markdown": detail_markdown,
|
| 497 |
+
"image": record.get("completed_image_url") or record.get("thumbnail_url"),
|
| 498 |
+
"top_countries_rows": top_countries_rows,
|
| 499 |
+
"scans_by_day_rows": scans_by_day_rows,
|
| 500 |
+
"settings_json": settings_json,
|
| 501 |
+
"is_standard": str(record.get("qr_mode") or "").lower() == "standard",
|
| 502 |
+
"is_artistic": str(record.get("qr_mode") or "").lower() == "artistic",
|
| 503 |
+
}
|
hf_space_dashboard_bootstrap.sql
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
create extension if not exists pgcrypto;
|
| 2 |
+
|
| 3 |
+
create table if not exists public.generations (
|
| 4 |
+
id uuid primary key default gen_random_uuid(),
|
| 5 |
+
user_id text not null,
|
| 6 |
+
destination_url text not null,
|
| 7 |
+
qr_mode text not null check (qr_mode in ('standard', 'artistic')),
|
| 8 |
+
preset_id text,
|
| 9 |
+
prompt text not null,
|
| 10 |
+
status text not null default 'mocked' check (status in ('queued', 'processing', 'completed', 'failed', 'mocked')),
|
| 11 |
+
credit_cost integer not null default 1 check (credit_cost > 0),
|
| 12 |
+
created_at timestamptz not null default now()
|
| 13 |
+
);
|
| 14 |
+
|
| 15 |
+
create table if not exists public.generation_variants (
|
| 16 |
+
id uuid primary key default gen_random_uuid(),
|
| 17 |
+
generation_id uuid not null references public.generations(id) on delete cascade,
|
| 18 |
+
variant_index integer not null,
|
| 19 |
+
image_url text,
|
| 20 |
+
thumbnail_url text,
|
| 21 |
+
metadata jsonb not null default '{}'::jsonb,
|
| 22 |
+
created_at timestamptz not null default now(),
|
| 23 |
+
unique (generation_id, variant_index)
|
| 24 |
+
);
|
| 25 |
+
|
| 26 |
+
create index if not exists generations_user_id_created_at_idx
|
| 27 |
+
on public.generations(user_id, created_at desc);
|
| 28 |
+
|
| 29 |
+
create index if not exists generation_variants_generation_id_idx
|
| 30 |
+
on public.generation_variants(generation_id);
|
tests-unit/hf_dashboard_test.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
import hf_dashboard
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class FakeRequest:
|
| 9 |
+
def __init__(self, username: str = "alice", oauth_info: dict | None = None):
|
| 10 |
+
self.username = username
|
| 11 |
+
self.session = {"oauth_info": oauth_info or {}}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
HF_OAUTH_INFO = {
|
| 15 |
+
"userinfo": {
|
| 16 |
+
"sub": "hf-user-123",
|
| 17 |
+
"preferred_username": "alice",
|
| 18 |
+
"name": "Alice Example",
|
| 19 |
+
"profile": "https://huggingface.co/alice",
|
| 20 |
+
"picture": "https://huggingface.co/alice.png",
|
| 21 |
+
"email": "alice@example.com",
|
| 22 |
+
},
|
| 23 |
+
"access_token": "secret-token",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_get_current_user_context_reads_hf_oauth_session() -> None:
|
| 28 |
+
request = FakeRequest(oauth_info=HF_OAUTH_INFO)
|
| 29 |
+
|
| 30 |
+
user = hf_dashboard.get_current_user_context(request)
|
| 31 |
+
|
| 32 |
+
assert user is not None
|
| 33 |
+
assert user["provider"] == "huggingface"
|
| 34 |
+
assert user["provider_subject"] == "hf-user-123"
|
| 35 |
+
assert user["username"] == "alice"
|
| 36 |
+
assert user["display_name"] == "Alice Example"
|
| 37 |
+
assert user["profile_url"] == "https://huggingface.co/alice"
|
| 38 |
+
assert "access_token" not in user["raw_profile"]["oauth_info"]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_list_my_generations_requires_sign_in(monkeypatch) -> None:
|
| 42 |
+
monkeypatch.setattr(hf_dashboard, "ensure_app_user_for_request", lambda request: None)
|
| 43 |
+
|
| 44 |
+
payload = hf_dashboard.list_my_generations(request=None)
|
| 45 |
+
|
| 46 |
+
assert payload["authenticated"] is False
|
| 47 |
+
assert "Sign in" in payload["status"]
|
| 48 |
+
assert payload["records"] == []
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_list_my_generations_returns_gallery_and_records(monkeypatch) -> None:
|
| 52 |
+
monkeypatch.setattr(
|
| 53 |
+
hf_dashboard,
|
| 54 |
+
"ensure_app_user_for_request",
|
| 55 |
+
lambda request: {"app_user_id": "user-1", "username": "alice", "display_name": "Alice"},
|
| 56 |
+
)
|
| 57 |
+
monkeypatch.setattr(
|
| 58 |
+
hf_dashboard,
|
| 59 |
+
"_table_select",
|
| 60 |
+
lambda table, query: [
|
| 61 |
+
{
|
| 62 |
+
"id": "gen-1",
|
| 63 |
+
"app_user_id": "user-1",
|
| 64 |
+
"created_at": "2026-05-31T10:00:00+00:00",
|
| 65 |
+
"qr_mode": "standard",
|
| 66 |
+
"thumbnail_url": "https://cdn.example/thumb.png",
|
| 67 |
+
"completed_image_url": "https://cdn.example/full.png",
|
| 68 |
+
"metadata": {"settings": {"pipeline": "standard", "prompt": "hello"}},
|
| 69 |
+
}
|
| 70 |
+
],
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
payload = hf_dashboard.list_my_generations(FakeRequest(oauth_info=HF_OAUTH_INFO))
|
| 74 |
+
|
| 75 |
+
assert payload["authenticated"] is True
|
| 76 |
+
assert len(payload["records"]) == 1
|
| 77 |
+
assert payload["gallery"] == [
|
| 78 |
+
("https://cdn.example/thumb.png", "Standard · 2026-05-31 10:00")
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_get_generation_detail_aggregates_scan_analytics(monkeypatch) -> None:
|
| 83 |
+
monkeypatch.setattr(
|
| 84 |
+
hf_dashboard,
|
| 85 |
+
"ensure_app_user_for_request",
|
| 86 |
+
lambda request: {"app_user_id": "user-1", "username": "alice", "display_name": "Alice"},
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
def fake_table_select(table: str, query: str):
|
| 90 |
+
if table == "short_link_scan_events":
|
| 91 |
+
return [
|
| 92 |
+
{
|
| 93 |
+
"scanned_at": "2026-05-31T12:00:00+00:00",
|
| 94 |
+
"country_code": "US",
|
| 95 |
+
"visitor_hash": "v1",
|
| 96 |
+
"is_bot": False,
|
| 97 |
+
"is_prefetch": False,
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"scanned_at": "2026-05-31T13:00:00+00:00",
|
| 101 |
+
"country_code": "NL",
|
| 102 |
+
"visitor_hash": "v2",
|
| 103 |
+
"is_bot": False,
|
| 104 |
+
"is_prefetch": False,
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"scanned_at": "2026-05-31T14:00:00+00:00",
|
| 108 |
+
"country_code": "US",
|
| 109 |
+
"visitor_hash": "v1",
|
| 110 |
+
"is_bot": False,
|
| 111 |
+
"is_prefetch": False,
|
| 112 |
+
},
|
| 113 |
+
]
|
| 114 |
+
raise AssertionError(f"Unexpected table: {table}")
|
| 115 |
+
|
| 116 |
+
monkeypatch.setattr(hf_dashboard, "_table_select", fake_table_select)
|
| 117 |
+
records = [
|
| 118 |
+
{
|
| 119 |
+
"id": "gen-1",
|
| 120 |
+
"app_user_id": "user-1",
|
| 121 |
+
"created_at": "2026-05-31T10:00:00+00:00",
|
| 122 |
+
"status": "completed",
|
| 123 |
+
"input_type": "URL",
|
| 124 |
+
"qr_mode": "standard",
|
| 125 |
+
"destination_url_original": "https://example.com",
|
| 126 |
+
"short_link_id": "short-link-1",
|
| 127 |
+
"short_url": "https://qrcut.co/abc123",
|
| 128 |
+
"effective_qr_text": "qrcut.co/abc123",
|
| 129 |
+
"completed_image_url": "https://cdn.example/full.png",
|
| 130 |
+
"metadata": {"settings": {"pipeline": "standard", "prompt": "hello"}},
|
| 131 |
+
}
|
| 132 |
+
]
|
| 133 |
+
|
| 134 |
+
detail = hf_dashboard.get_generation_detail(FakeRequest(oauth_info=HF_OAUTH_INFO), records, 0)
|
| 135 |
+
|
| 136 |
+
assert "Total scans: 3" in detail["detail_markdown"]
|
| 137 |
+
assert "Approx. unique scans: 2" in detail["detail_markdown"]
|
| 138 |
+
assert detail["image"] == "https://cdn.example/full.png"
|
| 139 |
+
assert detail["top_countries_rows"] == [["US", 2], ["NL", 1]]
|
| 140 |
+
assert detail["scans_by_day_rows"] == [["2026-05-31", 3]]
|
| 141 |
+
assert json.loads(detail["settings_json"])["pipeline"] == "standard"
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_ensure_app_user_for_request_creates_identity_when_missing(monkeypatch) -> None:
|
| 145 |
+
request = FakeRequest(oauth_info=HF_OAUTH_INFO)
|
| 146 |
+
calls: list[tuple[str, dict]] = []
|
| 147 |
+
|
| 148 |
+
monkeypatch.setattr(hf_dashboard, "_table_select", lambda table, query: [])
|
| 149 |
+
|
| 150 |
+
def fake_insert(table: str, row: dict, upsert: bool = False):
|
| 151 |
+
calls.append((table, row))
|
| 152 |
+
if table == "app_users":
|
| 153 |
+
return [{"id": "app-user-1"}]
|
| 154 |
+
if table == "user_identities":
|
| 155 |
+
return [{"id": "identity-1", "app_user_id": "app-user-1"}]
|
| 156 |
+
raise AssertionError(table)
|
| 157 |
+
|
| 158 |
+
monkeypatch.setattr(hf_dashboard, "_insert_row", fake_insert)
|
| 159 |
+
monkeypatch.setattr(hf_dashboard, "_patch_rows", lambda *args, **kwargs: [])
|
| 160 |
+
|
| 161 |
+
user = hf_dashboard.ensure_app_user_for_request(request)
|
| 162 |
+
|
| 163 |
+
assert user is not None
|
| 164 |
+
assert user["app_user_id"] == "app-user-1"
|
| 165 |
+
assert [table for table, _row in calls] == ["app_users", "user_identities"]
|