feedback-hub 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- feedback_hub/__init__.py +117 -0
- feedback_hub/_github.py +148 -0
- feedback_hub/_schema.py +160 -0
- feedback_hub/_storage.py +137 -0
- feedback_hub/compat_glow.py +91 -0
- feedback_hub/flask_blueprint.py +267 -0
- feedback_hub/wx_dialog.py +246 -0
- feedback_hub-1.0.0.dist-info/METADATA +52 -0
- feedback_hub-1.0.0.dist-info/RECORD +11 -0
- feedback_hub-1.0.0.dist-info/WHEEL +4 -0
- feedback_hub-1.0.0.dist-info/licenses/LICENSE +21 -0
feedback_hub/__init__.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""feedback-hub - Multi-framework GitHub issue submission library.
|
|
2
|
+
|
|
3
|
+
Each app uses its native UI; all submit directly to GitHub Issues.
|
|
4
|
+
|
|
5
|
+
Quick start (wxPython)::
|
|
6
|
+
|
|
7
|
+
from feedback_hub import load_schema
|
|
8
|
+
from feedback_hub.wx_dialog import FeedbackDialog
|
|
9
|
+
|
|
10
|
+
schema = load_schema({"app": "MyApp", "github_repo": "org/repo", "fields": [...]})
|
|
11
|
+
dlg = FeedbackDialog(parent, schema=schema, github_token=TOKEN)
|
|
12
|
+
dlg.ShowModal()
|
|
13
|
+
dlg.Destroy()
|
|
14
|
+
|
|
15
|
+
Quick start (Flask)::
|
|
16
|
+
|
|
17
|
+
from feedback_hub.flask_blueprint import make_blueprint
|
|
18
|
+
|
|
19
|
+
feedback_bp = make_blueprint(app_name="MyApp", github_repo="org/repo")
|
|
20
|
+
app.register_blueprint(feedback_bp, url_prefix="/feedback")
|
|
21
|
+
|
|
22
|
+
Quick start (headless / CLI)::
|
|
23
|
+
|
|
24
|
+
from feedback_hub import submit
|
|
25
|
+
|
|
26
|
+
issue_url, error = submit(
|
|
27
|
+
app="MyApp",
|
|
28
|
+
github_repo="org/repo",
|
|
29
|
+
github_token=TOKEN,
|
|
30
|
+
summary="Something is broken",
|
|
31
|
+
message="Details here",
|
|
32
|
+
category="Bug Report",
|
|
33
|
+
)
|
|
34
|
+
"""
|
|
35
|
+
from feedback_hub._github import GitHubConfig, resolve_token
|
|
36
|
+
from feedback_hub._schema import AppSchema, FieldSchema, build_entry, load_schema
|
|
37
|
+
from feedback_hub._storage import list_all, save
|
|
38
|
+
|
|
39
|
+
__version__ = "1.0.0"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"AppSchema",
|
|
43
|
+
"FieldSchema",
|
|
44
|
+
"GitHubConfig",
|
|
45
|
+
"build_entry",
|
|
46
|
+
"list_all",
|
|
47
|
+
"load_schema",
|
|
48
|
+
"resolve_token",
|
|
49
|
+
"save",
|
|
50
|
+
"submit",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def submit(
|
|
55
|
+
*,
|
|
56
|
+
app: str,
|
|
57
|
+
github_repo: str,
|
|
58
|
+
github_token: str = "",
|
|
59
|
+
summary: str = "",
|
|
60
|
+
message: str,
|
|
61
|
+
category: str = "feedback",
|
|
62
|
+
name: str = "",
|
|
63
|
+
email: str = "",
|
|
64
|
+
app_version: str = "",
|
|
65
|
+
github_labels: list[str] | None = None,
|
|
66
|
+
github_assignee: str = "",
|
|
67
|
+
metadata: dict | None = None,
|
|
68
|
+
db_path=None,
|
|
69
|
+
) -> tuple:
|
|
70
|
+
"""Headless submission -- no UI required.
|
|
71
|
+
|
|
72
|
+
Returns ``(issue_url, error_message)``.
|
|
73
|
+
"""
|
|
74
|
+
from datetime import UTC, datetime
|
|
75
|
+
from pathlib import Path
|
|
76
|
+
|
|
77
|
+
from feedback_hub._github import create_issue
|
|
78
|
+
from feedback_hub._storage import save as _save, update_github_sync
|
|
79
|
+
|
|
80
|
+
token = resolve_token(github_token)
|
|
81
|
+
entry = {
|
|
82
|
+
"app": app,
|
|
83
|
+
"version": app_version,
|
|
84
|
+
"platform": "",
|
|
85
|
+
"category": category,
|
|
86
|
+
"name": name,
|
|
87
|
+
"email": email,
|
|
88
|
+
"summary": summary,
|
|
89
|
+
"message": message,
|
|
90
|
+
"metadata": metadata,
|
|
91
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_db = db_path or Path.home() / ".local" / "share" / "feedback-hub" / "feedback.db"
|
|
95
|
+
try:
|
|
96
|
+
row_id = _save(entry, Path(_db))
|
|
97
|
+
except Exception:
|
|
98
|
+
row_id = None
|
|
99
|
+
|
|
100
|
+
if not token:
|
|
101
|
+
return None, "GitHub token not configured"
|
|
102
|
+
|
|
103
|
+
cfg = GitHubConfig(
|
|
104
|
+
token=token,
|
|
105
|
+
repo=github_repo,
|
|
106
|
+
assignee=github_assignee,
|
|
107
|
+
labels=github_labels or ["needs-triage"],
|
|
108
|
+
)
|
|
109
|
+
number, url, error = create_issue(entry, cfg)
|
|
110
|
+
|
|
111
|
+
if row_id is not None:
|
|
112
|
+
try:
|
|
113
|
+
update_github_sync(row_id, issue_number=number, issue_url=url, error=error, db_path=Path(_db))
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
return url, error
|
feedback_hub/_github.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""GitHub Issues API client.
|
|
2
|
+
|
|
3
|
+
Framework-agnostic. Accepts a config and a prepared entry dict,
|
|
4
|
+
posts it as a GitHub issue, returns (issue_number, issue_url, error).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Optional
|
|
12
|
+
from urllib import error as urlerror
|
|
13
|
+
from urllib import request as urlrequest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class GitHubConfig:
|
|
18
|
+
token: str
|
|
19
|
+
repo: str
|
|
20
|
+
assignee: str = ""
|
|
21
|
+
labels: list[str] = field(default_factory=lambda: ["needs-triage"])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_token(*candidates: str) -> str:
|
|
25
|
+
"""Resolve GitHub token from candidates in priority order.
|
|
26
|
+
|
|
27
|
+
Candidates are tried first. Then standard env vars are checked.
|
|
28
|
+
This lets each app pass its own bundled fine-grained PAT as the
|
|
29
|
+
last candidate while still allowing env var overrides.
|
|
30
|
+
|
|
31
|
+
Safe to bundle issues-only fine-grained PATs in desktop apps:
|
|
32
|
+
worst case misuse is filing extra issues, not code/repo access.
|
|
33
|
+
"""
|
|
34
|
+
for candidate in candidates:
|
|
35
|
+
if candidate and candidate.strip():
|
|
36
|
+
return candidate.strip()
|
|
37
|
+
for env_var in (
|
|
38
|
+
"FEEDBACK_HUB_GITHUB_TOKEN",
|
|
39
|
+
"SUPPORT_HUB_GITHUB_TOKEN",
|
|
40
|
+
"FEEDBACK_GITHUB_TOKEN",
|
|
41
|
+
):
|
|
42
|
+
value = os.environ.get(env_var, "").strip()
|
|
43
|
+
if value:
|
|
44
|
+
return value
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_issue(
|
|
49
|
+
entry: dict[str, object],
|
|
50
|
+
config: GitHubConfig,
|
|
51
|
+
) -> tuple[Optional[int], Optional[str], Optional[str]]:
|
|
52
|
+
"""Post entry as a GitHub issue.
|
|
53
|
+
|
|
54
|
+
Returns (issue_number, issue_url, error_message).
|
|
55
|
+
On success error_message is None; on failure number and url are None.
|
|
56
|
+
"""
|
|
57
|
+
if not config.token:
|
|
58
|
+
return None, None, "GitHub token not configured"
|
|
59
|
+
|
|
60
|
+
payload = _build_payload(entry, config)
|
|
61
|
+
req = urlrequest.Request(
|
|
62
|
+
f"https://api.github.com/repos/{config.repo}/issues",
|
|
63
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
64
|
+
method="POST",
|
|
65
|
+
headers={
|
|
66
|
+
"Accept": "application/vnd.github+json",
|
|
67
|
+
"Authorization": f"Bearer {config.token}",
|
|
68
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
"User-Agent": "feedback-hub/1.0",
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
with urlrequest.urlopen(req, timeout=20) as resp:
|
|
75
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
76
|
+
return data.get("number"), data.get("html_url"), None
|
|
77
|
+
except urlerror.HTTPError as exc:
|
|
78
|
+
try:
|
|
79
|
+
details = exc.read().decode("utf-8")
|
|
80
|
+
except Exception:
|
|
81
|
+
details = str(exc)
|
|
82
|
+
return None, None, f"GitHub API error {exc.code}: {details}"
|
|
83
|
+
except Exception as exc: # noqa: BLE001
|
|
84
|
+
return None, None, f"GitHub request failed: {exc}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_payload(entry: dict[str, object], cfg: GitHubConfig) -> dict[str, object]:
|
|
88
|
+
app = str(entry.get("app", "Unknown")).strip()
|
|
89
|
+
category = str(entry.get("category", "feedback")).strip().lower()
|
|
90
|
+
summary = str(entry.get("summary") or entry.get("message", ""))[:120].strip()
|
|
91
|
+
title = f"[{app}] {category}: {summary}"
|
|
92
|
+
|
|
93
|
+
lines = [
|
|
94
|
+
"## Feedback Report",
|
|
95
|
+
"",
|
|
96
|
+
f"- **App**: `{app}`",
|
|
97
|
+
f"- **Version**: `{entry.get('version', 'unknown')}`",
|
|
98
|
+
f"- **Platform**: `{entry.get('platform', 'unknown')}`",
|
|
99
|
+
f"- **Category**: `{category}`",
|
|
100
|
+
f"- **Submitted**: `{entry.get('timestamp', 'unknown')}`",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
submitter_name = str(entry.get("name", "")).strip()
|
|
104
|
+
submitter_email = str(entry.get("email", "")).strip()
|
|
105
|
+
if submitter_name or submitter_email:
|
|
106
|
+
lines.append("")
|
|
107
|
+
lines.append("### Submitter")
|
|
108
|
+
if submitter_name:
|
|
109
|
+
lines.append(f"- **Name**: {submitter_name}")
|
|
110
|
+
if submitter_email:
|
|
111
|
+
lines.append(f"- **Email**: {submitter_email}")
|
|
112
|
+
|
|
113
|
+
lines += ["", "### Details", "", str(entry.get("message", "")).strip()]
|
|
114
|
+
|
|
115
|
+
extra_fields = entry.get("extra_fields")
|
|
116
|
+
if isinstance(extra_fields, dict):
|
|
117
|
+
rendered = []
|
|
118
|
+
for label, value in extra_fields.items():
|
|
119
|
+
if value and str(value).strip():
|
|
120
|
+
rendered.append(f"**{label}**: {value}")
|
|
121
|
+
if rendered:
|
|
122
|
+
lines += ["", "### Additional Information", ""]
|
|
123
|
+
lines += rendered
|
|
124
|
+
|
|
125
|
+
metadata = entry.get("metadata")
|
|
126
|
+
if isinstance(metadata, dict) and metadata:
|
|
127
|
+
lines += [
|
|
128
|
+
"",
|
|
129
|
+
"### Environment",
|
|
130
|
+
"```",
|
|
131
|
+
json.dumps(metadata, indent=2, sort_keys=True),
|
|
132
|
+
"```",
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
lines += [
|
|
136
|
+
"",
|
|
137
|
+
"---",
|
|
138
|
+
"_Submitted via feedback-hub_",
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
payload: dict[str, object] = {
|
|
142
|
+
"title": title,
|
|
143
|
+
"body": "\n".join(lines),
|
|
144
|
+
"labels": cfg.labels,
|
|
145
|
+
}
|
|
146
|
+
if cfg.assignee:
|
|
147
|
+
payload["assignees"] = [cfg.assignee]
|
|
148
|
+
return payload
|
feedback_hub/_schema.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Dynamic form schema system.
|
|
2
|
+
|
|
3
|
+
Each app defines a schema (dict or JSON file) describing which fields
|
|
4
|
+
to show and how to validate them. The schema drives:
|
|
5
|
+
- wxPython dialog field generation
|
|
6
|
+
- Flask form rendering
|
|
7
|
+
- Validation rules
|
|
8
|
+
- GitHub issue formatting
|
|
9
|
+
|
|
10
|
+
Minimal schema example::
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
"app": "ChapterForge",
|
|
14
|
+
"version": "2.0.0",
|
|
15
|
+
"github_repo": "BITS-ACB/chapterforge",
|
|
16
|
+
"github_labels": ["bug", "needs-triage"],
|
|
17
|
+
"categories": ["Bug Report", "Feature Request", "Accessibility Issue", "Other"],
|
|
18
|
+
"fields": [
|
|
19
|
+
{"name": "summary", "label": "Summary", "type": "text", "required": true, "max_length": 120},
|
|
20
|
+
{"name": "message", "label": "What happened", "type": "textarea", "required": true, "max_length": 5000},
|
|
21
|
+
{"name": "expected", "label": "What you expected","type": "textarea", "required": false},
|
|
22
|
+
{"name": "steps", "label": "Steps to reproduce","type": "textarea","required": false},
|
|
23
|
+
{"name": "version", "label": "App version", "type": "text", "required": false, "default": "__app_version__"},
|
|
24
|
+
{"name": "platform", "label": "Operating system", "type": "text", "required": false, "default": "__platform__"}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import platform
|
|
32
|
+
import sys
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Optional
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class FieldSchema:
|
|
40
|
+
name: str
|
|
41
|
+
label: str
|
|
42
|
+
type: str = "text" # text | textarea | select | checkbox
|
|
43
|
+
required: bool = False
|
|
44
|
+
max_length: int = 0 # 0 = no limit
|
|
45
|
+
options: list[str] = field(default_factory=list) # for select
|
|
46
|
+
default: str = ""
|
|
47
|
+
placeholder: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class AppSchema:
|
|
52
|
+
app: str
|
|
53
|
+
github_repo: str
|
|
54
|
+
github_labels: list[str] = field(default_factory=lambda: ["needs-triage"])
|
|
55
|
+
github_assignee: str = ""
|
|
56
|
+
version: str = ""
|
|
57
|
+
categories: list[str] = field(default_factory=lambda: [
|
|
58
|
+
"Bug Report", "Feature Request", "Accessibility Issue", "Other"
|
|
59
|
+
])
|
|
60
|
+
fields: list[FieldSchema] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
def resolve_defaults(self, app_version: str = "") -> dict[str, str]:
|
|
63
|
+
"""Return a dict of field name -> resolved default value."""
|
|
64
|
+
os_str = f"{platform.system()} {platform.release()}"
|
|
65
|
+
py_str = sys.version.splitlines()[0]
|
|
66
|
+
magic = {
|
|
67
|
+
"__app_version__": app_version or self.version,
|
|
68
|
+
"__platform__": os_str,
|
|
69
|
+
"__python__": py_str,
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
f.name: magic.get(f.default, f.default)
|
|
73
|
+
for f in self.fields
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def validate(self, values: dict[str, str]) -> list[str]:
|
|
77
|
+
errors = []
|
|
78
|
+
for f in self.fields:
|
|
79
|
+
val = values.get(f.name, "").strip()
|
|
80
|
+
if f.required and not val:
|
|
81
|
+
errors.append(f"{f.label} is required.")
|
|
82
|
+
if f.max_length and len(val) > f.max_length:
|
|
83
|
+
errors.append(f"{f.label} must be {f.max_length} characters or fewer.")
|
|
84
|
+
if f.type == "select" and f.options and val and val not in f.options:
|
|
85
|
+
errors.append(f"{f.label} has an invalid value.")
|
|
86
|
+
return errors
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_schema(source: str | dict | Path) -> AppSchema:
|
|
90
|
+
"""Load a schema from a dict, JSON string, or Path to a JSON file."""
|
|
91
|
+
if isinstance(source, Path):
|
|
92
|
+
raw = json.loads(source.read_text(encoding="utf-8"))
|
|
93
|
+
elif isinstance(source, str):
|
|
94
|
+
raw = json.loads(source)
|
|
95
|
+
else:
|
|
96
|
+
raw = source
|
|
97
|
+
|
|
98
|
+
fields = [
|
|
99
|
+
FieldSchema(
|
|
100
|
+
name=f["name"],
|
|
101
|
+
label=f.get("label", f["name"].title()),
|
|
102
|
+
type=f.get("type", "text"),
|
|
103
|
+
required=f.get("required", False),
|
|
104
|
+
max_length=f.get("max_length", 0),
|
|
105
|
+
options=f.get("options", []),
|
|
106
|
+
default=f.get("default", ""),
|
|
107
|
+
placeholder=f.get("placeholder", ""),
|
|
108
|
+
)
|
|
109
|
+
for f in raw.get("fields", [])
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
return AppSchema(
|
|
113
|
+
app=raw["app"],
|
|
114
|
+
github_repo=raw["github_repo"],
|
|
115
|
+
github_labels=raw.get("github_labels", ["needs-triage"]),
|
|
116
|
+
github_assignee=raw.get("github_assignee", ""),
|
|
117
|
+
version=raw.get("version", ""),
|
|
118
|
+
categories=raw.get("categories", [
|
|
119
|
+
"Bug Report", "Feature Request", "Accessibility Issue", "Other"
|
|
120
|
+
]),
|
|
121
|
+
fields=fields,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_entry(
|
|
126
|
+
schema: AppSchema,
|
|
127
|
+
values: dict[str, str],
|
|
128
|
+
*,
|
|
129
|
+
name: str = "",
|
|
130
|
+
email: str = "",
|
|
131
|
+
category: str = "",
|
|
132
|
+
app_version: str = "",
|
|
133
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
134
|
+
timestamp: str = "",
|
|
135
|
+
) -> dict[str, object]:
|
|
136
|
+
"""Build a normalized entry dict from submitted form values."""
|
|
137
|
+
from datetime import UTC, datetime
|
|
138
|
+
|
|
139
|
+
# The fields listed in the schema go into extra_fields so the
|
|
140
|
+
# GitHub formatter can render them with their human labels.
|
|
141
|
+
extra_fields = {
|
|
142
|
+
schema_field.label: values.get(schema_field.name, "")
|
|
143
|
+
for schema_field in schema.fields
|
|
144
|
+
if schema_field.name not in ("summary", "message", "version", "platform")
|
|
145
|
+
and values.get(schema_field.name, "").strip()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
"app": schema.app,
|
|
150
|
+
"version": values.get("version", app_version or schema.version),
|
|
151
|
+
"platform": values.get("platform", ""),
|
|
152
|
+
"category": category or "feedback",
|
|
153
|
+
"name": name,
|
|
154
|
+
"email": email,
|
|
155
|
+
"summary": values.get("summary", ""),
|
|
156
|
+
"message": values.get("message", ""),
|
|
157
|
+
"extra_fields": extra_fields or None,
|
|
158
|
+
"metadata": metadata,
|
|
159
|
+
"timestamp": timestamp or datetime.now(UTC).isoformat(),
|
|
160
|
+
}
|
feedback_hub/_storage.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""SQLite-backed local storage for feedback entries.
|
|
2
|
+
|
|
3
|
+
Stores every submission locally before attempting GitHub sync,
|
|
4
|
+
so nothing is lost if the network call fails.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import sqlite3
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_SCHEMA = """
|
|
18
|
+
CREATE TABLE IF NOT EXISTS feedback (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
timestamp TEXT NOT NULL,
|
|
21
|
+
app TEXT,
|
|
22
|
+
version TEXT,
|
|
23
|
+
platform TEXT,
|
|
24
|
+
category TEXT,
|
|
25
|
+
name TEXT,
|
|
26
|
+
email TEXT,
|
|
27
|
+
summary TEXT,
|
|
28
|
+
message TEXT NOT NULL,
|
|
29
|
+
extra_fields_json TEXT,
|
|
30
|
+
metadata_json TEXT,
|
|
31
|
+
github_issue_number INTEGER,
|
|
32
|
+
github_issue_url TEXT,
|
|
33
|
+
github_sync_status TEXT,
|
|
34
|
+
github_sync_error TEXT,
|
|
35
|
+
github_synced_at TEXT
|
|
36
|
+
)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|
41
|
+
conn.execute(_SCHEMA)
|
|
42
|
+
existing = {row[1] for row in conn.execute("PRAGMA table_info(feedback)").fetchall()}
|
|
43
|
+
additions = {
|
|
44
|
+
"app": "TEXT",
|
|
45
|
+
"version": "TEXT",
|
|
46
|
+
"platform": "TEXT",
|
|
47
|
+
"summary": "TEXT",
|
|
48
|
+
"extra_fields_json": "TEXT",
|
|
49
|
+
"metadata_json": "TEXT",
|
|
50
|
+
"github_issue_number": "INTEGER",
|
|
51
|
+
"github_issue_url": "TEXT",
|
|
52
|
+
"github_sync_status": "TEXT",
|
|
53
|
+
"github_sync_error": "TEXT",
|
|
54
|
+
"github_synced_at": "TEXT",
|
|
55
|
+
}
|
|
56
|
+
for col, col_type in additions.items():
|
|
57
|
+
if col not in existing:
|
|
58
|
+
conn.execute(f"ALTER TABLE feedback ADD COLUMN {col} {col_type}")
|
|
59
|
+
conn.commit()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def open_db(path: Path) -> sqlite3.Connection:
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
conn = sqlite3.connect(str(path))
|
|
65
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
66
|
+
_ensure_schema(conn)
|
|
67
|
+
return conn
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def save(entry: dict[str, object], db_path: Path) -> int:
|
|
71
|
+
"""Persist entry and return its row id."""
|
|
72
|
+
extra = entry.get("extra_fields")
|
|
73
|
+
metadata = entry.get("metadata")
|
|
74
|
+
conn = open_db(db_path)
|
|
75
|
+
cur = conn.execute(
|
|
76
|
+
"""INSERT INTO feedback
|
|
77
|
+
(timestamp, app, version, platform, category, name, email,
|
|
78
|
+
summary, message, extra_fields_json, metadata_json)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
80
|
+
(
|
|
81
|
+
str(entry.get("timestamp", datetime.now(UTC).isoformat())),
|
|
82
|
+
str(entry.get("app", "")),
|
|
83
|
+
str(entry.get("version", "")),
|
|
84
|
+
str(entry.get("platform", "")),
|
|
85
|
+
str(entry.get("category", "feedback")),
|
|
86
|
+
str(entry.get("name", "")),
|
|
87
|
+
str(entry.get("email", "")),
|
|
88
|
+
str(entry.get("summary", "")),
|
|
89
|
+
str(entry.get("message", "")),
|
|
90
|
+
json.dumps(extra, sort_keys=True) if isinstance(extra, dict) else "",
|
|
91
|
+
json.dumps(metadata, sort_keys=True) if isinstance(metadata, dict) else "",
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
row_id = int(cur.lastrowid)
|
|
95
|
+
conn.commit()
|
|
96
|
+
conn.close()
|
|
97
|
+
return row_id
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def update_github_sync(
|
|
101
|
+
row_id: int,
|
|
102
|
+
*,
|
|
103
|
+
issue_number: Optional[int],
|
|
104
|
+
issue_url: Optional[str],
|
|
105
|
+
error: Optional[str],
|
|
106
|
+
db_path: Path,
|
|
107
|
+
) -> None:
|
|
108
|
+
status = "synced" if issue_url else "failed"
|
|
109
|
+
conn = open_db(db_path)
|
|
110
|
+
conn.execute(
|
|
111
|
+
"""UPDATE feedback
|
|
112
|
+
SET github_issue_number=?, github_issue_url=?,
|
|
113
|
+
github_sync_status=?, github_sync_error=?,
|
|
114
|
+
github_synced_at=?
|
|
115
|
+
WHERE id=?""",
|
|
116
|
+
(
|
|
117
|
+
issue_number,
|
|
118
|
+
issue_url,
|
|
119
|
+
status,
|
|
120
|
+
error,
|
|
121
|
+
datetime.now(UTC).isoformat(),
|
|
122
|
+
row_id,
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
conn.commit()
|
|
126
|
+
conn.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def list_all(db_path: Path) -> list[dict]:
|
|
130
|
+
if not db_path.exists():
|
|
131
|
+
return []
|
|
132
|
+
conn = open_db(db_path)
|
|
133
|
+
cur = conn.execute("SELECT * FROM feedback ORDER BY id DESC")
|
|
134
|
+
cols = [d[0] for d in cur.description]
|
|
135
|
+
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
|
|
136
|
+
conn.close()
|
|
137
|
+
return rows
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""GLOW backward-compatibility shim.
|
|
2
|
+
|
|
3
|
+
Replace the entire content of
|
|
4
|
+
``web/src/acb_large_print_web/routes/feedback.py`` with::
|
|
5
|
+
|
|
6
|
+
from feedback_hub.compat_glow import feedback_bp
|
|
7
|
+
|
|
8
|
+
That is the only change needed. All route names are preserved:
|
|
9
|
+
feedback.feedback_form
|
|
10
|
+
feedback.feedback_submit
|
|
11
|
+
feedback.feedback_submit_api
|
|
12
|
+
feedback.feedback_review
|
|
13
|
+
|
|
14
|
+
The GLOW app also imports support_hub.create_support_issue and
|
|
15
|
+
support_hub.load_support_hub_config in a few places. Those still work via::
|
|
16
|
+
|
|
17
|
+
from feedback_hub.compat_glow import create_support_issue, load_support_hub_config
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
from feedback_hub._github import GitHubConfig, create_issue, resolve_token
|
|
24
|
+
from feedback_hub.flask_blueprint import make_blueprint
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _glow_version() -> str:
|
|
28
|
+
try:
|
|
29
|
+
from acb_large_print_web.version import get_version
|
|
30
|
+
return get_version()
|
|
31
|
+
except Exception:
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
feedback_bp = make_blueprint(
|
|
36
|
+
app_name="GLOW",
|
|
37
|
+
github_repo=os.environ.get("FEEDBACK_GITHUB_REPO", "Community-Access/support"),
|
|
38
|
+
github_labels=[
|
|
39
|
+
l.strip()
|
|
40
|
+
for l in os.environ.get("FEEDBACK_GITHUB_LABELS", "needs-triage").split(",")
|
|
41
|
+
if l.strip()
|
|
42
|
+
],
|
|
43
|
+
github_assignee=os.environ.get("FEEDBACK_GITHUB_ASSIGNEE", ""),
|
|
44
|
+
app_version_fn=_glow_version,
|
|
45
|
+
blueprint_name="feedback",
|
|
46
|
+
extra_fields=[
|
|
47
|
+
{"name": "task", "label": "What were you trying to do", "type": "text", "max_length": 240},
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Legacy API preserved for any GLOW code that imports from support_hub directly
|
|
53
|
+
def load_support_hub_config():
|
|
54
|
+
"""Backward-compatible stub returning a simple namespace."""
|
|
55
|
+
class _Cfg:
|
|
56
|
+
token = resolve_token()
|
|
57
|
+
repo = os.environ.get("FEEDBACK_GITHUB_REPO", "Community-Access/support")
|
|
58
|
+
assignee = os.environ.get("FEEDBACK_GITHUB_ASSIGNEE", "")
|
|
59
|
+
labels = [
|
|
60
|
+
l.strip()
|
|
61
|
+
for l in os.environ.get("FEEDBACK_GITHUB_LABELS", "needs-triage").split(",")
|
|
62
|
+
if l.strip()
|
|
63
|
+
]
|
|
64
|
+
api_token = os.environ.get("FEEDBACK_API_TOKEN", "").strip()
|
|
65
|
+
return _Cfg()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def create_support_issue(entry: dict) -> tuple:
|
|
69
|
+
"""Backward-compatible wrapper over the new create_issue."""
|
|
70
|
+
cfg_raw = load_support_hub_config()
|
|
71
|
+
cfg = GitHubConfig(
|
|
72
|
+
token=cfg_raw.token,
|
|
73
|
+
repo=cfg_raw.repo,
|
|
74
|
+
assignee=cfg_raw.assignee,
|
|
75
|
+
labels=cfg_raw.labels,
|
|
76
|
+
)
|
|
77
|
+
# Map old GLOW entry keys to new format
|
|
78
|
+
mapped = {
|
|
79
|
+
"app": entry.get("source_app", "GLOW"),
|
|
80
|
+
"version": entry.get("source_version", ""),
|
|
81
|
+
"platform": entry.get("platform", ""),
|
|
82
|
+
"category": entry.get("category", "feedback"),
|
|
83
|
+
"name": entry.get("name", ""),
|
|
84
|
+
"email": entry.get("email", ""),
|
|
85
|
+
"summary": entry.get("summary", ""),
|
|
86
|
+
"message": entry.get("message", ""),
|
|
87
|
+
"timestamp": entry.get("timestamp", ""),
|
|
88
|
+
"extra_fields": {"Task": entry.get("task", "")} if entry.get("task") else None,
|
|
89
|
+
}
|
|
90
|
+
number, url, error = create_issue(mapped, cfg)
|
|
91
|
+
return number, url, error
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Flask Blueprint adapter.
|
|
2
|
+
|
|
3
|
+
Drop-in replacement for GLOW's feedback.py routes. Registers a blueprint
|
|
4
|
+
that serves:
|
|
5
|
+
GET / -- feedback form (HTML)
|
|
6
|
+
POST / -- form submission
|
|
7
|
+
POST /api -- JSON submission (Bearer token auth)
|
|
8
|
+
GET /review -- protected review dashboard
|
|
9
|
+
|
|
10
|
+
GLOW migration (non-breaking)::
|
|
11
|
+
|
|
12
|
+
# web/src/acb_large_print_web/routes/feedback.py
|
|
13
|
+
# Replace the entire file with:
|
|
14
|
+
from feedback_hub.flask_blueprint import make_blueprint
|
|
15
|
+
feedback_bp = make_blueprint(
|
|
16
|
+
app_name="GLOW",
|
|
17
|
+
github_repo="Community-Access/support",
|
|
18
|
+
app_version_fn=lambda: get_version(),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
All existing route names (feedback.feedback_form, feedback.feedback_submit,
|
|
22
|
+
feedback.feedback_submit_api, feedback.feedback_review) are preserved.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hmac
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import platform
|
|
31
|
+
import sys
|
|
32
|
+
from datetime import UTC, datetime
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Callable, Optional
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def make_blueprint(
|
|
40
|
+
*,
|
|
41
|
+
app_name: str,
|
|
42
|
+
github_repo: str,
|
|
43
|
+
github_labels: Optional[list[str]] = None,
|
|
44
|
+
github_assignee: str = "",
|
|
45
|
+
github_token: str = "",
|
|
46
|
+
app_version_fn: Optional[Callable[[], str]] = None,
|
|
47
|
+
db_path: Optional[Path] = None,
|
|
48
|
+
url_prefix: str = "/feedback",
|
|
49
|
+
blueprint_name: str = "feedback",
|
|
50
|
+
categories: Optional[list[str]] = None,
|
|
51
|
+
extra_fields: Optional[list[dict]] = None,
|
|
52
|
+
):
|
|
53
|
+
"""Create and return a configured Flask Blueprint.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
app_name:
|
|
58
|
+
Human name shown on the form and in GitHub issue titles.
|
|
59
|
+
github_repo:
|
|
60
|
+
``owner/repo`` slug (e.g. ``BITS-ACB/chapterforge``).
|
|
61
|
+
github_token:
|
|
62
|
+
Optional token override. Falls back to env vars automatically.
|
|
63
|
+
app_version_fn:
|
|
64
|
+
Callable returning the current app version string.
|
|
65
|
+
db_path:
|
|
66
|
+
SQLite database path. Defaults to Flask's ``instance/feedback.db``.
|
|
67
|
+
categories:
|
|
68
|
+
Issue category choices shown in the form drop-down.
|
|
69
|
+
extra_fields:
|
|
70
|
+
List of ``{"name": ..., "label": ..., "type": ..., "required": ...}``
|
|
71
|
+
dicts for app-specific fields beyond the defaults.
|
|
72
|
+
"""
|
|
73
|
+
from flask import (
|
|
74
|
+
Blueprint,
|
|
75
|
+
abort,
|
|
76
|
+
current_app,
|
|
77
|
+
jsonify,
|
|
78
|
+
render_template,
|
|
79
|
+
request,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
from feedback_hub._github import GitHubConfig, create_issue, resolve_token
|
|
83
|
+
from feedback_hub._schema import AppSchema, FieldSchema, build_entry, load_schema
|
|
84
|
+
from feedback_hub._storage import (
|
|
85
|
+
save as _save,
|
|
86
|
+
update_github_sync as _update_sync,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
bp = Blueprint(blueprint_name, __name__, template_folder="templates")
|
|
90
|
+
|
|
91
|
+
_categories = categories or [
|
|
92
|
+
"Bug Report",
|
|
93
|
+
"Feature Request",
|
|
94
|
+
"Accessibility Issue",
|
|
95
|
+
"Documentation",
|
|
96
|
+
"Other",
|
|
97
|
+
]
|
|
98
|
+
_schema = AppSchema(
|
|
99
|
+
app=app_name,
|
|
100
|
+
github_repo=github_repo,
|
|
101
|
+
github_labels=github_labels or ["needs-triage"],
|
|
102
|
+
github_assignee=github_assignee,
|
|
103
|
+
categories=_categories,
|
|
104
|
+
fields=[
|
|
105
|
+
FieldSchema("summary", "Summary", "text", required=True, max_length=120),
|
|
106
|
+
FieldSchema("message", "Details", "textarea", required=True, max_length=5000),
|
|
107
|
+
*(
|
|
108
|
+
FieldSchema(
|
|
109
|
+
f["name"],
|
|
110
|
+
f.get("label", f["name"].title()),
|
|
111
|
+
f.get("type", "text"),
|
|
112
|
+
required=f.get("required", False),
|
|
113
|
+
max_length=f.get("max_length", 0),
|
|
114
|
+
options=f.get("options", []),
|
|
115
|
+
default=f.get("default", ""),
|
|
116
|
+
)
|
|
117
|
+
for f in (extra_fields or [])
|
|
118
|
+
),
|
|
119
|
+
],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _resolved_token() -> str:
|
|
123
|
+
return resolve_token(
|
|
124
|
+
github_token,
|
|
125
|
+
os.environ.get("SUPPORT_HUB_GITHUB_TOKEN", ""),
|
|
126
|
+
os.environ.get("FEEDBACK_GITHUB_TOKEN", ""),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _get_db() -> Path:
|
|
130
|
+
if db_path:
|
|
131
|
+
return db_path
|
|
132
|
+
try:
|
|
133
|
+
return Path(current_app.instance_path) / "feedback.db"
|
|
134
|
+
except RuntimeError:
|
|
135
|
+
return Path("instance") / "feedback.db"
|
|
136
|
+
|
|
137
|
+
def _get_version() -> str:
|
|
138
|
+
if app_version_fn:
|
|
139
|
+
try:
|
|
140
|
+
return app_version_fn()
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
def _submit(entry: dict) -> tuple[Optional[str], Optional[str]]:
|
|
146
|
+
"""Store locally then sync to GitHub. Returns (issue_url, error)."""
|
|
147
|
+
try:
|
|
148
|
+
row_id = _save(entry, _get_db())
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
log.exception("Failed to save feedback to SQLite")
|
|
151
|
+
return None, str(exc)
|
|
152
|
+
|
|
153
|
+
token = _resolved_token()
|
|
154
|
+
if not token:
|
|
155
|
+
return None, "GitHub token not configured"
|
|
156
|
+
|
|
157
|
+
cfg = GitHubConfig(
|
|
158
|
+
token=token,
|
|
159
|
+
repo=_schema.github_repo,
|
|
160
|
+
assignee=_schema.github_assignee,
|
|
161
|
+
labels=_schema.github_labels,
|
|
162
|
+
)
|
|
163
|
+
number, url, error = create_issue(entry, cfg)
|
|
164
|
+
try:
|
|
165
|
+
_update_sync(row_id, issue_number=number, issue_url=url, error=error, db_path=_get_db())
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
return url, error
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Routes
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
@bp.route("/", methods=["GET"])
|
|
175
|
+
def feedback_form():
|
|
176
|
+
return render_template(
|
|
177
|
+
"feedback_hub/form.html",
|
|
178
|
+
schema=_schema,
|
|
179
|
+
error="",
|
|
180
|
+
values={},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@bp.route("/", methods=["POST"])
|
|
184
|
+
def feedback_submit():
|
|
185
|
+
values = {
|
|
186
|
+
f.name: request.form.get(f.name, "").strip()
|
|
187
|
+
for f in _schema.fields
|
|
188
|
+
}
|
|
189
|
+
errors = _schema.validate(values)
|
|
190
|
+
if errors:
|
|
191
|
+
return render_template(
|
|
192
|
+
"feedback_hub/form.html",
|
|
193
|
+
schema=_schema,
|
|
194
|
+
error=" ".join(errors),
|
|
195
|
+
values=values,
|
|
196
|
+
), 400
|
|
197
|
+
|
|
198
|
+
entry = build_entry(
|
|
199
|
+
_schema,
|
|
200
|
+
values,
|
|
201
|
+
name=request.form.get("name", "").strip(),
|
|
202
|
+
email=request.form.get("email", "").strip(),
|
|
203
|
+
category=request.form.get("category", _categories[0]),
|
|
204
|
+
app_version=_get_version(),
|
|
205
|
+
metadata={
|
|
206
|
+
"source_channel": "web",
|
|
207
|
+
"user_agent": request.headers.get("User-Agent", ""),
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
issue_url, _error = _submit(entry)
|
|
211
|
+
return render_template(
|
|
212
|
+
"feedback_hub/thanks.html",
|
|
213
|
+
schema=_schema,
|
|
214
|
+
issue_url=issue_url,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
@bp.route("/api", methods=["POST"])
|
|
218
|
+
def feedback_submit_api():
|
|
219
|
+
api_token = os.environ.get("FEEDBACK_HUB_API_TOKEN", "").strip() or \
|
|
220
|
+
os.environ.get("FEEDBACK_API_TOKEN", "").strip()
|
|
221
|
+
if not api_token:
|
|
222
|
+
abort(404)
|
|
223
|
+
auth = request.headers.get("Authorization", "")
|
|
224
|
+
if not auth or not hmac.compare_digest(auth, f"Bearer {api_token}"):
|
|
225
|
+
return jsonify({"error": "Unauthorized"}), 403
|
|
226
|
+
|
|
227
|
+
payload = request.get_json(silent=True)
|
|
228
|
+
if not isinstance(payload, dict):
|
|
229
|
+
return jsonify({"error": "JSON object required"}), 400
|
|
230
|
+
|
|
231
|
+
values = {f.name: str(payload.get(f.name, "")).strip() for f in _schema.fields}
|
|
232
|
+
errors = _schema.validate(values)
|
|
233
|
+
if errors:
|
|
234
|
+
return jsonify({"error": " ".join(errors)}), 400
|
|
235
|
+
|
|
236
|
+
entry = build_entry(
|
|
237
|
+
_schema,
|
|
238
|
+
values,
|
|
239
|
+
name=str(payload.get("name", "")),
|
|
240
|
+
email=str(payload.get("email", "")),
|
|
241
|
+
category=str(payload.get("category", _categories[0])),
|
|
242
|
+
app_version=str(payload.get("version", _get_version())),
|
|
243
|
+
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else None,
|
|
244
|
+
)
|
|
245
|
+
issue_url, sync_error = _submit(entry)
|
|
246
|
+
return jsonify({
|
|
247
|
+
"status": "accepted",
|
|
248
|
+
"issue_url": issue_url,
|
|
249
|
+
"github_sync_status": "synced" if issue_url else "failed",
|
|
250
|
+
"github_sync_error": sync_error,
|
|
251
|
+
}), 202
|
|
252
|
+
|
|
253
|
+
@bp.route("/review")
|
|
254
|
+
def feedback_review():
|
|
255
|
+
from feedback_hub._storage import list_all
|
|
256
|
+
|
|
257
|
+
password = os.environ.get("FEEDBACK_PASSWORD", "").strip()
|
|
258
|
+
if not password:
|
|
259
|
+
abort(404)
|
|
260
|
+
provided = request.args.get("key", "")
|
|
261
|
+
if not provided or not hmac.compare_digest(provided, password):
|
|
262
|
+
abort(403)
|
|
263
|
+
|
|
264
|
+
rows = list_all(_get_db())
|
|
265
|
+
return render_template("feedback_hub/review.html", schema=_schema, rows=rows)
|
|
266
|
+
|
|
267
|
+
return bp
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""wxPython native feedback dialog.
|
|
2
|
+
|
|
3
|
+
Usage (ChapterForge, QUILL, any wxPython app)::
|
|
4
|
+
|
|
5
|
+
from feedback_hub.wx_dialog import FeedbackDialog
|
|
6
|
+
from feedback_hub import AppSchema, load_schema
|
|
7
|
+
|
|
8
|
+
schema = load_schema(Path("schemas/chapterforge.json"))
|
|
9
|
+
dlg = FeedbackDialog(parent, schema=schema, github_token=TOKEN)
|
|
10
|
+
dlg.ShowModal()
|
|
11
|
+
dlg.Destroy()
|
|
12
|
+
|
|
13
|
+
The dialog submits directly to GitHub Issues -- no browser, no clipboard.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import platform
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
from feedback_hub._github import GitHubConfig, create_issue, resolve_token
|
|
23
|
+
from feedback_hub._schema import AppSchema, build_entry
|
|
24
|
+
from feedback_hub._storage import save as save_entry, update_github_sync
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FeedbackDialog:
|
|
28
|
+
"""Accessible wxPython dialog that submits issues directly to GitHub.
|
|
29
|
+
|
|
30
|
+
Requires wxPython to be importable. It is imported lazily so the
|
|
31
|
+
rest of feedback_hub works in environments without wx (e.g. GLOW).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
parent: Any,
|
|
37
|
+
*,
|
|
38
|
+
schema: AppSchema,
|
|
39
|
+
github_token: str = "",
|
|
40
|
+
db_path: Optional[Path] = None,
|
|
41
|
+
app_version: str = "",
|
|
42
|
+
) -> None:
|
|
43
|
+
import wx
|
|
44
|
+
|
|
45
|
+
self._wx = wx
|
|
46
|
+
self._schema = schema
|
|
47
|
+
self._app_version = app_version or schema.version
|
|
48
|
+
self._db_path = db_path or (
|
|
49
|
+
Path.home() / "AppData" / "Roaming" / schema.app / "feedback.db"
|
|
50
|
+
if sys.platform == "win32"
|
|
51
|
+
else Path.home() / ".local" / "share" / schema.app.lower() / "feedback.db"
|
|
52
|
+
)
|
|
53
|
+
self._token = resolve_token(github_token)
|
|
54
|
+
self._dialog = wx.Dialog(
|
|
55
|
+
parent,
|
|
56
|
+
title=f"Report an Issue - {schema.app}",
|
|
57
|
+
size=(720, 680),
|
|
58
|
+
)
|
|
59
|
+
self._field_controls: dict[str, Any] = {}
|
|
60
|
+
self._build_ui()
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Public
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def ShowModal(self) -> int:
|
|
67
|
+
return self._dialog.ShowModal()
|
|
68
|
+
|
|
69
|
+
def Destroy(self) -> None:
|
|
70
|
+
self._dialog.Destroy()
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Internal
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def _build_ui(self) -> None:
|
|
77
|
+
wx = self._wx
|
|
78
|
+
schema = self._schema
|
|
79
|
+
dlg = self._dialog
|
|
80
|
+
panel = wx.Panel(dlg)
|
|
81
|
+
root = wx.BoxSizer(wx.VERTICAL)
|
|
82
|
+
|
|
83
|
+
# Instruction text
|
|
84
|
+
intro = wx.StaticText(
|
|
85
|
+
panel,
|
|
86
|
+
label=(
|
|
87
|
+
f"Describe your issue and it will be submitted directly to the "
|
|
88
|
+
f"{schema.app} issue tracker. No account required."
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
intro.Wrap(660)
|
|
92
|
+
root.Add(intro, 0, wx.ALL | wx.EXPAND, 8)
|
|
93
|
+
|
|
94
|
+
# Name / email row
|
|
95
|
+
name_row = wx.BoxSizer(wx.HORIZONTAL)
|
|
96
|
+
name_label = wx.StaticText(panel, label="Your name (optional)")
|
|
97
|
+
self._name_ctrl = wx.TextCtrl(panel)
|
|
98
|
+
self._name_ctrl.SetName("Your name")
|
|
99
|
+
email_label = wx.StaticText(panel, label="Email (optional)")
|
|
100
|
+
self._email_ctrl = wx.TextCtrl(panel)
|
|
101
|
+
self._email_ctrl.SetName("Email address")
|
|
102
|
+
name_row.Add(name_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4)
|
|
103
|
+
name_row.Add(self._name_ctrl, 1, wx.RIGHT, 16)
|
|
104
|
+
name_row.Add(email_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4)
|
|
105
|
+
name_row.Add(self._email_ctrl, 1)
|
|
106
|
+
root.Add(name_row, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 8)
|
|
107
|
+
|
|
108
|
+
# Category
|
|
109
|
+
cat_label = wx.StaticText(panel, label="Category")
|
|
110
|
+
self._cat_ctrl = wx.Choice(panel, choices=schema.categories)
|
|
111
|
+
self._cat_ctrl.SetName("Issue category")
|
|
112
|
+
self._cat_ctrl.SetSelection(0)
|
|
113
|
+
root.Add(cat_label, 0, wx.LEFT | wx.RIGHT | wx.TOP, 8)
|
|
114
|
+
root.Add(self._cat_ctrl, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 8)
|
|
115
|
+
|
|
116
|
+
# Schema-driven fields
|
|
117
|
+
defaults = schema.resolve_defaults(self._app_version)
|
|
118
|
+
for f in schema.fields:
|
|
119
|
+
label = wx.StaticText(panel, label=f.label + (" *" if f.required else ""))
|
|
120
|
+
root.Add(label, 0, wx.LEFT | wx.RIGHT | wx.TOP, 8)
|
|
121
|
+
if f.type == "textarea":
|
|
122
|
+
ctrl = wx.TextCtrl(panel, style=wx.TE_MULTILINE, size=(-1, 90))
|
|
123
|
+
elif f.type == "select" and f.options:
|
|
124
|
+
ctrl = wx.Choice(panel, choices=f.options)
|
|
125
|
+
else:
|
|
126
|
+
ctrl = wx.TextCtrl(panel)
|
|
127
|
+
|
|
128
|
+
ctrl.SetName(f.label)
|
|
129
|
+
if isinstance(ctrl, wx.TextCtrl) and defaults.get(f.name):
|
|
130
|
+
ctrl.SetValue(defaults[f.name])
|
|
131
|
+
if f.placeholder and isinstance(ctrl, wx.TextCtrl):
|
|
132
|
+
ctrl.SetHint(f.placeholder)
|
|
133
|
+
root.Add(ctrl, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 8)
|
|
134
|
+
self._field_controls[f.name] = ctrl
|
|
135
|
+
|
|
136
|
+
# Buttons
|
|
137
|
+
buttons = wx.StdDialogButtonSizer()
|
|
138
|
+
self._submit_btn = wx.Button(dlg, wx.ID_OK, label="Submit Issue")
|
|
139
|
+
cancel_btn = wx.Button(dlg, wx.ID_CANCEL, label="Cancel")
|
|
140
|
+
buttons.AddButton(self._submit_btn)
|
|
141
|
+
buttons.AddButton(cancel_btn)
|
|
142
|
+
buttons.Realize()
|
|
143
|
+
root.Add(buttons, 0, wx.ALL | wx.EXPAND, 8)
|
|
144
|
+
|
|
145
|
+
panel.SetSizer(root)
|
|
146
|
+
panel.Layout()
|
|
147
|
+
|
|
148
|
+
self._submit_btn.Bind(wx.EVT_BUTTON, self._on_submit)
|
|
149
|
+
self._name_ctrl.SetFocus()
|
|
150
|
+
|
|
151
|
+
def _on_submit(self, _event: Any) -> None:
|
|
152
|
+
wx = self._wx
|
|
153
|
+
values = {}
|
|
154
|
+
for name, ctrl in self._field_controls.items():
|
|
155
|
+
if isinstance(ctrl, wx.Choice):
|
|
156
|
+
sel = ctrl.GetSelection()
|
|
157
|
+
values[name] = ctrl.GetString(sel) if sel != wx.NOT_FOUND else ""
|
|
158
|
+
else:
|
|
159
|
+
values[name] = ctrl.GetValue().strip()
|
|
160
|
+
|
|
161
|
+
cat_sel = self._cat_ctrl.GetSelection()
|
|
162
|
+
category = (
|
|
163
|
+
self._cat_ctrl.GetString(cat_sel)
|
|
164
|
+
if cat_sel != wx.NOT_FOUND
|
|
165
|
+
else self._schema.categories[0]
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
errors = self._schema.validate(values)
|
|
169
|
+
if errors:
|
|
170
|
+
wx.MessageBox(
|
|
171
|
+
"\n".join(errors),
|
|
172
|
+
f"{self._schema.app} - Validation Error",
|
|
173
|
+
wx.OK | wx.ICON_WARNING,
|
|
174
|
+
self._dialog,
|
|
175
|
+
)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
entry = build_entry(
|
|
179
|
+
self._schema,
|
|
180
|
+
values,
|
|
181
|
+
name=self._name_ctrl.GetValue().strip(),
|
|
182
|
+
email=self._email_ctrl.GetValue().strip(),
|
|
183
|
+
category=category,
|
|
184
|
+
app_version=self._app_version,
|
|
185
|
+
metadata={
|
|
186
|
+
"python": sys.version.splitlines()[0],
|
|
187
|
+
"platform": platform.platform(),
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Store locally first
|
|
192
|
+
try:
|
|
193
|
+
row_id = save_entry(entry, self._db_path)
|
|
194
|
+
except Exception:
|
|
195
|
+
row_id = None
|
|
196
|
+
|
|
197
|
+
# Submit to GitHub
|
|
198
|
+
if not self._token:
|
|
199
|
+
wx.MessageBox(
|
|
200
|
+
"Your report was saved locally but could not be submitted to GitHub - "
|
|
201
|
+
"no token is configured.",
|
|
202
|
+
f"{self._schema.app} - Saved Locally",
|
|
203
|
+
wx.OK | wx.ICON_INFORMATION,
|
|
204
|
+
self._dialog,
|
|
205
|
+
)
|
|
206
|
+
self._dialog.EndModal(wx.ID_OK)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
cfg = GitHubConfig(
|
|
210
|
+
token=self._token,
|
|
211
|
+
repo=self._schema.github_repo,
|
|
212
|
+
assignee=self._schema.github_assignee,
|
|
213
|
+
labels=self._schema.github_labels,
|
|
214
|
+
)
|
|
215
|
+
issue_number, issue_url, error = create_issue(entry, cfg)
|
|
216
|
+
|
|
217
|
+
if row_id is not None:
|
|
218
|
+
try:
|
|
219
|
+
update_github_sync(
|
|
220
|
+
row_id,
|
|
221
|
+
issue_number=issue_number,
|
|
222
|
+
issue_url=issue_url,
|
|
223
|
+
error=error,
|
|
224
|
+
db_path=self._db_path,
|
|
225
|
+
)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
if issue_url:
|
|
230
|
+
wx.MessageBox(
|
|
231
|
+
f"Your report has been submitted successfully.\n\nIssue: {issue_url}",
|
|
232
|
+
f"{self._schema.app} - Thank You",
|
|
233
|
+
wx.OK | wx.ICON_INFORMATION,
|
|
234
|
+
self._dialog,
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
wx.MessageBox(
|
|
238
|
+
"Your report was saved but could not be submitted to GitHub right now.\n"
|
|
239
|
+
"We will retry automatically on next launch.\n\n"
|
|
240
|
+
f"Error: {error}",
|
|
241
|
+
f"{self._schema.app} - Saved",
|
|
242
|
+
wx.OK | wx.ICON_WARNING,
|
|
243
|
+
self._dialog,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
self._dialog.EndModal(wx.ID_OK)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: feedback-hub
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Multi-framework GitHub issue submission library - native UI per framework, centralized GitHub backend
|
|
5
|
+
Project-URL: Homepage, https://github.com/community-access/feedback-hub
|
|
6
|
+
Project-URL: Documentation, https://community-access.github.io/feedback-hub
|
|
7
|
+
Project-URL: Repository, https://github.com/community-access/feedback-hub
|
|
8
|
+
Project-URL: Issues, https://github.com/community-access/feedback-hub/issues
|
|
9
|
+
Author-email: "Blind Information Technology Solutions (BITS)" <info@chapterforge.org>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Blind Information Technology Solutions (BITS)
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Requires-Python: >=3.10
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: flask>=3.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: hatch; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
38
|
+
Provides-Extra: flask
|
|
39
|
+
Requires-Dist: flask>=3.0; extra == 'flask'
|
|
40
|
+
Provides-Extra: wx
|
|
41
|
+
Requires-Dist: wxpython>=4.2; extra == 'wx'
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
|
|
44
|
+
# feedback-hub
|
|
45
|
+
|
|
46
|
+
Multi-framework GitHub issue submission library. Native UI per framework, centralized GitHub backend.
|
|
47
|
+
|
|
48
|
+
- **wxPython apps** (ChapterForge, QUILL): native dialog, direct GitHub submission
|
|
49
|
+
- **Flask apps** (GLOW): web form, direct GitHub submission
|
|
50
|
+
- **CLI / headless**: function call, direct GitHub submission
|
|
51
|
+
|
|
52
|
+
See [INTEGRATING.md](INTEGRATING.md) for integration guides.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
feedback_hub/__init__.py,sha256=r6efyMZeNNVHyxX_15Lv-irdvy9adOjlrwtVPAnDpdo,3040
|
|
2
|
+
feedback_hub/_github.py,sha256=Utn8kvEkbE1YDDBg-7gwyFyQR8GbRBUD50SY0Vpc7qs,4839
|
|
3
|
+
feedback_hub/_schema.py,sha256=xGVeifv8wT_8GUyJTGbIqghpiv2iXgeSV791x3EEtvU,5708
|
|
4
|
+
feedback_hub/_storage.py,sha256=-Ur0ncU8MPHNy2ReDyQfYjEC4AQbKgkWExerCcEoals,4104
|
|
5
|
+
feedback_hub/compat_glow.py,sha256=ye4vSxbY8sROP17RGN24vYI_K9D7uln3sdE9pvxt_28,3077
|
|
6
|
+
feedback_hub/flask_blueprint.py,sha256=iWNRBKXB2J3sey5p-E0dehhurPFJAp_GgzKjHb-KIGc,8744
|
|
7
|
+
feedback_hub/wx_dialog.py,sha256=DcLpcVxfROm-9-L8E2CMuV4FEtD-wMI8tBWmvAJN3f8,8605
|
|
8
|
+
feedback_hub-1.0.0.dist-info/METADATA,sha256=hTf1ctrP1rD3FWsWpcs9XS-dParV_sDCwB-n8zIkMVk,2609
|
|
9
|
+
feedback_hub-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
feedback_hub-1.0.0.dist-info/licenses/LICENSE,sha256=aW5a-ckzPcVmKQLFJIiCDUJ71vO6IA5VUG9jOT_inKM,1102
|
|
11
|
+
feedback_hub-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Blind Information Technology Solutions (BITS)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|