django-snapnote 0.1.1__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.
- django_snapnote-0.1.1.dist-info/METADATA +112 -0
- django_snapnote-0.1.1.dist-info/RECORD +34 -0
- django_snapnote-0.1.1.dist-info/WHEEL +4 -0
- django_snapnote-0.1.1.dist-info/licenses/LICENSE +21 -0
- snapnote/__init__.py +1 -0
- snapnote/admin.py +12 -0
- snapnote/apps.py +14 -0
- snapnote/checks.py +22 -0
- snapnote/conf.py +25 -0
- snapnote/forms.py +83 -0
- snapnote/locale/es/LC_MESSAGES/django.mo +0 -0
- snapnote/locale/es/LC_MESSAGES/django.po +186 -0
- snapnote/management/__init__.py +0 -0
- snapnote/management/commands/__init__.py +0 -0
- snapnote/management/commands/snapnote_purge.py +26 -0
- snapnote/middleware.py +32 -0
- snapnote/migrations/0001_initial.py +92 -0
- snapnote/migrations/__init__.py +0 -0
- snapnote/models.py +120 -0
- snapnote/notifiers.py +42 -0
- snapnote/permissions.py +2 -0
- snapnote/signals.py +5 -0
- snapnote/static/snapnote/management.css +26 -0
- snapnote/static/snapnote/snapnote.js +40 -0
- snapnote/templates/snapnote/base.html +12 -0
- snapnote/templates/snapnote/button.html +1 -0
- snapnote/templates/snapnote/includes/status_badge.html +1 -0
- snapnote/templates/snapnote/ticket_confirm_delete.html +12 -0
- snapnote/templates/snapnote/ticket_detail.html +42 -0
- snapnote/templates/snapnote/ticket_list.html +39 -0
- snapnote/templatetags/__init__.py +0 -0
- snapnote/templatetags/snapnote_tags.py +65 -0
- snapnote/urls.py +14 -0
- snapnote/views.py +123 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-snapnote
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Report problems from any page of a Django site: screenshot, annotations and a ticket list.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Environment :: Web Environment
|
|
8
|
+
Classifier: Framework :: Django
|
|
9
|
+
Classifier: Framework :: Django :: 4.2
|
|
10
|
+
Classifier: Framework :: Django :: 5.0
|
|
11
|
+
Classifier: Framework :: Django :: 5.1
|
|
12
|
+
Classifier: Framework :: Django :: 5.2
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: django>=4.2
|
|
17
|
+
Requires-Dist: pillow>=9.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# django-snapnote
|
|
21
|
+
|
|
22
|
+
Let your users report a problem from any page: a floating button captures the tab, lets them
|
|
23
|
+
draw on it and creates a ticket you can review in the admin or in built-in views.
|
|
24
|
+
|
|
25
|
+
Requires only Django (4.2–5.2) and Pillow. No DRF, no Node, no build step for you. The widget is a
|
|
26
|
+
self-contained bundle rendered in a Shadow DOM, so your CSS and ours don't interfere.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install django-snapnote
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
# settings.py
|
|
36
|
+
INSTALLED_APPS += ["snapnote"] # needs django.contrib.auth and staticfiles
|
|
37
|
+
|
|
38
|
+
# urls.py
|
|
39
|
+
path("snapnote/", include("snapnote.urls")), # report endpoint + management views
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```django
|
|
43
|
+
{# in your base template (the view needs the request context processor) #}
|
|
44
|
+
{% load snapnote_tags %}
|
|
45
|
+
{% snapnote_button %}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then `python manage.py migrate`. Uploaded images use your `MEDIA_ROOT`/storage.
|
|
49
|
+
|
|
50
|
+
## Settings
|
|
51
|
+
|
|
52
|
+
| Setting | Default | Purpose |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `SNAPNOTE_CAN_REPORT` | `"snapnote.permissions.is_authenticated"` | Dotted path to `user -> bool`: who sees the button and may report |
|
|
55
|
+
| `SNAPNOTE_NOTIFIERS` | `["snapnote.notifiers.email"]` | Callables `(ticket, absolute_url)` run after a ticket is created |
|
|
56
|
+
| `SNAPNOTE_NOTIFY_EMAILS` | `[]` | Recipients of the email notifier (nothing is sent if empty) |
|
|
57
|
+
| `SNAPNOTE_BASE_TEMPLATE` | `None` | Template the management views extend; it only needs `{% block content %}` |
|
|
58
|
+
| `SNAPNOTE_ENABLE_MANAGEMENT_VIEWS` | `True` | List/detail views (the admin is always registered) |
|
|
59
|
+
| `SNAPNOTE_SEARCH_UNACCENT` | `False` | Accent-insensitive search (PostgreSQL + `django.contrib.postgres` only) |
|
|
60
|
+
| `SNAPNOTE_MAX_UPLOAD_MB` | `5` | Per-image size limit |
|
|
61
|
+
| `SNAPNOTE_MAX_DIMENSION` | `8000` | Max width/height in pixels |
|
|
62
|
+
| `SNAPNOTE_UPLOAD_TO` | `"snapnote/"` | Prefix in the storage |
|
|
63
|
+
| `SNAPNOTE_THROTTLE` | `"10/hour"` | Reports per user (`second`, `minute`, `hour`, `day`); uses Django's cache. `None` disables |
|
|
64
|
+
| `SNAPNOTE_AUTO_INJECT` | `False` | Also add `snapnote.middleware.AutoInjectMiddleware` to `MIDDLEWARE` to insert the button automatically. Incompatible with strict CSP and streaming responses |
|
|
65
|
+
|
|
66
|
+
Customise the look by overriding CSS variables on `#snapnote-root`
|
|
67
|
+
(`--snapnote-accent`, `--snapnote-accent-text`, `--snapnote-z-index`, `--snapnote-font`).
|
|
68
|
+
|
|
69
|
+
## Permissions
|
|
70
|
+
|
|
71
|
+
Reporting follows `SNAPNOTE_CAN_REPORT`. Managing uses the model permissions
|
|
72
|
+
`snapnote.view_ticket`, `change_ticket` and `delete_ticket` (superusers have them all).
|
|
73
|
+
Anonymous users are redirected to `LOGIN_URL`; authenticated users without permission get 403.
|
|
74
|
+
|
|
75
|
+
## Notifiers
|
|
76
|
+
|
|
77
|
+
The signal `snapnote.signals.ticket_created(sender, ticket, request_url)` is sent after commit.
|
|
78
|
+
A notifier is any callable; a failing one is logged and never breaks the report:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
# myproject/notifiers.py — Slack recipe
|
|
82
|
+
import requests
|
|
83
|
+
def slack(ticket, url):
|
|
84
|
+
requests.post(settings.SLACK_WEBHOOK, json={"text": f"New ticket: {ticket.title} {url}"}, timeout=5)
|
|
85
|
+
|
|
86
|
+
SNAPNOTE_NOTIFIERS = ["snapnote.notifiers.email", "myproject.notifiers.slack"]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Security and deployment notes
|
|
90
|
+
|
|
91
|
+
- **Screenshots can contain sensitive data.** Serve `MEDIA_ROOT` only to authenticated staff (or use a
|
|
92
|
+
private storage); do not expose it publicly. Purge old reports with
|
|
93
|
+
`python manage.py snapnote_purge --older-than 90` (`--dry-run` to preview).
|
|
94
|
+
- **Permissions-Policy**: the browser needs `display-capture=(self)`. With `display-capture=()`
|
|
95
|
+
(also sent by some proxies) capture never works and users get the upload fallback.
|
|
96
|
+
- **CSP**: allow `img-src blob: data:` and `style-src 'unsafe-inline'` (the Shadow DOM stylesheet
|
|
97
|
+
is inline) and `script-src` for the static bundle.
|
|
98
|
+
- Uploads are validated (PNG/JPEG/WebP only, no SVG), size/dimension limited and re-encoded with Pillow.
|
|
99
|
+
- `page_url` must be http(s) and is never rendered as a clickable link otherwise.
|
|
100
|
+
- The report endpoint uses Django's CSRF protection; the template tag sets the cookie.
|
|
101
|
+
- Capture uses `getDisplayMedia` (desktop browsers). On mobile the user attaches an image.
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pip install -e . pytest pytest-django
|
|
107
|
+
pytest # SQLite; SNAPNOTE_TEST_DB=postgres for PostgreSQL
|
|
108
|
+
cd frontend && npm ci && npm run build # writes src/snapnote/static/snapnote/snapnote.js
|
|
109
|
+
npm test && npm run e2e
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The built `snapnote.js` is committed/packaged so installing the wheel never needs Node.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
snapnote/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
|
|
2
|
+
snapnote/admin.py,sha256=8SpOdeu_xcvj9hq0CTtSINJ5i9PEfL0spTMDKU414ik,398
|
|
3
|
+
snapnote/apps.py,sha256=ASR7xi2EOT23E5uddRi7jMB2cjiXbl3494svOdV7j0U,452
|
|
4
|
+
snapnote/checks.py,sha256=JYcd8UA5VvFKHF2_JRufDY093ndVQ50o5DZCMkhIuOk,672
|
|
5
|
+
snapnote/conf.py,sha256=AGJiH3IDxtsmMjaE_12eSFDqxYcZwmMdTzwLf0cz9FA,661
|
|
6
|
+
snapnote/forms.py,sha256=kNO3SGn8PqW3ZOiDzZQ-IbqHuaXnQnwRw43qt7s49jU,2966
|
|
7
|
+
snapnote/middleware.py,sha256=OcF9bc7X8gxoEwrawMiFHxnNdn1brOPdNRzFT6OYrPg,1224
|
|
8
|
+
snapnote/models.py,sha256=M6VlT-TCdCPckffz5vhnJ9MJIrAz14Y3gP4xXCpQJh8,3992
|
|
9
|
+
snapnote/notifiers.py,sha256=NvV41RIRMMcQVwBq2RDuwiB9yjAYtK_64h3ij71SCok,1215
|
|
10
|
+
snapnote/permissions.py,sha256=Z2tr344MBwHqZYiL987vEIm9-74WThQ7-Lmj17uQu3c,76
|
|
11
|
+
snapnote/signals.py,sha256=E3akLPzYQGfkQgvR8e7qZXxne8ajluJQiXu4ij_ogmQ,206
|
|
12
|
+
snapnote/urls.py,sha256=ksVPeeVwouEpe6Oo0mQ2KnS-v_lpYYEXhtuFfYrZouQ,578
|
|
13
|
+
snapnote/views.py,sha256=vBmc2LnriG29u-hIOR2YxE4ascbqstrBsnn2GaIhv3I,4264
|
|
14
|
+
snapnote/locale/es/LC_MESSAGES/django.mo,sha256=JncSwBm73vBqreNuxsFUwAl6McEqW2T4v6HRsKutcTU,2934
|
|
15
|
+
snapnote/locale/es/LC_MESSAGES/django.po,sha256=tso7WVBMKnIFZJ6fUefHB__SE43ixyTGkSHOn_ojdvM,3055
|
|
16
|
+
snapnote/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
snapnote/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
snapnote/management/commands/snapnote_purge.py,sha256=DgRBF-F_qiiaIej-aaIyuU4B457RCkkN9ArO59X_Ads,964
|
|
19
|
+
snapnote/migrations/0001_initial.py,sha256=OsrI176xEDLf7iqTup4r8yMjqeuFcorjy_f5vMlpOmY,3343
|
|
20
|
+
snapnote/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
snapnote/static/snapnote/management.css,sha256=e5x4vpUE29PsxPfWnCZaRa7aYQj3-b1XW9N-Lotr_hs,1913
|
|
22
|
+
snapnote/static/snapnote/snapnote.js,sha256=wUXb8woyoTscCLhCrdIo9HtprJ3q6aHEtIDjGqorJ8E,156495
|
|
23
|
+
snapnote/templates/snapnote/base.html,sha256=pG-amUv_AYSFDQxgG56YUD2cDP9Aa8xvdLdLQXFAF94,372
|
|
24
|
+
snapnote/templates/snapnote/button.html,sha256=vfgN3_yUIW6RGZXkudu_CazenLodGRVrL2xIxTdwEpY,141
|
|
25
|
+
snapnote/templates/snapnote/ticket_confirm_delete.html,sha256=ksd4UukbtRqaitN1j29Gg-fhu5bp1k7_orydIztQMQo,554
|
|
26
|
+
snapnote/templates/snapnote/ticket_detail.html,sha256=3TT5TQFTjs9XpbHWNVVRc0kNwnJji_2laRS4lNWgatY,2403
|
|
27
|
+
snapnote/templates/snapnote/ticket_list.html,sha256=5xaJhpO5XsKD7OUwIp-XvuQLkay68xHfA-E7qxLh1Ig,1663
|
|
28
|
+
snapnote/templates/snapnote/includes/status_badge.html,sha256=VS66ixwUbGXgGl_JtSLaQGEfUE3c6JneQZKwJ3aJMQg,103
|
|
29
|
+
snapnote/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
30
|
+
snapnote/templatetags/snapnote_tags.py,sha256=FAsBgoRh06Lm6jXdE0llG96kn6xapIpz2_fvj8_jNT8,2032
|
|
31
|
+
django_snapnote-0.1.1.dist-info/METADATA,sha256=Fsrim2eelPngKepSbnkteSfGUdTLKrcNi-UcoNghmRo,5020
|
|
32
|
+
django_snapnote-0.1.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
33
|
+
django_snapnote-0.1.1.dist-info/licenses/LICENSE,sha256=6w-wMxs-japVMrB73w0A5dGaX1tMYeV8wl5N6M25REE,1067
|
|
34
|
+
django_snapnote-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Magnet SpA
|
|
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.
|
snapnote/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
snapnote/admin.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
|
|
3
|
+
from .models import Ticket
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@admin.register(Ticket)
|
|
7
|
+
class TicketAdmin(admin.ModelAdmin):
|
|
8
|
+
list_display = ("id", "title", "status", "reported_by", "created_at")
|
|
9
|
+
list_filter = ("status", "created_at")
|
|
10
|
+
search_fields = ("title", "description", "page_url")
|
|
11
|
+
raw_id_fields = ("reported_by", "closed_by")
|
|
12
|
+
readonly_fields = ("created_at", "updated_at")
|
snapnote/apps.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.utils.translation import gettext_lazy as _
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SnapnoteConfig(AppConfig):
|
|
6
|
+
name = "snapnote"
|
|
7
|
+
verbose_name = _("Snapnote")
|
|
8
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
from . import checks, notifiers # noqa: F401
|
|
12
|
+
from .signals import ticket_created
|
|
13
|
+
|
|
14
|
+
ticket_created.connect(notifiers.run_notifiers, dispatch_uid="snapnote.run_notifiers")
|
snapnote/checks.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from django.apps import apps
|
|
2
|
+
from django.core.checks import Warning, register
|
|
3
|
+
from django.db import connection
|
|
4
|
+
|
|
5
|
+
from . import conf
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@register()
|
|
9
|
+
def check_settings(app_configs, **kwargs):
|
|
10
|
+
messages = []
|
|
11
|
+
if conf.get("SEARCH_UNACCENT") and (
|
|
12
|
+
connection.vendor != "postgresql" or not apps.is_installed("django.contrib.postgres")
|
|
13
|
+
):
|
|
14
|
+
messages.append(
|
|
15
|
+
Warning(
|
|
16
|
+
"SNAPNOTE_SEARCH_UNACCENT is ignored.",
|
|
17
|
+
hint="It needs PostgreSQL and 'django.contrib.postgres' in INSTALLED_APPS "
|
|
18
|
+
"(plus the unaccent extension).",
|
|
19
|
+
id="snapnote.W001",
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
return messages
|
snapnote/conf.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.utils.module_loading import import_string
|
|
3
|
+
|
|
4
|
+
DEFAULTS = {
|
|
5
|
+
"CAN_REPORT": "snapnote.permissions.is_authenticated",
|
|
6
|
+
"NOTIFIERS": ["snapnote.notifiers.email"],
|
|
7
|
+
"NOTIFY_EMAILS": [],
|
|
8
|
+
"BASE_TEMPLATE": None,
|
|
9
|
+
"ENABLE_MANAGEMENT_VIEWS": True,
|
|
10
|
+
"SEARCH_UNACCENT": False,
|
|
11
|
+
"MAX_UPLOAD_MB": 5,
|
|
12
|
+
"MAX_DIMENSION": 8000,
|
|
13
|
+
"UPLOAD_TO": "snapnote/",
|
|
14
|
+
"THROTTLE": "10/hour",
|
|
15
|
+
"AUTO_INJECT": False,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get(name):
|
|
20
|
+
return getattr(settings, f"SNAPNOTE_{name}", DEFAULTS[name])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_callable(name):
|
|
24
|
+
value = get(name)
|
|
25
|
+
return import_string(value) if isinstance(value, str) else value
|
snapnote/forms.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from urllib.parse import urlsplit
|
|
3
|
+
|
|
4
|
+
from django import forms
|
|
5
|
+
from django.core.files.base import ContentFile
|
|
6
|
+
from django.utils.translation import gettext_lazy as _
|
|
7
|
+
from PIL import Image, UnidentifiedImageError
|
|
8
|
+
|
|
9
|
+
from . import conf
|
|
10
|
+
from .models import Ticket
|
|
11
|
+
|
|
12
|
+
ALLOWED_FORMATS = {"PNG": "png", "JPEG": "jpg", "WEBP": "webp"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def clean_image(upload):
|
|
16
|
+
"""Validates size and dimensions, rejects non raster formats and re-encodes with Pillow."""
|
|
17
|
+
max_bytes = conf.get("MAX_UPLOAD_MB") * 1024 * 1024
|
|
18
|
+
if upload.size > max_bytes:
|
|
19
|
+
raise forms.ValidationError(
|
|
20
|
+
_("The image is too large (max %(mb)s MB)."), params={"mb": conf.get("MAX_UPLOAD_MB")}
|
|
21
|
+
)
|
|
22
|
+
try:
|
|
23
|
+
upload.seek(0)
|
|
24
|
+
image = Image.open(upload)
|
|
25
|
+
fmt = image.format
|
|
26
|
+
if fmt not in ALLOWED_FORMATS:
|
|
27
|
+
raise forms.ValidationError(_("Unsupported image format."))
|
|
28
|
+
limit = conf.get("MAX_DIMENSION")
|
|
29
|
+
if max(image.size) > limit:
|
|
30
|
+
raise forms.ValidationError(
|
|
31
|
+
_("The image is too large (max %(px)s px per side)."), params={"px": limit}
|
|
32
|
+
)
|
|
33
|
+
image.load()
|
|
34
|
+
except (UnidentifiedImageError, OSError, Image.DecompressionBombError):
|
|
35
|
+
raise forms.ValidationError(_("Upload a valid image.")) from None
|
|
36
|
+
if fmt == "JPEG" and image.mode not in ("RGB", "L"):
|
|
37
|
+
image = image.convert("RGB")
|
|
38
|
+
out = io.BytesIO()
|
|
39
|
+
# Saving a fresh image drops metadata and any trailing payload.
|
|
40
|
+
image.save(out, format=fmt)
|
|
41
|
+
return ContentFile(
|
|
42
|
+
out.getvalue(), name=f"{upload.name.rsplit('.', 1)[0]}.{ALLOWED_FORMATS[fmt]}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TicketReportForm(forms.ModelForm):
|
|
47
|
+
class Meta:
|
|
48
|
+
model = Ticket
|
|
49
|
+
fields = ("title", "description", "page_url", "screenshot", "annotations")
|
|
50
|
+
|
|
51
|
+
def clean_page_url(self):
|
|
52
|
+
value = self.cleaned_data["page_url"].strip()
|
|
53
|
+
if urlsplit(value).scheme not in ("http", "https"):
|
|
54
|
+
raise forms.ValidationError(_("Enter an http or https URL."))
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
def _clean_file(self, name):
|
|
58
|
+
upload = self.cleaned_data.get(name)
|
|
59
|
+
# Falsy when nothing was uploaded (or an empty FieldFile).
|
|
60
|
+
if not upload or not hasattr(upload, "size"):
|
|
61
|
+
return upload
|
|
62
|
+
return clean_image(upload)
|
|
63
|
+
|
|
64
|
+
def clean_screenshot(self):
|
|
65
|
+
return self._clean_file("screenshot")
|
|
66
|
+
|
|
67
|
+
def clean_annotations(self):
|
|
68
|
+
return self._clean_file("annotations")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TicketFilterForm(forms.Form):
|
|
72
|
+
q = forms.CharField(label=_("Search"), required=False)
|
|
73
|
+
status = forms.ChoiceField(
|
|
74
|
+
label=_("Status"), required=False, choices=[("", _("All"))] + Ticket.Status.choices
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def filter(self, queryset):
|
|
78
|
+
if not self.is_valid():
|
|
79
|
+
return queryset
|
|
80
|
+
queryset = queryset.search(self.cleaned_data["q"])
|
|
81
|
+
if self.cleaned_data["status"]:
|
|
82
|
+
queryset = queryset.filter(status=self.cleaned_data["status"])
|
|
83
|
+
return queryset
|
|
Binary file
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#
|
|
2
|
+
msgid ""
|
|
3
|
+
msgstr ""
|
|
4
|
+
"Project-Id-Version: django-snapnote\n"
|
|
5
|
+
"Language: es\n"
|
|
6
|
+
"MIME-Version: 1.0\n"
|
|
7
|
+
"Content-Type: text/plain; charset=utf-8\n"
|
|
8
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
9
|
+
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
10
|
+
|
|
11
|
+
msgid "Snapnote"
|
|
12
|
+
msgstr "Snapnote"
|
|
13
|
+
|
|
14
|
+
msgid "open"
|
|
15
|
+
msgstr "abierto"
|
|
16
|
+
|
|
17
|
+
msgid "closed"
|
|
18
|
+
msgstr "cerrado"
|
|
19
|
+
|
|
20
|
+
msgid "title"
|
|
21
|
+
msgstr "título"
|
|
22
|
+
|
|
23
|
+
msgid "description"
|
|
24
|
+
msgstr "descripción"
|
|
25
|
+
|
|
26
|
+
msgid "page url"
|
|
27
|
+
msgstr "URL de la página"
|
|
28
|
+
|
|
29
|
+
msgid "screenshot"
|
|
30
|
+
msgstr "captura"
|
|
31
|
+
|
|
32
|
+
msgid "annotations"
|
|
33
|
+
msgstr "anotaciones"
|
|
34
|
+
|
|
35
|
+
msgid "status"
|
|
36
|
+
msgstr "estado"
|
|
37
|
+
|
|
38
|
+
msgid "reported by"
|
|
39
|
+
msgstr "reportado por"
|
|
40
|
+
|
|
41
|
+
msgid "closed at"
|
|
42
|
+
msgstr "cerrado el"
|
|
43
|
+
|
|
44
|
+
msgid "closed by"
|
|
45
|
+
msgstr "cerrado por"
|
|
46
|
+
|
|
47
|
+
msgid "created at"
|
|
48
|
+
msgstr "creado el"
|
|
49
|
+
|
|
50
|
+
msgid "updated at"
|
|
51
|
+
msgstr "actualizado el"
|
|
52
|
+
|
|
53
|
+
msgid "ticket"
|
|
54
|
+
msgstr "ticket"
|
|
55
|
+
|
|
56
|
+
msgid "tickets"
|
|
57
|
+
msgstr "tickets"
|
|
58
|
+
|
|
59
|
+
msgid "Page"
|
|
60
|
+
msgstr "Página"
|
|
61
|
+
|
|
62
|
+
msgid "Ticket"
|
|
63
|
+
msgstr "Ticket"
|
|
64
|
+
|
|
65
|
+
msgid "Search"
|
|
66
|
+
msgstr "Buscar"
|
|
67
|
+
|
|
68
|
+
msgid "Status"
|
|
69
|
+
msgstr "Estado"
|
|
70
|
+
|
|
71
|
+
msgid "All"
|
|
72
|
+
msgstr "Todos"
|
|
73
|
+
|
|
74
|
+
msgid "The image is too large (max %(mb)s MB)."
|
|
75
|
+
msgstr "La imagen es demasiado grande (máx. %(mb)s MB)."
|
|
76
|
+
|
|
77
|
+
msgid "Unsupported image format."
|
|
78
|
+
msgstr "Formato de imagen no soportado."
|
|
79
|
+
|
|
80
|
+
msgid "The image is too large (max %(px)s px per side)."
|
|
81
|
+
msgstr "La imagen es demasiado grande (máx. %(px)s px por lado)."
|
|
82
|
+
|
|
83
|
+
msgid "Upload a valid image."
|
|
84
|
+
msgstr "Sube una imagen válida."
|
|
85
|
+
|
|
86
|
+
msgid "Enter an http or https URL."
|
|
87
|
+
msgstr "Ingresa una URL http o https."
|
|
88
|
+
|
|
89
|
+
msgid "Report a problem"
|
|
90
|
+
msgstr "Reportar un problema"
|
|
91
|
+
|
|
92
|
+
msgid "Title"
|
|
93
|
+
msgstr "Título"
|
|
94
|
+
|
|
95
|
+
msgid "Description"
|
|
96
|
+
msgstr "Descripción"
|
|
97
|
+
|
|
98
|
+
msgid "Preview"
|
|
99
|
+
msgstr "Vista previa"
|
|
100
|
+
|
|
101
|
+
msgid "Edit annotations"
|
|
102
|
+
msgstr "Editar anotaciones"
|
|
103
|
+
|
|
104
|
+
msgid "Send"
|
|
105
|
+
msgstr "Enviar"
|
|
106
|
+
|
|
107
|
+
msgid "Cancel"
|
|
108
|
+
msgstr "Cancelar"
|
|
109
|
+
|
|
110
|
+
msgid "Sending…"
|
|
111
|
+
msgstr "Enviando…"
|
|
112
|
+
|
|
113
|
+
msgid "Thanks! Your report was sent."
|
|
114
|
+
msgstr "¡Gracias! Tu reporte fue enviado."
|
|
115
|
+
|
|
116
|
+
msgid "The report could not be sent. Try again."
|
|
117
|
+
msgstr "No se pudo enviar el reporte. Inténtalo de nuevo."
|
|
118
|
+
|
|
119
|
+
msgid "Could not capture the screen. You can attach a screenshot instead."
|
|
120
|
+
msgstr "No se pudo capturar la pantalla. Puedes adjuntar una captura."
|
|
121
|
+
|
|
122
|
+
msgid "Attach a screenshot"
|
|
123
|
+
msgstr "Adjuntar una captura"
|
|
124
|
+
|
|
125
|
+
msgid "Pen"
|
|
126
|
+
msgstr "Lápiz"
|
|
127
|
+
|
|
128
|
+
msgid "Rectangle"
|
|
129
|
+
msgstr "Rectángulo"
|
|
130
|
+
|
|
131
|
+
msgid "Text"
|
|
132
|
+
msgstr "Texto"
|
|
133
|
+
|
|
134
|
+
msgid "Undo"
|
|
135
|
+
msgstr "Deshacer"
|
|
136
|
+
|
|
137
|
+
msgid "Clear all"
|
|
138
|
+
msgstr "Borrar todo"
|
|
139
|
+
|
|
140
|
+
msgid "Done"
|
|
141
|
+
msgstr "Listo"
|
|
142
|
+
|
|
143
|
+
msgid "The title is required."
|
|
144
|
+
msgstr "El título es obligatorio."
|
|
145
|
+
|
|
146
|
+
msgid "Tickets"
|
|
147
|
+
msgstr "Tickets"
|
|
148
|
+
|
|
149
|
+
msgid "Filter"
|
|
150
|
+
msgstr "Filtrar"
|
|
151
|
+
|
|
152
|
+
msgid "Reported by"
|
|
153
|
+
msgstr "Reportado por"
|
|
154
|
+
|
|
155
|
+
msgid "Created at"
|
|
156
|
+
msgstr "Creado el"
|
|
157
|
+
|
|
158
|
+
msgid "No tickets found."
|
|
159
|
+
msgstr "No se encontraron tickets."
|
|
160
|
+
|
|
161
|
+
msgid "Close"
|
|
162
|
+
msgstr "Cerrar"
|
|
163
|
+
|
|
164
|
+
msgid "Reopen"
|
|
165
|
+
msgstr "Reabrir"
|
|
166
|
+
|
|
167
|
+
msgid "Delete"
|
|
168
|
+
msgstr "Eliminar"
|
|
169
|
+
|
|
170
|
+
msgid "Closed"
|
|
171
|
+
msgstr "Cerrado"
|
|
172
|
+
|
|
173
|
+
msgid "Show annotations"
|
|
174
|
+
msgstr "Mostrar anotaciones"
|
|
175
|
+
|
|
176
|
+
msgid "Screenshot"
|
|
177
|
+
msgstr "Captura"
|
|
178
|
+
|
|
179
|
+
msgid "Annotations"
|
|
180
|
+
msgstr "Anotaciones"
|
|
181
|
+
|
|
182
|
+
msgid "Confirm"
|
|
183
|
+
msgstr "Confirmar"
|
|
184
|
+
|
|
185
|
+
msgid "Are you sure you want to delete \"%(object)s\"?"
|
|
186
|
+
msgstr "¿Seguro que quieres eliminar \"%(object)s\"?"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
2
|
+
|
|
3
|
+
from django.core.management.base import BaseCommand
|
|
4
|
+
from django.utils import timezone
|
|
5
|
+
|
|
6
|
+
from snapnote.models import Ticket
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Command(BaseCommand):
|
|
10
|
+
help = "Deletes tickets (and their images) older than N days."
|
|
11
|
+
|
|
12
|
+
def add_arguments(self, parser):
|
|
13
|
+
parser.add_argument("--older-than", type=int, required=True, metavar="DAYS")
|
|
14
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
15
|
+
|
|
16
|
+
def handle(self, *args, older_than, dry_run, **options):
|
|
17
|
+
cutoff = timezone.now() - timedelta(days=older_than)
|
|
18
|
+
tickets = Ticket.objects.filter(created_at__lt=cutoff)
|
|
19
|
+
count = tickets.count()
|
|
20
|
+
if not dry_run:
|
|
21
|
+
for ticket in tickets:
|
|
22
|
+
ticket.screenshot.delete(save=False)
|
|
23
|
+
ticket.annotations.delete(save=False)
|
|
24
|
+
ticket.delete()
|
|
25
|
+
verb = "Would delete" if dry_run else "Deleted"
|
|
26
|
+
self.stdout.write(f"{verb} {count} ticket(s).")
|
snapnote/middleware.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from . import conf
|
|
2
|
+
from .templatetags.snapnote_tags import render_button
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AutoInjectMiddleware:
|
|
6
|
+
"""Inserts the button before </body> in HTML responses. Opt in with SNAPNOTE_AUTO_INJECT.
|
|
7
|
+
|
|
8
|
+
Not compatible with streaming responses or a strict CSP.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, get_response):
|
|
12
|
+
self.get_response = get_response
|
|
13
|
+
|
|
14
|
+
def __call__(self, request):
|
|
15
|
+
response = self.get_response(request)
|
|
16
|
+
if (
|
|
17
|
+
not conf.get("AUTO_INJECT")
|
|
18
|
+
or getattr(response, "streaming", False)
|
|
19
|
+
or "text/html" not in response.get("Content-Type", "")
|
|
20
|
+
or response.get("Content-Encoding")
|
|
21
|
+
):
|
|
22
|
+
return response
|
|
23
|
+
content = response.content.decode(response.charset or "utf-8")
|
|
24
|
+
if "snapnote-root" in content or "</body>" not in content:
|
|
25
|
+
return response
|
|
26
|
+
snippet = render_button(request)
|
|
27
|
+
if snippet.strip():
|
|
28
|
+
head, _, tail = content.rpartition("</body>")
|
|
29
|
+
response.content = f"{head}{snippet}</body>{tail}".encode(response.charset or "utf-8")
|
|
30
|
+
if response.has_header("Content-Length"):
|
|
31
|
+
response["Content-Length"] = len(response.content)
|
|
32
|
+
return response
|