Oysiyl commited on
Commit
3574fa8
·
1 Parent(s): 8fc048d

Add HF auth QR dashboard and saved generation backend

Browse files
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(final_status, url_normalization_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(final_status, url_normalization_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"]