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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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