From 9135e8849558fbb498101fdf3d2c2c53dee72893 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 19 Jun 2025 09:44:55 +0200 Subject: [PATCH 01/41] Wrap one more BASE_URL usages with str() In 59372aba3381 a lazily evaluated BASE_URL tool was introduced. I found 1 more case in which BASE_URL was not "collapsed into a string" magically by `__add__`, causing an `AttributeError: 'TenantBaseURL' object has no attribute 'decode'` --- bsmain/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bsmain/__init__.py b/bsmain/__init__.py index 792a705..aadfb10 100644 --- a/bsmain/__init__.py +++ b/bsmain/__init__.py @@ -38,7 +38,7 @@ def check_event_storage_properly_configured(app_configs, **kwargs): @register("bsmain") def check_base_url_is_url(app_configs, **kwargs): try: - parts = urllib.parse.urlsplit(get_settings().BASE_URL) + parts = urllib.parse.urlsplit(str(get_settings().BASE_URL)) except ValueError as e: return [Warning( str(e), From 072ed7068115d5da6136725ed860e24801b38c58 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 19 Jun 2025 20:44:27 +0200 Subject: [PATCH 02/41] 1.6.2 CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5695457..0ded32b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changes +## 1.6.2 (19 June 2025) + +* Too many quotes in local-vars display (Fix #119) + ## 1.6.1 (11 June 2025) Remove hard-coded slack `webhook_url` from the "test this connector" loop. From 6c8624f68316e4ccc67145ca995bf0721e77b7b5 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Tue, 24 Jun 2025 22:16:20 +0200 Subject: [PATCH 03/41] Development settings: email using os.getenv --- bugsink/settings/development.py | 10 ++++------ theme/static/css/dist/styles.css | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/bugsink/settings/development.py b/bugsink/settings/development.py index 5e79221..cc0db7a 100644 --- a/bugsink/settings/development.py +++ b/bugsink/settings/development.py @@ -91,15 +91,13 @@ SNAPPEA = { "NUM_WORKERS": 1, } -POSTMARK_API_KEY = os.getenv('POSTMARK_API_KEY') - -EMAIL_HOST = 'smtp.postmarkapp.com' -EMAIL_HOST_USER = POSTMARK_API_KEY -EMAIL_HOST_PASSWORD = POSTMARK_API_KEY +EMAIL_HOST = os.getenv("EMAIL_HOST") +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") EMAIL_PORT = 587 EMAIL_USE_TLS = True -SERVER_EMAIL = DEFAULT_FROM_EMAIL = 'Klaas van Schelven ' +SERVER_EMAIL = DEFAULT_FROM_EMAIL = 'Klaas van Schelven ' BUGSINK = { diff --git a/theme/static/css/dist/styles.css b/theme/static/css/dist/styles.css index 33bad01..1c8d8b2 100644 --- a/theme/static/css/dist/styles.css +++ b/theme/static/css/dist/styles.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:IBM Plex Sans,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-1\/2{left:50%}.z-50{z-index:50}.float-right{float:right}.m-1{margin:.25rem}.m-4{margin:1rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0{margin-left:0}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.size-6{width:1.5rem;height:1.5rem}.size-8{width:2rem;height:2rem}.h-12{height:3rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-1\/4{width:25%}.w-1\/6{width:16.666667%}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-128{width:32rem}.w-2\/3{width:66.666667%}.w-24{width:6rem}.w-3\/4{width:75%}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-full{width:100%}.max-w-4xl{max-width:56rem}.flex-\[2_1_96rem\]{flex:2 1 96rem}.flex-auto{flex:1 1 auto}.flex-none{flex:none}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.rotate-180{--tw-rotate:180deg}.rotate-180,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.place-content-end{place-content:end}.content-start{align-content:flex-start}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.self-stretch{align-self:stretch}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-scroll{overflow-y:scroll}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-e-md{border-start-end-radius:.375rem;border-end-end-radius:.375rem}.rounded-s-md{border-start-start-radius:.375rem;border-end-start-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b-2{border-bottom-width:2px}.border-b-4{border-bottom-width:4px}.border-l-2{border-left-width:2px}.border-r-2{border-right-width:2px}.border-t-2{border-top-width:2px}.border-dotted{border-style:dotted}.border-cyan-500{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity))}.border-cyan-800{--tw-border-opacity:1;border-color:rgb(21 94 117/var(--tw-border-opacity))}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity))}.border-red-800{--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity))}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity))}.border-slate-400{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity))}.border-slate-50{--tw-border-opacity:1;border-color:rgb(248 250 252/var(--tw-border-opacity))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity))}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity))}.bg-cyan-100{--tw-bg-opacity:1;background-color:rgb(207 250 254/var(--tw-bg-opacity))}.bg-cyan-200{--tw-bg-opacity:1;background-color:rgb(165 243 252/var(--tw-bg-opacity))}.bg-cyan-50{--tw-bg-opacity:1;background-color:rgb(236 254 255/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity))}.bg-slate-600{--tw-bg-opacity:1;background-color:rgb(71 85 105/var(--tw-bg-opacity))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity))}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-slate-300{--tw-gradient-from:#cbd5e1 var(--tw-gradient-from-position);--tw-gradient-to:rgba(203,213,225,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.fill-cyan-500{fill:#06b6d4}.fill-slate-300{fill:#cbd5e1}.fill-slate-500{fill:#64748b}.fill-slate-800{fill:#1e293b}.stroke-slate-300{stroke:#cbd5e1}.p-12{padding:3rem}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pl-1{padding-left:.25rem}.pl-12{padding-left:3rem}.pl-2{padding-left:.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-6{padding-right:1.5rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-serif{font-family:ui-serif,Georgia,Cambria,Times New Roman,Times,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.leading-normal{line-height:1.5}.tracking-normal{letter-spacing:0}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-cyan-500{--tw-text-opacity:1;color:rgb(6 182 212/var(--tw-text-opacity))}.text-cyan-800{--tw-text-opacity:1;color:rgb(21 94 117/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}.dropdown{position:relative;display:inline-block}.dropdown-content-right{display:none;position:absolute;z-index:1;margin-left:auto;right:0}.dropdown-content-left{display:none;position:absolute;z-index:1;left:0}.dropdown:hover .dropdown-content-left,.dropdown:hover .dropdown-content-right{display:flex}.triangle-left{position:relative}.triangle-left:before{content:"";border-color:transparent #cbd5e1 transparent transparent;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-8px;top:20px}.triangle-left:after{content:"";border-color:transparent #fff transparent transparent;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-6px;top:20px}pre{line-height:125%}.syntax-coloring .c{color:#3d7b7b;font-style:italic}.syntax-coloring .err{border:1px solid red}.syntax-coloring .k{color:green;font-weight:700}.syntax-coloring .o{color:#666}.syntax-coloring .ch,.syntax-coloring .cm{color:#3d7b7b;font-style:italic}.syntax-coloring .cp{color:#9c6500}.syntax-coloring .c1,.syntax-coloring .cpf,.syntax-coloring .cs{color:#3d7b7b;font-style:italic}.syntax-coloring .gd{color:#a00000}.syntax-coloring .ge{font-style:italic}.syntax-coloring .ges{font-weight:700;font-style:italic}.syntax-coloring .gr{color:#e40000}.syntax-coloring .gh{color:navy;font-weight:700}.syntax-coloring .gi{color:#008400}.syntax-coloring .go{color:#717171}.syntax-coloring .gp{color:navy;font-weight:700}.syntax-coloring .gs{font-weight:700}.syntax-coloring .gu{color:purple;font-weight:700}.syntax-coloring .gt{color:#04d}.syntax-coloring .kc,.syntax-coloring .kd,.syntax-coloring .kn{color:green;font-weight:700}.syntax-coloring .kp{color:green}.syntax-coloring .kr{color:green;font-weight:700}.syntax-coloring .kt{color:#b00040}.syntax-coloring .m{color:#666}.syntax-coloring .s{color:#ba2121}.syntax-coloring .na{color:#687822}.syntax-coloring .nb{color:green}.syntax-coloring .nc{color:#00f;font-weight:700}.syntax-coloring .no{color:#800}.syntax-coloring .nd{color:#a2f}.syntax-coloring .ni{color:#717171;font-weight:700}.syntax-coloring .ne{color:#cb3f38;font-weight:700}.syntax-coloring .nf{color:#00f}.syntax-coloring .nl{color:#767600}.syntax-coloring .nn{color:#00f;font-weight:700}.syntax-coloring .nt{color:green;font-weight:700}.syntax-coloring .nv{color:#19177c}.syntax-coloring .ow{color:#a2f;font-weight:700}.syntax-coloring .w{color:#bbb}.syntax-coloring .mb,.syntax-coloring .mf,.syntax-coloring .mh,.syntax-coloring .mi,.syntax-coloring .mo{color:#666}.syntax-coloring .dl,.syntax-coloring .sa,.syntax-coloring .sb,.syntax-coloring .sc{color:#ba2121}.syntax-coloring .sd{color:#ba2121;font-style:italic}.syntax-coloring .s2{color:#ba2121}.syntax-coloring .se{color:#aa5d1f;font-weight:700}.syntax-coloring .sh{color:#ba2121}.syntax-coloring .si{color:#a45a77;font-weight:700}.syntax-coloring .sx{color:green}.syntax-coloring .sr{color:#a45a77}.syntax-coloring .s1{color:#ba2121}.syntax-coloring .ss{color:#19177c}.syntax-coloring .bp{color:green}.syntax-coloring .fm{color:#00f}.syntax-coloring .vc,.syntax-coloring .vg,.syntax-coloring .vi,.syntax-coloring .vm{color:#19177c}.syntax-coloring .il{color:#666}input[type=radio]{color:#06b6d4}.hover\:border-b-4:hover{border-bottom-width:4px}.hover\:border-slate-400:hover{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity))}.hover\:bg-cyan-400:hover{--tw-bg-opacity:1;background-color:rgb(34 211 238/var(--tw-bg-opacity))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity))}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.hover\:bg-slate-200:hover{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity))}.hover\:bg-slate-300:hover{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity))}.focus\:border-cyan-500:focus{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-cyan-200:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(165 243 252/var(--tw-ring-opacity))}.active\:ring:active{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}@media (min-width:768px){.md\:mb-8{margin-bottom:2rem}.md\:h-16{height:4rem}.md\:w-16{width:4rem}.md\:p-4{padding:1rem}.md\:p-8{padding:2rem}.md\:py-4{padding-top:1rem;padding-bottom:1rem}.md\:pb-16{padding-bottom:4rem}.md\:pl-24{padding-left:6rem}.md\:pr-24{padding-right:6rem}.md\:pt-24{padding-top:6rem}}@media (min-width:1024px){.lg\:w-5\/12{width:41.666667%}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:pb-0{padding-bottom:0}}@media (min-width:1280px){.xl\:flex{display:flex}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:IBM Plex Sans,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-1\/2{left:50%}.z-50{z-index:50}.float-right{float:right}.m-1{margin:.25rem}.m-4{margin:1rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0{margin-left:0}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.size-6{width:1.5rem;height:1.5rem}.size-8{width:2rem;height:2rem}.h-12{height:3rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-1\/4{width:25%}.w-1\/6{width:16.666667%}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-128{width:32rem}.w-2\/3{width:66.666667%}.w-24{width:6rem}.w-3\/4{width:75%}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-full{width:100%}.max-w-4xl{max-width:56rem}.flex-\[2_1_96rem\]{flex:2 1 96rem}.flex-auto{flex:1 1 auto}.flex-none{flex:none}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.rotate-180{--tw-rotate:180deg}.rotate-180,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.place-content-end{place-content:end}.content-start{align-content:flex-start}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.self-stretch{align-self:stretch}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-scroll{overflow-y:scroll}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-e-md{border-start-end-radius:.375rem;border-end-end-radius:.375rem}.rounded-s-md{border-start-start-radius:.375rem;border-end-start-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b-2{border-bottom-width:2px}.border-b-4{border-bottom-width:4px}.border-l-2{border-left-width:2px}.border-r-2{border-right-width:2px}.border-t-2{border-top-width:2px}.border-dotted{border-style:dotted}.border-cyan-500{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.border-cyan-800{--tw-border-opacity:1;border-color:rgb(21 94 117/var(--tw-border-opacity,1))}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity,1))}.border-red-800{--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1))}.border-slate-400{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.border-slate-50{--tw-border-opacity:1;border-color:rgb(248 250 252/var(--tw-border-opacity,1))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity,1))}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.bg-cyan-100{--tw-bg-opacity:1;background-color:rgb(207 250 254/var(--tw-bg-opacity,1))}.bg-cyan-200{--tw-bg-opacity:1;background-color:rgb(165 243 252/var(--tw-bg-opacity,1))}.bg-cyan-50{--tw-bg-opacity:1;background-color:rgb(236 254 255/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-slate-600{--tw-bg-opacity:1;background-color:rgb(71 85 105/var(--tw-bg-opacity,1))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-slate-300{--tw-gradient-from:#cbd5e1 var(--tw-gradient-from-position);--tw-gradient-to:rgba(203,213,225,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.fill-cyan-500{fill:#06b6d4}.fill-slate-300{fill:#cbd5e1}.fill-slate-500{fill:#64748b}.fill-slate-800{fill:#1e293b}.stroke-slate-300{stroke:#cbd5e1}.p-12{padding:3rem}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pl-1{padding-left:.25rem}.pl-12{padding-left:3rem}.pl-2{padding-left:.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-6{padding-right:1.5rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-serif{font-family:ui-serif,Georgia,Cambria,Times New Roman,Times,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.leading-normal{line-height:1.5}.tracking-normal{letter-spacing:0}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-cyan-500{--tw-text-opacity:1;color:rgb(6 182 212/var(--tw-text-opacity,1))}.text-cyan-800{--tw-text-opacity:1;color:rgb(21 94 117/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}.dropdown{position:relative;display:inline-block}.dropdown-content-right{display:none;position:absolute;z-index:1;margin-left:auto;right:0}.dropdown-content-left{display:none;position:absolute;z-index:1;left:0}.dropdown:hover .dropdown-content-left,.dropdown:hover .dropdown-content-right{display:flex}.triangle-left{position:relative}.triangle-left:before{content:"";border-color:transparent #cbd5e1 transparent transparent;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-8px;top:20px}.triangle-left:after{content:"";border-color:transparent #fff transparent transparent;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-6px;top:20px}pre{line-height:125%}.syntax-coloring .c{color:#3d7b7b;font-style:italic}.syntax-coloring .err{border:1px solid red}.syntax-coloring .k{color:green;font-weight:700}.syntax-coloring .o{color:#666}.syntax-coloring .ch,.syntax-coloring .cm{color:#3d7b7b;font-style:italic}.syntax-coloring .cp{color:#9c6500}.syntax-coloring .c1,.syntax-coloring .cpf,.syntax-coloring .cs{color:#3d7b7b;font-style:italic}.syntax-coloring .gd{color:#a00000}.syntax-coloring .ge{font-style:italic}.syntax-coloring .ges{font-weight:700;font-style:italic}.syntax-coloring .gr{color:#e40000}.syntax-coloring .gh{color:navy;font-weight:700}.syntax-coloring .gi{color:#008400}.syntax-coloring .go{color:#717171}.syntax-coloring .gp{color:navy;font-weight:700}.syntax-coloring .gs{font-weight:700}.syntax-coloring .gu{color:purple;font-weight:700}.syntax-coloring .gt{color:#04d}.syntax-coloring .kc,.syntax-coloring .kd,.syntax-coloring .kn{color:green;font-weight:700}.syntax-coloring .kp{color:green}.syntax-coloring .kr{color:green;font-weight:700}.syntax-coloring .kt{color:#b00040}.syntax-coloring .m{color:#666}.syntax-coloring .s{color:#ba2121}.syntax-coloring .na{color:#687822}.syntax-coloring .nb{color:green}.syntax-coloring .nc{color:#00f;font-weight:700}.syntax-coloring .no{color:#800}.syntax-coloring .nd{color:#a2f}.syntax-coloring .ni{color:#717171;font-weight:700}.syntax-coloring .ne{color:#cb3f38;font-weight:700}.syntax-coloring .nf{color:#00f}.syntax-coloring .nl{color:#767600}.syntax-coloring .nn{color:#00f;font-weight:700}.syntax-coloring .nt{color:green;font-weight:700}.syntax-coloring .nv{color:#19177c}.syntax-coloring .ow{color:#a2f;font-weight:700}.syntax-coloring .w{color:#bbb}.syntax-coloring .mb,.syntax-coloring .mf,.syntax-coloring .mh,.syntax-coloring .mi,.syntax-coloring .mo{color:#666}.syntax-coloring .dl,.syntax-coloring .sa,.syntax-coloring .sb,.syntax-coloring .sc{color:#ba2121}.syntax-coloring .sd{color:#ba2121;font-style:italic}.syntax-coloring .s2{color:#ba2121}.syntax-coloring .se{color:#aa5d1f;font-weight:700}.syntax-coloring .sh{color:#ba2121}.syntax-coloring .si{color:#a45a77;font-weight:700}.syntax-coloring .sx{color:green}.syntax-coloring .sr{color:#a45a77}.syntax-coloring .s1{color:#ba2121}.syntax-coloring .ss{color:#19177c}.syntax-coloring .bp{color:green}.syntax-coloring .fm{color:#00f}.syntax-coloring .vc,.syntax-coloring .vg,.syntax-coloring .vi,.syntax-coloring .vm{color:#19177c}.syntax-coloring .il{color:#666}input[type=radio]{color:#06b6d4}.hover\:border-b-4:hover{border-bottom-width:4px}.hover\:border-slate-400:hover{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.hover\:bg-cyan-400:hover{--tw-bg-opacity:1;background-color:rgb(34 211 238/var(--tw-bg-opacity,1))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.hover\:bg-slate-200:hover{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.hover\:bg-slate-300:hover{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity,1))}.focus\:border-cyan-500:focus{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-cyan-200:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(165 243 252/var(--tw-ring-opacity,1))}.active\:ring:active{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}@media (min-width:768px){.md\:mb-8{margin-bottom:2rem}.md\:h-16{height:4rem}.md\:w-16{width:4rem}.md\:p-4{padding:1rem}.md\:p-8{padding:2rem}.md\:py-4{padding-top:1rem;padding-bottom:1rem}.md\:pb-16{padding-bottom:4rem}.md\:pl-24{padding-left:6rem}.md\:pr-24{padding-right:6rem}.md\:pt-24{padding-top:6rem}}@media (min-width:1024px){.lg\:w-5\/12{width:41.666667%}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:pb-0{padding-bottom:0}}@media (min-width:1280px){.xl\:flex{display:flex}} \ No newline at end of file From 70e0f147b523134a028881774c60f3f9b95639d2 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 26 Jun 2025 09:06:21 +0200 Subject: [PATCH 04/41] Tags in event_data can be lists; deal with that Fix #130 --- tags/utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tags/utils.py b/tags/utils.py index 2f6479c..4a5143b 100644 --- a/tags/utils.py +++ b/tags/utils.py @@ -87,6 +87,9 @@ def deduce_tags(event_data): # we start with the explicitly provided tags tags = event_data.get('tags', {}) + if isinstance(tags, list): + tags = {k: v for k, v in tags} + for tag_key, lookup_path in EVENT_DATA_CONVERSION_TABLE.items(): value = get_path(event_data, *lookup_path) From 4ca15c71592ee4a0ade56c700a6726025ff26180 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 26 Jun 2025 10:56:31 +0200 Subject: [PATCH 05/41] fix make_consistent on mysql Problem: on mysql `make_consistent` cannot always clean up `Event`s, because `EventTag` objects still point to them, leading to an integrityerror. The problem does not happen for `sqlite`, because sqlite does FK-checks on-commit. And the offending `EventTag` objects are "eventually cleaned up" (in the same transaction, in make_consistent) This is the "mostly works" solution, for the scenario we've encountered. Namely: remove EventTags which have no issue before removing Events. This works in practice because of the way Events-to-cleanup were created in the UI in practice, namely by removal of some Issue in the admin, triggering a `SET_NULL` on the `issue_id`. Removal of issue implies an analagous `SET_NULL` on the `EventTag`'s `issue_id`, and by removing those `EventTag`s before proceeding with the `Event`s, you avoid the FK constraint triggering. We don't want to fully reimplement `CASCADE` (as in Django) here, and the values of `on_delete` are "Design Decision Needed" and non-homogonous anyway, and we might soon implement proper deletions (see #50) anyway, so the "mostly works" solution will have to do for now. Fixes #132 --- events/management/commands/make_consistent.py | 4 +++- tags/models.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/events/management/commands/make_consistent.py b/events/management/commands/make_consistent.py index 52935bc..2ced1b0 100644 --- a/events/management/commands/make_consistent.py +++ b/events/management/commands/make_consistent.py @@ -23,7 +23,7 @@ def _delete_for_missing_fk(clazz, field_name): ## Dangling FKs: Non-existing objects may come into being when people muddle in the database directly with foreign key checks turned - off (note that fk checks are turned off by default in SQLite for backwards compatibility reasons). + off (note that fk checks are turned off by default in sqlite's CLI for backwards compatibility reasons). In the future it's further possible that there will be pieces the actual Bugsink code where FK-checks are turned off temporarily (e.g. when deleting a project with very many related objects). (In March 2025 there was no such code @@ -76,6 +76,8 @@ def make_consistent(): _delete_for_missing_fk(Release, 'project') + _delete_for_missing_fk(EventTag, 'issue') # See #132 for the ordering of this statement + _delete_for_missing_fk(Event, 'project') _delete_for_missing_fk(Event, 'issue') diff --git a/tags/models.py b/tags/models.py index a2c4db7..85c9660 100644 --- a/tags/models.py +++ b/tags/models.py @@ -75,7 +75,7 @@ class EventTag(models.Model): value = models.ForeignKey(TagValue, blank=False, null=False, on_delete=models.CASCADE) # issue is a denormalization that allows for a single-table-index for efficient search. - # SET_NULL: Issue deletion is not actually possible yet, so this is moot (for now). + # SET_NULL: Issue deletion is not actually possible yet (in the regular UI), so this is somewhat moot (for now). issue = models.ForeignKey( 'issues.Issue', blank=False, null=True, on_delete=models.SET_NULL, related_name="event_tags") From b5ffa154c1a99fc850b167d79a79e25064885a44 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Fri, 27 Jun 2025 11:09:58 +0200 Subject: [PATCH 06/41] Support the combination (delay_on_commit, immediate_atomic, TASK_ALWAYS_EAGER) As per the comment. Additionally, the original of _start_transaction_under_autocommit is stored on the contextprocessor now (which is the place with the best match of the actual call-stack anyway). (Was needed for nested invocations) --- bugsink/transaction.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/bugsink/transaction.py b/bugsink/transaction.py index 23fda45..0be1a88 100644 --- a/bugsink/transaction.py +++ b/bugsink/transaction.py @@ -8,6 +8,8 @@ import threading from django.db import transaction as django_db_transaction from django.db import DEFAULT_DB_ALIAS +from snappea.settings import get_settings as get_snappea_settings + performance_logger = logging.getLogger("bugsink.performance.db") local_storage = threading.local() @@ -153,7 +155,7 @@ class ImmediateAtomic(SuperDurableAtomic): connection = django_db_transaction.get_connection(self.using) if hasattr(connection, "_start_transaction_under_autocommit"): - connection._start_transaction_under_autocommit_original = connection._start_transaction_under_autocommit + self._start_transaction_under_autocommit_original = connection._start_transaction_under_autocommit connection._start_transaction_under_autocommit = types.MethodType( _start_transaction_under_autocommit_patched, connection) @@ -183,9 +185,9 @@ class ImmediateAtomic(SuperDurableAtomic): performance_logger.info(f"{took * 1000:6.2f}ms IMMEDIATE transaction{using_clause}") connection = django_db_transaction.get_connection(self.using) - if hasattr(connection, "_start_transaction_under_autocommit"): - connection._start_transaction_under_autocommit = connection._start_transaction_under_autocommit_original - del connection._start_transaction_under_autocommit_original + if hasattr(self, "_start_transaction_under_autocommit_original"): + connection._start_transaction_under_autocommit = self._start_transaction_under_autocommit_original + del self._start_transaction_under_autocommit_original @contextmanager @@ -206,10 +208,21 @@ def immediate_atomic(using=None, savepoint=True, durable=True): else: immediate_atomic = ImmediateAtomic(using, savepoint, durable) - # https://stackoverflow.com/a/45681273/339144 provides some context on nesting context managers; and how to proceed - # if you want to do this with an arbitrary number of context managers. - with SemaphoreContext(using), immediate_atomic: - yield + if get_snappea_settings().TASK_ALWAYS_EAGER: + # In ALWAYS_EAGER mode we cannot use SemaphoreContext as the outermost context, because any delay_on_commit + # tasks that are triggered on __exit__ of the (in that case, inner) immediate_atomic, when themselves initiating + # a new task-with-transaction, will not be able to acquire the semaphore (it's not been released yet). + # Fundamentally the solution would be to push the "on commit" logic onto the outermost context, but that seems + # fragile (monkeypatching/heavy overriding) and since the whole SemaphoreContext is only needed as an extra + # guard against WAL growth (not something we care about in the non-production setup), we just simplify for that + # case. + with immediate_atomic: + yield + else: + # https://stackoverflow.com/a/45681273/339144 provides some context on nesting context managers; and how to + # proceed if you want to do this with an arbitrary number of context managers. + with SemaphoreContext(using), immediate_atomic: + yield def delay_on_commit(function, *args, **kwargs): From e5dbeae5141dcea4573aef6c7d93215174891988 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Fri, 27 Jun 2025 11:39:03 +0200 Subject: [PATCH 07/41] Issue.delete_deferred(): first version (WIP) Implemented using a batch-wise dependency-scanner in delayed (snappea) style. * no tests yet. * no real point-of-entry in the (regular, non-admin) UI yet. * no hiding of Issues which are delete-in-progress from the UI * file storage not yet cleaned up * project issue counts not yet updated * dangling tag values: no cleanup mechanism yet. See #50 --- bugsink/utils.py | 67 +++++++++++++++++++ issues/admin.py | 31 +++++++++ issues/migrations/0018_issue_is_deleted.py | 16 +++++ .../0019_alter_grouping_grouping_key_hash.py | 16 +++++ issues/models.py | 22 +++++- issues/tasks.py | 37 ++++++++++ tags/models.py | 6 +- 7 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 issues/migrations/0018_issue_is_deleted.py create mode 100644 issues/migrations/0019_alter_grouping_grouping_key_hash.py create mode 100644 issues/tasks.py diff --git a/bugsink/utils.py b/bugsink/utils.py index 2682820..71466d4 100644 --- a/bugsink/utils.py +++ b/bugsink/utils.py @@ -1,7 +1,10 @@ +from collections import defaultdict from urllib.parse import urlparse from django.core.mail import EmailMultiAlternatives from django.template.loader import get_template +from django.apps import apps +from django.db.models import ForeignKey from .version import version @@ -161,3 +164,67 @@ def eat_your_own_dogfood(sentry_dsn, **kwargs): sentry_sdk.init( **default_kwargs, ) + + +def get_model_topography(): + """ + Returns a dependency graph mapping: + referenced_model_key -> [ + (referrer_model_class, fk_name), + ... + ] + """ + dep_graph = defaultdict(list) + for model in apps.get_models(): + for field in model._meta.get_fields(include_hidden=True): + if isinstance(field, ForeignKey): + referenced_model = field.related_model + referenced_key = f"{referenced_model._meta.app_label}.{referenced_model.__name__}" + dep_graph[referenced_key].append((model, field.name)) + return dep_graph + + +def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_graph): + """ + Deletes all objects of type referring_model that refer to any of the referred_ids via fk_name. + Returns the number of deleted objects. + And does this recursively (i.e. if there are further dependencies, it will delete those as well). + """ + num_deleted = 0 + + # Fetch ids of referring objects and their referred ids + relevant_ids_here = list( + referring_model.objects.filter(**{f"{fk_name}__in": referred_ids}).order_by(f"{fk_name}_id", 'pk').values( + 'pk', fk_name + )[:budget] + ) + + if not relevant_ids_here: + # we didn't find any referring objects. optimization: skip any recursion and referring_model.delete() + return 0 + + # The recursing bit: + for_recursion = dep_graph.get(f"{referring_model._meta.app_label}.{referring_model.__name__}", []) + + for model_for_recursion, fk_name_for_recursion in for_recursion: + this_num_deleted = delete_deps_with_budget( + model_for_recursion, + fk_name_for_recursion, + [d["pk"] for d in relevant_ids_here], + budget - num_deleted, + dep_graph, + ) + + num_deleted += this_num_deleted + + if num_deleted >= budget: + return num_deleted + + # If this point is reached: we have deleted all referring objects that we could delete, and we still have budget + # left. We can now delete the referring objects themselves (limited by budget). + relevant_ids_after_rec = relevant_ids_here[:budget - num_deleted] + + my_num_deleted, _ = referring_model.objects.filter(pk__in=[d['pk'] for d in relevant_ids_after_rec]).delete() + num_deleted += my_num_deleted + + return num_deleted diff --git a/issues/admin.py b/issues/admin.py index 67508f7..cc9402c 100644 --- a/issues/admin.py +++ b/issues/admin.py @@ -1,8 +1,14 @@ from django.contrib import admin +from bugsink.transaction import immediate_atomic +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect + from .models import Issue, Grouping, TurningPoint from .forms import IssueAdminForm +csrf_protect_m = method_decorator(csrf_protect) + class GroupingInline(admin.TabularInline): model = Grouping @@ -79,3 +85,28 @@ class IssueAdmin(admin.ModelAdmin): 'digested_event_count', 'stored_event_count', ] + + def get_deleted_objects(self, objs, request): + to_delete = list(objs) + ["...all its related objects... (delayed)"] + model_count = { + Issue: len(objs), + } + perms_needed = set() + protected = [] + return to_delete, model_count, perms_needed, protected + + def delete_queryset(self, request, queryset): + # NOTE: not the most efficient; it will do for a first version. + with immediate_atomic(): + for obj in queryset: + obj.delete_deferred() + + def delete_model(self, request, obj): + with immediate_atomic(): + obj.delete_deferred() + + @csrf_protect_m + def delete_view(self, request, object_id, extra_context=None): + # the superclass version, but with the transaction.atomic context manager commented out (we do this ourselves) + # with transaction.atomic(using=router.db_for_write(self.model)): + return self._delete_view(request, object_id, extra_context) diff --git a/issues/migrations/0018_issue_is_deleted.py b/issues/migrations/0018_issue_is_deleted.py new file mode 100644 index 0000000..42f8a92 --- /dev/null +++ b/issues/migrations/0018_issue_is_deleted.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("issues", "0017_issue_list_indexes_must_start_with_project"), + ] + + operations = [ + migrations.AddField( + model_name="issue", + name="is_deleted", + field=models.BooleanField(default=False), + ), + ] diff --git a/issues/migrations/0019_alter_grouping_grouping_key_hash.py b/issues/migrations/0019_alter_grouping_grouping_key_hash.py new file mode 100644 index 0000000..fb6955f --- /dev/null +++ b/issues/migrations/0019_alter_grouping_grouping_key_hash.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("issues", "0018_issue_is_deleted"), + ] + + operations = [ + migrations.AlterField( + model_name="grouping", + name="grouping_key_hash", + field=models.CharField(max_length=64, null=True), + ), + ] diff --git a/issues/models.py b/issues/models.py index 8297129..d29beb6 100644 --- a/issues/models.py +++ b/issues/models.py @@ -10,6 +10,7 @@ from django.conf import settings from django.utils.functional import cached_property from bugsink.volume_based_condition import VolumeBasedCondition +from bugsink.transaction import delay_on_commit from alerts.tasks import send_unmute_alert from compat.timestamp import parse_timestamp, format_timestamp from tags.models import IssueTag, TagValue @@ -18,6 +19,8 @@ from .utils import ( parse_lines, serialize_lines, filter_qs_for_fixed_at, exclude_qs_for_fixed_at, get_title_for_exception_type_and_value) +from .tasks import delete_issue_deps + class IncongruentStateException(Exception): pass @@ -34,6 +37,8 @@ class Issue(models.Model): project = models.ForeignKey( "projects.Project", blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' + is_deleted = models.BooleanField(default=False) + # 1-based for the same reasons as Event.digest_order digest_order = models.PositiveIntegerField(blank=False, null=False) @@ -72,6 +77,21 @@ class Issue(models.Model): self.digest_order = max_current + 1 if max_current is not None else 1 super().save(*args, **kwargs) + def delete_deferred(self): + """Marks the issue as deleted, and schedules deletion of all related objects""" + self.is_deleted = True + self.save(update_fields=["is_deleted"]) + + # we set grouping_key_hash to None to ensure that event digests that happen simultaneously with the delayed + # cleanup will get their own fresh Grouping and hence Issue. This matches with the behavior that would happen + # if Issue deletion would have been instantaneous (i.e. it's the least surprising behavior). + # + # `issue=None` is explicitly _not_ part of this update, such that the actual deletion of the Groupings will be + # picked up as part of the delete_issue_deps task. + self.grouping_set.all().update(grouping_key_hash=None) + + delay_on_commit(delete_issue_deps, str(self.id)) + def friendly_id(self): return f"{ self.project.slug.upper() }-{ self.digest_order }" @@ -198,7 +218,7 @@ class Grouping(models.Model): grouping_key = models.TextField(blank=False, null=False) # we hash the key to make it indexable on MySQL, see https://code.djangoproject.com/ticket/2495 - grouping_key_hash = models.CharField(max_length=64, blank=False, null=False) + grouping_key_hash = models.CharField(max_length=64, blank=False, null=True) issue = models.ForeignKey("Issue", blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' diff --git a/issues/tasks.py b/issues/tasks.py new file mode 100644 index 0000000..d1df79b --- /dev/null +++ b/issues/tasks.py @@ -0,0 +1,37 @@ +from snappea.decorators import shared_task + +from bugsink.utils import get_model_topography, delete_deps_with_budget +from bugsink.transaction import immediate_atomic, delay_on_commit + + +@shared_task +def delete_issue_deps(issue_id): + from .models import Issue # avoid circular import + with immediate_atomic(): + budget = 500 + num_deleted = 0 + + dep_graph = get_model_topography() + + for model_for_recursion, fk_name_for_recursion in dep_graph["issues.Issue"]: + this_num_deleted = delete_deps_with_budget( + model_for_recursion, + fk_name_for_recursion, + [issue_id], + budget - num_deleted, + dep_graph, + ) + + num_deleted += this_num_deleted + + if num_deleted >= budget: + delay_on_commit(delete_issue_deps, issue_id) + return + + if budget - num_deleted <= 0: + # no more budget for the self-delete. + delay_on_commit(delete_issue_deps, issue_id) + + else: + # final step: delete the issue itself + Issue.objects.filter(pk=issue_id).delete() diff --git a/tags/models.py b/tags/models.py index 85c9660..84cee27 100644 --- a/tags/models.py +++ b/tags/models.py @@ -75,7 +75,7 @@ class EventTag(models.Model): value = models.ForeignKey(TagValue, blank=False, null=False, on_delete=models.CASCADE) # issue is a denormalization that allows for a single-table-index for efficient search. - # SET_NULL: Issue deletion is not actually possible yet (in the regular UI), so this is somewhat moot (for now). + # SET_NULL: to be re-evaulated in the context of Issue.delete_deferred issue = models.ForeignKey( 'issues.Issue', blank=False, null=True, on_delete=models.SET_NULL, related_name="event_tags") @@ -84,7 +84,7 @@ class EventTag(models.Model): # DO_NOTHING: we manually implement CASCADE (i.e. when an event is cleaned up, clean up associated tags) in the # eviction process. Why CASCADE? [1] you'll have to do it "at some point", so you might as well do it right when - # evicting (async in the 'most resilient setup' anyway, b/c that happens when ingesting) [2] the order of magnitude + # evicting (async in the 'most resilient setup' anyway, b/c that happens when digesting) [2] the order of magnitude # is "tens of deletions per event", so that's no reason to postpone. "Why manually" is explained in events/retention event = models.ForeignKey('events.Event', blank=False, null=False, on_delete=models.DO_NOTHING, related_name='tags') @@ -115,7 +115,7 @@ class IssueTag(models.Model): # value already implies key in our current setup. value = models.ForeignKey(TagValue, blank=False, null=False, on_delete=models.CASCADE) - # SET_NULL: Issue deletion is not actually possible yet, so this is moot (for now). + # SET_NULL: to be re-evaulated in the context of Issue.delete_deferred issue = models.ForeignKey('issues.Issue', blank=False, null=True, on_delete=models.SET_NULL, related_name='tags') # 1. As it stands, there is only a single counter per issue-tagvalue combination. In principle/theory this type of From 38397bf2f29c520964b06fc26ac03d60ec2f041e Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Fri, 27 Jun 2025 12:57:24 +0200 Subject: [PATCH 08/41] Remove superfluous comment --- tags/tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tags/tests.py b/tags/tests.py index 1d24d4f..8c72d01 100644 --- a/tags/tests.py +++ b/tags/tests.py @@ -17,7 +17,6 @@ class DeduceTagsTestCase(RegularTestCase): self.assertEqual(deduce_tags({}), {}) self.assertEqual(deduce_tags({"tags": {"foo": "bar"}}), {"foo": "bar"}) - # finally, a more complex example (more or less real-world) event_data = { "server_name": "server", "release": "1.0", From 6e9defedb48ff32ecb54ce37277dadc664bcb42c Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Fri, 27 Jun 2025 12:57:51 +0200 Subject: [PATCH 09/41] Simple IntegrationTest for issue deletion --- issues/tests.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/issues/tests.py b/issues/tests.py index 3698ff5..c423e98 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -13,6 +13,7 @@ from django.test import TestCase as DjangoTestCase from django.contrib.auth import get_user_model from django.test import tag from django.conf import settings +from django.apps import apps from bugsink.test_utils import TransactionTestCase25251 as TransactionTestCase from projects.models import Project, ProjectMembership @@ -23,8 +24,9 @@ from compat.dsn import get_header_value from events.models import Event from ingest.views import BaseIngestAPIView from issues.factories import get_or_create_issue +from tags.models import store_tags -from .models import Issue, IssueStateManager +from .models import Issue, IssueStateManager, TurningPoint, TurningPointKind from .regressions import is_regression, is_regression_2, issue_is_regression from .factories import denormalized_issue_fields from .utils import get_issue_grouper_for_data @@ -665,3 +667,37 @@ class GroupingUtilsTestCase(DjangoTestCase): def test_fingerprint_with_default(self): self.assertEqual("Log Message: ⋄ ⋄ fixed string", get_issue_grouper_for_data({"fingerprint": ["{{ default }}", "fixed string"]})) + + +class IssueDeletionTestCase(TransactionTestCase): + + def setUp(self): + super().setUp() + self.project = Project.objects.create(name="Test Project") + self.issue, _ = get_or_create_issue(self.project) + self.event = create_event(self.project, issue=self.issue) + + TurningPoint.objects.create( + issue=self.issue, triggering_event=self.event, timestamp=self.event.ingested_at, + kind=TurningPointKind.FIRST_SEEN) + + self.event.never_evict = True + self.event.save() + + store_tags(self.event, self.issue, {"foo": "bar"}) + + def test_delete_issue(self): + models = [apps.get_model(app_label=s.split('.')[0], model_name=s.split('.')[1].lower()) for s in [ + 'events.Event', 'issues.TurningPoint', 'tags.EventTag', 'issues.Grouping', 'issues.TurningPoint', + 'events.Event', 'tags.EventTag' + ]] + + for model in models: + # test-the-test: make sure some instances of the models actually exist after setup + self.assertTrue(model.objects.exists(), f"Some {model.__name__} should exist") + + self.issue.delete_deferred() + + # tests run w/ TASK_ALWAYS_EAGER, so in the below we can just check the database directly + for model in models: + self.assertFalse(model.objects.exists(), f"No {model.__name__}s should exist after issue deletion") From 0e3bbd9ab5b8bd67456027553228ca0403e2783e Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Fri, 27 Jun 2025 13:01:46 +0200 Subject: [PATCH 10/41] 1.6.3 CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ded32b..1a3a7ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changes +## 1.6.3 (27 June 2025) + +* fix `make_consistent` on mysql (Fix #132) +* Tags in `event_data` can be lists; deal with that (Fix #130) + ## 1.6.2 (19 June 2025) * Too many quotes in local-vars display (Fix #119) From 7996ed773e00ea60a5803e4fd14c19ac9e46aa80 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Tue, 1 Jul 2025 13:51:58 +0200 Subject: [PATCH 11/41] README: condense verbose wordings --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3eca414..b713152 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,12 @@ # Bugsink: Self-hosted Error Tracking -[Bugsink](https://www.bugsink.com/) offers [Error Tracking](https://www.bugsink.com/error-tracking/) for your applications with full control -through self-hosting. - +* [Error Tracking](https://www.bugsink.com/error-tracking/) * [Built to self-host](https://www.bugsink.com/built-to-self-host/) * [Sentry-SDK compatible](https://www.bugsink.com/connect-any-application/) * [Scalable and reliable](https://www.bugsink.com/scalable-and-reliable/) ### Screenshot -This is what you'll get: - ![Screenshot](https://www.bugsink.com/static/images/JsonSchemaDefinitionException.5e02c1544273.png) @@ -22,7 +18,7 @@ The **quickest way to evaluate Bugsink** is to spin up a throw-away instance usi docker pull bugsink/bugsink:latest docker run \ - -e SECRET_KEY={{ random_secret }} \ + -e SECRET_KEY=PUT_AN_ACTUAL_RANDOM_SECRET_HERE_OF_AT_LEAST_50_CHARS \ -e CREATE_SUPERUSER=admin:admin \ -e PORT=8000 \ -p 8000:8000 \ From f8345eb919870349eaab9261066cf2670d176915 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Wed, 2 Jul 2025 16:15:50 +0200 Subject: [PATCH 12/41] Rename & inline for clarity --- bugsink/utils.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/bugsink/utils.py b/bugsink/utils.py index 71466d4..5d621ab 100644 --- a/bugsink/utils.py +++ b/bugsink/utils.py @@ -193,13 +193,13 @@ def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_ num_deleted = 0 # Fetch ids of referring objects and their referred ids - relevant_ids_here = list( + relevant_ids = list( referring_model.objects.filter(**{f"{fk_name}__in": referred_ids}).order_by(f"{fk_name}_id", 'pk').values( 'pk', fk_name )[:budget] ) - if not relevant_ids_here: + if not relevant_ids: # we didn't find any referring objects. optimization: skip any recursion and referring_model.delete() return 0 @@ -207,22 +207,20 @@ def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_ for_recursion = dep_graph.get(f"{referring_model._meta.app_label}.{referring_model.__name__}", []) for model_for_recursion, fk_name_for_recursion in for_recursion: - this_num_deleted = delete_deps_with_budget( + num_deleted += delete_deps_with_budget( model_for_recursion, fk_name_for_recursion, - [d["pk"] for d in relevant_ids_here], + [d["pk"] for d in relevant_ids], budget - num_deleted, dep_graph, ) - num_deleted += this_num_deleted - if num_deleted >= budget: return num_deleted # If this point is reached: we have deleted all referring objects that we could delete, and we still have budget # left. We can now delete the referring objects themselves (limited by budget). - relevant_ids_after_rec = relevant_ids_here[:budget - num_deleted] + relevant_ids_after_rec = relevant_ids[:budget - num_deleted] my_num_deleted, _ = referring_model.objects.filter(pk__in=[d['pk'] for d in relevant_ids_after_rec]).delete() num_deleted += my_num_deleted From cb765dae3019aec0a1e46a5b002ad6a4ca337cf3 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Wed, 2 Jul 2025 16:19:08 +0200 Subject: [PATCH 13/41] simplify query a previous version (prob. not committed) of this function used both values, but the current version only uses 'pk' so that's all we fetch --- bugsink/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bugsink/utils.py b/bugsink/utils.py index 5d621ab..d7d15d2 100644 --- a/bugsink/utils.py +++ b/bugsink/utils.py @@ -194,8 +194,8 @@ def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_ # Fetch ids of referring objects and their referred ids relevant_ids = list( - referring_model.objects.filter(**{f"{fk_name}__in": referred_ids}).order_by(f"{fk_name}_id", 'pk').values( - 'pk', fk_name + referring_model.objects.filter(**{f"{fk_name}__in": referred_ids}).order_by(f"{fk_name}_id", 'pk').values_list( + 'pk', flat=True )[:budget] ) @@ -210,7 +210,7 @@ def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_ num_deleted += delete_deps_with_budget( model_for_recursion, fk_name_for_recursion, - [d["pk"] for d in relevant_ids], + relevant_ids, budget - num_deleted, dep_graph, ) @@ -222,7 +222,7 @@ def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_ # left. We can now delete the referring objects themselves (limited by budget). relevant_ids_after_rec = relevant_ids[:budget - num_deleted] - my_num_deleted, _ = referring_model.objects.filter(pk__in=[d['pk'] for d in relevant_ids_after_rec]).delete() + my_num_deleted, _ = referring_model.objects.filter(pk__in=relevant_ids_after_rec).delete() num_deleted += my_num_deleted return num_deleted From 1601aa0b1ff5397230cd49b3e22cecd614897b5e Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Wed, 2 Jul 2025 16:21:47 +0200 Subject: [PATCH 14/41] Remove 'order_by' from delete_with_deps logic I _think_ I added this in the first version for [1] some idea around determinism and [2] with the idea that by "grouping" around fk_name, you'd get deletions close-to-root sooner, but I don't think either of those are valuable goals (in both cases: as long as it's gone eventually). The drawback of ordering is that it forces further thinking about indexes (esp. when ordering on 2 items) so I'd rather avoid that. --- bugsink/utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bugsink/utils.py b/bugsink/utils.py index d7d15d2..abbf3fc 100644 --- a/bugsink/utils.py +++ b/bugsink/utils.py @@ -194,9 +194,7 @@ def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_ # Fetch ids of referring objects and their referred ids relevant_ids = list( - referring_model.objects.filter(**{f"{fk_name}__in": referred_ids}).order_by(f"{fk_name}_id", 'pk').values_list( - 'pk', flat=True - )[:budget] + referring_model.objects.filter(**{f"{fk_name}__in": referred_ids}).values_list('pk', flat=True)[:budget] ) if not relevant_ids: From 20b14ed69cee815fe2195fa6f13ea2ae5db088e1 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Wed, 2 Jul 2025 16:30:59 +0200 Subject: [PATCH 15/41] Document index-usage --- bugsink/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bugsink/utils.py b/bugsink/utils.py index abbf3fc..a49ef49 100644 --- a/bugsink/utils.py +++ b/bugsink/utils.py @@ -192,7 +192,9 @@ def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_ """ num_deleted = 0 - # Fetch ids of referring objects and their referred ids + # Fetch ids of referring objects and their referred ids. Note that an index of fk_name can be assumed to exist, + # because fk_name is a ForeignKey field, and Django automatically creates an index for ForeignKey fields unless + # instructed otherwise: https://github.com/django/django/blob/7feafd79a481/django/db/models/fields/related.py#L1025 relevant_ids = list( referring_model.objects.filter(**{f"{fk_name}__in": referred_ids}).values_list('pk', flat=True)[:budget] ) From aed19e70d395825fff2bccc86032308e09831ed5 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Wed, 2 Jul 2025 16:41:42 +0200 Subject: [PATCH 16/41] Issue deletion test: actually test the Issue itself --- issues/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/tests.py b/issues/tests.py index c423e98..5f335c5 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -689,7 +689,7 @@ class IssueDeletionTestCase(TransactionTestCase): def test_delete_issue(self): models = [apps.get_model(app_label=s.split('.')[0], model_name=s.split('.')[1].lower()) for s in [ 'events.Event', 'issues.TurningPoint', 'tags.EventTag', 'issues.Grouping', 'issues.TurningPoint', - 'events.Event', 'tags.EventTag' + 'events.Event', 'tags.EventTag', 'issues.Issue', ]] for model in models: From ee9add5e5f29e5d90ea29de602b0a30997c2d3b2 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Wed, 2 Jul 2025 17:34:33 +0200 Subject: [PATCH 17/41] Vacuum Tags command See #135 --- issues/tests.py | 19 +++++- tags/management/commands/vacuum_tags.py | 10 +++ tags/tasks.py | 84 +++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tags/management/commands/vacuum_tags.py create mode 100644 tags/tasks.py diff --git a/issues/tests.py b/issues/tests.py index 5f335c5..c845909 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -25,6 +25,7 @@ from events.models import Event from ingest.views import BaseIngestAPIView from issues.factories import get_or_create_issue from tags.models import store_tags +from tags.tasks import vacuum_tagvalues from .models import Issue, IssueStateManager, TurningPoint, TurningPointKind from .regressions import is_regression, is_regression_2, issue_is_regression @@ -692,7 +693,12 @@ class IssueDeletionTestCase(TransactionTestCase): 'events.Event', 'tags.EventTag', 'issues.Issue', ]] - for model in models: + # 'vacuum' models are those that are not deleted when an issue is deleted, because they are exclusively owned + # by any given issue. + vacuum_models = [apps.get_model(app_label=s.split('.')[0], model_name=s.split('.')[1].lower()) + for s in ['tags.TagKey', 'tags.TagValue']] + + for model in models + vacuum_models: # test-the-test: make sure some instances of the models actually exist after setup self.assertTrue(model.objects.exists(), f"Some {model.__name__} should exist") @@ -701,3 +707,14 @@ class IssueDeletionTestCase(TransactionTestCase): # tests run w/ TASK_ALWAYS_EAGER, so in the below we can just check the database directly for model in models: self.assertFalse(model.objects.exists(), f"No {model.__name__}s should exist after issue deletion") + + for model in vacuum_models: + # 'should' in quotes because this isn't so because we believe it's better if they did, but because the + # code currently does not delete them. + self.assertTrue(model.objects.exists(), f"Some {model.__name__}s 'should' exist after issue deletion") + + vacuum_tagvalues() + # tests run w/ TASK_ALWAYS_EAGER, so any "delayed" (recursive) calls can be expected to have run + + for model in vacuum_models: + self.assertFalse(model.objects.exists(), f"No {model.__name__}s should exist after vacuuming") diff --git a/tags/management/commands/vacuum_tags.py b/tags/management/commands/vacuum_tags.py new file mode 100644 index 0000000..df975b1 --- /dev/null +++ b/tags/management/commands/vacuum_tags.py @@ -0,0 +1,10 @@ +from django.core.management.base import BaseCommand +from tags.tasks import vacuum_tagvalues + + +class Command(BaseCommand): + help = "Kick off tag cleanup by vacuuming orphaned TagValue and TagKey entries." + + def handle(self, *args, **options): + vacuum_tagvalues.delay() + self.stdout.write("Started tag vacuum via task queue.") diff --git a/tags/tasks.py b/tags/tasks.py new file mode 100644 index 0000000..9d93846 --- /dev/null +++ b/tags/tasks.py @@ -0,0 +1,84 @@ +from snappea.decorators import shared_task + +from bugsink.transaction import immediate_atomic, delay_on_commit +from tags.models import TagValue, TagKey, EventTag, IssueTag + +BATCH_SIZE = 10_000 + + +@shared_task +def vacuum_tagvalues(min_id=0): + # This task cleans up unused TagValue in batches. A TagValue can be unused if no IssueTag or EventTag references it, + # this can happen if IssueTag or EventTag entries are deleted. Cleanup is avoided in that case to avoid repeated + # checks. But it still needs to be done eventually to avoid bloating the database, which is what this task does. + + # Impl. notes: + # + # * select id_to_check first, and then check which of those are used in EventTag or IssueTag. This avoids doing + # TagValue.exclude(some_usage_pattern) which may be slow / for which reasoning about performance is hard. + # * batched to allow for incremental cleanup, using a defer-with-min-id pattern to implement the batching. + # + # Known limitation: + # with _many_ TagValues (whether used or not) and when running in EAGER mode, this thing overflows the stack. + # Basically: because then the "delayed recursion" is not actually delayed, it just runs immediately. Answer: for + # "big things" (basically: serious setups) set up snappea. + + with immediate_atomic(): + # Select candidate TagValue IDs above min_id + ids_to_check = list( + TagValue.objects + .filter(id__gt=min_id) + .order_by('id') + .values_list('id', flat=True)[:BATCH_SIZE] + ) + + if not ids_to_check: + # Done with TagValues → start TagKey cleanup + delay_on_commit(vacuum_tagkeys, 0) + return + + # Determine which ids_to_check are referenced + used_in_event = set( + EventTag.objects.filter(value_id__in=ids_to_check).values_list('value_id', flat=True) + ) + used_in_issue = set( + IssueTag.objects.filter(value_id__in=ids_to_check).values_list('value_id', flat=True) + ) + + unused = [pk for pk in ids_to_check if pk not in used_in_event and pk not in used_in_issue] + + # Actual deletion + if unused: + TagValue.objects.filter(id__in=unused).delete() + + # Defer next batch + vacuum_tagvalues.delay(ids_to_check[-1]) + + +@shared_task +def vacuum_tagkeys(min_id=0): + with immediate_atomic(): + # Select candidate TagKey IDs above min_id + ids_to_check = list( + TagKey.objects + .filter(id__gt=min_id) + .order_by('id') + .values_list('id', flat=True)[:BATCH_SIZE] + ) + + if not ids_to_check: + return # done + + # Determine which ids_to_check are referenced + used = set( + TagValue.objects.filter(key_id__in=ids_to_check).values_list('key_id', flat=True) + ) + + unused = [pk for pk in ids_to_check if pk not in used] + + # Actual deletion + if unused: + TagKey.objects.filter(id__in=unused).delete() + + # Defer next batch + vacuum_tagkeys.delay(ids_to_check[-1]) From ebd9cceb6d01d36c18ba3752242b92b9dabce0b2 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Wed, 2 Jul 2025 23:05:35 +0200 Subject: [PATCH 18/41] Issue cleanup: hardcode order of visited models --- issues/tasks.py | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/issues/tasks.py b/issues/tasks.py index d1df79b..735b175 100644 --- a/issues/tasks.py +++ b/issues/tasks.py @@ -4,6 +4,45 @@ from bugsink.utils import get_model_topography, delete_deps_with_budget from bugsink.transaction import immediate_atomic, delay_on_commit +def get_model_topography_with_issue_override(): + """ + Returns the model topography with ordering adjusted to prefer deletions via .issue, when available. + + This assumes that Issue is not only the root of the dependency graph, but also that if a model has an .issue + ForeignKey, deleting it via that path is sufficient, meaning we can safely avoid visiting the same model again + through other ForeignKey routes (e.g. Event.grouping or TurningPoint.triggering_event). + + The preference is encoded via an explicit list of models, which are visited early and only via their .issue path. + """ + from issues.models import TurningPoint, Grouping + from events.models import Event + from tags.models import IssueTag, EventTag + + preferred = [ + TurningPoint, # above Event, to avoid deletions via .triggering_event + EventTag, # above Event, to avoid deletions via .event + Event, # above Grouping, to avoid deletions via .grouping + Grouping, + IssueTag, + ] + + def as_preferred(lst): + """ + Sorts the list of (model, fk_name) tuples such that the models are in the preferred order as indicated above, + and models which occur with another fk_name are pruned + """ + return sorted( + [(model, fk_name) for model, fk_name in lst if fk_name == "issue" or model not in preferred], + key=lambda x: preferred.index(x[0]) if x[0] in preferred else len(preferred), + ) + + topo = get_model_topography() + for k, lst in topo.items(): + topo[k] = as_preferred(lst) + + return topo + + @shared_task def delete_issue_deps(issue_id): from .models import Issue # avoid circular import @@ -11,7 +50,7 @@ def delete_issue_deps(issue_id): budget = 500 num_deleted = 0 - dep_graph = get_model_topography() + dep_graph = get_model_topography_with_issue_override() for model_for_recursion, fk_name_for_recursion in dep_graph["issues.Issue"]: this_num_deleted = delete_deps_with_budget( From f1878da154c7c5178ddf42a4cb14d1fa2779b70b Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Wed, 2 Jul 2025 23:35:23 +0200 Subject: [PATCH 19/41] Fix the test tests were green but IssueTag was missing from the assertions (and some others doubled) --- issues/tests.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/issues/tests.py b/issues/tests.py index c845909..57bcd3b 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -689,8 +689,7 @@ class IssueDeletionTestCase(TransactionTestCase): def test_delete_issue(self): models = [apps.get_model(app_label=s.split('.')[0], model_name=s.split('.')[1].lower()) for s in [ - 'events.Event', 'issues.TurningPoint', 'tags.EventTag', 'issues.Grouping', 'issues.TurningPoint', - 'events.Event', 'tags.EventTag', 'issues.Issue', + 'events.Event', 'issues.Grouping', 'issues.TurningPoint', 'tags.EventTag', 'issues.Issue', 'tags.IssueTag', ]] # 'vacuum' models are those that are not deleted when an issue is deleted, because they are exclusively owned From 29238ece430cfc94b49a859d68b6cf548d553c86 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Wed, 2 Jul 2025 23:48:10 +0200 Subject: [PATCH 20/41] Test for model-topo overrides --- issues/tests.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/issues/tests.py b/issues/tests.py index 57bcd3b..62eed5d 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -16,6 +16,7 @@ from django.conf import settings from django.apps import apps from bugsink.test_utils import TransactionTestCase25251 as TransactionTestCase +from bugsink.utils import get_model_topography from projects.models import Project, ProjectMembership from releases.models import create_release_if_needed from events.factories import create_event @@ -31,6 +32,7 @@ from .models import Issue, IssueStateManager, TurningPoint, TurningPointKind from .regressions import is_regression, is_regression_2, issue_is_regression from .factories import denormalized_issue_fields from .utils import get_issue_grouper_for_data +from .tasks import get_model_topography_with_issue_override User = get_user_model() @@ -717,3 +719,39 @@ class IssueDeletionTestCase(TransactionTestCase): for model in vacuum_models: self.assertFalse(model.objects.exists(), f"No {model.__name__}s should exist after vacuuming") + + def test_dependency_graphs(self): + # tests for an implementation detail of defered deletion, namely 1 test that asserts what the actual + # model-topography is, and one test that shows how we manually override it; this is to trigger a failure when + # the topology changes (and forces us to double-check that the override is still correct). + + orig = get_model_topography() + override = get_model_topography_with_issue_override() + + def walk(topo, model_name): + results = [] + for model, fk_name in topo[model_name]: + results.append((model, fk_name)) + results.extend(walk(topo, model._meta.label)) + return results + + self.assertEqual(walk(orig, 'issues.Issue'), [ + (apps.get_model('issues', 'Grouping'), 'issue'), + (apps.get_model('events', 'Event'), 'grouping'), + (apps.get_model('issues', 'TurningPoint'), 'triggering_event'), + (apps.get_model('tags', 'EventTag'), 'event'), + (apps.get_model('issues', 'TurningPoint'), 'issue'), + (apps.get_model('events', 'Event'), 'issue'), + (apps.get_model('issues', 'TurningPoint'), 'triggering_event'), + (apps.get_model('tags', 'EventTag'), 'event'), + (apps.get_model('tags', 'EventTag'), 'issue'), + (apps.get_model('tags', 'IssueTag'), 'issue'), + ]) + + self.assertEqual(walk(override, 'issues.Issue'), [ + (apps.get_model('issues', 'TurningPoint'), 'issue'), + (apps.get_model('tags', 'EventTag'), 'issue'), + (apps.get_model('events', 'Event'), 'issue'), + (apps.get_model('issues', 'Grouping'), 'issue'), + (apps.get_model('tags', 'IssueTag'), 'issue'), + ]) From a68a30bcad7f063823ee0b4954ce8fc911d1fea9 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 08:28:29 +0200 Subject: [PATCH 21/41] Diagram in comment for delete_deps --- bugsink/utils.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bugsink/utils.py b/bugsink/utils.py index a49ef49..5eff86f 100644 --- a/bugsink/utils.py +++ b/bugsink/utils.py @@ -185,10 +185,19 @@ def get_model_topography(): def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_graph): - """ + r""" Deletes all objects of type referring_model that refer to any of the referred_ids via fk_name. Returns the number of deleted objects. And does this recursively (i.e. if there are further dependencies, it will delete those as well). + + Caller This Func + | | + V V + referring_model + ^ / + \-------fk_name---- + + referred_ids relevant_ids (deduced using a query) """ num_deleted = 0 From 428011ff9b34b71cb4316a55973ec63a678b286f Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 09:39:32 +0200 Subject: [PATCH 22/41] Prune orphans (TagValue, via IssueTag) inline See #135 --- bugsink/utils.py | 60 +++++++++++++++++++++++++++++++++++++++++++++--- issues/tests.py | 6 ++--- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/bugsink/utils.py b/bugsink/utils.py index 5eff86f..09d6b82 100644 --- a/bugsink/utils.py +++ b/bugsink/utils.py @@ -184,6 +184,53 @@ def get_model_topography(): return dep_graph +def fields_for_prune_orphans(model): + if model.__name__ == "IssueTag": + return ("value_id",) + return () + + +def prune_orphans(model, d_ids_to_check): + """For some model, does dangling-model-cleanup. + + In a sense the oposite of delete_deps; delete_deps takes care of deleting the recursive closure of things that point + to some root. The present function cleans up things that are being pointed to (and, after some other thing is + deleted, potentially are no longer being pointed to, hence 'orphaned'). + + This is the hardcoded edition (IssueTag only); we _could_ try to think about doing this generically based on the + dependency graph, but it's quite questionably whether a combination of generic & performant is easy to arrive at and + worth it. + + pruning of TagValue is done "inline" (as opposed to using a GC-like vacuum "later") because, whatever the exact + performance trade-offs may be, the following holds true: + + 1. the inline version is easier to reason about, it "just happens ASAP", and in the context of a given issue; + vacuum-based has to take into consideration the full DB including non-orphaned values. + 2. repeated work is somewhat minimalized b/c of the IssueTag/EventTag relationship as described below. + """ + + from tags.models import TagValue, IssueTag # avoid circular import + + if model.__name__ != "IssueTag": + return # we only prune IssueTag orphans + + ids_to_check = [d["value_id"] for d in d_ids_to_check] + + # used_in_event check is not needed, because non-existence of IssueTag always implies non-existince of EventTag, + # since [1] EventTag creation implies IssueTag creation and [2] in the cleanup code EventTag is deleted first. + used_in_issue = set( + IssueTag.objects.filter(value_id__in=ids_to_check).values_list('value_id', flat=True) + ) + unused = [pk for pk in ids_to_check if pk not in used_in_issue] + + if unused: + TagValue.objects.filter(id__in=unused).delete() + + # The principled approach would be to clean up TagKeys as well at this point, but in practice there will be orders + # of magnitude fewer TagKey objects, and they are much less likely to become dangling, so the GC-like algo of "just + # vacuuming once in a while" is a much better fit for that. + + def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_graph): r""" Deletes all objects of type referring_model that refer to any of the referred_ids via fk_name. @@ -205,7 +252,9 @@ def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_ # because fk_name is a ForeignKey field, and Django automatically creates an index for ForeignKey fields unless # instructed otherwise: https://github.com/django/django/blob/7feafd79a481/django/db/models/fields/related.py#L1025 relevant_ids = list( - referring_model.objects.filter(**{f"{fk_name}__in": referred_ids}).values_list('pk', flat=True)[:budget] + referring_model.objects.filter(**{f"{fk_name}__in": referred_ids}).order_by(f"{fk_name}_id", 'pk').values( + *(('pk',) + fields_for_prune_orphans(referring_model)) + )[:budget] ) if not relevant_ids: @@ -219,7 +268,7 @@ def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_ num_deleted += delete_deps_with_budget( model_for_recursion, fk_name_for_recursion, - relevant_ids, + [d["pk"] for d in relevant_ids], budget - num_deleted, dep_graph, ) @@ -231,7 +280,12 @@ def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_ # left. We can now delete the referring objects themselves (limited by budget). relevant_ids_after_rec = relevant_ids[:budget - num_deleted] - my_num_deleted, _ = referring_model.objects.filter(pk__in=relevant_ids_after_rec).delete() + my_num_deleted, _ = referring_model.objects.filter(pk__in=[d['pk'] for d in relevant_ids_after_rec]).delete() num_deleted += my_num_deleted + # Note that prune_orphans doesn't respect the budget. Reason: it's not easy to do, b/c the order is reversed (we + # would need to predict somehow at the previous step how much budget to leave unused) and we don't care _that much_ + # about a precise budget "at the edges of our algo", as long as we don't have a "single huge blocking thing". + prune_orphans(referring_model, relevant_ids_after_rec) + return num_deleted diff --git a/issues/tests.py b/issues/tests.py index 62eed5d..c32e84f 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -692,12 +692,12 @@ class IssueDeletionTestCase(TransactionTestCase): def test_delete_issue(self): models = [apps.get_model(app_label=s.split('.')[0], model_name=s.split('.')[1].lower()) for s in [ 'events.Event', 'issues.Grouping', 'issues.TurningPoint', 'tags.EventTag', 'issues.Issue', 'tags.IssueTag', + 'tags.TagValue', # TagValue 'feels like' a vacuum_model (FKs reversed) but is cleaned up in `prune_orphans` ]] - # 'vacuum' models are those that are not deleted when an issue is deleted, because they are exclusively owned - # by any given issue. + # see the note in `prune_orphans` about TagKey to understand why it's special. vacuum_models = [apps.get_model(app_label=s.split('.')[0], model_name=s.split('.')[1].lower()) - for s in ['tags.TagKey', 'tags.TagValue']] + for s in ['tags.TagKey',]] for model in models + vacuum_models: # test-the-test: make sure some instances of the models actually exist after setup From e58be0018fe2ea398f530e9e9d698ff997a8d94b Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 10:16:10 +0200 Subject: [PATCH 23/41] Tag models: no CASCADE CASCADE was defined for keys & values, but in practice those are never directly deleted except in the very case in which it has been established that they are 'orphaned', i.e. no longer being referrred to. That's exactly the case in which CASCADE is superfluous. As a result, in the test for issue deletion (which contains a prune of tagvalue), the following 3 queries are no longer done: ``` SELECT "tags_tagvalue"."id", "tags_tagvalue"."project_id", "tags_tagvalue"."key_id", "tags_tagvalue"."value" FROM "tags_tagvalue" WHERE "tags_tagvalue"."id" IN (1) DELETE FROM "tags_eventtag" WHERE "tags_eventtag"."value_id" IN (1) DELETE FROM "tags_issuetag" WHERE "tags_issuetag"."value_id" IN (1) ``` --- issues/tests.py | 5 +++- tags/migrations/0002_no_cascade.py | 40 ++++++++++++++++++++++++++++++ tags/models.py | 8 +++--- 3 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 tags/migrations/0002_no_cascade.py diff --git a/issues/tests.py b/issues/tests.py index c32e84f..f02334b 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -703,7 +703,10 @@ class IssueDeletionTestCase(TransactionTestCase): # test-the-test: make sure some instances of the models actually exist after setup self.assertTrue(model.objects.exists(), f"Some {model.__name__} should exist") - self.issue.delete_deferred() + # assertNumQueries() is brittle and opaque. But at least the brittle part is quick to fix (a single number) and + # provides a canary for performance regressions. + with self.assertNumQueries(25): + self.issue.delete_deferred() # tests run w/ TASK_ALWAYS_EAGER, so in the below we can just check the database directly for model in models: diff --git a/tags/migrations/0002_no_cascade.py b/tags/migrations/0002_no_cascade.py new file mode 100644 index 0000000..26af011 --- /dev/null +++ b/tags/migrations/0002_no_cascade.py @@ -0,0 +1,40 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("tags", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="eventtag", + name="value", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="tags.tagvalue" + ), + ), + migrations.AlterField( + model_name="issuetag", + name="key", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="tags.tagkey" + ), + ), + migrations.AlterField( + model_name="issuetag", + name="value", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="tags.tagvalue" + ), + ), + migrations.AlterField( + model_name="tagvalue", + name="key", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="tags.tagkey" + ), + ), + ] diff --git a/tags/models.py b/tags/models.py index 84cee27..d73d7b5 100644 --- a/tags/models.py +++ b/tags/models.py @@ -55,7 +55,7 @@ class TagKey(models.Model): class TagValue(models.Model): project = models.ForeignKey(Project, blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' - key = models.ForeignKey(TagKey, blank=False, null=False, on_delete=models.CASCADE) + key = models.ForeignKey(TagKey, blank=False, null=False, on_delete=models.DO_NOTHING) value = models.CharField(max_length=200, blank=False, null=False, db_index=True) class Meta: @@ -72,7 +72,7 @@ class EventTag(models.Model): project = models.ForeignKey(Project, blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' # value already implies key in our current setup. - value = models.ForeignKey(TagValue, blank=False, null=False, on_delete=models.CASCADE) + value = models.ForeignKey(TagValue, blank=False, null=False, on_delete=models.DO_NOTHING) # issue is a denormalization that allows for a single-table-index for efficient search. # SET_NULL: to be re-evaulated in the context of Issue.delete_deferred @@ -110,10 +110,10 @@ class IssueTag(models.Model): project = models.ForeignKey(Project, blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' # denormalization that allows for a single-table-index for efficient search. - key = models.ForeignKey(TagKey, blank=False, null=False, on_delete=models.CASCADE) + key = models.ForeignKey(TagKey, blank=False, null=False, on_delete=models.DO_NOTHING) # value already implies key in our current setup. - value = models.ForeignKey(TagValue, blank=False, null=False, on_delete=models.CASCADE) + value = models.ForeignKey(TagValue, blank=False, null=False, on_delete=models.DO_NOTHING) # SET_NULL: to be re-evaulated in the context of Issue.delete_deferred issue = models.ForeignKey('issues.Issue', blank=False, null=True, on_delete=models.SET_NULL, related_name='tags') From e45c61d6f0197d408cc6a41c7471add57ca7ae35 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 10:53:38 +0200 Subject: [PATCH 24/41] Various models: .issue and .grouping; SET_NULL => DO_NOTHING I originally thought `SET_NULL` would be a good way to "do stuff later", but that's only so the degree that [1] updates are cheaper than deletes and [2] 2nd-order effects (further deletes in the dep-tree) are avoided. Now that we have explicit Issue-deletion (deps-first, delayed, properly batched) the SET_NULL behavior is always a no-op (but with cost in queries). As a result, in the test for issue deletion (which has deletes for many of the altered models), the following 8 queries are no longer done: ``` SELECT "issues_grouping"."id", [..many fields..] FROM "issues_grouping" WHERE "issues_grouping"."id" IN (1) UPDATE "events_event" SET "grouping_id" = NULL WHERE "events_event"."grouping_id" IN (1) [.. a few moments later..] SELECT "issues_issue"."id", [..many fields..] FROM "issues_issue" WHERE "issues_issue"."id" = 'uuid' UPDATE "issues_grouping" SET "issue_id" = NULL WHERE "issues_grouping"."issue_id" IN ('uuid') UPDATE "issues_turningpoint" SET "issue_id" = NULL WHERE "issues_turningpoint"."issue_id" IN ('uuid') UPDATE "events_event" SET "issue_id" = NULL WHERE "events_event"."issue_id" IN ('uuid') UPDATE "tags_eventtag" SET "issue_id" = NULL WHERE "tags_eventtag"."issue_id" IN ('uuid') UPDATE "tags_issuetag" SET "issue_id" = NULL WHERE "tags_issuetag"."issue_id" IN ('uuid') ``` (breaks the tests b/c of constraints and not always using factories; will fix next) --- ...move_events_with_null_issue_or_grouping.py | 34 +++++++++++++++++++ events/migrations/0021_alter_do_nothing.py | 27 +++++++++++++++ events/models.py | 7 ++-- .../0020_remove_objects_with_null_issue.py | 26 ++++++++++++++ issues/migrations/0021_alter_do_nothing.py | 26 ++++++++++++++ issues/models.py | 4 +-- issues/tests.py | 2 +- .../0003_remove_objects_with_null_issue.py | 26 ++++++++++++++ tags/migrations/0004_alter_do_nothing.py | 31 +++++++++++++++++ tags/models.py | 6 ++-- 10 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 events/migrations/0020_remove_events_with_null_issue_or_grouping.py create mode 100644 events/migrations/0021_alter_do_nothing.py create mode 100644 issues/migrations/0020_remove_objects_with_null_issue.py create mode 100644 issues/migrations/0021_alter_do_nothing.py create mode 100644 tags/migrations/0003_remove_objects_with_null_issue.py create mode 100644 tags/migrations/0004_alter_do_nothing.py diff --git a/events/migrations/0020_remove_events_with_null_issue_or_grouping.py b/events/migrations/0020_remove_events_with_null_issue_or_grouping.py new file mode 100644 index 0000000..888bb1e --- /dev/null +++ b/events/migrations/0020_remove_events_with_null_issue_or_grouping.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.21 on 2025-07-03 08:30 + +from django.db import migrations + + +def remove_events_with_null_fks(apps, schema_editor): + # Up until now, we have various models w/ .issue=FK(null=True, on_delete=models.SET_NULL) + # Although it is "not expected" in the interface, issue-deletion would have led to those + # objects with a null issue. We're about to change that to .issue=FK(null=False, ...) which + # would crash if we don't remove those objects first. Object-removal is "fine" though, because + # as per the meaning of the SET_NULL, these objects were "dangling" anyway. + + Event = apps.get_model("events", "Event") + + Event.objects.filter(issue__isnull=True).delete() + + # overcomplete b/c .issue would imply this, done anyway in the name of "defensive programming" + Event.objects.filter(grouping__isnull=True).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("events", "0019_event_storage_backend"), + + # "in principle" order shouldn't matter, because the various objects that are being deleted here are all "fully + # contained" by the .issue; to be safe, however, we depend on the below, because of Grouping.objects.delete() + # (which would set Event.grouping=NULL, which the present migration takes into account). + ("issues", "0020_remove_objects_with_null_issue"), + ] + + operations = [ + migrations.RunPython(remove_events_with_null_fks, reverse_code=migrations.RunPython.noop), + ] diff --git a/events/migrations/0021_alter_do_nothing.py b/events/migrations/0021_alter_do_nothing.py new file mode 100644 index 0000000..a8a9c34 --- /dev/null +++ b/events/migrations/0021_alter_do_nothing.py @@ -0,0 +1,27 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("issues", "0021_alter_do_nothing"), + ("events", "0020_remove_events_with_null_issue_or_grouping"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="grouping", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="issues.grouping" + ), + ), + migrations.AlterField( + model_name="event", + name="issue", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="issues.issue" + ), + ), + ] diff --git a/events/models.py b/events/models.py index 974108b..d24fbb6 100644 --- a/events/models.py +++ b/events/models.py @@ -72,11 +72,8 @@ class Event(models.Model): ingested_at = models.DateTimeField(blank=False, null=False) digested_at = models.DateTimeField(db_index=True, blank=False, null=False) - # not actually expected to be null, but we want to be able to delete issues without deleting events (cleanup later) - issue = models.ForeignKey("issues.Issue", blank=False, null=True, on_delete=models.SET_NULL) - - # not actually expected to be null - grouping = models.ForeignKey("issues.Grouping", blank=False, null=True, on_delete=models.SET_NULL) + issue = models.ForeignKey("issues.Issue", blank=False, null=False, on_delete=models.DO_NOTHING) + grouping = models.ForeignKey("issues.Grouping", blank=False, null=False, on_delete=models.DO_NOTHING) # The docs say: # > Required. Hexadecimal string representing a uuid4 value. The length is exactly 32 characters. Dashes are not diff --git a/issues/migrations/0020_remove_objects_with_null_issue.py b/issues/migrations/0020_remove_objects_with_null_issue.py new file mode 100644 index 0000000..cfb26ce --- /dev/null +++ b/issues/migrations/0020_remove_objects_with_null_issue.py @@ -0,0 +1,26 @@ +from django.db import migrations + + +def remove_objects_with_null_issue(apps, schema_editor): + # Up until now, we have various models w/ .issue=FK(null=True, on_delete=models.SET_NULL) + # Although it is "not expected" in the interface, issue-deletion would have led to those + # objects with a null issue. We're about to change that to .issue=FK(null=False, ...) which + # would crash if we don't remove those objects first. Object-removal is "fine" though, because + # as per the meaning of the SET_NULL, these objects were "dangling" anyway. + + Grouping = apps.get_model("issues", "Grouping") + TurningPoint = apps.get_model("issues", "TurningPoint") + + Grouping.objects.filter(issue__isnull=True).delete() + TurningPoint.objects.filter(issue__isnull=True).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("issues", "0019_alter_grouping_grouping_key_hash"), + ] + + operations = [ + migrations.RunPython(remove_objects_with_null_issue, reverse_code=migrations.RunPython.noop), + ] diff --git a/issues/migrations/0021_alter_do_nothing.py b/issues/migrations/0021_alter_do_nothing.py new file mode 100644 index 0000000..1824073 --- /dev/null +++ b/issues/migrations/0021_alter_do_nothing.py @@ -0,0 +1,26 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("issues", "0020_remove_objects_with_null_issue"), + ] + + operations = [ + migrations.AlterField( + model_name="grouping", + name="issue", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="issues.issue" + ), + ), + migrations.AlterField( + model_name="turningpoint", + name="issue", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="issues.issue" + ), + ), + ] diff --git a/issues/models.py b/issues/models.py index d29beb6..467cfd3 100644 --- a/issues/models.py +++ b/issues/models.py @@ -220,7 +220,7 @@ class Grouping(models.Model): # we hash the key to make it indexable on MySQL, see https://code.djangoproject.com/ticket/2495 grouping_key_hash = models.CharField(max_length=64, blank=False, null=True) - issue = models.ForeignKey("Issue", blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' + issue = models.ForeignKey("Issue", blank=False, null=False, on_delete=models.DO_NOTHING) def __str__(self): return self.grouping_key @@ -491,7 +491,7 @@ class TurningPoint(models.Model): # basically: an Event, but that name was already taken in our system :-) alternative names I considered: # "milestone", "state_change", "transition", "annotation", "episode" - issue = models.ForeignKey("Issue", blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' + issue = models.ForeignKey("Issue", blank=False, null=False, on_delete=models.DO_NOTHING) triggering_event = models.ForeignKey("events.Event", blank=True, null=True, on_delete=models.DO_NOTHING) # null: the system-user diff --git a/issues/tests.py b/issues/tests.py index f02334b..fd818f6 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -705,7 +705,7 @@ class IssueDeletionTestCase(TransactionTestCase): # assertNumQueries() is brittle and opaque. But at least the brittle part is quick to fix (a single number) and # provides a canary for performance regressions. - with self.assertNumQueries(25): + with self.assertNumQueries(17): self.issue.delete_deferred() # tests run w/ TASK_ALWAYS_EAGER, so in the below we can just check the database directly diff --git a/tags/migrations/0003_remove_objects_with_null_issue.py b/tags/migrations/0003_remove_objects_with_null_issue.py new file mode 100644 index 0000000..a28d427 --- /dev/null +++ b/tags/migrations/0003_remove_objects_with_null_issue.py @@ -0,0 +1,26 @@ +from django.db import migrations + + +def remove_objects_with_null_issue(apps, schema_editor): + # Up until now, we have various models w/ .issue=FK(null=True, on_delete=models.SET_NULL) + # Although it is "not expected" in the interface, issue-deletion would have led to those + # objects with a null issue. We're about to change that to .issue=FK(null=False, ...) which + # would crash if we don't remove those objects first. Object-removal is "fine" though, because + # as per the meaning of the SET_NULL, these objects were "dangling" anyway. + + EventTag = apps.get_model("tags", "EventTag") + IssueTag = apps.get_model("tags", "IssueTag") + + EventTag.objects.filter(issue__isnull=True).delete() + IssueTag.objects.filter(issue__isnull=True).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("tags", "0002_no_cascade"), + ] + + operations = [ + migrations.RunPython(remove_objects_with_null_issue, reverse_code=migrations.RunPython.noop), + ] diff --git a/tags/migrations/0004_alter_do_nothing.py b/tags/migrations/0004_alter_do_nothing.py new file mode 100644 index 0000000..f48e77e --- /dev/null +++ b/tags/migrations/0004_alter_do_nothing.py @@ -0,0 +1,31 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("issues", "0021_alter_do_nothing"), + ("tags", "0003_remove_objects_with_null_issue"), + ] + + operations = [ + migrations.AlterField( + model_name="eventtag", + name="issue", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="event_tags", + to="issues.issue", + ), + ), + migrations.AlterField( + model_name="issuetag", + name="issue", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="tags", + to="issues.issue", + ), + ), + ] diff --git a/tags/models.py b/tags/models.py index d73d7b5..ee195c6 100644 --- a/tags/models.py +++ b/tags/models.py @@ -75,9 +75,8 @@ class EventTag(models.Model): value = models.ForeignKey(TagValue, blank=False, null=False, on_delete=models.DO_NOTHING) # issue is a denormalization that allows for a single-table-index for efficient search. - # SET_NULL: to be re-evaulated in the context of Issue.delete_deferred issue = models.ForeignKey( - 'issues.Issue', blank=False, null=True, on_delete=models.SET_NULL, related_name="event_tags") + 'issues.Issue', blank=False, null=False, on_delete=models.DO_NOTHING, related_name="event_tags") # digest_order is a denormalization that allows for a single-table-index for efficient search. digest_order = models.PositiveIntegerField(blank=False, null=False) @@ -115,8 +114,7 @@ class IssueTag(models.Model): # value already implies key in our current setup. value = models.ForeignKey(TagValue, blank=False, null=False, on_delete=models.DO_NOTHING) - # SET_NULL: to be re-evaulated in the context of Issue.delete_deferred - issue = models.ForeignKey('issues.Issue', blank=False, null=True, on_delete=models.SET_NULL, related_name='tags') + issue = models.ForeignKey('issues.Issue', blank=False, null=False, on_delete=models.DO_NOTHING, related_name='tags') # 1. As it stands, there is only a single counter per issue-tagvalue combination. In principle/theory this type of # denormalization will break down when you want to show this kind of information filtered by some other dimension, From 3b3ce782c51c532cf7fe35d737d8f0f59b019ab8 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 11:33:27 +0200 Subject: [PATCH 25/41] Fix the tests for prev. commit tests were broken b/c not respecting constraints / not properly using the factories. --- events/factories.py | 17 +++++++++++++++-- events/tests.py | 9 ++++----- issues/factories.py | 1 + issues/tests.py | 24 +++++++++++------------- tags/tests.py | 12 ++++++------ 5 files changed, 37 insertions(+), 26 deletions(-) diff --git a/events/factories.py b/events/factories.py index bd3a9ef..18567e6 100644 --- a/events/factories.py +++ b/events/factories.py @@ -43,11 +43,24 @@ def create_event(project=None, issue=None, timestamp=None, event_data=None): ) -def create_event_data(): +def create_event_data(exception_type=None): # create minimal event data that is valid as per from_json() - return { + result = { "event_id": uuid.uuid4().hex, "timestamp": timezone.now().isoformat(), "platform": "python", } + + if exception_type is not None: + # allow for a specific exception type to get unique groupers/issues + result["exception"] = { + "values": [ + { + "type": exception_type, + "value": "This is a test exception", + } + ] + } + + return result diff --git a/events/tests.py b/events/tests.py index 82ac2be..672414c 100644 --- a/events/tests.py +++ b/events/tests.py @@ -9,8 +9,7 @@ from django.utils import timezone from bugsink.test_utils import TransactionTestCase25251 as TransactionTestCase from projects.models import Project, ProjectMembership -from issues.models import Issue -from issues.factories import denormalized_issue_fields +from issues.factories import get_or_create_issue from .factories import create_event from .retention import ( @@ -28,7 +27,7 @@ class ViewTests(TransactionTestCase): self.user = User.objects.create_user(username='test', password='test') self.project = Project.objects.create() ProjectMembership.objects.create(project=self.project, user=self.user) - self.issue = Issue.objects.create(project=self.project, **denormalized_issue_fields()) + self.issue, _ = get_or_create_issue(project=self.project) self.event = create_event(self.project, self.issue) self.client.force_login(self.user) @@ -154,7 +153,7 @@ class RetentionTestCase(DjangoTestCase): digested_at = timezone.now() self.project = Project.objects.create(retention_max_event_count=5) - self.issue = Issue.objects.create(project=self.project, **denormalized_issue_fields()) + self.issue, _ = get_or_create_issue(project=self.project) for digest_order in range(1, 7): project_stored_event_count += 1 # +1 pre-create, as in the ingestion view @@ -180,7 +179,7 @@ class RetentionTestCase(DjangoTestCase): project_stored_event_count = 0 self.project = Project.objects.create(retention_max_event_count=999) - self.issue = Issue.objects.create(project=self.project, **denormalized_issue_fields()) + self.issue, _ = get_or_create_issue(project=self.project) current_timestamp = datetime.datetime(2022, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) diff --git a/issues/factories.py b/issues/factories.py index 1438ec2..4b9efd6 100644 --- a/issues/factories.py +++ b/issues/factories.py @@ -12,6 +12,7 @@ def get_or_create_issue(project=None, event_data=None): if event_data is None: from events.factories import create_event_data event_data = create_event_data() + if project is None: project = Project.objects.create(name="Test project") diff --git a/issues/tests.py b/issues/tests.py index fd818f6..68a7faf 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -356,12 +356,11 @@ class MuteUnmuteTestCase(TransactionTestCase): def test_unmute_simple_case(self, send_unmute_alert): project = Project.objects.create() - issue = Issue.objects.create( - project=project, - unmute_on_volume_based_conditions='[{"period": "day", "nr_of_periods": 1, "volume": 1}]', - is_muted=True, - **denormalized_issue_fields(), - ) + issue, _ = get_or_create_issue(project) + + issue.unmute_on_volume_based_conditions = '[{"period": "day", "nr_of_periods": 1, "volume": 1}]' + issue.is_muted = True + issue.save() event = create_event(project, issue) BaseIngestAPIView.count_issue_periods_and_act_on_it(issue, event, datetime.now(timezone.utc)) @@ -376,15 +375,14 @@ class MuteUnmuteTestCase(TransactionTestCase): def test_unmute_two_simultaneously_should_lead_to_one_alert(self, send_unmute_alert): project = Project.objects.create() - issue = Issue.objects.create( - project=project, - unmute_on_volume_based_conditions='''[ + issue, _ = get_or_create_issue(project) + + issue. unmute_on_volume_based_conditions = '''[ {"period": "day", "nr_of_periods": 1, "volume": 1}, {"period": "month", "nr_of_periods": 1, "volume": 1} -]''', - is_muted=True, - **denormalized_issue_fields(), - ) +]''' + issue.is_muted = True + issue.save() event = create_event(project, issue) BaseIngestAPIView.count_issue_periods_and_act_on_it(issue, event, datetime.now(timezone.utc)) diff --git a/tags/tests.py b/tags/tests.py index 8c72d01..b124b1e 100644 --- a/tags/tests.py +++ b/tags/tests.py @@ -3,7 +3,7 @@ from django.test import TestCase as DjangoTestCase from projects.models import Project from issues.factories import get_or_create_issue, denormalized_issue_fields -from events.factories import create_event +from events.factories import create_event, create_event_data from issues.models import Issue from .models import store_tags, EventTag @@ -178,12 +178,12 @@ class SearchTestCase(DjangoTestCase): # scenario (in which there would be some relation between the tags of issues and events), but it allows us to # test event_search more easily (if each event is tied to a different issue, searching for tags is meaningless, # since you always search within the context of an issue). - self.global_issue = Issue.objects.create(project=self.project, **denormalized_issue_fields()) + self.global_issue, _ = get_or_create_issue(project=self.project, event_data=create_event_data("global")) - issue_with_tags_and_text = Issue.objects.create(project=self.project, **denormalized_issue_fields()) + issue_with_tags_and_text, _ = get_or_create_issue(project=self.project, event_data=create_event_data("tag_txt")) event_with_tags_and_text = create_event(self.project, issue=self.global_issue) - issue_with_tags_no_text = Issue.objects.create(project=self.project, **denormalized_issue_fields()) + issue_with_tags_no_text, _ = get_or_create_issue(project=self.project, event_data=create_event_data("no_text")) event_with_tags_no_text = create_event(self.project, issue=self.global_issue) store_tags(event_with_tags_and_text, issue_with_tags_and_text, {f"k-{i}": f"v-{i}" for i in range(5)}) @@ -191,7 +191,7 @@ class SearchTestCase(DjangoTestCase): # fix the EventTag objects' issue, which is broken per the non-real-world setup (see above) EventTag.objects.all().update(issue=self.global_issue) - issue_without_tags = Issue.objects.create(project=self.project, **denormalized_issue_fields()) + issue_without_tags, _ = get_or_create_issue(project=self.project, event_data=create_event_data("no_tags")) event_without_tags = create_event(self.project, issue=self.global_issue) for obj in [issue_with_tags_and_text, event_with_tags_and_text, issue_without_tags, event_without_tags]: @@ -199,7 +199,7 @@ class SearchTestCase(DjangoTestCase): obj.calculated_value = "findable value" obj.save() - Issue.objects.create(project=self.project, **denormalized_issue_fields()) + get_or_create_issue(project=self.project, event_data=create_event_data("no_text")) create_event(self.project, issue=self.global_issue) def _test_search(self, search_x): From ad2aa08e0aa9f277efc2afb6cba1d25ff30edae1 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 11:44:01 +0200 Subject: [PATCH 26/41] Rename local var. for understanding --- events/retention.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/events/retention.py b/events/retention.py index d3a7799..53c1dad 100644 --- a/events/retention.py +++ b/events/retention.py @@ -376,7 +376,7 @@ def evict_for_epoch_and_irrelevance(project, max_epoch, max_irrelevance, max_eve Event.objects.filter(pk__in=pks_to_delete).exclude(storage_backend=None) .values_list("id", "storage_backend") ) - issue_deletions = { + deletions_per_issue = { d['issue_id']: d['count'] for d in Event.objects.filter(pk__in=pks_to_delete).values("issue_id").annotate(count=Count("issue_id"))} @@ -387,9 +387,9 @@ def evict_for_epoch_and_irrelevance(project, max_epoch, max_irrelevance, max_eve nr_of_deletions = Event.objects.filter(pk__in=pks_to_delete).delete()[1].get("events.Event", 0) else: nr_of_deletions = 0 - issue_deletions = {} + deletions_per_issue = {} - return EvictionCounts(nr_of_deletions, issue_deletions) + return EvictionCounts(nr_of_deletions, deletions_per_issue) def cleanup_events_on_storage(todos): From 2baa4446fda18252ae1b6962e58bc87a84d5ada1 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 13:15:47 +0200 Subject: [PATCH 27/41] Issue deletion: event pre-deletes (storage, project-counts) --- bugsink/utils.py | 26 ++++++++++++++++++++++++-- issues/models.py | 2 +- issues/tasks.py | 3 ++- issues/tests.py | 6 ++++-- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/bugsink/utils.py b/bugsink/utils.py index 09d6b82..cd0f6e8 100644 --- a/bugsink/utils.py +++ b/bugsink/utils.py @@ -4,7 +4,7 @@ from urllib.parse import urlparse from django.core.mail import EmailMultiAlternatives from django.template.loader import get_template from django.apps import apps -from django.db.models import ForeignKey +from django.db.models import ForeignKey, F from .version import version @@ -231,7 +231,26 @@ def prune_orphans(model, d_ids_to_check): # vacuuming once in a while" is a much better fit for that. -def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_graph): +def do_pre_delete(project_id, model, pks_to_delete): + "More model-specific cleanup, if needed; only for Event model at the moment." + + if model.__name__ != "Event": + return # we only do more cleanup for Event + + from projects.models import Project + from events.models import Event + from events.retention import cleanup_events_on_storage + + cleanup_events_on_storage( + Event.objects.filter(pk__in=pks_to_delete).exclude(storage_backend=None) + .values_list("id", "storage_backend") + ) + + # note: don't bother to do the same thing for Issue.stored_event_count, since we're in the process of deleting Issue + Project.objects.filter(id=project_id).update(stored_event_count=F('stored_event_count') - len(pks_to_delete)) + + +def delete_deps_with_budget(project_id, referring_model, fk_name, referred_ids, budget, dep_graph): r""" Deletes all objects of type referring_model that refer to any of the referred_ids via fk_name. Returns the number of deleted objects. @@ -266,6 +285,7 @@ def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_ for model_for_recursion, fk_name_for_recursion in for_recursion: num_deleted += delete_deps_with_budget( + project_id, model_for_recursion, fk_name_for_recursion, [d["pk"] for d in relevant_ids], @@ -280,6 +300,8 @@ def delete_deps_with_budget(referring_model, fk_name, referred_ids, budget, dep_ # left. We can now delete the referring objects themselves (limited by budget). relevant_ids_after_rec = relevant_ids[:budget - num_deleted] + do_pre_delete(project_id, referring_model, [d['pk'] for d in relevant_ids_after_rec]) + my_num_deleted, _ = referring_model.objects.filter(pk__in=[d['pk'] for d in relevant_ids_after_rec]).delete() num_deleted += my_num_deleted diff --git a/issues/models.py b/issues/models.py index 467cfd3..e0d52eb 100644 --- a/issues/models.py +++ b/issues/models.py @@ -90,7 +90,7 @@ class Issue(models.Model): # picked up as part of the delete_issue_deps task. self.grouping_set.all().update(grouping_key_hash=None) - delay_on_commit(delete_issue_deps, str(self.id)) + delay_on_commit(delete_issue_deps, str(self.project_id), str(self.id)) def friendly_id(self): return f"{ self.project.slug.upper() }-{ self.digest_order }" diff --git a/issues/tasks.py b/issues/tasks.py index 735b175..ef50b66 100644 --- a/issues/tasks.py +++ b/issues/tasks.py @@ -44,7 +44,7 @@ def get_model_topography_with_issue_override(): @shared_task -def delete_issue_deps(issue_id): +def delete_issue_deps(project_id, issue_id): from .models import Issue # avoid circular import with immediate_atomic(): budget = 500 @@ -54,6 +54,7 @@ def delete_issue_deps(issue_id): for model_for_recursion, fk_name_for_recursion in dep_graph["issues.Issue"]: this_num_deleted = delete_deps_with_budget( + project_id, model_for_recursion, fk_name_for_recursion, [issue_id], diff --git a/issues/tests.py b/issues/tests.py index 68a7faf..c349034 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -674,7 +674,7 @@ class IssueDeletionTestCase(TransactionTestCase): def setUp(self): super().setUp() - self.project = Project.objects.create(name="Test Project") + self.project = Project.objects.create(name="Test Project", stored_event_count=1) # 1, in prep. of the below self.issue, _ = get_or_create_issue(self.project) self.event = create_event(self.project, issue=self.issue) @@ -703,7 +703,7 @@ class IssueDeletionTestCase(TransactionTestCase): # assertNumQueries() is brittle and opaque. But at least the brittle part is quick to fix (a single number) and # provides a canary for performance regressions. - with self.assertNumQueries(17): + with self.assertNumQueries(19): self.issue.delete_deferred() # tests run w/ TASK_ALWAYS_EAGER, so in the below we can just check the database directly @@ -715,6 +715,8 @@ class IssueDeletionTestCase(TransactionTestCase): # code currently does not delete them. self.assertTrue(model.objects.exists(), f"Some {model.__name__}s 'should' exist after issue deletion") + self.assertEqual(0, Project.objects.get().stored_event_count) + vacuum_tagvalues() # tests run w/ TASK_ALWAYS_EAGER, so any "delayed" (recursive) calls can be expected to have run From 5637e04cece2ac4e26e31652c085f720e83a4dc1 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 13:23:08 +0200 Subject: [PATCH 28/41] Assert no automatic cascades happen in our manual dep-deletion --- bugsink/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bugsink/utils.py b/bugsink/utils.py index cd0f6e8..7395733 100644 --- a/bugsink/utils.py +++ b/bugsink/utils.py @@ -302,8 +302,9 @@ def delete_deps_with_budget(project_id, referring_model, fk_name, referred_ids, do_pre_delete(project_id, referring_model, [d['pk'] for d in relevant_ids_after_rec]) - my_num_deleted, _ = referring_model.objects.filter(pk__in=[d['pk'] for d in relevant_ids_after_rec]).delete() + my_num_deleted, del_d = referring_model.objects.filter(pk__in=[d['pk'] for d in relevant_ids_after_rec]).delete() num_deleted += my_num_deleted + assert set(del_d.keys()) == {referring_model._meta.label} # assert no-cascading (we do that ourselves) # Note that prune_orphans doesn't respect the budget. Reason: it's not easy to do, b/c the order is reversed (we # would need to predict somehow at the previous step how much budget to leave unused) and we don't care _that much_ From 6a75216c8fc464a630947be40cd6aa3c9df5c8fe Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 13:46:16 +0200 Subject: [PATCH 29/41] Document issue-deletion batch size budget --- issues/tasks.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/issues/tasks.py b/issues/tasks.py index ef50b66..e43afc7 100644 --- a/issues/tasks.py +++ b/issues/tasks.py @@ -47,6 +47,10 @@ def get_model_topography_with_issue_override(): def delete_issue_deps(project_id, issue_id): from .models import Issue # avoid circular import with immediate_atomic(): + # matches what we do in events/retention.py (and for which argumentation exists); in practive I have seen _much_ + # faster deletion times (in the order of .03s per task on my local laptop) when using a budget of 500, _but_ + # it's not a given those were for "expensive objects" (e.g. events); and I'd rather err on the side of caution + # (worst case we have a bit of inefficiency; in any case this avoids hogging the global write lock / timeouts). budget = 500 num_deleted = 0 From eb5e2d2a48dba0fbc51189f26d1f55c5f1f0199c Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 13:46:40 +0200 Subject: [PATCH 30/41] Fix 'delay_on_commit' calls for project-id passing-around --- issues/tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/issues/tasks.py b/issues/tasks.py index e43afc7..af95743 100644 --- a/issues/tasks.py +++ b/issues/tasks.py @@ -69,12 +69,12 @@ def delete_issue_deps(project_id, issue_id): num_deleted += this_num_deleted if num_deleted >= budget: - delay_on_commit(delete_issue_deps, issue_id) + delay_on_commit(delete_issue_deps, project_id, issue_id) return if budget - num_deleted <= 0: # no more budget for the self-delete. - delay_on_commit(delete_issue_deps, issue_id) + delay_on_commit(delete_issue_deps, project_id, issue_id) else: # final step: delete the issue itself From 99f71b8a471296ed5630c5d02373ede28a7740d7 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 14:48:51 +0200 Subject: [PATCH 31/41] Fix the tests (query-counting across DBs) --- issues/tests.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/issues/tests.py b/issues/tests.py index c349034..31fe980 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -703,7 +703,11 @@ class IssueDeletionTestCase(TransactionTestCase): # assertNumQueries() is brittle and opaque. But at least the brittle part is quick to fix (a single number) and # provides a canary for performance regressions. - with self.assertNumQueries(19): + + # correct for bugsink/transaction.py's select_for_update for non-sqlite databases + correct_for_select_for_update = 1 if 'sqlite' not in settings.DATABASES['default']['ENGINE'] else 0 + + with self.assertNumQueries(19 + correct_for_select_for_update): self.issue.delete_deferred() # tests run w/ TASK_ALWAYS_EAGER, so in the below we can just check the database directly From 6b9e4d801164549ea4d526f70a3301c20efa595b Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 21:01:28 +0200 Subject: [PATCH 32/41] Project.delete_deferred(): first version (WIP) Implemented using a batch-wise dependency-scanner in delayed (snappea) style. * no real point-of-entry in the (regular, non-admin) UI yet. * no hiding of Projects which are delete-in-progress from the UI * lack of DRY * some unnessary work (needed in the Issue-context, but not here) is still being done. See #50 --- ingest/views.py | 4 + .../migrations/0022_turningpoint_project.py | 22 +++ .../0023_turningpoint_set_project.py | 36 +++++ ...024_turningpoint_project_alter_not_null.py | 20 +++ issues/models.py | 2 + issues/tests.py | 1 + issues/views.py | 6 +- projects/admin.py | 38 ++++- .../migrations/0012_project_is_deleted.py | 18 +++ projects/models.py | 11 ++ projects/tasks.py | 97 +++++++++++- projects/tests.py | 139 +++++++++++++++++- releases/models.py | 1 + 13 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 issues/migrations/0022_turningpoint_project.py create mode 100644 issues/migrations/0023_turningpoint_set_project.py create mode 100644 issues/migrations/0024_turningpoint_project_alter_not_null.py create mode 100644 projects/migrations/0012_project_is_deleted.py diff --git a/ingest/views.py b/ingest/views.py index e45923c..49c8131 100644 --- a/ingest/views.py +++ b/ingest/views.py @@ -252,6 +252,8 @@ class BaseIngestAPIView(View): digested_at = datetime.now(timezone.utc) if digested_at is None else digested_at # explicit passing: test only project = Project.objects.get(pk=event_metadata["project_id"]) + if project.is_deleted: + return # don't process events for deleted projects if not cls.count_project_periods_and_act_on_it(project, digested_at): return # if over-quota: just return (any cleanup is done calling-side) @@ -360,6 +362,7 @@ class BaseIngestAPIView(View): if issue_created: TurningPoint.objects.create( + project=project, issue=issue, triggering_event=event, timestamp=ingested_at, kind=TurningPointKind.FIRST_SEEN) event.never_evict = True @@ -371,6 +374,7 @@ class BaseIngestAPIView(View): # new issues cannot be regressions by definition, hence this is in the 'else' branch if issue_is_regression(issue, event.release): TurningPoint.objects.create( + project=project, issue=issue, triggering_event=event, timestamp=ingested_at, kind=TurningPointKind.REGRESSED) event.never_evict = True diff --git a/issues/migrations/0022_turningpoint_project.py b/issues/migrations/0022_turningpoint_project.py new file mode 100644 index 0000000..1ce2bc0 --- /dev/null +++ b/issues/migrations/0022_turningpoint_project.py @@ -0,0 +1,22 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0012_project_is_deleted"), + ("issues", "0021_alter_do_nothing"), + ] + + operations = [ + migrations.AddField( + model_name="turningpoint", + name="project", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="projects.project", + ), + ), + ] diff --git a/issues/migrations/0023_turningpoint_set_project.py b/issues/migrations/0023_turningpoint_set_project.py new file mode 100644 index 0000000..c5d6845 --- /dev/null +++ b/issues/migrations/0023_turningpoint_set_project.py @@ -0,0 +1,36 @@ +from django.db import migrations + + +def turningpoint_set_project(apps, schema_editor): + TurningPoint = apps.get_model("issues", "TurningPoint") + + # TurningPoint.objects.update(project=F("issue__project")) + # fails with 'Joined field references are not permitted in this query" + + # This one's elegant and works in sqlite but not in MySQL: + # TurningPoint.objects.update( + # project=Subquery( + # TurningPoint.objects + # .filter(pk=OuterRef('pk')) + # .values('issue__project')[:1] + # ) + # ) + # django.db.utils.OperationalError: (1093, "You can't specify target table 'issues_turningpoint' for update in FROM + # clause") + + # so in the end we'll just loop: + + for turningpoint in TurningPoint.objects.all(): + turningpoint.project = turningpoint.issue.project + turningpoint.save(update_fields=["project"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("issues", "0022_turningpoint_project"), + ] + + operations = [ + migrations.RunPython(turningpoint_set_project, migrations.RunPython.noop), + ] diff --git a/issues/migrations/0024_turningpoint_project_alter_not_null.py b/issues/migrations/0024_turningpoint_project_alter_not_null.py new file mode 100644 index 0000000..a14be18 --- /dev/null +++ b/issues/migrations/0024_turningpoint_project_alter_not_null.py @@ -0,0 +1,20 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0012_project_is_deleted"), + ("issues", "0023_turningpoint_set_project"), + ] + + operations = [ + migrations.AlterField( + model_name="turningpoint", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project" + ), + ), + ] diff --git a/issues/models.py b/issues/models.py index e0d52eb..634fbe1 100644 --- a/issues/models.py +++ b/issues/models.py @@ -348,6 +348,7 @@ class IssueStateManager(object): # path is never reached via UI-based paths (because those are by definition not event-triggered); thus # the 2 ways of creating TurningPoints do not collide. TurningPoint.objects.create( + project_id=issue.project_id, issue=issue, triggering_event=triggering_event, timestamp=triggering_event.ingested_at, kind=TurningPointKind.UNMUTED, metadata=json.dumps(unmute_metadata)) triggering_event.never_evict = True # .save() will be called by the caller of this function @@ -491,6 +492,7 @@ class TurningPoint(models.Model): # basically: an Event, but that name was already taken in our system :-) alternative names I considered: # "milestone", "state_change", "transition", "annotation", "episode" + project = models.ForeignKey("projects.Project", blank=False, null=False, on_delete=models.DO_NOTHING) issue = models.ForeignKey("Issue", blank=False, null=False, on_delete=models.DO_NOTHING) triggering_event = models.ForeignKey("events.Event", blank=True, null=True, on_delete=models.DO_NOTHING) diff --git a/issues/tests.py b/issues/tests.py index 31fe980..0e04e7b 100644 --- a/issues/tests.py +++ b/issues/tests.py @@ -679,6 +679,7 @@ class IssueDeletionTestCase(TransactionTestCase): self.event = create_event(self.project, issue=self.issue) TurningPoint.objects.create( + project=self.project, issue=self.issue, triggering_event=self.event, timestamp=self.event.ingested_at, kind=TurningPointKind.FIRST_SEEN) diff --git a/issues/views.py b/issues/views.py index 40ebfa8..a1ae135 100644 --- a/issues/views.py +++ b/issues/views.py @@ -210,10 +210,13 @@ def _make_history(issue_or_qs, action, user): now = timezone.now() if isinstance(issue_or_qs, Issue): TurningPoint.objects.create( + project=issue_or_qs.project, issue=issue_or_qs, kind=kind, user=user, metadata=json.dumps(metadata), timestamp=now) else: TurningPoint.objects.bulk_create([ - TurningPoint(issue=issue, kind=kind, user=user, metadata=json.dumps(metadata), timestamp=now) + TurningPoint( + project_id=issue.project_id, issue=issue, kind=kind, user=user, metadata=json.dumps(metadata), + timestamp=now) for issue in issue_or_qs ]) @@ -767,6 +770,7 @@ def history_comment_new(request, issue): # think that's amount of magic to have: it still allows one to erase comments (possibly for non-manual # kinds) but it saves you from what is obviously a mistake (without complaining with a red box or something) TurningPoint.objects.create( + project=issue.project, issue=issue, kind=TurningPointKind.MANUAL_ANNOTATION, user=request.user, comment=form.cleaned_data["comment"], timestamp=timezone.now()) diff --git a/projects/admin.py b/projects/admin.py index dccd827..2b823f7 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -1,9 +1,17 @@ from django.contrib import admin +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect + from admin_auto_filters.filters import AutocompleteFilter +from bugsink.transaction import immediate_atomic + from .models import Project, ProjectMembership +csrf_protect_m = method_decorator(csrf_protect) + + class ProjectFilter(AutocompleteFilter): title = 'Project' field_name = 'project' @@ -31,9 +39,8 @@ class ProjectAdmin(admin.ModelAdmin): list_display = [ 'name', 'dsn', - 'alert_on_new_issue', - 'alert_on_regression', - 'alert_on_unmute', + 'digested_event_count', + 'stored_event_count', ] readonly_fields = [ @@ -47,6 +54,31 @@ class ProjectAdmin(admin.ModelAdmin): 'slug': ['name'], } + def get_deleted_objects(self, objs, request): + to_delete = list(objs) + ["...all its related objects... (delayed)"] + model_count = { + Project: len(objs), + } + perms_needed = set() + protected = [] + return to_delete, model_count, perms_needed, protected + + def delete_queryset(self, request, queryset): + # NOTE: not the most efficient; it will do for a first version. + with immediate_atomic(): + for obj in queryset: + obj.delete_deferred() + + def delete_model(self, request, obj): + with immediate_atomic(): + obj.delete_deferred() + + @csrf_protect_m + def delete_view(self, request, object_id, extra_context=None): + # the superclass version, but with the transaction.atomic context manager commented out (we do this ourselves) + # with transaction.atomic(using=router.db_for_write(self.model)): + return self._delete_view(request, object_id, extra_context) + # the preferred way to deal with ProjectMembership is actually through the inline above; however, because this may prove # to not scale well with (very? more than 50?) memberships per project, we've left the separate admin interface here for diff --git a/projects/migrations/0012_project_is_deleted.py b/projects/migrations/0012_project_is_deleted.py new file mode 100644 index 0000000..6e3625a --- /dev/null +++ b/projects/migrations/0012_project_is_deleted.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.21 on 2025-07-03 13:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0011_fill_stored_event_count"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="is_deleted", + field=models.BooleanField(default=False), + ), + ] diff --git a/projects/models.py b/projects/models.py index 73f9b71..eb1f0df 100644 --- a/projects/models.py +++ b/projects/models.py @@ -5,11 +5,14 @@ from django.conf import settings from django.utils.text import slugify from bugsink.app_settings import get_settings +from bugsink.transaction import delay_on_commit from compat.dsn import build_dsn from teams.models import TeamMembership +from .tasks import delete_project_deps + # ## Visibility/Access-design # @@ -74,6 +77,7 @@ class Project(models.Model): name = models.CharField(max_length=255, blank=False, null=False, unique=True) slug = models.SlugField(max_length=50, blank=False, null=False, unique=True) + is_deleted = models.BooleanField(default=False) # sentry_key mirrors the "public" part of the sentry DSN. As of late 2023 Sentry's docs say the this about DSNs: # @@ -143,6 +147,13 @@ class Project(models.Model): super().save(*args, **kwargs) + def delete_deferred(self): + """Marks the project as deleted, and schedules deletion of all related objects""" + self.is_deleted = True + self.save(update_fields=["is_deleted"]) + + delay_on_commit(delete_project_deps, str(self.id)) + def is_joinable(self, user=None): if user is not None: # take the user's team membership into account diff --git a/projects/tasks.py b/projects/tasks.py index 219320e..e060ea2 100644 --- a/projects/tasks.py +++ b/projects/tasks.py @@ -4,12 +4,13 @@ from snappea.decorators import shared_task from bugsink.app_settings import get_settings from bugsink.utils import send_rendered_email - -from .models import Project +from bugsink.transaction import immediate_atomic, delay_on_commit +from bugsink.utils import get_model_topography, delete_deps_with_budget @shared_task def send_project_invite_email_new_user(email, project_pk, token): + from .models import Project # avoid circular import project = Project.objects.get(pk=project_pk) send_rendered_email( @@ -30,6 +31,7 @@ def send_project_invite_email_new_user(email, project_pk, token): @shared_task def send_project_invite_email(email, project_pk): + from .models import Project # avoid circular import project = Project.objects.get(pk=project_pk) send_rendered_email( @@ -45,3 +47,94 @@ def send_project_invite_email(email, project_pk): }), }, ) + + +def get_model_topography_with_project_override(): + """ + Returns the model topography with ordering adjusted to prefer deletions via .project, when available. + + This assumes that Project is not only the root of the dependency graph, but also that if a model has an .project + ForeignKey, deleting it via that path is sufficient, meaning we can safely avoid visiting the same model again + through other ForeignKey routes (e.g. any of the .issue paths). + + The preference is encoded via an explicit list of models, which are visited early and only via their .project path. + """ + from issues.models import Issue, TurningPoint, Grouping + from events.models import Event + from tags.models import IssueTag, EventTag, TagValue, TagKey + from alerts.models import MessagingServiceConfig + from releases.models import Release + from projects.models import ProjectMembership + + preferred = [ + # Tag-related: remove the "depending" models first and the most depended on last. + EventTag, # above Event, to avoid deletions via .event + IssueTag, + TagValue, + TagKey, + + TurningPoint, # above Event, to avoid deletions via .triggering_event + Event, # above Grouping, to avoid deletions via .grouping + Grouping, + + # these things "could be anywhere" in the ordering; they're not that connected; we put them at the end. + MessagingServiceConfig, + ProjectMembership, + Release, + + Issue, # at the bottom, most everything points to this, we'd rather delete those things via .project + ] + + def as_preferred(lst): + """ + Sorts the list of (model, fk_name) tuples such that the models are in the preferred order as indicated above, + and models which occur with another fk_name are pruned + """ + return sorted( + [(model, fk_name) for model, fk_name in lst if fk_name == "project" or model not in preferred], + key=lambda x: preferred.index(x[0]) if x[0] in preferred else len(preferred), + ) + + topo = get_model_topography() + for k, lst in topo.items(): + topo[k] = as_preferred(lst) + + return topo + + +@shared_task +def delete_project_deps(project_id): + from .models import Project # avoid circular import + with immediate_atomic(): + # matches what we do in events/retention.py (and for which argumentation exists); in practive I have seen _much_ + # faster deletion times (in the order of .03s per task on my local laptop) when using a budget of 500, _but_ + # it's not a given those were for "expensive objects" (e.g. events); and I'd rather err on the side of caution + # (worst case we have a bit of inefficiency; in any case this avoids hogging the global write lock / timeouts). + budget = 500 + num_deleted = 0 + + dep_graph = get_model_topography_with_project_override() + + for model_for_recursion, fk_name_for_recursion in dep_graph["projects.Project"]: + this_num_deleted = delete_deps_with_budget( + project_id, + model_for_recursion, + fk_name_for_recursion, + [project_id], + budget - num_deleted, + dep_graph, + ) + + num_deleted += this_num_deleted + + if num_deleted >= budget: + delay_on_commit(delete_project_deps, project_id) + return + + if budget - num_deleted <= 0: + # no more budget for the self-delete. + delay_on_commit(delete_project_deps, project_id) + + else: + # final step: delete the issue itself + Project.objects.filter(pk=project_id).delete() diff --git a/projects/tests.py b/projects/tests.py index 38a61ce..f8b3ff5 100644 --- a/projects/tests.py +++ b/projects/tests.py @@ -1 +1,138 @@ -# from django.test import TestCase as DjangoTestCase +from django.conf import settings +from django.apps import apps +from django.contrib.auth import get_user_model + +from bugsink.test_utils import TransactionTestCase25251 as TransactionTestCase +from bugsink.utils import get_model_topography +from projects.models import Project, ProjectMembership +from events.factories import create_event +from issues.factories import get_or_create_issue +from tags.models import store_tags +from issues.models import TurningPoint, TurningPointKind +from alerts.models import MessagingServiceConfig +from releases.models import Release + +from .tasks import get_model_topography_with_project_override + +User = get_user_model() + + +class ProjectDeletionTestCase(TransactionTestCase): + + def setUp(self): + super().setUp() + self.project = Project.objects.create(name="Test Project", stored_event_count=1) # 1, in prep. of the below + self.issue, _ = get_or_create_issue(self.project) + self.event = create_event(self.project, issue=self.issue) + self.user = User.objects.create_user(username='test', password='test') + + TurningPoint.objects.create( + project=self.project, + issue=self.issue, triggering_event=self.event, timestamp=self.event.ingested_at, + kind=TurningPointKind.FIRST_SEEN) + + MessagingServiceConfig.objects.create(project=self.project) + ProjectMembership.objects.create(project=self.project, user=self.user) + Release.objects.create(project=self.project, version="1.0.0") + + self.event.never_evict = True + self.event.save() + + store_tags(self.event, self.issue, {"foo": "bar"}) + + def test_delete_project(self): + models = [apps.get_model(app_label=s.split('.')[0], model_name=s.split('.')[1].lower()) for s in [ + "tags.EventTag", + "tags.IssueTag", + "tags.TagValue", + "tags.TagKey", + "issues.TurningPoint", + "events.Event", + "issues.Grouping", + "alerts.MessagingServiceConfig", + "projects.ProjectMembership", + "releases.Release", + "issues.Issue", + "projects.Project", + ]] + + for model in models: + # test-the-test: make sure some instances of the models actually exist after setup + self.assertTrue(model.objects.exists(), f"Some {model.__name__} should exist") + + # assertNumQueries() is brittle and opaque. But at least the brittle part is quick to fix (a single number) and + # provides a canary for performance regressions. + + # correct for bugsink/transaction.py's select_for_update for non-sqlite databases + correct_for_select_for_update = 1 if 'sqlite' not in settings.DATABASES['default']['ENGINE'] else 0 + + with self.assertNumQueries(40 + correct_for_select_for_update): + self.project.delete_deferred() + + # tests run w/ TASK_ALWAYS_EAGER, so in the below we can just check the database directly + for model in models: + self.assertFalse(model.objects.exists(), f"No {model.__name__}s should exist after issue deletion") + + def test_dependency_graphs(self): + # tests for an implementation detail of defered deletion, namely 1 test that asserts what the actual + # model-topography is, and one test that shows how we manually override it; this is to trigger a failure when + # the topology changes (and forces us to double-check that the override is still correct). + + orig = get_model_topography() + override = get_model_topography_with_project_override() + + def walk(topo, model_name): + results = [] + for model, fk_name in topo[model_name]: + results.append((model, fk_name)) + results.extend(walk(topo, model._meta.label)) + return results + + self.assertEqual(walk(orig, 'projects.Project'), [ + (apps.get_model('projects', 'ProjectMembership'), 'project'), + (apps.get_model('releases', 'Release'), 'project'), + (apps.get_model('issues', 'Issue'), 'project'), + (apps.get_model('issues', 'Grouping'), 'issue'), + (apps.get_model('events', 'Event'), 'grouping'), + (apps.get_model('issues', 'TurningPoint'), 'triggering_event'), + (apps.get_model('tags', 'EventTag'), 'event'), + (apps.get_model('issues', 'TurningPoint'), 'issue'), + (apps.get_model('events', 'Event'), 'issue'), + (apps.get_model('issues', 'TurningPoint'), 'triggering_event'), + (apps.get_model('tags', 'EventTag'), 'event'), + (apps.get_model('tags', 'EventTag'), 'issue'), + (apps.get_model('tags', 'IssueTag'), 'issue'), + (apps.get_model('issues', 'Grouping'), 'project'), + (apps.get_model('events', 'Event'), 'grouping'), + (apps.get_model('issues', 'TurningPoint'), 'triggering_event'), + (apps.get_model('tags', 'EventTag'), 'event'), + (apps.get_model('issues', 'TurningPoint'), 'project'), + (apps.get_model('events', 'Event'), 'project'), + (apps.get_model('issues', 'TurningPoint'), 'triggering_event'), + (apps.get_model('tags', 'EventTag'), 'event'), + (apps.get_model('tags', 'TagKey'), 'project'), + (apps.get_model('tags', 'TagValue'), 'key'), + (apps.get_model('tags', 'EventTag'), 'value'), + (apps.get_model('tags', 'IssueTag'), 'value'), + (apps.get_model('tags', 'IssueTag'), 'key'), + (apps.get_model('tags', 'TagValue'), 'project'), + (apps.get_model('tags', 'EventTag'), 'value'), + (apps.get_model('tags', 'IssueTag'), 'value'), + (apps.get_model('tags', 'EventTag'), 'project'), + (apps.get_model('tags', 'IssueTag'), 'project'), + (apps.get_model('alerts', 'MessagingServiceConfig'), 'project'), + ]) + + self.assertEqual(walk(override, 'projects.Project'), [ + (apps.get_model('tags', 'EventTag'), 'project'), + (apps.get_model('tags', 'IssueTag'), 'project'), + (apps.get_model('tags', 'TagValue'), 'project'), + (apps.get_model('tags', 'TagKey'), 'project'), + (apps.get_model('issues', 'TurningPoint'), 'project'), + (apps.get_model('events', 'Event'), 'project'), + (apps.get_model('issues', 'Grouping'), 'project'), + (apps.get_model('alerts', 'MessagingServiceConfig'), 'project'), + (apps.get_model('projects', 'ProjectMembership'), 'project'), + (apps.get_model('releases', 'Release'), 'project'), + (apps.get_model('issues', 'Issue'), 'project') + ]) diff --git a/releases/models.py b/releases/models.py index 872f49f..a103db8 100644 --- a/releases/models.py +++ b/releases/models.py @@ -124,6 +124,7 @@ def create_release_if_needed(project, version, event, issue=None): # triggering event anymore for our timestamp. TurningPoint.objects.bulk_create([TurningPoint( + project=project, issue=issue, kind=TurningPointKind.NEXT_MATERIALIZED, triggering_event=event, metadata=json.dumps({"actual_release": release.version}), timestamp=event.ingested_at) for issue in resolved_by_next_qs From 28b2ce0eaf33220ed31dc7e32d2c94a56dd4f370 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 21:49:49 +0200 Subject: [PATCH 33/41] Various models: .project SET_NULL => DO_NOTHING Like e45c61d6f019, but for .project. I originally thought `SET_NULL` would be a good way to "do stuff later", but that's only so the degree that [1] updates are cheaper than deletes and [2] 2nd-order effects (further deletes in the dep-tree) are avoided. Now that we have explicit Project-deletion (deps-first, delayed, properly batched) the SET_NULL behavior is always a no-op (but with cost in queries). As a result, in the test for project deletion (which has deletes for many of the altered models), the following 12 queries are no longer done: ``` SELECT "projects_project"."id", [..many fields..] FROM "projects_project" WHERE "projects_project"."id" = 1 DELETE FROM "projects_projectmembership" WHERE "projects_projectmembership"."project_id" IN (1) DELETE FROM "alerts_messagingserviceconfig" WHERE "alerts_messagingserviceconfig"."project_id" IN (1) UPDATE "releases_release" SET "project_id" = NULL WHERE "releases_release"."project_id" IN (1) UPDATE "issues_issue" SET "project_id" = NULL WHERE "issues_issue"."project_id" IN (1) UPDATE "issues_grouping" SET "project_id" = NULL WHERE "issues_grouping"."project_id" IN (1) UPDATE "events_event" SET "project_id" = NULL WHERE "events_event"."project_id" IN (1) UPDATE "tags_tagkey" SET "project_id" = NULL WHERE "tags_tagkey"."project_id" IN (1) UPDATE "tags_tagvalue" SET "project_id" = NULL WHERE "tags_tagvalue"."project_id" IN (1) UPDATE "tags_eventtag" SET "project_id" = NULL WHERE "tags_eventtag"."project_id" IN (1) UPDATE "tags_issuetag" SET "project_id" = NULL WHERE "tags_issuetag"."project_id" IN (1) ``` --- ...02_alter_messagingserviceconfig_project.py | 23 +++++++++ alerts/models.py | 2 +- events/migrations/0022_alter_event_project.py | 21 ++++++++ events/models.py | 2 +- ...er_grouping_project_alter_issue_project.py | 28 +++++++++++ issues/models.py | 4 +- ...delete_objects_pointing_to_null_project.py | 48 +++++++++++++++++++ .../0014_alter_projectmembership_project.py | 19 ++++++++ projects/models.py | 2 +- projects/tests.py | 2 +- .../migrations/0003_alter_release_project.py | 21 ++++++++ releases/models.py | 3 +- releases/tests.py | 12 +++-- ...project_alter_issuetag_project_and_more.py | 42 ++++++++++++++++ tags/models.py | 8 ++-- 15 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 alerts/migrations/0002_alter_messagingserviceconfig_project.py create mode 100644 events/migrations/0022_alter_event_project.py create mode 100644 issues/migrations/0025_alter_grouping_project_alter_issue_project.py create mode 100644 projects/migrations/0013_delete_objects_pointing_to_null_project.py create mode 100644 projects/migrations/0014_alter_projectmembership_project.py create mode 100644 releases/migrations/0003_alter_release_project.py create mode 100644 tags/migrations/0005_alter_eventtag_project_alter_issuetag_project_and_more.py diff --git a/alerts/migrations/0002_alter_messagingserviceconfig_project.py b/alerts/migrations/0002_alter_messagingserviceconfig_project.py new file mode 100644 index 0000000..dad1812 --- /dev/null +++ b/alerts/migrations/0002_alter_messagingserviceconfig_project.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + # Django came up with 0014, whatever the reason, I'm sure that 0013 is at least required (as per comments there) + ("projects", "0014_alter_projectmembership_project"), + ("alerts", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="messagingserviceconfig", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="service_configs", + to="projects.project", + ), + ), + ] diff --git a/alerts/models.py b/alerts/models.py index 2d452c5..e7eec32 100644 --- a/alerts/models.py +++ b/alerts/models.py @@ -5,7 +5,7 @@ from .service_backends.slack import SlackBackend class MessagingServiceConfig(models.Model): - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="service_configs") + project = models.ForeignKey(Project, on_delete=models.DO_NOTHING, related_name="service_configs") display_name = models.CharField(max_length=100, blank=False, help_text='For display in the UI, e.g. "#general on company Slack"') diff --git a/events/migrations/0022_alter_event_project.py b/events/migrations/0022_alter_event_project.py new file mode 100644 index 0000000..86a04e9 --- /dev/null +++ b/events/migrations/0022_alter_event_project.py @@ -0,0 +1,21 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + # Django came up with 0014, whatever the reason, I'm sure that 0013 is at least required (as per comments there) + ("projects", "0014_alter_projectmembership_project"), + ("events", "0021_alter_do_nothing"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project" + ), + ), + ] diff --git a/events/models.py b/events/models.py index d24fbb6..680b2c5 100644 --- a/events/models.py +++ b/events/models.py @@ -82,7 +82,7 @@ class Event(models.Model): # uuid4 clientside". In any case, we just rely on the envelope's event_id (required per the envelope spec). # Not a primary key: events may be duplicated across projects event_id = models.UUIDField(primary_key=False, null=False, editable=False, help_text="As per the sent data") - project = models.ForeignKey(Project, blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' + project = models.ForeignKey(Project, blank=False, null=False, on_delete=models.DO_NOTHING) data = models.TextField(blank=False, null=False) diff --git a/issues/migrations/0025_alter_grouping_project_alter_issue_project.py b/issues/migrations/0025_alter_grouping_project_alter_issue_project.py new file mode 100644 index 0000000..5c49f63 --- /dev/null +++ b/issues/migrations/0025_alter_grouping_project_alter_issue_project.py @@ -0,0 +1,28 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + # Django came up with 0014, whatever the reason, I'm sure that 0013 is at least required (as per comments there) + ("projects", "0014_alter_projectmembership_project"), + ("issues", "0024_turningpoint_project_alter_not_null"), + ] + + operations = [ + migrations.AlterField( + model_name="grouping", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project" + ), + ), + migrations.AlterField( + model_name="issue", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project" + ), + ), + ] diff --git a/issues/models.py b/issues/models.py index 634fbe1..150e03f 100644 --- a/issues/models.py +++ b/issues/models.py @@ -35,7 +35,7 @@ class Issue(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) project = models.ForeignKey( - "projects.Project", blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' + "projects.Project", blank=False, null=False, on_delete=models.DO_NOTHING) is_deleted = models.BooleanField(default=False) @@ -213,7 +213,7 @@ class Grouping(models.Model): into a single issue. (such manual merging is not yet implemented, but the data-model is already prepared for it) """ project = models.ForeignKey( - "projects.Project", blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' + "projects.Project", blank=False, null=False, on_delete=models.DO_NOTHING) grouping_key = models.TextField(blank=False, null=False) diff --git a/projects/migrations/0013_delete_objects_pointing_to_null_project.py b/projects/migrations/0013_delete_objects_pointing_to_null_project.py new file mode 100644 index 0000000..aee41be --- /dev/null +++ b/projects/migrations/0013_delete_objects_pointing_to_null_project.py @@ -0,0 +1,48 @@ +from django.db import migrations + + +def delete_objects_pointing_to_null_project(apps, schema_editor): + # Up until now, we have various models w/ .project=FK(null=True, on_delete=models.SET_NULL) + # Although it is "not expected" in the interface, project-deletion would have led to those + # objects with a null project. We're about to change that to .project=FK(null=False, ...) which + # would crash if we don't remove those objects first. Object-removal is "fine" though, because + # as per the meaning of the SET_NULL, these objects were "dangling" anyway. + + # We implement this as a _single_ cross-app migration so that reasoning about the order of deletions is easy (and + # we can just copy the correct order from the project/tasks.py `preferred` variable. This cross-appness does mean + # that we must specify all dependencies here, and all the set-null migrations (from various apps) must point at this + # migration as their dependency. + + # from tasks.py, but in "strings" form + preferred = [ + 'tags.EventTag', + 'tags.IssueTag', + 'tags.TagValue', + 'tags.TagKey', + # 'issues.TurningPoint', # not needed, .project is already not-null (we just added it) + 'events.Event', + 'issues.Grouping', + # 'alerts.MessagingServiceConfig', was CASCADE (not null), so no deletion needed + # 'projects.ProjectMembership', was CASCADE (not null), so no deletion needed + 'releases.Release', + 'issues.Issue', + ] + + for model_name in preferred: + model = apps.get_model(*model_name.split('.')) + model.objects.filter(project__isnull=True).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0012_project_is_deleted"), + ("issues", "0024_turningpoint_project_alter_not_null"), + ("tags", "0004_alter_do_nothing"), + ("releases", "0002_release_releases_re_sort_ep_5c07c8_idx"), + ("events", "0021_alter_do_nothing"), + ] + + operations = [ + migrations.RunPython(delete_objects_pointing_to_null_project, reverse_code=migrations.RunPython.noop), + ] diff --git a/projects/migrations/0014_alter_projectmembership_project.py b/projects/migrations/0014_alter_projectmembership_project.py new file mode 100644 index 0000000..cd13986 --- /dev/null +++ b/projects/migrations/0014_alter_projectmembership_project.py @@ -0,0 +1,19 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0013_delete_objects_pointing_to_null_project"), + ] + + operations = [ + migrations.AlterField( + model_name="projectmembership", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project" + ), + ), + ] diff --git a/projects/models.py b/projects/models.py index eb1f0df..ac8c367 100644 --- a/projects/models.py +++ b/projects/models.py @@ -175,7 +175,7 @@ class Project(models.Model): class ProjectMembership(models.Model): - project = models.ForeignKey(Project, on_delete=models.CASCADE) + project = models.ForeignKey(Project, on_delete=models.DO_NOTHING) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) send_email_alerts = models.BooleanField(default=None, null=True) diff --git a/projects/tests.py b/projects/tests.py index f8b3ff5..182a0a0 100644 --- a/projects/tests.py +++ b/projects/tests.py @@ -66,7 +66,7 @@ class ProjectDeletionTestCase(TransactionTestCase): # correct for bugsink/transaction.py's select_for_update for non-sqlite databases correct_for_select_for_update = 1 if 'sqlite' not in settings.DATABASES['default']['ENGINE'] else 0 - with self.assertNumQueries(40 + correct_for_select_for_update): + with self.assertNumQueries(29 + correct_for_select_for_update): self.project.delete_deferred() # tests run w/ TASK_ALWAYS_EAGER, so in the below we can just check the database directly diff --git a/releases/migrations/0003_alter_release_project.py b/releases/migrations/0003_alter_release_project.py new file mode 100644 index 0000000..b2f3ed3 --- /dev/null +++ b/releases/migrations/0003_alter_release_project.py @@ -0,0 +1,21 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + # Django came up with 0014, whatever the reason, I'm sure that 0013 is at least required (as per comments there) + ("projects", "0014_alter_projectmembership_project"), + ("releases", "0002_release_releases_re_sort_ep_5c07c8_idx"), + ] + + operations = [ + migrations.AlterField( + model_name="release", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project" + ), + ), + ] diff --git a/releases/models.py b/releases/models.py index a103db8..5d57288 100644 --- a/releases/models.py +++ b/releases/models.py @@ -44,8 +44,7 @@ class Release(models.Model): # sentry does releases per-org; we don't follow that example. our belief is basically: [1] in reality releases are # per software package and a software package is basically a bugsink project and [2] any cross-project-per-org # analysis you might do is more likely to be in the realm of "transactions", something we don't want to support. - project = models.ForeignKey( - "projects.Project", blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' + project = models.ForeignKey("projects.Project", blank=False, null=False, on_delete=models.DO_NOTHING) # full version as provided by either implicit (per-event) or explicit (some API) means, including package name # max_length matches Even.release (which is deduced from Sentry) diff --git a/releases/tests.py b/releases/tests.py index 89bdc31..b91652e 100644 --- a/releases/tests.py +++ b/releases/tests.py @@ -1,13 +1,16 @@ from django.test import TestCase as DjangoTestCase from datetime import timedelta +from projects.models import Project from .models import Release, ordered_releases, RE_PACKAGE_VERSION class ReleaseTestCase(DjangoTestCase): def test_create_and_order(self): - r0 = Release.objects.create(version="e80f98923f7426a8087009f4c629d25a35565a6a") + project = Project.objects.create(name="Test Project") + + r0 = Release.objects.create(project=project, version="e80f98923f7426a8087009f4c629d25a35565a6a") self.assertFalse(r0.is_semver) self.assertEqual(0, r0.sort_epoch) @@ -17,6 +20,7 @@ class ReleaseTestCase(DjangoTestCase): # real usage too) # * it ensures that dates are ignored when comparing r1 and r2 (r2 has a smaller date than r1, but comes later) r1 = Release.objects.create( + project=project, version="2a678dbbbecd2978ccaa76c326a0fb2e70073582", date_released=r0.date_released + timedelta(seconds=10), ) @@ -24,17 +28,17 @@ class ReleaseTestCase(DjangoTestCase): self.assertEqual(0, r1.sort_epoch) # switch to semver, epoch 1 - r2 = Release.objects.create(version="1.0.0") + r2 = Release.objects.create(project=project, version="1.0.0") self.assertTrue(r2.is_semver) self.assertEqual(1, r2.sort_epoch) # stick with semver, but use a lower version - r3 = Release.objects.create(version="0.1.0") + r3 = Release.objects.create(project=project, version="0.1.0") self.assertTrue(r3.is_semver) self.assertEqual(1, r3.sort_epoch) # put in package name; this is basically ignored for ordering purposes - r4 = Release.objects.create(version="package@2.0.0") + r4 = Release.objects.create(project=project, version="package@2.0.0") self.assertTrue(r4.is_semver) self.assertEqual(ordered_releases(), [r0, r1, r3, r2, r4]) diff --git a/tags/migrations/0005_alter_eventtag_project_alter_issuetag_project_and_more.py b/tags/migrations/0005_alter_eventtag_project_alter_issuetag_project_and_more.py new file mode 100644 index 0000000..d04aba4 --- /dev/null +++ b/tags/migrations/0005_alter_eventtag_project_alter_issuetag_project_and_more.py @@ -0,0 +1,42 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + # Django came up with 0014, whatever the reason, I'm sure that 0013 is at least required (as per comments there) + ("projects", "0014_alter_projectmembership_project"), + ("tags", "0004_alter_do_nothing"), + ] + + operations = [ + migrations.AlterField( + model_name="eventtag", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project" + ), + ), + migrations.AlterField( + model_name="issuetag", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project" + ), + ), + migrations.AlterField( + model_name="tagkey", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project" + ), + ), + migrations.AlterField( + model_name="tagvalue", + name="project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, to="projects.project" + ), + ), + ] diff --git a/tags/models.py b/tags/models.py index ee195c6..97a4eb2 100644 --- a/tags/models.py +++ b/tags/models.py @@ -36,7 +36,7 @@ from tags.utils import deduce_tags, is_mostly_unique class TagKey(models.Model): - project = models.ForeignKey(Project, blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' + project = models.ForeignKey(Project, blank=False, null=False, on_delete=models.DO_NOTHING) key = models.CharField(max_length=32, blank=False, null=False) # Tags that are "mostly unique" are not displayed in the issue tag counts, because the distribution of values is @@ -54,7 +54,7 @@ class TagKey(models.Model): class TagValue(models.Model): - project = models.ForeignKey(Project, blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' + project = models.ForeignKey(Project, blank=False, null=False, on_delete=models.DO_NOTHING) key = models.ForeignKey(TagKey, blank=False, null=False, on_delete=models.DO_NOTHING) value = models.CharField(max_length=200, blank=False, null=False, db_index=True) @@ -69,7 +69,7 @@ class TagValue(models.Model): class EventTag(models.Model): - project = models.ForeignKey(Project, blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' + project = models.ForeignKey(Project, blank=False, null=False, on_delete=models.DO_NOTHING) # value already implies key in our current setup. value = models.ForeignKey(TagValue, blank=False, null=False, on_delete=models.DO_NOTHING) @@ -106,7 +106,7 @@ class EventTag(models.Model): class IssueTag(models.Model): - project = models.ForeignKey(Project, blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later' + project = models.ForeignKey(Project, blank=False, null=False, on_delete=models.DO_NOTHING) # denormalization that allows for a single-table-index for efficient search. key = models.ForeignKey(TagKey, blank=False, null=False, on_delete=models.DO_NOTHING) From 4900f0447e2cac622c7578f4fff886c82dffe7f0 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Thu, 3 Jul 2025 22:04:51 +0200 Subject: [PATCH 34/41] Project-deletion: slight optimization Removes the following 2 redundant queries from the deletion process: ``` SELECT "tags_tagkey"."id" FROM "tags_tagkey" WHERE "tags_tagkey"."project_id" IN (1) ORDER BY "tags_tagkey"."project_id" ASC, "tags_tagkey"."id" ASC LIMIT 498 UPDATE "projects_project" SET "stored_event_count" = ("projects_project"."stored_event_count" - 1) WHERE "projects_project"."id" = 1 ``` --- bugsink/utils.py | 15 ++++++++++++--- issues/tasks.py | 1 + projects/tasks.py | 1 + projects/tests.py | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/bugsink/utils.py b/bugsink/utils.py index 7395733..8583bd4 100644 --- a/bugsink/utils.py +++ b/bugsink/utils.py @@ -231,7 +231,7 @@ def prune_orphans(model, d_ids_to_check): # vacuuming once in a while" is a much better fit for that. -def do_pre_delete(project_id, model, pks_to_delete): +def do_pre_delete(project_id, model, pks_to_delete, is_for_project): "More model-specific cleanup, if needed; only for Event model at the moment." if model.__name__ != "Event": @@ -246,11 +246,15 @@ def do_pre_delete(project_id, model, pks_to_delete): .values_list("id", "storage_backend") ) + if is_for_project: + # no need to update the stored_event_count for the project, because the project is being deleted + return + # note: don't bother to do the same thing for Issue.stored_event_count, since we're in the process of deleting Issue Project.objects.filter(id=project_id).update(stored_event_count=F('stored_event_count') - len(pks_to_delete)) -def delete_deps_with_budget(project_id, referring_model, fk_name, referred_ids, budget, dep_graph): +def delete_deps_with_budget(project_id, referring_model, fk_name, referred_ids, budget, dep_graph, is_for_project): r""" Deletes all objects of type referring_model that refer to any of the referred_ids via fk_name. Returns the number of deleted objects. @@ -291,6 +295,7 @@ def delete_deps_with_budget(project_id, referring_model, fk_name, referred_ids, [d["pk"] for d in relevant_ids], budget - num_deleted, dep_graph, + is_for_project, ) if num_deleted >= budget: @@ -300,12 +305,16 @@ def delete_deps_with_budget(project_id, referring_model, fk_name, referred_ids, # left. We can now delete the referring objects themselves (limited by budget). relevant_ids_after_rec = relevant_ids[:budget - num_deleted] - do_pre_delete(project_id, referring_model, [d['pk'] for d in relevant_ids_after_rec]) + do_pre_delete(project_id, referring_model, [d['pk'] for d in relevant_ids_after_rec], is_for_project) my_num_deleted, del_d = referring_model.objects.filter(pk__in=[d['pk'] for d in relevant_ids_after_rec]).delete() num_deleted += my_num_deleted assert set(del_d.keys()) == {referring_model._meta.label} # assert no-cascading (we do that ourselves) + if is_for_project: + # short-circuit: project-deletion implies "no orphans" because the project kill everything with it. + return num_deleted + # Note that prune_orphans doesn't respect the budget. Reason: it's not easy to do, b/c the order is reversed (we # would need to predict somehow at the previous step how much budget to leave unused) and we don't care _that much_ # about a precise budget "at the edges of our algo", as long as we don't have a "single huge blocking thing". diff --git a/issues/tasks.py b/issues/tasks.py index af95743..ba6fe9b 100644 --- a/issues/tasks.py +++ b/issues/tasks.py @@ -64,6 +64,7 @@ def delete_issue_deps(project_id, issue_id): [issue_id], budget - num_deleted, dep_graph, + is_for_project=False, ) num_deleted += this_num_deleted diff --git a/projects/tasks.py b/projects/tasks.py index e060ea2..5b51b28 100644 --- a/projects/tasks.py +++ b/projects/tasks.py @@ -123,6 +123,7 @@ def delete_project_deps(project_id): [project_id], budget - num_deleted, dep_graph, + is_for_project=True, ) num_deleted += this_num_deleted diff --git a/projects/tests.py b/projects/tests.py index 182a0a0..986209a 100644 --- a/projects/tests.py +++ b/projects/tests.py @@ -66,7 +66,7 @@ class ProjectDeletionTestCase(TransactionTestCase): # correct for bugsink/transaction.py's select_for_update for non-sqlite databases correct_for_select_for_update = 1 if 'sqlite' not in settings.DATABASES['default']['ENGINE'] else 0 - with self.assertNumQueries(29 + correct_for_select_for_update): + with self.assertNumQueries(27 + correct_for_select_for_update): self.project.delete_deferred() # tests run w/ TASK_ALWAYS_EAGER, so in the below we can just check the database directly From 3e2bc290fd28a82d13e3f4610f0f3c9d7cb0995e Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Fri, 4 Jul 2025 17:16:16 +0200 Subject: [PATCH 35/41] Fix typo in 'breadcrumbs' first/last urls.py --- issues/urls.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/issues/urls.py b/issues/urls.py index 2d8a93f..cf26d43 100644 --- a/issues/urls.py +++ b/issues/urls.py @@ -54,7 +54,8 @@ urlpatterns = [ path('issue//event//', issue_event_stacktrace, name="event_stacktrace"), path('issue//event//details/', issue_event_details, name="event_details"), - path('issue//event//breadcrumbs/', issue_event_details, name="event_breadcrumbs"), + path( + 'issue//event//breadcrumbs/', issue_event_breadcrumbs, name="event_breadcrumbs"), path('issue//tags/', issue_tags), path('issue//history/', issue_history), From cb3168133e566f3853a3c53e4511236f5416e2d1 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Fri, 4 Jul 2025 17:17:38 +0200 Subject: [PATCH 36/41] Fix Event-deletion from the admin b/c of the introduction of on_delete=DO_NOTHING, this was broken. this is the quick & dirty fix (no tests) --- events/admin.py | 33 +++++++++++++++++++++++++++++- events/models.py | 13 ++++++++++++ events/tasks.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 events/tasks.py diff --git a/events/admin.py b/events/admin.py index d9a4fb4..73783c3 100644 --- a/events/admin.py +++ b/events/admin.py @@ -1,11 +1,17 @@ +import json + from django.utils.html import escape, mark_safe from django.contrib import admin +from django.views.decorators.csrf import csrf_protect +from django.utils.decorators import method_decorator -import json +from bugsink.transaction import immediate_atomic from projects.admin import ProjectFilter from .models import Event +csrf_protect_m = method_decorator(csrf_protect) + @admin.register(Event) class EventAdmin(admin.ModelAdmin): @@ -90,3 +96,28 @@ class EventAdmin(admin.ModelAdmin): def on_site(self, obj): return mark_safe('View') + + def get_deleted_objects(self, objs, request): + to_delete = list(objs) + ["...all its related objects... (delayed)"] + model_count = { + Event: len(objs), + } + perms_needed = set() + protected = [] + return to_delete, model_count, perms_needed, protected + + def delete_queryset(self, request, queryset): + # NOTE: not the most efficient; it will do for a first version. + with immediate_atomic(): + for obj in queryset: + obj.delete_deferred() + + def delete_model(self, request, obj): + with immediate_atomic(): + obj.delete_deferred() + + @csrf_protect_m + def delete_view(self, request, object_id, extra_context=None): + # the superclass version, but with the transaction.atomic context manager commented out (we do this ourselves) + # with transaction.atomic(using=router.db_for_write(self.model)): + return self._delete_view(request, object_id, extra_context) diff --git a/events/models.py b/events/models.py index 680b2c5..359d697 100644 --- a/events/models.py +++ b/events/models.py @@ -8,12 +8,15 @@ from django.utils.functional import cached_property from projects.models import Project from compat.timestamp import parse_timestamp +from bugsink.transaction import delay_on_commit from issues.utils import get_title_for_exception_type_and_value from .retention import get_random_irrelevance from .storage_registry import get_write_storage, get_storage +from .tasks import delete_event_deps + class Platform(models.TextChoices): AS3 = "as3" @@ -282,3 +285,13 @@ class Event(models.Model): return list( self.tags.all().select_related("value", "value__key").order_by("value__key__key") ) + + def delete_deferred(self): + """Schedules deletion of all related objects""" + # NOTE: for such a small closure, I couldn't be bothered to have an .is_deleted field and deal with it. (the + # idea being that the deletion will be relatively quick anyway). We still need "something" though, since we've + # set DO_NOTHING everywhere. An alternative would be the "full inline", i.e. delete everything right in the + # request w/o any delay. That diverges even more from the approach for Issue/Project, making such things a + # "design decision needed". Maybe if we get more `delete_deferred` impls. we'll have a bit more info to figure + # out if we can harmonize on (e.g.) 2 approaches. + delay_on_commit(delete_event_deps, str(self.project_id), str(self.id)) diff --git a/events/tasks.py b/events/tasks.py new file mode 100644 index 0000000..c88a006 --- /dev/null +++ b/events/tasks.py @@ -0,0 +1,53 @@ +from snappea.decorators import shared_task + +from bugsink.utils import get_model_topography, delete_deps_with_budget +from bugsink.transaction import immediate_atomic, delay_on_commit + + +@shared_task +def delete_event_deps(project_id, event_id): + from .models import Event # avoid circular import + with immediate_atomic(): + # matches what we do in events/retention.py (and for which argumentation exists); in practive I have seen _much_ + # faster deletion times (in the order of .03s per task on my local laptop) when using a budget of 500, _but_ + # it's not a given those were for "expensive objects" (e.g. events); and I'd rather err on the side of caution + # (worst case we have a bit of inefficiency; in any case this avoids hogging the global write lock / timeouts). + budget = 500 + num_deleted = 0 + + # NOTE: for this delete_x_deps, we didn't bother optimizing the topography graph (the dependency-graph of a + # single event is believed to be small enough to not warrent further optimization). + dep_graph = get_model_topography() + + for model_for_recursion, fk_name_for_recursion in dep_graph["events.Event"]: + this_num_deleted = delete_deps_with_budget( + project_id, + model_for_recursion, + fk_name_for_recursion, + [event_id], + budget - num_deleted, + dep_graph, + is_for_project=False, + ) + + num_deleted += this_num_deleted + + if num_deleted >= budget: + delay_on_commit(delete_event_deps, project_id, event_id) + return + + if budget - num_deleted <= 0: + # no more budget for the self-delete. + delay_on_commit(delete_event_deps, project_id, event_id) + + else: + # final step: delete the event itself + issue = Event.objects.get(pk=event_id).issue + + Event.objects.filter(pk=event_id).delete() + + # manual (outside of delete_deps_with_budget) b/c the special-case in that function is (ATM) specific to + # project (it was built around Issue-deletion initially, so Issue outliving the event-deletion was not + # part of that functionality). we might refactor this at some point. + issue.stored_event_count -= 1 + issue.save(update_fields=["stored_event_count"]) From 72479fe9829d5e061eee9f6d36a7f626623744e0 Mon Sep 17 00:00:00 2001 From: Animesh Agrawal Date: Mon, 12 May 2025 15:25:09 +0530 Subject: [PATCH 37/41] add button for project-delete in UI Implement delete functionality with confirmation modals for projects. cherry picked (by Klaas) from commit 6764fbf343fb; but: * projects only * `delete_deferred` * flake8 See #84 for the original PR --- projects/templates/projects/project_edit.html | 40 ++++++++++++++++--- projects/views.py | 17 +++++++- teams/views.py | 16 +++++++- theme/static/css/dist/styles.css | 2 +- 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/projects/templates/projects/project_edit.html b/projects/templates/projects/project_edit.html index ac0510c..ac838af 100644 --- a/projects/templates/projects/project_edit.html +++ b/projects/templates/projects/project_edit.html @@ -5,13 +5,32 @@ {% block title %}Edit {{ project.name }} · {{ site_title }}{% endblock %} {% block content %} -{# div class="text-cyan-800" here in an attempt to trigger tailwind, which does not pick up Pyhton code #} + +
-
-
+ {% csrf_token %}
@@ -27,11 +46,20 @@ {% tailwind_formfield form.retention_max_event_count %} {% tailwind_formfield form.dsn %} - - Cancel +
+ + Cancel + +
-
{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/projects/views.py b/projects/views.py index ed15710..607c5dd 100644 --- a/projects/views.py +++ b/projects/views.py @@ -163,8 +163,23 @@ def project_edit(request, project_pk): _check_project_admin(project, request.user) if request.method == 'POST': - form = ProjectForm(request.POST, instance=project) + action = request.POST.get('action') + if action == 'delete': + # Double-check that the user is an admin or superuser + if (not request.user.is_superuser + and not ProjectMembership.objects.filter( + project=project, user=request.user, role=ProjectRole.ADMIN, accepted=True).exists() + and not TeamMembership.objects.filter( + team=project.team, user=request.user, role=TeamRole.ADMIN, accepted=True).exists()): + raise PermissionDenied("Only project or team admins can delete projects") + + # Delete the project + project.delete_deferred() + messages.success(request, f'Project "{project.name}" has been deleted successfully.') + return redirect('project_list') + + form = ProjectForm(request.POST, instance=project) if form.is_valid(): form.save() return redirect('project_members', project_pk=project.id) diff --git a/teams/views.py b/teams/views.py index fdf0b33..19e5d4a 100644 --- a/teams/views.py +++ b/teams/views.py @@ -122,8 +122,22 @@ def team_edit(request, team_pk): raise PermissionDenied("You are not an admin of this team") if request.method == 'POST': - form = TeamForm(request.POST, instance=team) + action = request.POST.get('action') + if action == 'delete': + # Double-check that the user is an admin or superuser + if not (TeamMembership.objects.filter(team=team, user=request.user, role=TeamRole.ADMIN, accepted=True).exists() or + request.user.is_superuser): + raise PermissionDenied("Only team admins can delete teams") + + # Delete all associated projects first + team.project_set.all().delete() + # Delete the team itself + team.delete() + messages.success(request, f'Team "{team.name}" has been deleted successfully.') + return redirect('team_list') + + form = TeamForm(request.POST, instance=team) if form.is_valid(): form.save() return redirect('team_members', team_pk=team.id) diff --git a/theme/static/css/dist/styles.css b/theme/static/css/dist/styles.css index 1c8d8b2..74464af 100644 --- a/theme/static/css/dist/styles.css +++ b/theme/static/css/dist/styles.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:IBM Plex Sans,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-1\/2{left:50%}.z-50{z-index:50}.float-right{float:right}.m-1{margin:.25rem}.m-4{margin:1rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0{margin-left:0}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.size-6{width:1.5rem;height:1.5rem}.size-8{width:2rem;height:2rem}.h-12{height:3rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-1\/4{width:25%}.w-1\/6{width:16.666667%}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-128{width:32rem}.w-2\/3{width:66.666667%}.w-24{width:6rem}.w-3\/4{width:75%}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-full{width:100%}.max-w-4xl{max-width:56rem}.flex-\[2_1_96rem\]{flex:2 1 96rem}.flex-auto{flex:1 1 auto}.flex-none{flex:none}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.rotate-180{--tw-rotate:180deg}.rotate-180,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.place-content-end{place-content:end}.content-start{align-content:flex-start}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.self-stretch{align-self:stretch}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-scroll{overflow-y:scroll}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-e-md{border-start-end-radius:.375rem;border-end-end-radius:.375rem}.rounded-s-md{border-start-start-radius:.375rem;border-end-start-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b-2{border-bottom-width:2px}.border-b-4{border-bottom-width:4px}.border-l-2{border-left-width:2px}.border-r-2{border-right-width:2px}.border-t-2{border-top-width:2px}.border-dotted{border-style:dotted}.border-cyan-500{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.border-cyan-800{--tw-border-opacity:1;border-color:rgb(21 94 117/var(--tw-border-opacity,1))}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity,1))}.border-red-800{--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1))}.border-slate-400{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.border-slate-50{--tw-border-opacity:1;border-color:rgb(248 250 252/var(--tw-border-opacity,1))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity,1))}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.bg-cyan-100{--tw-bg-opacity:1;background-color:rgb(207 250 254/var(--tw-bg-opacity,1))}.bg-cyan-200{--tw-bg-opacity:1;background-color:rgb(165 243 252/var(--tw-bg-opacity,1))}.bg-cyan-50{--tw-bg-opacity:1;background-color:rgb(236 254 255/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-slate-600{--tw-bg-opacity:1;background-color:rgb(71 85 105/var(--tw-bg-opacity,1))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-slate-300{--tw-gradient-from:#cbd5e1 var(--tw-gradient-from-position);--tw-gradient-to:rgba(203,213,225,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.fill-cyan-500{fill:#06b6d4}.fill-slate-300{fill:#cbd5e1}.fill-slate-500{fill:#64748b}.fill-slate-800{fill:#1e293b}.stroke-slate-300{stroke:#cbd5e1}.p-12{padding:3rem}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pl-1{padding-left:.25rem}.pl-12{padding-left:3rem}.pl-2{padding-left:.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-6{padding-right:1.5rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-serif{font-family:ui-serif,Georgia,Cambria,Times New Roman,Times,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.leading-normal{line-height:1.5}.tracking-normal{letter-spacing:0}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-cyan-500{--tw-text-opacity:1;color:rgb(6 182 212/var(--tw-text-opacity,1))}.text-cyan-800{--tw-text-opacity:1;color:rgb(21 94 117/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}.dropdown{position:relative;display:inline-block}.dropdown-content-right{display:none;position:absolute;z-index:1;margin-left:auto;right:0}.dropdown-content-left{display:none;position:absolute;z-index:1;left:0}.dropdown:hover .dropdown-content-left,.dropdown:hover .dropdown-content-right{display:flex}.triangle-left{position:relative}.triangle-left:before{content:"";border-color:transparent #cbd5e1 transparent transparent;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-8px;top:20px}.triangle-left:after{content:"";border-color:transparent #fff transparent transparent;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-6px;top:20px}pre{line-height:125%}.syntax-coloring .c{color:#3d7b7b;font-style:italic}.syntax-coloring .err{border:1px solid red}.syntax-coloring .k{color:green;font-weight:700}.syntax-coloring .o{color:#666}.syntax-coloring .ch,.syntax-coloring .cm{color:#3d7b7b;font-style:italic}.syntax-coloring .cp{color:#9c6500}.syntax-coloring .c1,.syntax-coloring .cpf,.syntax-coloring .cs{color:#3d7b7b;font-style:italic}.syntax-coloring .gd{color:#a00000}.syntax-coloring .ge{font-style:italic}.syntax-coloring .ges{font-weight:700;font-style:italic}.syntax-coloring .gr{color:#e40000}.syntax-coloring .gh{color:navy;font-weight:700}.syntax-coloring .gi{color:#008400}.syntax-coloring .go{color:#717171}.syntax-coloring .gp{color:navy;font-weight:700}.syntax-coloring .gs{font-weight:700}.syntax-coloring .gu{color:purple;font-weight:700}.syntax-coloring .gt{color:#04d}.syntax-coloring .kc,.syntax-coloring .kd,.syntax-coloring .kn{color:green;font-weight:700}.syntax-coloring .kp{color:green}.syntax-coloring .kr{color:green;font-weight:700}.syntax-coloring .kt{color:#b00040}.syntax-coloring .m{color:#666}.syntax-coloring .s{color:#ba2121}.syntax-coloring .na{color:#687822}.syntax-coloring .nb{color:green}.syntax-coloring .nc{color:#00f;font-weight:700}.syntax-coloring .no{color:#800}.syntax-coloring .nd{color:#a2f}.syntax-coloring .ni{color:#717171;font-weight:700}.syntax-coloring .ne{color:#cb3f38;font-weight:700}.syntax-coloring .nf{color:#00f}.syntax-coloring .nl{color:#767600}.syntax-coloring .nn{color:#00f;font-weight:700}.syntax-coloring .nt{color:green;font-weight:700}.syntax-coloring .nv{color:#19177c}.syntax-coloring .ow{color:#a2f;font-weight:700}.syntax-coloring .w{color:#bbb}.syntax-coloring .mb,.syntax-coloring .mf,.syntax-coloring .mh,.syntax-coloring .mi,.syntax-coloring .mo{color:#666}.syntax-coloring .dl,.syntax-coloring .sa,.syntax-coloring .sb,.syntax-coloring .sc{color:#ba2121}.syntax-coloring .sd{color:#ba2121;font-style:italic}.syntax-coloring .s2{color:#ba2121}.syntax-coloring .se{color:#aa5d1f;font-weight:700}.syntax-coloring .sh{color:#ba2121}.syntax-coloring .si{color:#a45a77;font-weight:700}.syntax-coloring .sx{color:green}.syntax-coloring .sr{color:#a45a77}.syntax-coloring .s1{color:#ba2121}.syntax-coloring .ss{color:#19177c}.syntax-coloring .bp{color:green}.syntax-coloring .fm{color:#00f}.syntax-coloring .vc,.syntax-coloring .vg,.syntax-coloring .vi,.syntax-coloring .vm{color:#19177c}.syntax-coloring .il{color:#666}input[type=radio]{color:#06b6d4}.hover\:border-b-4:hover{border-bottom-width:4px}.hover\:border-slate-400:hover{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.hover\:bg-cyan-400:hover{--tw-bg-opacity:1;background-color:rgb(34 211 238/var(--tw-bg-opacity,1))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.hover\:bg-slate-200:hover{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.hover\:bg-slate-300:hover{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity,1))}.focus\:border-cyan-500:focus{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-cyan-200:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(165 243 252/var(--tw-ring-opacity,1))}.active\:ring:active{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}@media (min-width:768px){.md\:mb-8{margin-bottom:2rem}.md\:h-16{height:4rem}.md\:w-16{width:4rem}.md\:p-4{padding:1rem}.md\:p-8{padding:2rem}.md\:py-4{padding-top:1rem;padding-bottom:1rem}.md\:pb-16{padding-bottom:4rem}.md\:pl-24{padding-left:6rem}.md\:pr-24{padding-right:6rem}.md\:pt-24{padding-top:6rem}}@media (min-width:1024px){.lg\:w-5\/12{width:41.666667%}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:pb-0{padding-bottom:0}}@media (min-width:1280px){.xl\:flex{display:flex}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:IBM Plex Sans,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-1\/2{left:50%}.z-50{z-index:50}.float-right{float:right}.m-1{margin:.25rem}.m-4{margin:1rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0{margin-left:0}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.size-6{width:1.5rem;height:1.5rem}.size-8{width:2rem;height:2rem}.h-12{height:3rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-1\/4{width:25%}.w-1\/6{width:16.666667%}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-128{width:32rem}.w-2\/3{width:66.666667%}.w-24{width:6rem}.w-3\/4{width:75%}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-full{width:100%}.max-w-4xl{max-width:56rem}.flex-\[2_1_96rem\]{flex:2 1 96rem}.flex-auto{flex:1 1 auto}.flex-none{flex:none}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.rotate-180{--tw-rotate:180deg}.rotate-180,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.place-content-end{place-content:end}.content-start{align-content:flex-start}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.self-stretch{align-self:stretch}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-scroll{overflow-y:scroll}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-e-md{border-start-end-radius:.375rem;border-end-end-radius:.375rem}.rounded-s-md{border-start-start-radius:.375rem;border-end-start-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b-2{border-bottom-width:2px}.border-b-4{border-bottom-width:4px}.border-l-2{border-left-width:2px}.border-r-2{border-right-width:2px}.border-t-2{border-top-width:2px}.border-dotted{border-style:dotted}.border-cyan-500{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.border-cyan-800{--tw-border-opacity:1;border-color:rgb(21 94 117/var(--tw-border-opacity,1))}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity,1))}.border-red-800{--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1))}.border-slate-400{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.border-slate-50{--tw-border-opacity:1;border-color:rgb(248 250 252/var(--tw-border-opacity,1))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity,1))}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.bg-cyan-100{--tw-bg-opacity:1;background-color:rgb(207 250 254/var(--tw-bg-opacity,1))}.bg-cyan-200{--tw-bg-opacity:1;background-color:rgb(165 243 252/var(--tw-bg-opacity,1))}.bg-cyan-50{--tw-bg-opacity:1;background-color:rgb(236 254 255/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-slate-600{--tw-bg-opacity:1;background-color:rgb(71 85 105/var(--tw-bg-opacity,1))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-slate-300{--tw-gradient-from:#cbd5e1 var(--tw-gradient-from-position);--tw-gradient-to:rgba(203,213,225,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.fill-cyan-500{fill:#06b6d4}.fill-slate-300{fill:#cbd5e1}.fill-slate-500{fill:#64748b}.fill-slate-800{fill:#1e293b}.stroke-slate-300{stroke:#cbd5e1}.p-12{padding:3rem}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pl-1{padding-left:.25rem}.pl-12{padding-left:3rem}.pl-2{padding-left:.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-6{padding-right:1.5rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-serif{font-family:ui-serif,Georgia,Cambria,Times New Roman,Times,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.leading-normal{line-height:1.5}.tracking-normal{letter-spacing:0}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-cyan-500{--tw-text-opacity:1;color:rgb(6 182 212/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}.dropdown{position:relative;display:inline-block}.dropdown-content-right{display:none;position:absolute;z-index:1;margin-left:auto;right:0}.dropdown-content-left{display:none;position:absolute;z-index:1;left:0}.dropdown:hover .dropdown-content-left,.dropdown:hover .dropdown-content-right{display:flex}.triangle-left{position:relative}.triangle-left:before{content:"";border-color:transparent #cbd5e1 transparent transparent;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-8px;top:20px}.triangle-left:after{content:"";border-color:transparent #fff transparent transparent;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-6px;top:20px}pre{line-height:125%}.syntax-coloring .c{color:#3d7b7b;font-style:italic}.syntax-coloring .err{border:1px solid red}.syntax-coloring .k{color:green;font-weight:700}.syntax-coloring .o{color:#666}.syntax-coloring .ch,.syntax-coloring .cm{color:#3d7b7b;font-style:italic}.syntax-coloring .cp{color:#9c6500}.syntax-coloring .c1,.syntax-coloring .cpf,.syntax-coloring .cs{color:#3d7b7b;font-style:italic}.syntax-coloring .gd{color:#a00000}.syntax-coloring .ge{font-style:italic}.syntax-coloring .ges{font-weight:700;font-style:italic}.syntax-coloring .gr{color:#e40000}.syntax-coloring .gh{color:navy;font-weight:700}.syntax-coloring .gi{color:#008400}.syntax-coloring .go{color:#717171}.syntax-coloring .gp{color:navy;font-weight:700}.syntax-coloring .gs{font-weight:700}.syntax-coloring .gu{color:purple;font-weight:700}.syntax-coloring .gt{color:#04d}.syntax-coloring .kc,.syntax-coloring .kd,.syntax-coloring .kn{color:green;font-weight:700}.syntax-coloring .kp{color:green}.syntax-coloring .kr{color:green;font-weight:700}.syntax-coloring .kt{color:#b00040}.syntax-coloring .m{color:#666}.syntax-coloring .s{color:#ba2121}.syntax-coloring .na{color:#687822}.syntax-coloring .nb{color:green}.syntax-coloring .nc{color:#00f;font-weight:700}.syntax-coloring .no{color:#800}.syntax-coloring .nd{color:#a2f}.syntax-coloring .ni{color:#717171;font-weight:700}.syntax-coloring .ne{color:#cb3f38;font-weight:700}.syntax-coloring .nf{color:#00f}.syntax-coloring .nl{color:#767600}.syntax-coloring .nn{color:#00f;font-weight:700}.syntax-coloring .nt{color:green;font-weight:700}.syntax-coloring .nv{color:#19177c}.syntax-coloring .ow{color:#a2f;font-weight:700}.syntax-coloring .w{color:#bbb}.syntax-coloring .mb,.syntax-coloring .mf,.syntax-coloring .mh,.syntax-coloring .mi,.syntax-coloring .mo{color:#666}.syntax-coloring .dl,.syntax-coloring .sa,.syntax-coloring .sb,.syntax-coloring .sc{color:#ba2121}.syntax-coloring .sd{color:#ba2121;font-style:italic}.syntax-coloring .s2{color:#ba2121}.syntax-coloring .se{color:#aa5d1f;font-weight:700}.syntax-coloring .sh{color:#ba2121}.syntax-coloring .si{color:#a45a77;font-weight:700}.syntax-coloring .sx{color:green}.syntax-coloring .sr{color:#a45a77}.syntax-coloring .s1{color:#ba2121}.syntax-coloring .ss{color:#19177c}.syntax-coloring .bp{color:green}.syntax-coloring .fm{color:#00f}.syntax-coloring .vc,.syntax-coloring .vg,.syntax-coloring .vi,.syntax-coloring .vm{color:#19177c}.syntax-coloring .il{color:#666}input[type=radio]{color:#06b6d4}.hover\:border-b-4:hover{border-bottom-width:4px}.hover\:border-slate-400:hover{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.hover\:bg-cyan-400:hover{--tw-bg-opacity:1;background-color:rgb(34 211 238/var(--tw-bg-opacity,1))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.hover\:bg-slate-200:hover{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.hover\:bg-slate-300:hover{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity,1))}.focus\:border-cyan-500:focus{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-cyan-200:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(165 243 252/var(--tw-ring-opacity,1))}.active\:ring:active{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}@media (min-width:768px){.md\:mb-8{margin-bottom:2rem}.md\:h-16{height:4rem}.md\:w-16{width:4rem}.md\:p-4{padding:1rem}.md\:p-8{padding:2rem}.md\:py-4{padding-top:1rem;padding-bottom:1rem}.md\:pb-16{padding-bottom:4rem}.md\:pl-24{padding-left:6rem}.md\:pr-24{padding-right:6rem}.md\:pt-24{padding-top:6rem}}@media (min-width:1024px){.lg\:w-5\/12{width:41.666667%}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:pb-0{padding-bottom:0}}@media (min-width:1280px){.xl\:flex{display:flex}} \ No newline at end of file From 308034aadd56097b26f48c4415becede324b9689 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Fri, 4 Jul 2025 21:25:57 +0200 Subject: [PATCH 38/41] Issue-delete from the UI (in the list-view) See #50 --- issues/models.py | 9 ++++ issues/templates/issues/issue_list.html | 60 ++++++++++++++++++++++++- issues/views.py | 15 ++++++- theme/static/css/dist/styles.css | 2 +- 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/issues/models.py b/issues/models.py index 150e03f..7bf0003 100644 --- a/issues/models.py +++ b/issues/models.py @@ -353,6 +353,10 @@ class IssueStateManager(object): kind=TurningPointKind.UNMUTED, metadata=json.dumps(unmute_metadata)) triggering_event.never_evict = True # .save() will be called by the caller of this function + @staticmethod + def delete(issue): + issue.delete_deferred() + @staticmethod def get_unmute_thresholds(issue): unmute_vbcs = [ @@ -471,6 +475,11 @@ class IssueQuerysetStateManager(object): for issue in issue_qs: IssueStateManager.unmute(issue, triggering_event) + @staticmethod + def delete(issue_qs): + for issue in issue_qs: + issue.delete_deferred() + class TurningPointKind(models.IntegerChoices): # The language of the kinds reflects a historic view of the system, e.g. "first seen" as opposed to "new issue"; an diff --git a/issues/templates/issues/issue_list.html b/issues/templates/issues/issue_list.html index 3075d3c..fb7fa48 100644 --- a/issues/templates/issues/issue_list.html +++ b/issues/templates/issues/issue_list.html @@ -7,6 +7,23 @@ {% block content %} + +
@@ -35,7 +52,7 @@
-
+ {% csrf_token %} @@ -122,8 +139,18 @@ {% endif %} + + {% endspaceless %} + + {# NOTE: "reopen" is not available in the UI as per the notes in issue_detail #} {# only for resolved/muted items #} @@ -281,5 +308,36 @@ {% endblock %} {% block extra_js %} + + + {% endblock %} diff --git a/issues/views.py b/issues/views.py index a1ae135..dc97dfc 100644 --- a/issues/views.py +++ b/issues/views.py @@ -128,6 +128,10 @@ def _is_valid_action(action, issue): """We take the 'strict' approach of complaining even when the action is simply a no-op, because you're already in the desired state.""" + if action == "delete": + # any type of issue can be deleted + return True + if issue.is_resolved: # any action is illegal on resolved issues (as per our current UI) return False @@ -153,6 +157,10 @@ def _is_valid_action(action, issue): def _q_for_invalid_for_action(action): """returns a Q obj of issues for which the action is not valid.""" + if action == "delete": + # delete is always valid, so we don't want any issues to be returned, https://stackoverflow.com/a/39001190 + return Q(pk__in=[]) + illegal_conditions = Q(is_resolved=True) # any action is illegal on resolved issues (as per our current UI) if action.startswith("resolved_release:"): @@ -169,7 +177,10 @@ def _q_for_invalid_for_action(action): def _make_history(issue_or_qs, action, user): - if action == "resolve": + if action == "delete": + return # we're about to delete the issue, so no history is needed (nor possible) + + elif action == "resolve": kind = TurningPointKind.RESOLVED elif action.startswith("resolved"): kind = TurningPointKind.RESOLVED @@ -252,6 +263,8 @@ def _apply_action(manager, issue_or_qs, action, user): }])) elif action == "unmute": manager.unmute(issue_or_qs) + elif action == "delete": + manager.delete(issue_or_qs) def issue_list(request, project_pk, state_filter="open"): diff --git a/theme/static/css/dist/styles.css b/theme/static/css/dist/styles.css index 74464af..608e84d 100644 --- a/theme/static/css/dist/styles.css +++ b/theme/static/css/dist/styles.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:IBM Plex Sans,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-1\/2{left:50%}.z-50{z-index:50}.float-right{float:right}.m-1{margin:.25rem}.m-4{margin:1rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0{margin-left:0}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.size-6{width:1.5rem;height:1.5rem}.size-8{width:2rem;height:2rem}.h-12{height:3rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-1\/4{width:25%}.w-1\/6{width:16.666667%}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-128{width:32rem}.w-2\/3{width:66.666667%}.w-24{width:6rem}.w-3\/4{width:75%}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-full{width:100%}.max-w-4xl{max-width:56rem}.flex-\[2_1_96rem\]{flex:2 1 96rem}.flex-auto{flex:1 1 auto}.flex-none{flex:none}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.rotate-180{--tw-rotate:180deg}.rotate-180,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.place-content-end{place-content:end}.content-start{align-content:flex-start}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.self-stretch{align-self:stretch}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-scroll{overflow-y:scroll}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-e-md{border-start-end-radius:.375rem;border-end-end-radius:.375rem}.rounded-s-md{border-start-start-radius:.375rem;border-end-start-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b-2{border-bottom-width:2px}.border-b-4{border-bottom-width:4px}.border-l-2{border-left-width:2px}.border-r-2{border-right-width:2px}.border-t-2{border-top-width:2px}.border-dotted{border-style:dotted}.border-cyan-500{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.border-cyan-800{--tw-border-opacity:1;border-color:rgb(21 94 117/var(--tw-border-opacity,1))}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity,1))}.border-red-800{--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1))}.border-slate-400{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.border-slate-50{--tw-border-opacity:1;border-color:rgb(248 250 252/var(--tw-border-opacity,1))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity,1))}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.bg-cyan-100{--tw-bg-opacity:1;background-color:rgb(207 250 254/var(--tw-bg-opacity,1))}.bg-cyan-200{--tw-bg-opacity:1;background-color:rgb(165 243 252/var(--tw-bg-opacity,1))}.bg-cyan-50{--tw-bg-opacity:1;background-color:rgb(236 254 255/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-slate-600{--tw-bg-opacity:1;background-color:rgb(71 85 105/var(--tw-bg-opacity,1))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-slate-300{--tw-gradient-from:#cbd5e1 var(--tw-gradient-from-position);--tw-gradient-to:rgba(203,213,225,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.fill-cyan-500{fill:#06b6d4}.fill-slate-300{fill:#cbd5e1}.fill-slate-500{fill:#64748b}.fill-slate-800{fill:#1e293b}.stroke-slate-300{stroke:#cbd5e1}.p-12{padding:3rem}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pl-1{padding-left:.25rem}.pl-12{padding-left:3rem}.pl-2{padding-left:.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-6{padding-right:1.5rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-serif{font-family:ui-serif,Georgia,Cambria,Times New Roman,Times,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.leading-normal{line-height:1.5}.tracking-normal{letter-spacing:0}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-cyan-500{--tw-text-opacity:1;color:rgb(6 182 212/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}.dropdown{position:relative;display:inline-block}.dropdown-content-right{display:none;position:absolute;z-index:1;margin-left:auto;right:0}.dropdown-content-left{display:none;position:absolute;z-index:1;left:0}.dropdown:hover .dropdown-content-left,.dropdown:hover .dropdown-content-right{display:flex}.triangle-left{position:relative}.triangle-left:before{content:"";border-color:transparent #cbd5e1 transparent transparent;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-8px;top:20px}.triangle-left:after{content:"";border-color:transparent #fff transparent transparent;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-6px;top:20px}pre{line-height:125%}.syntax-coloring .c{color:#3d7b7b;font-style:italic}.syntax-coloring .err{border:1px solid red}.syntax-coloring .k{color:green;font-weight:700}.syntax-coloring .o{color:#666}.syntax-coloring .ch,.syntax-coloring .cm{color:#3d7b7b;font-style:italic}.syntax-coloring .cp{color:#9c6500}.syntax-coloring .c1,.syntax-coloring .cpf,.syntax-coloring .cs{color:#3d7b7b;font-style:italic}.syntax-coloring .gd{color:#a00000}.syntax-coloring .ge{font-style:italic}.syntax-coloring .ges{font-weight:700;font-style:italic}.syntax-coloring .gr{color:#e40000}.syntax-coloring .gh{color:navy;font-weight:700}.syntax-coloring .gi{color:#008400}.syntax-coloring .go{color:#717171}.syntax-coloring .gp{color:navy;font-weight:700}.syntax-coloring .gs{font-weight:700}.syntax-coloring .gu{color:purple;font-weight:700}.syntax-coloring .gt{color:#04d}.syntax-coloring .kc,.syntax-coloring .kd,.syntax-coloring .kn{color:green;font-weight:700}.syntax-coloring .kp{color:green}.syntax-coloring .kr{color:green;font-weight:700}.syntax-coloring .kt{color:#b00040}.syntax-coloring .m{color:#666}.syntax-coloring .s{color:#ba2121}.syntax-coloring .na{color:#687822}.syntax-coloring .nb{color:green}.syntax-coloring .nc{color:#00f;font-weight:700}.syntax-coloring .no{color:#800}.syntax-coloring .nd{color:#a2f}.syntax-coloring .ni{color:#717171;font-weight:700}.syntax-coloring .ne{color:#cb3f38;font-weight:700}.syntax-coloring .nf{color:#00f}.syntax-coloring .nl{color:#767600}.syntax-coloring .nn{color:#00f;font-weight:700}.syntax-coloring .nt{color:green;font-weight:700}.syntax-coloring .nv{color:#19177c}.syntax-coloring .ow{color:#a2f;font-weight:700}.syntax-coloring .w{color:#bbb}.syntax-coloring .mb,.syntax-coloring .mf,.syntax-coloring .mh,.syntax-coloring .mi,.syntax-coloring .mo{color:#666}.syntax-coloring .dl,.syntax-coloring .sa,.syntax-coloring .sb,.syntax-coloring .sc{color:#ba2121}.syntax-coloring .sd{color:#ba2121;font-style:italic}.syntax-coloring .s2{color:#ba2121}.syntax-coloring .se{color:#aa5d1f;font-weight:700}.syntax-coloring .sh{color:#ba2121}.syntax-coloring .si{color:#a45a77;font-weight:700}.syntax-coloring .sx{color:green}.syntax-coloring .sr{color:#a45a77}.syntax-coloring .s1{color:#ba2121}.syntax-coloring .ss{color:#19177c}.syntax-coloring .bp{color:green}.syntax-coloring .fm{color:#00f}.syntax-coloring .vc,.syntax-coloring .vg,.syntax-coloring .vi,.syntax-coloring .vm{color:#19177c}.syntax-coloring .il{color:#666}input[type=radio]{color:#06b6d4}.hover\:border-b-4:hover{border-bottom-width:4px}.hover\:border-slate-400:hover{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.hover\:bg-cyan-400:hover{--tw-bg-opacity:1;background-color:rgb(34 211 238/var(--tw-bg-opacity,1))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.hover\:bg-slate-200:hover{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.hover\:bg-slate-300:hover{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity,1))}.focus\:border-cyan-500:focus{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-cyan-200:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(165 243 252/var(--tw-ring-opacity,1))}.active\:ring:active{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}@media (min-width:768px){.md\:mb-8{margin-bottom:2rem}.md\:h-16{height:4rem}.md\:w-16{width:4rem}.md\:p-4{padding:1rem}.md\:p-8{padding:2rem}.md\:py-4{padding-top:1rem;padding-bottom:1rem}.md\:pb-16{padding-bottom:4rem}.md\:pl-24{padding-left:6rem}.md\:pr-24{padding-right:6rem}.md\:pt-24{padding-top:6rem}}@media (min-width:1024px){.lg\:w-5\/12{width:41.666667%}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:pb-0{padding-bottom:0}}@media (min-width:1280px){.xl\:flex{display:flex}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:IBM Plex Sans,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:transparent;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:transparent;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-1\/2{left:50%}.z-50{z-index:50}.float-right{float:right}.m-1{margin:.25rem}.m-4{margin:1rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0{margin-left:0}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.size-6{width:1.5rem;height:1.5rem}.size-8{width:2rem;height:2rem}.h-12{height:3rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-1\/4{width:25%}.w-1\/6{width:16.666667%}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-128{width:32rem}.w-2\/3{width:66.666667%}.w-24{width:6rem}.w-3\/4{width:75%}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-full{width:100%}.max-w-4xl{max-width:56rem}.flex-\[2_1_96rem\]{flex:2 1 96rem}.flex-auto{flex:1 1 auto}.flex-none{flex:none}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.rotate-180{--tw-rotate:180deg}.rotate-180,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.place-content-end{place-content:end}.content-start{align-content:flex-start}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.self-stretch{align-self:stretch}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-scroll{overflow-y:scroll}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-e-md{border-start-end-radius:.375rem;border-end-end-radius:.375rem}.rounded-s-md{border-start-start-radius:.375rem;border-end-start-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b-2{border-bottom-width:2px}.border-b-4{border-bottom-width:4px}.border-l-2{border-left-width:2px}.border-r-2{border-right-width:2px}.border-t-2{border-top-width:2px}.border-dotted{border-style:dotted}.border-cyan-500{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.border-cyan-800{--tw-border-opacity:1;border-color:rgb(21 94 117/var(--tw-border-opacity,1))}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity,1))}.border-red-800{--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1))}.border-slate-400{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.border-slate-50{--tw-border-opacity:1;border-color:rgb(248 250 252/var(--tw-border-opacity,1))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity,1))}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.bg-cyan-100{--tw-bg-opacity:1;background-color:rgb(207 250 254/var(--tw-bg-opacity,1))}.bg-cyan-200{--tw-bg-opacity:1;background-color:rgb(165 243 252/var(--tw-bg-opacity,1))}.bg-cyan-50{--tw-bg-opacity:1;background-color:rgb(236 254 255/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-slate-600{--tw-bg-opacity:1;background-color:rgb(71 85 105/var(--tw-bg-opacity,1))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-slate-300{--tw-gradient-from:#cbd5e1 var(--tw-gradient-from-position);--tw-gradient-to:rgba(203,213,225,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.fill-cyan-500{fill:#06b6d4}.fill-slate-300{fill:#cbd5e1}.fill-slate-500{fill:#64748b}.fill-slate-800{fill:#1e293b}.stroke-slate-300{stroke:#cbd5e1}.p-12{padding:3rem}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pl-1{padding-left:.25rem}.pl-12{padding-left:3rem}.pl-2{padding-left:.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-6{padding-right:1.5rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-serif{font-family:ui-serif,Georgia,Cambria,Times New Roman,Times,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.leading-normal{line-height:1.5}.tracking-normal{letter-spacing:0}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-cyan-500{--tw-text-opacity:1;color:rgb(6 182 212/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}.dropdown{position:relative;display:inline-block}.dropdown-content-right{display:none;position:absolute;z-index:1;margin-left:auto;right:0}.dropdown-content-left{display:none;position:absolute;z-index:1;left:0}.dropdown:hover .dropdown-content-left,.dropdown:hover .dropdown-content-right{display:flex}.triangle-left{position:relative}.triangle-left:before{content:"";border-color:transparent #cbd5e1 transparent transparent;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-8px;top:20px}.triangle-left:after{content:"";border-color:transparent #fff transparent transparent;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-6px;top:20px}pre{line-height:125%}.syntax-coloring .c{color:#3d7b7b;font-style:italic}.syntax-coloring .err{border:1px solid red}.syntax-coloring .k{color:green;font-weight:700}.syntax-coloring .o{color:#666}.syntax-coloring .ch,.syntax-coloring .cm{color:#3d7b7b;font-style:italic}.syntax-coloring .cp{color:#9c6500}.syntax-coloring .c1,.syntax-coloring .cpf,.syntax-coloring .cs{color:#3d7b7b;font-style:italic}.syntax-coloring .gd{color:#a00000}.syntax-coloring .ge{font-style:italic}.syntax-coloring .ges{font-weight:700;font-style:italic}.syntax-coloring .gr{color:#e40000}.syntax-coloring .gh{color:navy;font-weight:700}.syntax-coloring .gi{color:#008400}.syntax-coloring .go{color:#717171}.syntax-coloring .gp{color:navy;font-weight:700}.syntax-coloring .gs{font-weight:700}.syntax-coloring .gu{color:purple;font-weight:700}.syntax-coloring .gt{color:#04d}.syntax-coloring .kc,.syntax-coloring .kd,.syntax-coloring .kn{color:green;font-weight:700}.syntax-coloring .kp{color:green}.syntax-coloring .kr{color:green;font-weight:700}.syntax-coloring .kt{color:#b00040}.syntax-coloring .m{color:#666}.syntax-coloring .s{color:#ba2121}.syntax-coloring .na{color:#687822}.syntax-coloring .nb{color:green}.syntax-coloring .nc{color:#00f;font-weight:700}.syntax-coloring .no{color:#800}.syntax-coloring .nd{color:#a2f}.syntax-coloring .ni{color:#717171;font-weight:700}.syntax-coloring .ne{color:#cb3f38;font-weight:700}.syntax-coloring .nf{color:#00f}.syntax-coloring .nl{color:#767600}.syntax-coloring .nn{color:#00f;font-weight:700}.syntax-coloring .nt{color:green;font-weight:700}.syntax-coloring .nv{color:#19177c}.syntax-coloring .ow{color:#a2f;font-weight:700}.syntax-coloring .w{color:#bbb}.syntax-coloring .mb,.syntax-coloring .mf,.syntax-coloring .mh,.syntax-coloring .mi,.syntax-coloring .mo{color:#666}.syntax-coloring .dl,.syntax-coloring .sa,.syntax-coloring .sb,.syntax-coloring .sc{color:#ba2121}.syntax-coloring .sd{color:#ba2121;font-style:italic}.syntax-coloring .s2{color:#ba2121}.syntax-coloring .se{color:#aa5d1f;font-weight:700}.syntax-coloring .sh{color:#ba2121}.syntax-coloring .si{color:#a45a77;font-weight:700}.syntax-coloring .sx{color:green}.syntax-coloring .sr{color:#a45a77}.syntax-coloring .s1{color:#ba2121}.syntax-coloring .ss{color:#19177c}.syntax-coloring .bp{color:green}.syntax-coloring .fm{color:#00f}.syntax-coloring .vc,.syntax-coloring .vg,.syntax-coloring .vi,.syntax-coloring .vm{color:#19177c}.syntax-coloring .il{color:#666}input[type=radio]{color:#06b6d4}.hover\:border-b-4:hover{border-bottom-width:4px}.hover\:border-slate-400:hover{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.hover\:bg-cyan-400:hover{--tw-bg-opacity:1;background-color:rgb(34 211 238/var(--tw-bg-opacity,1))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.hover\:bg-slate-200:hover{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.hover\:bg-slate-300:hover{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity,1))}.focus\:border-cyan-500:focus{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-cyan-200:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(165 243 252/var(--tw-ring-opacity,1))}.active\:ring:active{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}@media (min-width:768px){.md\:mb-8{margin-bottom:2rem}.md\:h-16{height:4rem}.md\:w-16{width:4rem}.md\:p-4{padding:1rem}.md\:p-8{padding:2rem}.md\:py-4{padding-top:1rem;padding-bottom:1rem}.md\:pb-16{padding-bottom:4rem}.md\:pl-24{padding-left:6rem}.md\:pr-24{padding-right:6rem}.md\:pt-24{padding-top:6rem}}@media (min-width:1024px){.lg\:w-5\/12{width:41.666667%}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:pb-0{padding-bottom:0}}@media (min-width:1280px){.xl\:flex{display:flex}} \ No newline at end of file From 7b340fd8ff1da902b226e4b80ad9a40060a64815 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Mon, 7 Jul 2025 09:29:22 +0200 Subject: [PATCH 39/41] Hide in-progress deletions of Project & Issue from the UI I've done a full grep on Issue.objects, Project.objects and get_object_or_404 equivelents, and applying some common sense. The goal: avoid having confusing/half-broken pages in the UI. On index-usage: I've decided not to update the indexes. The assumption is: `is_deleted` items will be a tiny minority of items in general, making the cost/benefit analysis not turn out favorably (just scanning them out as a final step is more efficient). Also: sqlite is able to use the correct index without adding a special one, proof: ``` EXPLAIN QUERY PLAN SELECT [..] WHERE ("issues_issue"."project_id" = 1 AND "issues_issue"."is_muted" = (0) AND "issues_issue"."is_resolved" = (0)) ORDER BY "issues_issue"."last_seen" DESC LIMIT 250; QUERY PLAN `--SEARCH issues_issue USING INDEX issue_list_open (project_id=? AND is_resolved=? AND is_muted=?) EXPLAIN QUERY PLAN SELECT [..] WHERE ("issues_issue"."project_id" = 1 AND "issues_issue"."is_muted" = (0) AND "issues_issue"."is_resolved" = (0) AND "issues_issue"."is_deleted" = 0) ORDER BY "issues_issue"."last_seen" DESC LIMIT 250; QUERY PLAN `--SEARCH issues_issue USING INDEX issue_list_open (project_id=? AND is_resolved=? AND is_muted=?) ``` See #139 for the 0/1 notation in the above. (Project-indexes: not an issue, the scale is "below relevance for indexes") --- bugsink/decorators.py | 2 +- ingest/views.py | 11 +++++++---- issues/views.py | 2 +- projects/views.py | 27 +++++++++++++++------------ 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/bugsink/decorators.py b/bugsink/decorators.py index ae3d3fb..cb07865 100644 --- a/bugsink/decorators.py +++ b/bugsink/decorators.py @@ -39,7 +39,7 @@ def issue_membership_required(function): if "issue_pk" not in kwargs: raise TypeError("issue_pk must be passed as a keyword argument") issue_pk = kwargs.pop("issue_pk") - issue = get_object_or_404(Issue, pk=issue_pk) + issue = get_object_or_404(Issue, pk=issue_pk, is_deleted=False) kwargs["issue"] = issue if request.user.is_superuser: return function(request, *args, **kwargs) diff --git a/ingest/views.py b/ingest/views.py index 49c8131..c8d0562 100644 --- a/ingest/views.py +++ b/ingest/views.py @@ -131,7 +131,7 @@ class BaseIngestAPIView(View): @classmethod def get_project(cls, project_pk, sentry_key): try: - return Project.objects.get(pk=project_pk, sentry_key=sentry_key) + return Project.objects.get(pk=project_pk, sentry_key=sentry_key, is_deleted=False) except Project.DoesNotExist: # We don't distinguish between "project not found" and "key incorrect"; there's no real value in that from # the user perspective (they deal in dsns). Additional advantage: no need to do constant-time-comp on @@ -251,9 +251,12 @@ class BaseIngestAPIView(View): ingested_at = parse_timestamp(event_metadata["ingested_at"]) digested_at = datetime.now(timezone.utc) if digested_at is None else digested_at # explicit passing: test only - project = Project.objects.get(pk=event_metadata["project_id"]) - if project.is_deleted: - return # don't process events for deleted projects + try: + project = Project.objects.get(pk=event_metadata["project_id"], is_deleted=False) + except Project.DoesNotExist: + # we may get here if the project was deleted after the event was ingested, but before it was digested + # (covers both "deletion in progress (is_deleted=True)" and "fully deleted"). + return if not cls.count_project_periods_and_act_on_it(project, digested_at): return # if over-quota: just return (any cleanup is done calling-side) diff --git a/issues/views.py b/issues/views.py index dc97dfc..d547a17 100644 --- a/issues/views.py +++ b/issues/views.py @@ -308,7 +308,7 @@ def _issue_list_pt_2(request, project, state_filter, unapplied_issue_ids): } issue_list = d_state_filter[state_filter]( - Issue.objects.filter(project=project) + Issue.objects.filter(project=project, is_deleted=False) ).order_by("-last_seen") if request.GET.get("q"): diff --git a/projects/views.py b/projects/views.py index 607c5dd..357c8ca 100644 --- a/projects/views.py +++ b/projects/views.py @@ -35,21 +35,24 @@ def project_list(request, ownership_filter=None): my_memberships = ProjectMembership.objects.filter(user=request.user) my_team_memberships = TeamMembership.objects.filter(user=request.user) - my_projects = Project.objects.filter(projectmembership__in=my_memberships).order_by('name').distinct() + my_projects = Project.objects.filter( + projectmembership__in=my_memberships, is_deleted=False).order_by('name').distinct() my_teams_projects = \ Project.objects \ - .filter(team__teammembership__in=my_team_memberships) \ + .filter(team__teammembership__in=my_team_memberships, is_deleted=False) \ .exclude(projectmembership__in=my_memberships) \ .order_by('name').distinct() if request.user.is_superuser: # superusers can see all project, even hidden ones other_projects = Project.objects \ + .filter(is_deleted=False) \ .exclude(projectmembership__in=my_memberships) \ .exclude(team__teammembership__in=my_team_memberships) \ .order_by('name').distinct() else: other_projects = Project.objects \ + .filter(is_deleted=False) \ .exclude(projectmembership__in=my_memberships) \ .exclude(team__teammembership__in=my_team_memberships) \ .exclude(visibility=ProjectVisibility.TEAM_MEMBERS) \ @@ -158,7 +161,7 @@ def _check_project_admin(project, user): @atomic_for_request_method def project_edit(request, project_pk): - project = Project.objects.get(id=project_pk) + project = Project.objects.get(id=project_pk, is_deleted=False) _check_project_admin(project, request.user) @@ -195,7 +198,7 @@ def project_edit(request, project_pk): @atomic_for_request_method def project_members(request, project_pk): - project = Project.objects.get(id=project_pk) + project = Project.objects.get(id=project_pk, is_deleted=False) _check_project_admin(project, request.user) if request.method == 'POST': @@ -230,7 +233,7 @@ def project_members_invite(request, project_pk): # NOTE: project-member invite is just that: a direct invite to a project. If you want to also/instead invite someone # to a team, you need to just do that instead. - project = Project.objects.get(id=project_pk) + project = Project.objects.get(id=project_pk, is_deleted=False) _check_project_admin(project, request.user) @@ -292,7 +295,7 @@ def project_member_settings(request, project_pk, user_pk): this_is_you = str(user_pk) == str(request.user.id) if not this_is_you: - _check_project_admin(Project.objects.get(id=project_pk), request.user) + _check_project_admin(Project.objects.get(id=project_pk, is_deleted=False), request.user) membership = ProjectMembership.objects.get(project=project_pk, user=user_pk) create_form = lambda data: ProjectMembershipForm(data, instance=membership) # noqa @@ -317,7 +320,7 @@ def project_member_settings(request, project_pk, user_pk): return render(request, 'projects/project_member_settings.html', { 'this_is_you': this_is_you, 'user': User.objects.get(id=user_pk), - 'project': Project.objects.get(id=project_pk), + 'project': Project.objects.get(id=project_pk, is_deleted=False), 'form': form, }) @@ -377,7 +380,7 @@ def project_members_accept(request, project_pk): # invited as user B. Security-wise this is fine, but UX-wise it could be confusing. However, I'm in the assumption # here that normal people (i.e. not me) don't have multiple accounts, so I'm not going to bother with this. - project = Project.objects.get(id=project_pk) + project = Project.objects.get(id=project_pk, is_deleted=False) membership = ProjectMembership.objects.get(project=project, user=request.user) if membership.accepted: @@ -402,7 +405,7 @@ def project_members_accept(request, project_pk): @atomic_for_request_method def project_sdk_setup(request, project_pk, platform=""): - project = Project.objects.get(id=project_pk) + project = Project.objects.get(id=project_pk, is_deleted=False) if not request.user.is_superuser and not ProjectMembership.objects.filter(project=project, user=request.user, accepted=True).exists(): @@ -423,7 +426,7 @@ def project_sdk_setup(request, project_pk, platform=""): @atomic_for_request_method def project_alerts_setup(request, project_pk): - project = Project.objects.get(id=project_pk) + project = Project.objects.get(id=project_pk, is_deleted=False) _check_project_admin(project, request.user) if request.method == 'POST': @@ -446,7 +449,7 @@ def project_alerts_setup(request, project_pk): @atomic_for_request_method def project_messaging_service_add(request, project_pk): - project = Project.objects.get(id=project_pk) + project = Project.objects.get(id=project_pk, is_deleted=False) _check_project_admin(project, request.user) if request.method == 'POST': @@ -474,7 +477,7 @@ def project_messaging_service_add(request, project_pk): @atomic_for_request_method def project_messaging_service_edit(request, project_pk, service_pk): - project = Project.objects.get(id=project_pk) + project = Project.objects.get(id=project_pk, is_deleted=False) _check_project_admin(project, request.user) instance = project.service_configs.get(id=service_pk) From 91ae7992e3e55df527b0b658f299fc3122d4ee7c Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Mon, 7 Jul 2025 10:05:15 +0200 Subject: [PATCH 40/41] Comment on possible refactoring --- bugsink/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bugsink/utils.py b/bugsink/utils.py index 8583bd4..8ace562 100644 --- a/bugsink/utils.py +++ b/bugsink/utils.py @@ -250,6 +250,8 @@ def do_pre_delete(project_id, model, pks_to_delete, is_for_project): # no need to update the stored_event_count for the project, because the project is being deleted return + # Update project stored_event_count to reflect the deletion of the events. note: alternatively, we could do this + # on issue-delete (issue.stored_event_count is known too); potato, potato though. # note: don't bother to do the same thing for Issue.stored_event_count, since we're in the process of deleting Issue Project.objects.filter(id=project_id).update(stored_event_count=F('stored_event_count') - len(pks_to_delete)) From c2bc2e417475a5c2dfc64a588ac904278e49d8d4 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Mon, 7 Jul 2025 10:33:03 +0200 Subject: [PATCH 41/41] 'get_system_warnings as a callable to avoid querying the DB(s) unnecessarily (ran into this when seeing >0 queries on 404 requests) --- bugsink/context_processors.py | 40 ++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/bugsink/context_processors.py b/bugsink/context_processors.py index 7093b8f..90a61a4 100644 --- a/bugsink/context_processors.py +++ b/bugsink/context_processors.py @@ -99,33 +99,39 @@ def get_snappea_warnings(): def useful_settings_processor(request): # name is misnomer, but "who cares". - installation = Installation.objects.get() + def get_system_warnings(): + # implemented as an inner function to avoid calculating this when it's not actually needed. (i.e. anything + # except "the UI", e.g. ingest, API, admin, 404s). Actual 'cache' behavior is not needed, because this is called + # at most once per request (at the top of the template) + installation = Installation.objects.get() - system_warnings = [] + system_warnings = [] - # This list does not include e.g. the dummy.EmailBackend; intentional, because setting _that_ is always an - # indication of intentional "shut up I don't want to send emails" (and we don't want to warn about that). (as - # opposed to QuietConsoleEmailBackend, which is the default for the Docker "no EMAIL_HOST set" situation) - if settings.EMAIL_BACKEND in [ - 'bugsink.email_backends.QuietConsoleEmailBackend'] and not installation.silence_email_system_warning: + # This list does not include e.g. the dummy.EmailBackend; intentional, because setting _that_ is always an + # indication of intentional "shut up I don't want to send emails" (and we don't want to warn about that). (as + # opposed to QuietConsoleEmailBackend, which is the default for the Docker "no EMAIL_HOST set" situation) + if settings.EMAIL_BACKEND in [ + 'bugsink.email_backends.QuietConsoleEmailBackend'] and not installation.silence_email_system_warning: - if getattr(request, "user", AnonymousUser()).is_superuser: - ignore_url = reverse("silence_email_system_warning") - else: - # not a superuser, so can't silence the warning. I'm applying some heuristics here; - # * superusers (and only those) will be able to deal with this (have access to EMAIL_BACKEND) - # * better to still show (though not silencable) the message to non-superusers. - # this will not always be so, but it's a good start. - ignore_url = None + if getattr(request, "user", AnonymousUser()).is_superuser: + ignore_url = reverse("silence_email_system_warning") + else: + # not a superuser, so can't silence the warning. I'm applying some heuristics here; + # * superusers (and only those) will be able to deal with this (have access to EMAIL_BACKEND) + # * better to still show (though not silencable) the message to non-superusers. + # this will not always be so, but it's a good start. + ignore_url = None - system_warnings.append(SystemWarning(EMAIL_BACKEND_WARNING, ignore_url)) + system_warnings.append(SystemWarning(EMAIL_BACKEND_WARNING, ignore_url)) + + return system_warnings + get_snappea_warnings() return { # Note: no way to actually set the license key yet, so nagging always happens for now. 'site_title': get_settings().SITE_TITLE, 'registration_enabled': get_settings().USER_REGISTRATION == CB_ANYBODY, 'app_settings': get_settings(), - 'system_warnings': system_warnings + get_snappea_warnings(), + 'system_warnings': get_system_warnings, }