ckanext-webpageviewplus 0.1.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.
Files changed (45) hide show
  1. ckanext/webpageviewplus/__init__.py +0 -0
  2. ckanext/webpageviewplus/actions.py +128 -0
  3. ckanext/webpageviewplus/assets/style.css +227 -0
  4. ckanext/webpageviewplus/assets/webassets.yml +10 -0
  5. ckanext/webpageviewplus/assets/webpageviewplus.js +520 -0
  6. ckanext/webpageviewplus/cli.py +227 -0
  7. ckanext/webpageviewplus/config_declaration.yaml +211 -0
  8. ckanext/webpageviewplus/connections.py +157 -0
  9. ckanext/webpageviewplus/content.py +281 -0
  10. ckanext/webpageviewplus/downloads.py +100 -0
  11. ckanext/webpageviewplus/events.py +23 -0
  12. ckanext/webpageviewplus/frame.py +465 -0
  13. ckanext/webpageviewplus/frameability.py +228 -0
  14. ckanext/webpageviewplus/i18n/ckanext-webpageviewplus.pot +299 -0
  15. ckanext/webpageviewplus/migration/webpage_view_plus/alembic.ini +74 -0
  16. ckanext/webpageviewplus/migration/webpage_view_plus/env.py +90 -0
  17. ckanext/webpageviewplus/migration/webpage_view_plus/script.py.mako +24 -0
  18. ckanext/webpageviewplus/migration/webpage_view_plus/versions/35c91ad0b38c_view_counts.py +41 -0
  19. ckanext/webpageviewplus/migration/webpage_view_plus/versions/929956801471_connections.py +38 -0
  20. ckanext/webpageviewplus/plugin.py +326 -0
  21. ckanext/webpageviewplus/public/.gitignore +0 -0
  22. ckanext/webpageviewplus/settings.py +282 -0
  23. ckanext/webpageviewplus/stats.py +144 -0
  24. ckanext/webpageviewplus/templates/webpageviewplus/form.html +108 -0
  25. ckanext/webpageviewplus/templates/webpageviewplus/view.html +103 -0
  26. ckanext/webpageviewplus/tests/__init__.py +0 -0
  27. ckanext/webpageviewplus/tests/conftest.py +69 -0
  28. ckanext/webpageviewplus/tests/fixtures/qsv_protocol.html +85 -0
  29. ckanext/webpageviewplus/tests/test_cli.py +142 -0
  30. ckanext/webpageviewplus/tests/test_connections.py +186 -0
  31. ckanext/webpageviewplus/tests/test_content.py +311 -0
  32. ckanext/webpageviewplus/tests/test_downloads.py +101 -0
  33. ckanext/webpageviewplus/tests/test_frame.py +307 -0
  34. ckanext/webpageviewplus/tests/test_frameability.py +210 -0
  35. ckanext/webpageviewplus/tests/test_plugin.py +728 -0
  36. ckanext/webpageviewplus/tests/test_stats.py +186 -0
  37. ckanext/webpageviewplus/validators.py +184 -0
  38. ckanext_webpageviewplus-0.1.0-py3.12-nspkg.pth +1 -0
  39. ckanext_webpageviewplus-0.1.0.dist-info/METADATA +410 -0
  40. ckanext_webpageviewplus-0.1.0.dist-info/RECORD +45 -0
  41. ckanext_webpageviewplus-0.1.0.dist-info/WHEEL +5 -0
  42. ckanext_webpageviewplus-0.1.0.dist-info/entry_points.txt +2 -0
  43. ckanext_webpageviewplus-0.1.0.dist-info/licenses/LICENSE +661 -0
  44. ckanext_webpageviewplus-0.1.0.dist-info/namespace_packages.txt +1 -0
  45. ckanext_webpageviewplus-0.1.0.dist-info/top_level.txt +1 -0
File without changes
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import ckan.model as model
6
+ import ckan.plugins.toolkit as toolkit
7
+ from ckan.types import Context
8
+
9
+ from ckanext.webpageviewplus import (
10
+ connections,
11
+ events,
12
+ frame,
13
+ frameability,
14
+ settings,
15
+ stats,
16
+ )
17
+
18
+
19
+ def frameability_auth(context: Context, data_dict: dict[str, Any]) -> dict[str, Any]:
20
+ """Only someone who may add views to the resource may make CKAN fetch a
21
+ URL on its behalf."""
22
+ try:
23
+ toolkit.check_access(
24
+ "resource_view_create",
25
+ context,
26
+ {"resource_id": data_dict.get("resource_id")},
27
+ )
28
+ except toolkit.NotAuthorized:
29
+ return {"success": False}
30
+ return {"success": True}
31
+
32
+
33
+ @toolkit.side_effect_free
34
+ def webpageviewplus_frameability(
35
+ context: Context, data_dict: dict[str, Any]
36
+ ) -> dict[str, Any]:
37
+ """Whether the page at ``url`` allows this site to frame it.
38
+
39
+ :param url: the page URL
40
+ :param resource_id: the resource the view is for (for authorization)
41
+ :returns: ``{"verdict": "frameable" | "blocked" | "unknown" | "skipped",
42
+ "reason": "..."}``. Never refuses on the URL itself: the view's own
43
+ validation does that when it's saved.
44
+ """
45
+ toolkit.check_access("webpageviewplus_frameability", context, data_dict)
46
+ url = toolkit.get_or_bust(data_dict, "url")
47
+ if not settings.frameability_check():
48
+ return {"verdict": "skipped", "reason": "disabled"}
49
+ if not frame.is_http_url(url):
50
+ return {"verdict": "skipped", "reason": "not an http(s) URL"}
51
+ hosts = settings.ckan_hosts()
52
+ if frame.is_ckan_host(url, hosts):
53
+ return {"verdict": "skipped", "reason": "a page on this site"}
54
+ allowed = settings.allowed_hosts()
55
+ if allowed and frame.url_host(url) not in allowed:
56
+ return {"verdict": "skipped", "reason": "host not allowed"}
57
+ identity = frameability.ckan_identity(settings.site_url())
58
+ result = frameability.summary(frameability.check(url, identity))
59
+ if result.get("verdict") == "blocked":
60
+ events.emit("frameability_blocked", host=frame.url_host(url))
61
+ return result
62
+
63
+
64
+ def _counted_view(data_dict: dict[str, Any]):
65
+ view_id = toolkit.get_or_bust(data_dict, "id")
66
+ view = model.Session.query(model.ResourceView).get(view_id)
67
+ if view is None or view.view_type != "webpage_view_plus":
68
+ raise toolkit.ObjectNotFound("View not found")
69
+ return view
70
+
71
+
72
+ def view_stats_auth(context: Context, data_dict: dict[str, Any]) -> dict[str, Any]:
73
+ """Anyone who may update the view's resource."""
74
+ try:
75
+ view = _counted_view(data_dict)
76
+ toolkit.check_access("resource_update", context, {"id": view.resource_id})
77
+ except (toolkit.NotAuthorized, toolkit.ObjectNotFound, toolkit.ValidationError):
78
+ return {"success": False}
79
+ return {"success": True}
80
+
81
+
82
+ @toolkit.side_effect_free
83
+ def webpageviewplus_view_stats_show(
84
+ context: Context, data_dict: dict[str, Any]
85
+ ) -> dict[str, Any]:
86
+ """Opt-in view counts for one view.
87
+
88
+ :param id: the view's id
89
+ :param days: how many days back, today included (default 30, at most the
90
+ retention period)
91
+ :returns: ``{"counting": bool, "days": [{"day", "impressions", "seen",
92
+ "loaded", "engaged", "failed"}, ...], "totals": {...}}``. Counts hold
93
+ nothing about viewers.
94
+ """
95
+ toolkit.check_access("webpageviewplus_view_stats_show", context, data_dict)
96
+ view = _counted_view(data_dict)
97
+ try:
98
+ days = int(data_dict.get("days") or 30)
99
+ except (TypeError, ValueError):
100
+ raise toolkit.ValidationError({"days": ["Must be a whole number"]})
101
+ days = max(1, min(days, settings.view_counting_retention_days()))
102
+ result = stats.totals(view.id, days)
103
+ result["counting"] = stats.countable(view.config or {})
104
+ return result
105
+
106
+
107
+ def connections_auth(context: Context, data_dict: dict[str, Any]) -> dict[str, Any]:
108
+ """Anyone who may update the resource."""
109
+ try:
110
+ toolkit.check_access("resource_update", context, {"id": data_dict.get("id")})
111
+ except (toolkit.NotAuthorized, toolkit.ObjectNotFound):
112
+ return {"success": False}
113
+ return {"success": True}
114
+
115
+
116
+ @toolkit.side_effect_free
117
+ def webpageviewplus_connections_show(
118
+ context: Context, data_dict: dict[str, Any]
119
+ ) -> list[dict[str, Any]]:
120
+ """The hosts an uploaded page was seen contacting, from CSP reports on
121
+ the content origin. Reports can be posted by anyone: a hint, not proof.
122
+
123
+ :param id: the resource's id
124
+ :returns: ``[{"host", "reports", "last_seen"}, ...]``, most reported first
125
+ """
126
+ toolkit.check_access("webpageviewplus_connections_show", context, data_dict)
127
+ resource_id = toolkit.get_or_bust(data_dict, "id")
128
+ return connections.hosts_for(resource_id)
@@ -0,0 +1,227 @@
1
+ .webpageviewplus {
2
+ position: relative;
3
+ }
4
+
5
+ .webpageviewplus-frame {
6
+ display: block;
7
+ width: 100%;
8
+ border: 0;
9
+ }
10
+
11
+ .webpageviewplus-controls {
12
+ display: flex;
13
+ flex-wrap: wrap;
14
+ gap: 0.5rem;
15
+ margin-top: 0.5rem;
16
+ }
17
+
18
+ .webpageviewplus-controls .btn {
19
+ min-height: 24px;
20
+ min-width: 24px;
21
+ }
22
+
23
+ .webpageviewplus-controls .btn:focus-visible,
24
+ .webpageviewplus-card [data-webpageviewplus-load]:focus-visible,
25
+ .webpageviewplus-narrow-open:focus-visible,
26
+ .webpageviewplus-skip:focus-visible {
27
+ outline: 3px solid currentColor;
28
+ outline-offset: 2px;
29
+ }
30
+
31
+ /* Hidden until it has keyboard focus. Own rules rather than a Bootstrap
32
+ class, so it works whatever version the site's theme uses. */
33
+ .webpageviewplus-skip {
34
+ position: absolute;
35
+ width: 1px;
36
+ height: 1px;
37
+ overflow: hidden;
38
+ clip-path: inset(50%);
39
+ white-space: nowrap;
40
+ }
41
+
42
+ .webpageviewplus-skip:focus {
43
+ position: static;
44
+ display: inline-block;
45
+ width: auto;
46
+ height: auto;
47
+ min-height: 24px;
48
+ margin-bottom: 0.5rem;
49
+ overflow: visible;
50
+ clip-path: none;
51
+ }
52
+
53
+ /* Only a landing spot for the skip link. */
54
+ .webpageviewplus-after:focus {
55
+ outline: none;
56
+ }
57
+
58
+ .webpageviewplus-sr {
59
+ position: absolute;
60
+ width: 1px;
61
+ height: 1px;
62
+ margin: 0;
63
+ overflow: hidden;
64
+ clip-path: inset(50%);
65
+ white-space: nowrap;
66
+ }
67
+
68
+ /* Windows High Contrast and other forced-colour modes: system colours. */
69
+ @media (forced-colors: active) {
70
+ .webpageviewplus-controls .btn,
71
+ .webpageviewplus-narrow-open,
72
+ .webpageviewplus-shield span,
73
+ .webpageviewplus-skip {
74
+ border: 1px solid ButtonText;
75
+ color: ButtonText;
76
+ background: ButtonFace;
77
+ }
78
+
79
+ .webpageviewplus-controls .btn:focus-visible,
80
+ .webpageviewplus-narrow-open:focus-visible,
81
+ .webpageviewplus-skip:focus-visible {
82
+ outline-color: Highlight;
83
+ }
84
+ }
85
+
86
+ /* Responsive height: below the site's narrow_width (the script sets the
87
+ class), aspect-ratio and share-of-window views take 80% of the screen,
88
+ over their inline height. Not in fullscreen or Expand. */
89
+ .webpageviewplus-narrow:not(:fullscreen):not(.webpageviewplus-expanded)
90
+ .webpageviewplus-frame:is([data-height-mode="aspect"], [data-height-mode="viewport"]) {
91
+ height: 80vh !important;
92
+ height: 80svh !important;
93
+ aspect-ratio: auto !important;
94
+ }
95
+
96
+ /* Load on tap (Save-Data): the card stands in for the frame. */
97
+ .webpageviewplus-card {
98
+ padding: 1rem;
99
+ border: 1px dashed currentColor;
100
+ border-radius: 4px;
101
+ }
102
+
103
+ .webpageviewplus-card-title {
104
+ margin: 0 0 0.25rem;
105
+ font-weight: bold;
106
+ }
107
+
108
+ .webpageviewplus-card [data-webpageviewplus-load] {
109
+ min-height: 24px;
110
+ }
111
+
112
+ /* Tap to interact: over the frame (the script sets its top and height),
113
+ so a swipe scrolls the page instead of the framed page. */
114
+ .webpageviewplus-shield {
115
+ position: absolute;
116
+ left: 0;
117
+ right: 0;
118
+ z-index: 1;
119
+ display: flex;
120
+ align-items: flex-end;
121
+ justify-content: center;
122
+ padding: 0 0 1rem;
123
+ border: 0;
124
+ background: transparent;
125
+ }
126
+
127
+ .webpageviewplus-shield[hidden],
128
+ .webpageviewplus:fullscreen .webpageviewplus-shield,
129
+ .webpageviewplus-expanded .webpageviewplus-shield {
130
+ display: none;
131
+ }
132
+
133
+ .webpageviewplus-shield span {
134
+ display: inline-flex;
135
+ align-items: center;
136
+ min-height: 44px;
137
+ padding: 0 1rem;
138
+ border-radius: 22px;
139
+ color: #fff;
140
+ background: rgba(0, 0, 0, 0.8);
141
+ }
142
+
143
+ .webpageviewplus-shield:focus-visible span {
144
+ outline: 3px solid #fff;
145
+ outline-offset: 2px;
146
+ }
147
+
148
+ /* Reflow (WCAG 1.4.10): at a 320px width, which is 400% zoom on a
149
+ 1280px screen, content framed in a short box turns into a small nested
150
+ scroll area, so every height mode gets a tall floor. 600px matches
151
+ FALLBACK_HEIGHT in frame.py. Not in fullscreen or Expand, where the frame
152
+ fills the screen instead. */
153
+ .webpageviewplus-narrow-open {
154
+ display: none;
155
+ }
156
+
157
+ @media (max-width: 320px) {
158
+ .webpageviewplus:not(:fullscreen):not(.webpageviewplus-expanded) .webpageviewplus-frame {
159
+ min-height: 600px;
160
+ }
161
+
162
+ .webpageviewplus-narrow-open {
163
+ display: inline-block;
164
+ min-height: 24px;
165
+ margin-top: 0.5rem;
166
+ }
167
+ }
168
+
169
+ @media (pointer: coarse) {
170
+ .webpageviewplus-controls .btn,
171
+ .webpageviewplus-card [data-webpageviewplus-load],
172
+ .webpageviewplus-narrow-open {
173
+ min-height: 44px;
174
+ min-width: 44px;
175
+ }
176
+ }
177
+
178
+ /* Fullscreen: the frame fills the screen above the control bar, beating its
179
+ inline height. */
180
+ .webpageviewplus:fullscreen {
181
+ display: flex;
182
+ flex-direction: column;
183
+ background: Canvas;
184
+ padding: 0.5rem;
185
+ }
186
+
187
+ .webpageviewplus:fullscreen .webpageviewplus-frame {
188
+ flex: 1;
189
+ height: auto !important;
190
+ aspect-ratio: auto !important;
191
+ }
192
+
193
+ /* Expand, the fallback where there's no element fullscreen (iPhone
194
+ Safari): the view fills the viewport in place. dvh keeps it clear of the
195
+ address bar; the safe-area insets are 0 unless the site's viewport meta
196
+ has viewport-fit=cover. No transition. */
197
+ .webpageviewplus-expanded {
198
+ position: fixed;
199
+ inset: 0;
200
+ z-index: 2000;
201
+ display: flex;
202
+ flex-direction: column;
203
+ height: 100vh;
204
+ height: 100dvh;
205
+ margin: 0;
206
+ padding: max(0.5rem, env(safe-area-inset-top)) max(0.5rem, env(safe-area-inset-right))
207
+ max(0.5rem, env(safe-area-inset-bottom)) max(0.5rem, env(safe-area-inset-left));
208
+ background: Canvas;
209
+ }
210
+
211
+ .webpageviewplus-expanded .webpageviewplus-frame {
212
+ flex: 1;
213
+ /* Without this the frame keeps its intrinsic 150px and, on a short
214
+ screen, pushes the control bar (and Close) off it. */
215
+ min-height: 0;
216
+ height: auto !important;
217
+ aspect-ratio: auto !important;
218
+ }
219
+
220
+ .webpageviewplus-expanded .webpageviewplus-controls {
221
+ flex-shrink: 0;
222
+ }
223
+
224
+ .webpageviewplus-noscroll,
225
+ .webpageviewplus-noscroll body {
226
+ overflow: hidden;
227
+ }
@@ -0,0 +1,10 @@
1
+ webpageviewplus-css:
2
+ output: ckanext-webpageviewplus/%(version)s-webpageviewplus.css
3
+ contents:
4
+ - style.css
5
+
6
+ webpageviewplus-js:
7
+ filters: rjsmin
8
+ output: ckanext-webpageviewplus/%(version)s-webpageviewplus.js
9
+ contents:
10
+ - webpageviewplus.js