libreqda 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CHANGELOG.md +481 -0
- libreqda-1.2.0.dist-info/LICENSE.txt +28 -0
- libreqda-1.2.0.dist-info/METADATA +131 -0
- libreqda-1.2.0.dist-info/RECORD +255 -0
- libreqda-1.2.0.dist-info/WHEEL +4 -0
- libreqda-1.2.0.dist-info/entry_points.txt +4 -0
- scripts/babelrc +8 -0
- scripts/dist/LICENSE.txt +33 -0
- scripts/dist/RELEASE.txt +23 -0
- scripts/dist/macos/libreqda.icns +0 -0
- scripts/dist/macos/macos.spec +43 -0
- scripts/dist/macos/plist.patch +10 -0
- scripts/dist/macos/taguette.icns +0 -0
- scripts/dist/macos/taguette_console_wrapper +4 -0
- scripts/dist/macos.sh +32 -0
- scripts/dist/pyinstaller_entrypoint.py +19 -0
- scripts/dist/windows/windows-installer.iss +51 -0
- scripts/dist/windows/windows.spec +39 -0
- scripts/edugain/worker_update_edugain_idps.py +89 -0
- scripts/edugain/worker_update_edugain_idps.sh +9 -0
- scripts/make_password_reset_link.py +56 -0
- scripts/make_theme.py +99 -0
- scripts/new_db_revision.sh +8 -0
- scripts/register_user.py +76 -0
- scripts/update_pot.sh +33 -0
- scripts/update_translations.sh +17 -0
- scripts/validate.py +140 -0
- scripts/wipe_and_disable_user.py +90 -0
- taguette/__init__.py +13 -0
- taguette/access_control.py +145 -0
- taguette/convert.py +623 -0
- taguette/database/__init__.py +177 -0
- taguette/database/base.py +9 -0
- taguette/database/copy.py +433 -0
- taguette/database/models.py +920 -0
- taguette/demodata.py +688 -0
- taguette/export.py +500 -0
- taguette/extract.py +287 -0
- taguette/import_codebook.py +110 -0
- taguette/l10n/de_DE/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/de_DE/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/el_EL/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/el_GR/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/el_GR/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/en_US/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/en_US/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/es_ES/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/es_ES/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/fr_FR/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/fr_FR/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/id_ID/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/id_ID/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/it_IT/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/it_IT/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/nl_NL/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/nl_NL/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/pt_PT/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/pt_PT/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/sv_SE/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/sv_SE/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/loki_handler.py +107 -0
- taguette/main.py +649 -0
- taguette/migrations/env.py +72 -0
- taguette/migrations/script.py.mako +24 -0
- taguette/migrations/versions/09c662cd9483_make_documents_filename_not_null.py +39 -0
- taguette/migrations/versions/1b29e68900c1_add_user_roles.py +55 -0
- taguette/migrations/versions/1b96a606551d_update_user_to_have_a_display_name.py +34 -0
- taguette/migrations/versions/2467bab602b7_add_color_column_to_tags_table.py +33 -0
- taguette/migrations/versions/382db36f20f4_add_user_disabled.py +66 -0
- taguette/migrations/versions/43d6c240309d_remove_document_groups.py +81 -0
- taguette/migrations/versions/447d636f72c5_cascades.py +40 -0
- taguette/migrations/versions/491de2dc7cd7_add_indexes.py +51 -0
- taguette/migrations/versions/4c55b3b3ff30_user_login_length.py +58 -0
- taguette/migrations/versions/61a4abcc8769_user_with_parent_for_permissions.py +40 -0
- taguette/migrations/versions/6489b5f9cfb5_increase_password_hash_size.py +34 -0
- taguette/migrations/versions/679f625e6e6a_fix_unique_tags.py +59 -0
- taguette/migrations/versions/77c4543a5b9b_add_parent_id_column_to_collection_table.py +41 -0
- taguette/migrations/versions/7c94b7a31e69_add_is_archived_column_to_projets.py +39 -0
- taguette/migrations/versions/7f56de97e3c8_change_edugain_displayname_length.py +42 -0
- taguette/migrations/versions/807c0cc0ecf6_remove_invalid_hltags.py +43 -0
- taguette/migrations/versions/80b1cc9d4c22_add_user_email.py +35 -0
- taguette/migrations/versions/91ade71ccf4d_add_user_language.py +28 -0
- taguette/migrations/versions/955db2e523d1_add_collections_tables_and_associations_.py +60 -0
- taguette/migrations/versions/a1b2c3d4e5f6_add_user_hide_code_counts.py +28 -0
- taguette/migrations/versions/b23f3b7a638e_add_user_password_change_date.py +36 -0
- taguette/migrations/versions/b7c1e2d3f4a5_add_project_compact_layout.py +28 -0
- taguette/migrations/versions/bc8e0e0677e9_rename_command_fields.py +126 -0
- taguette/migrations/versions/bce44849c2f2_lowercase_logins.py +96 -0
- taguette/migrations/versions/ca076140922f_update_user_for_external_logins.py +32 -0
- taguette/migrations/versions/d86c45d7aa8a_add_document_direction.py +127 -0
- taguette/migrations/versions/db5e31a0233d_remove_blank_highlights.py +88 -0
- taguette/migrations/versions/dd37d9027a24_unique_tags.py +68 -0
- taguette/migrations/versions/de678cef2a94_add_project_id_to_collections_and_.py +41 -0
- taguette/migrations/versions/e459991d960e_add_table_edugainidps.py +39 -0
- taguette/migrations/versions/e4cf92942271_add_index.py +29 -0
- taguette/migrations/versions/e4e090a5b511_remove_commands_document_id_fk.py +38 -0
- taguette/migrations/versions/e9a9f1c77971_add_favorite_column_to_document.py +32 -0
- taguette/migrations/versions/ecb4065de575_don_t_use_server_default_now_timezone_.py +44 -0
- taguette/migrations/versions/fbdc29566f62_edugain_fix_field_type.py +42 -0
- taguette/migrations/versions/fdc6eb54b034_add_parent_id_column_to_tags_table.py +31 -0
- taguette/static/README.md +34 -0
- taguette/static/alt_templates/.gitkeep +0 -0
- taguette/static/assets/test/logos/monsters-university-logo.png +0 -0
- taguette/static/css/custom.css +1696 -0
- taguette/static/css/fa-all.min.css +5 -0
- taguette/static/css/themed.css +3 -0
- taguette/static/css/themed.css.map +25 -0
- taguette/static/css/vendor/bootstrap-treeview.min.css +1 -0
- taguette/static/css/vendor/bootstrap.min.css +6 -0
- taguette/static/css/vendor/font/context-menu-icons.eot +0 -0
- taguette/static/css/vendor/font/context-menu-icons.ttf +0 -0
- taguette/static/css/vendor/font/context-menu-icons.woff +0 -0
- taguette/static/css/vendor/font/context-menu-icons.woff2 +0 -0
- taguette/static/css/vendor/hierarchy-select.min.css +1 -0
- taguette/static/css/vendor/introjs.min.css +2 -0
- taguette/static/css/vendor/jquery.contextMenu.min.css +16 -0
- taguette/static/favicon.ico +0 -0
- taguette/static/js/codebook-viz.js +428 -0
- taguette/static/js/collection/context-menu.js +180 -0
- taguette/static/js/collection/hierarchy-select.js +71 -0
- taguette/static/js/collection/manager.js +217 -0
- taguette/static/js/document/context-menu.js +92 -0
- taguette/static/js/document/custom-select.js +82 -0
- taguette/static/js/document/manager.js +943 -0
- taguette/static/js/document/treeview.js +593 -0
- taguette/static/js/highlight/README.md +18 -0
- taguette/static/js/highlight/crud.js +119 -0
- taguette/static/js/highlight/extracts.js +497 -0
- taguette/static/js/highlight/loader.js +109 -0
- taguette/static/js/highlight/renderer.js +405 -0
- taguette/static/js/login.js +15 -0
- taguette/static/js/permissions.js +153 -0
- taguette/static/js/polling-event.js +145 -0
- taguette/static/js/project/intro.js +524 -0
- taguette/static/js/project/manager.js +0 -0
- taguette/static/js/project/search.js +124 -0
- taguette/static/js/tag/context-menu.js +133 -0
- taguette/static/js/tag/explore-codes.js +400 -0
- taguette/static/js/tag/hierarchy-select.js +69 -0
- taguette/static/js/tag/manager.js +545 -0
- taguette/static/js/tag/treeview.js +613 -0
- taguette/static/js/taguette.js +1790 -0
- taguette/static/js/utilities.js +714 -0
- taguette/static/js/vendor/bootstrap-treeview-custum.js +421 -0
- taguette/static/js/vendor/bootstrap-treeview-custum.min.js +1 -0
- taguette/static/js/vendor/bootstrap-treeview-default.min.js +1 -0
- taguette/static/js/vendor/bootstrap.bundle.min.js +7 -0
- taguette/static/js/vendor/bootstrap.bundle.min.js.map +1 -0
- taguette/static/js/vendor/hierarchy-select.min.js +1 -0
- taguette/static/js/vendor/introjs.min.js +17 -0
- taguette/static/js/vendor/jquery-3.7.1.js +2 -0
- taguette/static/js/vendor/jquery.contextMenu.min.js +2 -0
- taguette/static/libreqda.ico +0 -0
- taguette/static/logo-h.png +0 -0
- taguette/static/logo.png +0 -0
- taguette/static/logo_blanc.png +0 -0
- taguette/static/missing.png +0 -0
- taguette/static/robots.txt +2 -0
- taguette/static/scss/components/_badge.scss +42 -0
- taguette/static/scss/components/_button.scss +169 -0
- taguette/static/scss/components/_checkbox.scss +40 -0
- taguette/static/scss/components/_context_menu.scss +28 -0
- taguette/static/scss/components/_custom_select.scss +150 -0
- taguette/static/scss/components/_dropdown.scss +43 -0
- taguette/static/scss/components/_form.scss +54 -0
- taguette/static/scss/components/_modal.scss +57 -0
- taguette/static/scss/components/_project_card.scss +163 -0
- taguette/static/scss/components/_tag.scss +308 -0
- taguette/static/scss/components/_treeview.scss +76 -0
- taguette/static/scss/custom.scss +809 -0
- taguette/static/scss/layout/_credits.scss +62 -0
- taguette/static/scss/layout/_footer.scss +6 -0
- taguette/static/scss/layout/_navigation.scss +165 -0
- taguette/static/scss/layout/_sidebar.scss +37 -0
- taguette/static/scss/themes/default/_variables.scss +103 -0
- taguette/static/scss/themes/default/theme.css +3 -0
- taguette/static/scss/themes/default/theme.css.map +26 -0
- taguette/static/scss/themes/default/theme.scss +4 -0
- taguette/static/webfonts/BadScript-OFL.txt +94 -0
- taguette/static/webfonts/BadScript.ttf +0 -0
- taguette/static/webfonts/Montserrat-Light.ttf +0 -0
- taguette/static/webfonts/Montserrat-OFL.txt +93 -0
- taguette/static/webfonts/fa-brands-400.eot +0 -0
- taguette/static/webfonts/fa-brands-400.svg +1184 -0
- taguette/static/webfonts/fa-brands-400.ttf +0 -0
- taguette/static/webfonts/fa-brands-400.woff +0 -0
- taguette/static/webfonts/fa-brands-400.woff2 +0 -0
- taguette/static/webfonts/fa-regular-400.eot +0 -0
- taguette/static/webfonts/fa-regular-400.svg +467 -0
- taguette/static/webfonts/fa-regular-400.ttf +0 -0
- taguette/static/webfonts/fa-regular-400.woff +0 -0
- taguette/static/webfonts/fa-regular-400.woff2 +0 -0
- taguette/static/webfonts/fa-solid-900.eot +0 -0
- taguette/static/webfonts/fa-solid-900.svg +2618 -0
- taguette/static/webfonts/fa-solid-900.ttf +0 -0
- taguette/static/webfonts/fa-solid-900.woff +0 -0
- taguette/static/webfonts/fa-solid-900.woff2 +0 -0
- taguette/templates/alt_templates/.gitkeep +0 -0
- taguette/templates/archived_projects.html +50 -0
- taguette/templates/auth/account.html +74 -0
- taguette/templates/auth/cilogon.login.html +89 -0
- taguette/templates/auth/email_reset_password.html +5 -0
- taguette/templates/auth/external_account.html +60 -0
- taguette/templates/auth/login.html +144 -0
- taguette/templates/auth/reset_password.html +27 -0
- taguette/templates/auth/token_needed.html +7 -0
- taguette/templates/base.html +209 -0
- taguette/templates/bug_report.html +158 -0
- taguette/templates/cookies_prompt.html +15 -0
- taguette/templates/downloads.html +127 -0
- taguette/templates/email_bug_report.txt +9 -0
- taguette/templates/email_reset_password.txt +5 -0
- taguette/templates/error.html +8 -0
- taguette/templates/export_codebook.html +20 -0
- taguette/templates/export_document.html +20 -0
- taguette/templates/export_highlights.html +47 -0
- taguette/templates/index.html +347 -0
- taguette/templates/maintenance.html +8 -0
- taguette/templates/messages.js +74 -0
- taguette/templates/modal/change_document.html +68 -0
- taguette/templates/modal/create_collection.html +100 -0
- taguette/templates/modal/create_document.html +84 -0
- taguette/templates/modal/create_highlight.html +38 -0
- taguette/templates/modal/create_tag.html +73 -0
- taguette/templates/modal/explore_codes.html +58 -0
- taguette/templates/modal/manage_collaborators.html +44 -0
- taguette/templates/modal/merge_tag.html +49 -0
- taguette/templates/new_password.html +32 -0
- taguette/templates/partials/alert.html +16 -0
- taguette/templates/partials/codebook_viz_modal.html +34 -0
- taguette/templates/partials/confirm.html +17 -0
- taguette/templates/partials/document_toolbar.html +37 -0
- taguette/templates/partials/hlinfo.html +13 -0
- taguette/templates/partials/project_card.html +44 -0
- taguette/templates/partials/project_card_archived.html +41 -0
- taguette/templates/partials/project_sidebar.html +99 -0
- taguette/templates/partials/spinner.html +11 -0
- taguette/templates/permissions.html +54 -0
- taguette/templates/project.html +288 -0
- taguette/templates/project_delete.html +17 -0
- taguette/templates/project_import.html +122 -0
- taguette/templates/project_import_codebook.html +34 -0
- taguette/templates/project_import_codebook_confirm.html +49 -0
- taguette/templates/project_new.html +31 -0
- taguette/templates/tos.html +8 -0
- taguette/templates/trans.js +7 -0
- taguette/templates/welcome.html +15 -0
- taguette/utils.py +177 -0
- taguette/validate.py +141 -0
- taguette/version_check.py +102 -0
- taguette/web/__init__.py +252 -0
- taguette/web/api.py +1835 -0
- taguette/web/base.py +864 -0
- taguette/web/export.py +338 -0
- taguette/web/views.py +1618 -0
taguette/web/base.py
ADDED
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from json import JSONDecodeError
|
|
4
|
+
|
|
5
|
+
import bleach
|
|
6
|
+
import contextlib
|
|
7
|
+
import functools
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import jinja2
|
|
13
|
+
from markupsafe import Markup
|
|
14
|
+
import opentelemetry.trace
|
|
15
|
+
import os
|
|
16
|
+
import pkg_resources
|
|
17
|
+
from prometheus_async.aio import time as prom_async_time
|
|
18
|
+
import prometheus_client
|
|
19
|
+
import redis.asyncio as aioredis
|
|
20
|
+
import signal
|
|
21
|
+
import smtplib
|
|
22
|
+
from sqlalchemy.orm import joinedload, undefer
|
|
23
|
+
import tornado.ioloop
|
|
24
|
+
from tornado.httpclient import AsyncHTTPClient
|
|
25
|
+
import tornado.locale
|
|
26
|
+
import tornado.iostream
|
|
27
|
+
from tornado.web import HTTPError, RequestHandler
|
|
28
|
+
from urllib.parse import urlencode
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from .. import exact_version
|
|
32
|
+
from .. import database
|
|
33
|
+
from .. import version_check
|
|
34
|
+
from ..utils import background_task
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
tracer = opentelemetry.trace.get_tracer(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
PROM_DB_CONNECTIONS = prometheus_client.Gauge(
|
|
42
|
+
'database_connections',
|
|
43
|
+
"Number of currently open database connections",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
PROM_EMAILS = prometheus_client.Counter(
|
|
47
|
+
'emails_sent',
|
|
48
|
+
"Number of emails sent",
|
|
49
|
+
labelnames=['type'],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
PROM_EMAILS.labels('password_reset').inc(0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PseudoLocale(tornado.locale.Locale):
|
|
56
|
+
def __init__(self):
|
|
57
|
+
super().__init__('qps-ploc')
|
|
58
|
+
|
|
59
|
+
CHARS = {
|
|
60
|
+
'A': '\u00c5', 'B': '\u0181', 'C': '\u00c7', 'D': '\u00d0',
|
|
61
|
+
'E': '\u00c9', 'F': '\u0191', 'G': '\u011c', 'H': '\u0124',
|
|
62
|
+
'I': '\u00ce', 'J': '\u0134', 'K': '\u0136', 'L': '\u013b',
|
|
63
|
+
'M': '\u1e40', 'N': '\u00d1', 'O': '\u00d6', 'P': '\u00de',
|
|
64
|
+
'Q': '\u01ea', 'R': '\u0154', 'S': '\u0160', 'T': '\u0162',
|
|
65
|
+
'U': '\u00db', 'V': '\u1e7c', 'W': '\u0174', 'X': '\u1e8a',
|
|
66
|
+
'Y': '\u00dd', 'Z': '\u017d', 'a': '\u00e5', 'b': '\u0180',
|
|
67
|
+
'c': '\u00e7', 'd': '\u00f0', 'e': '\u00e9', 'f': '\u0192',
|
|
68
|
+
'g': '\u011d', 'h': '\u0125', 'i': '\u00ee', 'j': '\u0135',
|
|
69
|
+
'k': '\u0137', 'l': '\u013c', 'm': '\u0271', 'n': '\u00f1',
|
|
70
|
+
'o': '\u00f6', 'p': '\u00fe', 'q': '\u01eb', 'r': '\u0155',
|
|
71
|
+
's': '\u0161', 't': '\u0163', 'u': '\u00fb', 'v': '\u1e7d',
|
|
72
|
+
'w': '\u0175', 'x': '\u1e8b', 'y': '\u00fd', 'z': '\u017e',
|
|
73
|
+
' ': '\u2003',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@functools.lru_cache()
|
|
77
|
+
def mangle(self, text):
|
|
78
|
+
out = []
|
|
79
|
+
i = 0
|
|
80
|
+
while i < len(text):
|
|
81
|
+
if text[i] == '{' and text[i + 1] == '{':
|
|
82
|
+
# Jinja2 variable
|
|
83
|
+
j = i + 2
|
|
84
|
+
while text[j] != '}':
|
|
85
|
+
j += 1
|
|
86
|
+
j += 1
|
|
87
|
+
out.append(text[i:j + 1])
|
|
88
|
+
i = j
|
|
89
|
+
elif text[i] == '%' and text[i + 1] == '(':
|
|
90
|
+
# Python variable
|
|
91
|
+
j = i + 2
|
|
92
|
+
while text[j] != ')':
|
|
93
|
+
j += 1
|
|
94
|
+
j += 1
|
|
95
|
+
assert text[j] in 'srdf'
|
|
96
|
+
out.append(text[i:j + 1])
|
|
97
|
+
i = j
|
|
98
|
+
elif text[i] == '<':
|
|
99
|
+
# HTML tag
|
|
100
|
+
j = i + 1
|
|
101
|
+
while text[j] != '>':
|
|
102
|
+
j += 1
|
|
103
|
+
out.append(text[i:j + 1])
|
|
104
|
+
i = j
|
|
105
|
+
else:
|
|
106
|
+
out.append(self.CHARS.get(text[i], text[i]))
|
|
107
|
+
i += 1
|
|
108
|
+
|
|
109
|
+
return ''.join(out)
|
|
110
|
+
|
|
111
|
+
def translate(self, message, plural_message=None, count=None):
|
|
112
|
+
if plural_message is not None:
|
|
113
|
+
assert count is not None
|
|
114
|
+
return '[%s (n=%d)]' % (
|
|
115
|
+
self.mangle(plural_message),
|
|
116
|
+
count,
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
assert count is None
|
|
120
|
+
return '[%s]' % self.mangle(message)
|
|
121
|
+
|
|
122
|
+
def pgettext(self, context, message, plural_message=None, count=None):
|
|
123
|
+
return self.translate(message, plural_message, count)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class JSONLocale:
|
|
127
|
+
translations = {}
|
|
128
|
+
|
|
129
|
+
def load_json(self, json_path):
|
|
130
|
+
if os.path.isfile(json_path):
|
|
131
|
+
with open(json_path) as f:
|
|
132
|
+
try:
|
|
133
|
+
self.translations = json.load(f)
|
|
134
|
+
return True
|
|
135
|
+
except JSONDecodeError:
|
|
136
|
+
return False
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
def get_trans(self, msgid, language_code):
|
|
140
|
+
if msgid in self.translations:
|
|
141
|
+
if language_code in self.translations[msgid]:
|
|
142
|
+
return self.translations[msgid][language_code]
|
|
143
|
+
return msgid
|
|
144
|
+
|
|
145
|
+
def get_trans_from_json(self, msgid, language_code, json_path):
|
|
146
|
+
if (self.load_json(json_path)):
|
|
147
|
+
return self.get_trans(msgid, language_code)
|
|
148
|
+
return msgid
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class GracefulExitApplication(tornado.web.Application):
|
|
152
|
+
def __init__(self, *args, **kwargs):
|
|
153
|
+
super(GracefulExitApplication, self).__init__(*args, **kwargs)
|
|
154
|
+
|
|
155
|
+
self.is_exiting = False
|
|
156
|
+
|
|
157
|
+
exit_time = os.environ.get('TORNADO_SHUTDOWN_TIME')
|
|
158
|
+
if exit_time:
|
|
159
|
+
exit_time = int(exit_time, 10)
|
|
160
|
+
else:
|
|
161
|
+
exit_time = 1 # Default to 1 seconds
|
|
162
|
+
|
|
163
|
+
def exit():
|
|
164
|
+
logger.info("Shutting down")
|
|
165
|
+
tornado.ioloop.IOLoop.current().stop()
|
|
166
|
+
|
|
167
|
+
def exit_soon():
|
|
168
|
+
tornado.ioloop.IOLoop.current().call_later(exit_time, exit)
|
|
169
|
+
|
|
170
|
+
def signal_handler(signum, frame):
|
|
171
|
+
logger.info("Got SIGTERM, exiting")
|
|
172
|
+
self.is_exiting = True
|
|
173
|
+
tornado.ioloop.IOLoop.current().add_callback_from_signal(exit_soon)
|
|
174
|
+
|
|
175
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
176
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Application(GracefulExitApplication):
|
|
180
|
+
def __init__(self, handlers,
|
|
181
|
+
config, **kwargs):
|
|
182
|
+
self.config = config
|
|
183
|
+
self.io_loop = asyncio.get_event_loop()
|
|
184
|
+
|
|
185
|
+
# Don't reuse the secret
|
|
186
|
+
cookie_secret = config['SECRET_KEY']
|
|
187
|
+
|
|
188
|
+
super(Application, self).__init__(handlers,
|
|
189
|
+
cookie_secret=cookie_secret,
|
|
190
|
+
**kwargs)
|
|
191
|
+
|
|
192
|
+
d = pkg_resources.resource_filename('taguette', 'l10n')
|
|
193
|
+
tornado.locale.load_gettext_translations(d, 'taguette_main')
|
|
194
|
+
tornado.locale.set_default_locale(self.config['DEFAULT_LANGUAGE'])
|
|
195
|
+
|
|
196
|
+
self.DBSession = database.connect(config['DATABASE'])
|
|
197
|
+
self.event_waiters = {}
|
|
198
|
+
self.bug_reports = defaultdict(list)
|
|
199
|
+
|
|
200
|
+
if config['REDIS_SERVER'] is not None:
|
|
201
|
+
self.redis = aioredis.Redis.from_url(config['REDIS_SERVER'])
|
|
202
|
+
self.redis_pubsub = self.redis.pubsub()
|
|
203
|
+
background_task(self._redis_reader(), should_never_exit=True)
|
|
204
|
+
else:
|
|
205
|
+
self.redis = self.redis_pubsub = None
|
|
206
|
+
|
|
207
|
+
if config['TOS_FILE']:
|
|
208
|
+
with open(config['TOS_FILE']) as fp:
|
|
209
|
+
self.terms_of_service = fp.read()
|
|
210
|
+
else:
|
|
211
|
+
self.terms_of_service = None
|
|
212
|
+
|
|
213
|
+
if config['MULTIUSER']:
|
|
214
|
+
self.single_user_token = None
|
|
215
|
+
logger.info("Starting in multi-user mode")
|
|
216
|
+
if not self.terms_of_service:
|
|
217
|
+
logger.warning("No terms of service set")
|
|
218
|
+
elif config.get('UNIX_SOCKET'):
|
|
219
|
+
self.single_user_token = None
|
|
220
|
+
logger.info("Starting with unix socket")
|
|
221
|
+
else:
|
|
222
|
+
self.single_user_token = hmac.new(
|
|
223
|
+
cookie_secret.encode('utf-8'),
|
|
224
|
+
b'taguette_single_user',
|
|
225
|
+
digestmod=hashlib.sha256,
|
|
226
|
+
).hexdigest()
|
|
227
|
+
|
|
228
|
+
self.tag_default_color = config.get('TAG_DEFAULT_COLOR', '#000000')
|
|
229
|
+
|
|
230
|
+
# Get messages, from file, url or hardcoded.
|
|
231
|
+
self.messages = []
|
|
232
|
+
self.messages_file_mtime = None
|
|
233
|
+
self.messages_event = asyncio.Event()
|
|
234
|
+
self.check_messages()
|
|
235
|
+
|
|
236
|
+
MESSAGE_ALLOWED_TAGS = ['a', 'br', 'hr', 'strong', 'em', 'b', 'i',
|
|
237
|
+
'code', 'p']
|
|
238
|
+
MESSAGE_ALLOWED_ATTRS = {'a': ['href', 'title']}
|
|
239
|
+
MESSAGE_SEVERITIES = {'info', 'warning', 'success', 'danger', 'secondary'}
|
|
240
|
+
|
|
241
|
+
def _clean_html(self, html):
|
|
242
|
+
return bleach.clean(
|
|
243
|
+
html,
|
|
244
|
+
tags=self.MESSAGE_ALLOWED_TAGS,
|
|
245
|
+
attributes=self.MESSAGE_ALLOWED_ATTRS,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _sanitize_message(self, raw):
|
|
249
|
+
"""Normalize one message dict from JSON/URL/config into the shape
|
|
250
|
+
stored in memory. ``text`` and ``html`` may be either a string or a
|
|
251
|
+
{locale_code: string} mapping; per-locale resolution happens at serve
|
|
252
|
+
time in ``resolve_message_for_locale``.
|
|
253
|
+
"""
|
|
254
|
+
text = raw.get('text', '') or ''
|
|
255
|
+
html = raw.get('html') or text
|
|
256
|
+
severity = raw.get('severity', 'secondary')
|
|
257
|
+
if severity not in self.MESSAGE_SEVERITIES:
|
|
258
|
+
severity = 'secondary'
|
|
259
|
+
if isinstance(text, dict):
|
|
260
|
+
clean_text = {k: str(v) for k, v in text.items()}
|
|
261
|
+
id_seed = next(iter(clean_text.values()), '')
|
|
262
|
+
else:
|
|
263
|
+
clean_text = str(text)
|
|
264
|
+
id_seed = clean_text
|
|
265
|
+
if isinstance(html, dict):
|
|
266
|
+
clean_html = {k: self._clean_html(v) for k, v in html.items()}
|
|
267
|
+
else:
|
|
268
|
+
clean_html = self._clean_html(html)
|
|
269
|
+
return {
|
|
270
|
+
'id': str(raw.get('id') or hashlib.sha1(
|
|
271
|
+
id_seed.encode('utf-8')).hexdigest()[:12]),
|
|
272
|
+
'text': clean_text,
|
|
273
|
+
'html': clean_html,
|
|
274
|
+
'severity': severity,
|
|
275
|
+
'start_at': raw.get('start_at'),
|
|
276
|
+
'end_at': raw.get('end_at'),
|
|
277
|
+
'dismissible': bool(raw.get('dismissible', True)),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
def _pick_locale_variant(self, value, locale_code):
|
|
281
|
+
"""If ``value`` is a {locale_code: string} mapping, pick the best
|
|
282
|
+
match with the fallback chain: exact -> language prefix -> app
|
|
283
|
+
default -> first available. Strings are returned unchanged.
|
|
284
|
+
"""
|
|
285
|
+
if not isinstance(value, dict):
|
|
286
|
+
return value
|
|
287
|
+
if not value:
|
|
288
|
+
return ''
|
|
289
|
+
if locale_code in value:
|
|
290
|
+
return value[locale_code]
|
|
291
|
+
lang = locale_code.split('_', 1)[0] if locale_code else ''
|
|
292
|
+
for code in value:
|
|
293
|
+
if code.split('_', 1)[0] == lang:
|
|
294
|
+
return value[code]
|
|
295
|
+
default = self.config.get('DEFAULT_LANGUAGE')
|
|
296
|
+
if default and default in value:
|
|
297
|
+
return value[default]
|
|
298
|
+
return next(iter(value.values()))
|
|
299
|
+
|
|
300
|
+
def resolve_message_for_locale(self, message, locale_code):
|
|
301
|
+
"""Return a copy of ``message`` with ``text`` and ``html`` resolved
|
|
302
|
+
for the given locale.
|
|
303
|
+
"""
|
|
304
|
+
return dict(
|
|
305
|
+
message,
|
|
306
|
+
text=self._pick_locale_variant(message['text'], locale_code),
|
|
307
|
+
html=self._pick_locale_variant(message['html'], locale_code),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def _resolve_messages_file(self):
|
|
311
|
+
theme = self.config.get('THEME')
|
|
312
|
+
if theme:
|
|
313
|
+
templates_dir = Path(__file__).parent.parent / "templates"
|
|
314
|
+
theme_path = (
|
|
315
|
+
templates_dir / "alt_templates" / theme / "messages.json")
|
|
316
|
+
if theme_path.is_file():
|
|
317
|
+
return str(theme_path)
|
|
318
|
+
return self.config.get('MESSAGES_FILE')
|
|
319
|
+
|
|
320
|
+
def _load_messages_from_file(self, path):
|
|
321
|
+
try:
|
|
322
|
+
mtime = os.path.getmtime(path)
|
|
323
|
+
except OSError:
|
|
324
|
+
logger.warning("MESSAGES_FILE %r not found", path)
|
|
325
|
+
self.messages = []
|
|
326
|
+
self.messages_file_mtime = None
|
|
327
|
+
return
|
|
328
|
+
if mtime == self.messages_file_mtime:
|
|
329
|
+
return
|
|
330
|
+
try:
|
|
331
|
+
with open(path, 'r', encoding='utf-8') as fp:
|
|
332
|
+
data = json.load(fp)
|
|
333
|
+
except (OSError, JSONDecodeError) as e:
|
|
334
|
+
logger.error("Failed reading MESSAGES_FILE %r: %s", path, e)
|
|
335
|
+
return
|
|
336
|
+
raw_messages = data if isinstance(data, list) else data.get(
|
|
337
|
+
'messages', [])
|
|
338
|
+
self.messages = [self._sanitize_message(m) for m in raw_messages]
|
|
339
|
+
self.messages_file_mtime = mtime
|
|
340
|
+
for msg in self.messages:
|
|
341
|
+
logger.info("LibreQDA message loaded: %s", msg['text'])
|
|
342
|
+
|
|
343
|
+
def refresh_messages_if_file(self):
|
|
344
|
+
path = self._resolve_messages_file()
|
|
345
|
+
if path:
|
|
346
|
+
self._load_messages_from_file(path)
|
|
347
|
+
|
|
348
|
+
async def _check_messages(self):
|
|
349
|
+
# Check if a JSON file is configured (hot-reloadable)
|
|
350
|
+
path = self._resolve_messages_file()
|
|
351
|
+
if path:
|
|
352
|
+
self._load_messages_from_file(path)
|
|
353
|
+
self.messages_event.set()
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
# Check if a hardcoded message is configured
|
|
357
|
+
if self.config.get('HARDCODED_MESSAGE'):
|
|
358
|
+
msg_config = self.config['HARDCODED_MESSAGE']
|
|
359
|
+
if not isinstance(msg_config, dict):
|
|
360
|
+
msg_config = {'text': str(msg_config)}
|
|
361
|
+
self.messages = [self._sanitize_message(msg_config)]
|
|
362
|
+
for msg in self.messages:
|
|
363
|
+
logger.warning("LibreQDA message: %s", msg['text'])
|
|
364
|
+
self.messages_event.set()
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
# Check if a custom URL is configured
|
|
368
|
+
if self.config.get('MESSAGES_URL'):
|
|
369
|
+
http_client = AsyncHTTPClient()
|
|
370
|
+
response = await http_client.fetch(
|
|
371
|
+
self.config['MESSAGES_URL'],
|
|
372
|
+
headers={
|
|
373
|
+
'Accept': 'application/json',
|
|
374
|
+
'User-Agent': 'Taguette'
|
|
375
|
+
})
|
|
376
|
+
obj = json.loads(response.body.decode('utf-8'))
|
|
377
|
+
self.messages = [
|
|
378
|
+
self._sanitize_message(msg) for msg in obj['messages']
|
|
379
|
+
]
|
|
380
|
+
for msg in self.messages:
|
|
381
|
+
logger.warning("LibreQDA message: %s", msg['text'])
|
|
382
|
+
self.messages_event.set()
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
# No message configuration found
|
|
386
|
+
self.messages_event.set()
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def _check_messages_callback(future):
|
|
390
|
+
try:
|
|
391
|
+
future.result()
|
|
392
|
+
except Exception:
|
|
393
|
+
logger.exception("Error getting messages")
|
|
394
|
+
|
|
395
|
+
def check_messages(self):
|
|
396
|
+
if ("CHECK_MESSAGES" in self.config
|
|
397
|
+
and not self.config["CHECK_MESSAGES"]):
|
|
398
|
+
return
|
|
399
|
+
f_msg = self.io_loop.create_task(self._check_messages())
|
|
400
|
+
f_msg.add_done_callback(self._check_messages_callback)
|
|
401
|
+
self.io_loop.call_later(86400, # 24 hours
|
|
402
|
+
self.check_messages)
|
|
403
|
+
|
|
404
|
+
async def _redis_reader(self):
|
|
405
|
+
# FIXME: Can't call listen() with no subscription
|
|
406
|
+
await self.redis_pubsub.subscribe('_fixme')
|
|
407
|
+
|
|
408
|
+
async for message in self.redis_pubsub.listen():
|
|
409
|
+
if message['type'] == 'message':
|
|
410
|
+
data = message['data'].decode('utf-8')
|
|
411
|
+
cmd_json = json.loads(data)
|
|
412
|
+
project_id = cmd_json['project_id']
|
|
413
|
+
# Deliver to waiting handlers
|
|
414
|
+
for future in self.event_waiters.pop(project_id, []):
|
|
415
|
+
future.set_result(cmd_json)
|
|
416
|
+
# Unsubscribe
|
|
417
|
+
await self.redis_pubsub.unsubscribe('project:%d' % project_id)
|
|
418
|
+
|
|
419
|
+
async def observe_project(self, project_id, future):
|
|
420
|
+
assert isinstance(project_id, int)
|
|
421
|
+
if project_id in self.event_waiters:
|
|
422
|
+
# We're already watching, add a future to notify
|
|
423
|
+
self.event_waiters[project_id].add(future)
|
|
424
|
+
# We can't have missed an event, we were already watching
|
|
425
|
+
return False
|
|
426
|
+
else:
|
|
427
|
+
# Start watching
|
|
428
|
+
self.event_waiters[project_id] = set((future,))
|
|
429
|
+
if self.redis is None:
|
|
430
|
+
# We can't have missed an event, this is the only thread
|
|
431
|
+
return False
|
|
432
|
+
else:
|
|
433
|
+
# Listen for Redis messages
|
|
434
|
+
await self.redis_pubsub.subscribe('project:%d' % project_id)
|
|
435
|
+
# We may have missed events before subscription completed
|
|
436
|
+
return True
|
|
437
|
+
|
|
438
|
+
def unobserve_project(self, project_id, future):
|
|
439
|
+
assert isinstance(project_id, int)
|
|
440
|
+
if project_id in self.event_waiters:
|
|
441
|
+
self.event_waiters[project_id].discard(future)
|
|
442
|
+
# If there are no more watchers, remove dict entry and unsubscribe
|
|
443
|
+
if not self.event_waiters[project_id]:
|
|
444
|
+
del self.event_waiters[project_id]
|
|
445
|
+
if self.redis is not None:
|
|
446
|
+
background_task(self.redis_pubsub.unsubscribe(
|
|
447
|
+
'project:%d' % project_id,
|
|
448
|
+
))
|
|
449
|
+
|
|
450
|
+
def notify_project(self, project_id, cmd):
|
|
451
|
+
assert isinstance(project_id, int)
|
|
452
|
+
cmd_json = cmd.to_json()
|
|
453
|
+
if self.redis is None:
|
|
454
|
+
# Local delivery to waiting handlers
|
|
455
|
+
for future in self.event_waiters.pop(project_id, []):
|
|
456
|
+
future.set_result(cmd_json)
|
|
457
|
+
else:
|
|
458
|
+
# Push to Redis
|
|
459
|
+
data = json.dumps(cmd_json, sort_keys=True, separators=(',', ':'))
|
|
460
|
+
background_task(self.redis.publish(
|
|
461
|
+
'project:%d' % project_id,
|
|
462
|
+
data.encode('utf-8)'),
|
|
463
|
+
))
|
|
464
|
+
|
|
465
|
+
@tracer.start_as_current_span('send_mail')
|
|
466
|
+
def send_mail(self, msg):
|
|
467
|
+
config = self.config['MAIL_SERVER']
|
|
468
|
+
if config.get('ssl', False):
|
|
469
|
+
cls = smtplib.SMTP_SSL
|
|
470
|
+
else:
|
|
471
|
+
cls = smtplib.SMTP
|
|
472
|
+
with cls(config['host'], config.get('port', 25)) as smtp:
|
|
473
|
+
if 'user' in config or 'password' in config:
|
|
474
|
+
smtp.login(config['user'], config['password'])
|
|
475
|
+
smtp.send_message(msg)
|
|
476
|
+
PROM_EMAILS.labels('password_reset').inc()
|
|
477
|
+
|
|
478
|
+
def log_request(self, handler):
|
|
479
|
+
if handler.request.path == '/health' and handler.get_status() == 200:
|
|
480
|
+
return
|
|
481
|
+
if "log_function" in self.settings:
|
|
482
|
+
self.settings["log_function"](handler)
|
|
483
|
+
return
|
|
484
|
+
access_log = logging.getLogger("tornado.access")
|
|
485
|
+
if handler.get_status() < 400:
|
|
486
|
+
log_method = access_log.info
|
|
487
|
+
elif handler.get_status() < 500:
|
|
488
|
+
log_method = access_log.warning
|
|
489
|
+
else:
|
|
490
|
+
log_method = access_log.error
|
|
491
|
+
request_time = 1000.0 * handler.request.request_time()
|
|
492
|
+
log_method(
|
|
493
|
+
"%d %s %.2fms (%s) lang=%s",
|
|
494
|
+
handler.get_status(),
|
|
495
|
+
handler._request_summary(),
|
|
496
|
+
request_time,
|
|
497
|
+
handler.current_user,
|
|
498
|
+
handler.request.headers.get('Accept-Language'),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def get_languages():
|
|
503
|
+
return [
|
|
504
|
+
(loc,
|
|
505
|
+
tornado.locale.get(loc).name
|
|
506
|
+
if tornado.locale.get(loc).name != 'Unknown' else loc)
|
|
507
|
+
for loc in tornado.locale.get_supported_locales()
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class HandleStreamClosed(RequestHandler):
|
|
512
|
+
def log_exception(self, typ, value, tb):
|
|
513
|
+
if isinstance(value, tornado.iostream.StreamClosedError):
|
|
514
|
+
return
|
|
515
|
+
super(HandleStreamClosed, self).log_exception(typ, value, tb)
|
|
516
|
+
|
|
517
|
+
def send_error(self, status_code=500, **kwargs):
|
|
518
|
+
if 'exc_info' in kwargs:
|
|
519
|
+
exception = kwargs['exc_info'][1]
|
|
520
|
+
if isinstance(exception, tornado.iostream.StreamClosedError):
|
|
521
|
+
return
|
|
522
|
+
super(HandleStreamClosed, self).send_error(status_code, **kwargs)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class BaseHandler(HandleStreamClosed, RequestHandler):
|
|
526
|
+
"""Base class for all request handlers.
|
|
527
|
+
"""
|
|
528
|
+
application: Application
|
|
529
|
+
|
|
530
|
+
template_env = jinja2.Environment(
|
|
531
|
+
loader=jinja2.FileSystemLoader(
|
|
532
|
+
[pkg_resources.resource_filename('taguette', 'templates')]
|
|
533
|
+
),
|
|
534
|
+
autoescape=jinja2.select_autoescape(['html']),
|
|
535
|
+
extensions=['jinja2.ext.i18n'],
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
@jinja2.pass_context
|
|
539
|
+
def _tpl_static_url(context, path):
|
|
540
|
+
v = not context['handler'].application.settings.get('debug', False)
|
|
541
|
+
return context['handler'].static_url(path, include_version=v)
|
|
542
|
+
template_env.globals['static_url'] = _tpl_static_url
|
|
543
|
+
|
|
544
|
+
@jinja2.pass_context
|
|
545
|
+
def _tpl_reverse_url(context, path, *args):
|
|
546
|
+
return context['handler'].reverse_url(path, *args)
|
|
547
|
+
template_env.globals['reverse_url'] = _tpl_reverse_url
|
|
548
|
+
|
|
549
|
+
@jinja2.pass_context
|
|
550
|
+
def _tpl_xsrf_form_html(context):
|
|
551
|
+
return Markup(context['handler'].xsrf_form_html())
|
|
552
|
+
template_env.globals['xsrf_form_html'] = _tpl_xsrf_form_html
|
|
553
|
+
|
|
554
|
+
@jinja2.pass_context
|
|
555
|
+
def _tpl_trans(context, msgid):
|
|
556
|
+
return context['json_locale'].get_trans(
|
|
557
|
+
msgid,
|
|
558
|
+
context['handler'].language)
|
|
559
|
+
template_env.globals['alt_trans'] = _tpl_trans
|
|
560
|
+
template_env.globals['json_locale'] = JSONLocale()
|
|
561
|
+
|
|
562
|
+
def __init__(self, application, request, **kwargs):
|
|
563
|
+
super(BaseHandler, self).__init__(application, request, **kwargs)
|
|
564
|
+
self._db = None
|
|
565
|
+
self._gettext = None
|
|
566
|
+
self.language = self.get_cookie('language',
|
|
567
|
+
self.get_browser_locale().code)
|
|
568
|
+
if not self.language:
|
|
569
|
+
self.language = self.application.config['DEFAULT_LANGUAGE']
|
|
570
|
+
self.set_cookie('language', self.language)
|
|
571
|
+
|
|
572
|
+
self.init_json_locale()
|
|
573
|
+
|
|
574
|
+
def init_json_locale(self):
|
|
575
|
+
script_dir = Path(__file__).parent
|
|
576
|
+
templates_dir = script_dir.parent / "templates"
|
|
577
|
+
template_translation_file_path = \
|
|
578
|
+
(f"{templates_dir}/alt_templates/"
|
|
579
|
+
f"{self.application.config['THEME']}/trans.json")
|
|
580
|
+
self.template_env.globals['json_locale'].load_json(
|
|
581
|
+
template_translation_file_path)
|
|
582
|
+
|
|
583
|
+
def set_default_headers(self):
|
|
584
|
+
self.set_header('Server', 'LibreQDA/%s' % exact_version())
|
|
585
|
+
self.set_header('X-Frame-Options', 'DENY')
|
|
586
|
+
self.set_header('Content-Security-Policy', "frame-ancestors 'none';")
|
|
587
|
+
|
|
588
|
+
@property
|
|
589
|
+
def db(self):
|
|
590
|
+
if self._db is None:
|
|
591
|
+
self._db = self.application.DBSession()
|
|
592
|
+
PROM_DB_CONNECTIONS.inc()
|
|
593
|
+
return self._db
|
|
594
|
+
|
|
595
|
+
def on_finish(self):
|
|
596
|
+
super(BaseHandler, self).on_finish()
|
|
597
|
+
self.close_db_connection()
|
|
598
|
+
|
|
599
|
+
def close_db_connection(self):
|
|
600
|
+
if self._db is not None:
|
|
601
|
+
self._db.close()
|
|
602
|
+
self._db = None
|
|
603
|
+
PROM_DB_CONNECTIONS.dec()
|
|
604
|
+
|
|
605
|
+
def gettext(self, message):
|
|
606
|
+
return self.locale.translate(message)
|
|
607
|
+
|
|
608
|
+
def ngettext(self, singular, plural, n):
|
|
609
|
+
return self.locale.translate(singular, plural, n)
|
|
610
|
+
|
|
611
|
+
def pgettext(
|
|
612
|
+
self,
|
|
613
|
+
context, message, plural_message=None,
|
|
614
|
+
n=None,
|
|
615
|
+
):
|
|
616
|
+
return self.locale.pgettext(context, message, plural_message, n)
|
|
617
|
+
|
|
618
|
+
def get_current_user(self):
|
|
619
|
+
user = self.get_secure_cookie('user')
|
|
620
|
+
if user is not None:
|
|
621
|
+
user = self.db.query(database.User).get(user.decode('utf-8'))
|
|
622
|
+
if user is None or user.disabled:
|
|
623
|
+
return None
|
|
624
|
+
self.language = user.language
|
|
625
|
+
if not self.language:
|
|
626
|
+
self.language = self.application.config['DEFAULT_LANGUAGE']
|
|
627
|
+
self.set_cookie('language', self.language)
|
|
628
|
+
return user.login
|
|
629
|
+
else:
|
|
630
|
+
return None
|
|
631
|
+
|
|
632
|
+
def set_cookie(self, name, value, domain=None,
|
|
633
|
+
expires=None, path='/', expires_days=None,
|
|
634
|
+
*, dont_check=False,
|
|
635
|
+
**kwargs):
|
|
636
|
+
if (
|
|
637
|
+
dont_check
|
|
638
|
+
or not self.application.config['COOKIES_PROMPT']
|
|
639
|
+
or self.get_cookie('cookies_accepted')
|
|
640
|
+
or self.get_cookie('user')
|
|
641
|
+
):
|
|
642
|
+
return super(BaseHandler, self).set_cookie(name, value, **kwargs)
|
|
643
|
+
else:
|
|
644
|
+
self.redirect(
|
|
645
|
+
self.reverse_url('cookies_prompt') +
|
|
646
|
+
'?' +
|
|
647
|
+
urlencode(dict(next=self.request.uri)),
|
|
648
|
+
)
|
|
649
|
+
raise HTTPError(302)
|
|
650
|
+
|
|
651
|
+
if os.environ.get('TAGUETTE_TEST_LOCALE') == 'y':
|
|
652
|
+
_pseudolocale = PseudoLocale()
|
|
653
|
+
|
|
654
|
+
def get_user_locale(self):
|
|
655
|
+
return self._pseudolocale
|
|
656
|
+
else:
|
|
657
|
+
def get_user_locale(self):
|
|
658
|
+
# Note that reading current_user first is important, it will
|
|
659
|
+
# call get_current_user() which sets self.language
|
|
660
|
+
if self.language is not None:
|
|
661
|
+
return tornado.locale.get(self.language)
|
|
662
|
+
|
|
663
|
+
def login(self, username):
|
|
664
|
+
logger.info("Logged in as %r", username)
|
|
665
|
+
self.set_secure_cookie('user', username)
|
|
666
|
+
|
|
667
|
+
def logout(self):
|
|
668
|
+
logger.info("Logged out")
|
|
669
|
+
self.clear_cookie('user')
|
|
670
|
+
|
|
671
|
+
def render(self, template_name, **kwargs):
|
|
672
|
+
script_dir = Path(__file__).parent
|
|
673
|
+
templates_dir = script_dir.parent / "templates"
|
|
674
|
+
theme = self.application.config['THEME']
|
|
675
|
+
theme_template_name = f"alt_templates/{theme}/{template_name}"
|
|
676
|
+
if os.path.isfile(templates_dir / theme_template_name):
|
|
677
|
+
template_name = theme_template_name
|
|
678
|
+
return super(BaseHandler, self).render(template_name, **kwargs)
|
|
679
|
+
|
|
680
|
+
def render_string(self, template_name, **kwargs):
|
|
681
|
+
extra_footer = self.application.config['EXTRA_FOOTER']
|
|
682
|
+
if not extra_footer:
|
|
683
|
+
extra_footer = self.gettext(
|
|
684
|
+
' | Please report issues via '
|
|
685
|
+
+ '<a href="https://gitlab.com/bin-cirst/libreqda/libre-qda/">'
|
|
686
|
+
'GitLab</a> '
|
|
687
|
+
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
if self.application.config['MAINTENANCE_MODE']:
|
|
691
|
+
self.current_user = None
|
|
692
|
+
template_name = 'maintenance.html'
|
|
693
|
+
|
|
694
|
+
with tracer.start_as_current_span(
|
|
695
|
+
'render_template',
|
|
696
|
+
attributes={'template_name': template_name},
|
|
697
|
+
):
|
|
698
|
+
template = self.template_env.get_template(template_name)
|
|
699
|
+
kwargs.setdefault('languages', get_languages())
|
|
700
|
+
kwargs.setdefault('current_language', self.language)
|
|
701
|
+
return template.render(
|
|
702
|
+
handler=self,
|
|
703
|
+
maintenance_mode=self.application.config['MAINTENANCE_MODE'],
|
|
704
|
+
maintenance_message=self.application.config[
|
|
705
|
+
'MAINTENANCE_MESSAGE'
|
|
706
|
+
],
|
|
707
|
+
current_user=self.current_user,
|
|
708
|
+
multiuser=self.application.config['MULTIUSER'],
|
|
709
|
+
register_enabled=self.application.config[
|
|
710
|
+
'REGISTRATION_ENABLED'
|
|
711
|
+
],
|
|
712
|
+
tos=self.application.terms_of_service is not None,
|
|
713
|
+
extra_footer=extra_footer,
|
|
714
|
+
show_messages=self.current_user is not None,
|
|
715
|
+
version=exact_version(),
|
|
716
|
+
outdated_version=version_check.get_outdated_version(),
|
|
717
|
+
bug_report_url=self.get_bug_report_url(),
|
|
718
|
+
gettext=self.gettext,
|
|
719
|
+
ngettext=self.ngettext,
|
|
720
|
+
pgettext=self.pgettext,
|
|
721
|
+
base_path=self.application.config['BASE_PATH'],
|
|
722
|
+
theme=self.application.config['THEME'],
|
|
723
|
+
**kwargs)
|
|
724
|
+
|
|
725
|
+
def get_project(self, project_id):
|
|
726
|
+
try:
|
|
727
|
+
project_id = int(project_id)
|
|
728
|
+
except ValueError:
|
|
729
|
+
raise HTTPError(404)
|
|
730
|
+
project_member = (
|
|
731
|
+
self.db.query(database.ProjectMember)
|
|
732
|
+
.options(joinedload(database.ProjectMember.project))
|
|
733
|
+
.get((project_id, self.current_user))
|
|
734
|
+
)
|
|
735
|
+
if project_member is None:
|
|
736
|
+
raise HTTPError(404)
|
|
737
|
+
return project_member.project, project_member.privileges
|
|
738
|
+
|
|
739
|
+
def get_document(self, project_id, document_id, contents=False):
|
|
740
|
+
try:
|
|
741
|
+
project_id = int(project_id)
|
|
742
|
+
document_id = int(document_id)
|
|
743
|
+
except ValueError:
|
|
744
|
+
raise HTTPError(404)
|
|
745
|
+
|
|
746
|
+
query = (
|
|
747
|
+
self.db.query(database.ProjectMember, database.Document)
|
|
748
|
+
.join(
|
|
749
|
+
database.Document,
|
|
750
|
+
database.Document.project_id == project_id,
|
|
751
|
+
)
|
|
752
|
+
.filter(database.Document.id == document_id)
|
|
753
|
+
.filter(database.ProjectMember.user_login == self.current_user)
|
|
754
|
+
.filter(database.ProjectMember.project_id == project_id)
|
|
755
|
+
)
|
|
756
|
+
if contents:
|
|
757
|
+
query = query.options(undefer(database.Document.contents))
|
|
758
|
+
res = query.one_or_none()
|
|
759
|
+
if res is None:
|
|
760
|
+
raise HTTPError(404)
|
|
761
|
+
member, document = res
|
|
762
|
+
return document, member.privileges
|
|
763
|
+
|
|
764
|
+
def redirect(self, url, permanent=False, status=None):
|
|
765
|
+
if status is None:
|
|
766
|
+
if permanent:
|
|
767
|
+
status = 301
|
|
768
|
+
elif self.request.method == 'GET':
|
|
769
|
+
status = 302
|
|
770
|
+
else:
|
|
771
|
+
status = 303
|
|
772
|
+
return super(BaseHandler, self).redirect(url, status=status)
|
|
773
|
+
|
|
774
|
+
def get_json(self):
|
|
775
|
+
type_ = self.request.headers.get('Content-Type', '')
|
|
776
|
+
if not type_.startswith('application/json'):
|
|
777
|
+
raise HTTPError(400, "Expected JSON")
|
|
778
|
+
try:
|
|
779
|
+
return json.loads(self.request.body.decode('utf-8'))
|
|
780
|
+
except json.JSONDecodeError:
|
|
781
|
+
raise HTTPError(400, "Invalid JSON")
|
|
782
|
+
|
|
783
|
+
def send_json(self, obj):
|
|
784
|
+
if isinstance(obj, list):
|
|
785
|
+
obj = {'results': obj}
|
|
786
|
+
elif not isinstance(obj, dict):
|
|
787
|
+
raise ValueError("Can't encode %r to JSON" % type(obj))
|
|
788
|
+
self.set_header('Content-Type', 'application/json; charset=utf-8')
|
|
789
|
+
return self.finish(json.dumps(obj))
|
|
790
|
+
|
|
791
|
+
def send_error_json(self, status, message, reason=None):
|
|
792
|
+
self.set_status(status, reason)
|
|
793
|
+
return self.send_json({'error': message})
|
|
794
|
+
|
|
795
|
+
def write_error(self, status_code, **kwargs):
|
|
796
|
+
# If database session has failed, can't use it to render the error
|
|
797
|
+
self.close_db_connection()
|
|
798
|
+
|
|
799
|
+
if self.settings.get('serve_traceback'):
|
|
800
|
+
# Debug mode
|
|
801
|
+
super(BaseHandler, self).write_error(status_code, **kwargs)
|
|
802
|
+
elif status_code == 404:
|
|
803
|
+
self.render(
|
|
804
|
+
'error.html',
|
|
805
|
+
error_title=self.pgettext("page title", "Error 404"),
|
|
806
|
+
error_message=self.gettext("This page does not exist."),
|
|
807
|
+
)
|
|
808
|
+
else:
|
|
809
|
+
self.render(
|
|
810
|
+
'error.html',
|
|
811
|
+
error_title=(
|
|
812
|
+
self.pgettext("page title", "Error %d")
|
|
813
|
+
% status_code
|
|
814
|
+
),
|
|
815
|
+
error_message=self._reason + ".",
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
def get_bug_report_url(self):
|
|
819
|
+
if self.application.config['BUG_REPORT_URL'] is not None:
|
|
820
|
+
try:
|
|
821
|
+
if self.language in self.application.config['BUG_REPORT_URL']:
|
|
822
|
+
return self.application.config[
|
|
823
|
+
'BUG_REPORT_URL'
|
|
824
|
+
][self.language]
|
|
825
|
+
else:
|
|
826
|
+
return next(iter(
|
|
827
|
+
self.application.config['BUG_REPORT_URL'].values()),
|
|
828
|
+
None
|
|
829
|
+
)
|
|
830
|
+
except AttributeError:
|
|
831
|
+
return None
|
|
832
|
+
except TypeError:
|
|
833
|
+
return None
|
|
834
|
+
return self.reverse_url('bug_report')
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
class PromMeasureRequest(object):
|
|
838
|
+
def __init__(self, count, time):
|
|
839
|
+
self.count = count
|
|
840
|
+
self.time = time
|
|
841
|
+
|
|
842
|
+
def _wrap(self, name, timer):
|
|
843
|
+
counter = self.count.labels(name)
|
|
844
|
+
timer = timer(self.time.labels(name))
|
|
845
|
+
|
|
846
|
+
# Initialize count
|
|
847
|
+
counter.inc(0)
|
|
848
|
+
|
|
849
|
+
def decorator(func):
|
|
850
|
+
@contextlib.wraps(func)
|
|
851
|
+
def wrapper(*args, **kwargs):
|
|
852
|
+
# Count requests
|
|
853
|
+
counter.inc()
|
|
854
|
+
return func(*args, **kwargs)
|
|
855
|
+
|
|
856
|
+
return timer(wrapper)
|
|
857
|
+
|
|
858
|
+
return decorator
|
|
859
|
+
|
|
860
|
+
def sync(self, name):
|
|
861
|
+
return self._wrap(name, lambda metric: metric.time())
|
|
862
|
+
|
|
863
|
+
def async_(self, name):
|
|
864
|
+
return self._wrap(name, lambda metric: prom_async_time(metric))
|