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.
Files changed (255) hide show
  1. CHANGELOG.md +481 -0
  2. libreqda-1.2.0.dist-info/LICENSE.txt +28 -0
  3. libreqda-1.2.0.dist-info/METADATA +131 -0
  4. libreqda-1.2.0.dist-info/RECORD +255 -0
  5. libreqda-1.2.0.dist-info/WHEEL +4 -0
  6. libreqda-1.2.0.dist-info/entry_points.txt +4 -0
  7. scripts/babelrc +8 -0
  8. scripts/dist/LICENSE.txt +33 -0
  9. scripts/dist/RELEASE.txt +23 -0
  10. scripts/dist/macos/libreqda.icns +0 -0
  11. scripts/dist/macos/macos.spec +43 -0
  12. scripts/dist/macos/plist.patch +10 -0
  13. scripts/dist/macos/taguette.icns +0 -0
  14. scripts/dist/macos/taguette_console_wrapper +4 -0
  15. scripts/dist/macos.sh +32 -0
  16. scripts/dist/pyinstaller_entrypoint.py +19 -0
  17. scripts/dist/windows/windows-installer.iss +51 -0
  18. scripts/dist/windows/windows.spec +39 -0
  19. scripts/edugain/worker_update_edugain_idps.py +89 -0
  20. scripts/edugain/worker_update_edugain_idps.sh +9 -0
  21. scripts/make_password_reset_link.py +56 -0
  22. scripts/make_theme.py +99 -0
  23. scripts/new_db_revision.sh +8 -0
  24. scripts/register_user.py +76 -0
  25. scripts/update_pot.sh +33 -0
  26. scripts/update_translations.sh +17 -0
  27. scripts/validate.py +140 -0
  28. scripts/wipe_and_disable_user.py +90 -0
  29. taguette/__init__.py +13 -0
  30. taguette/access_control.py +145 -0
  31. taguette/convert.py +623 -0
  32. taguette/database/__init__.py +177 -0
  33. taguette/database/base.py +9 -0
  34. taguette/database/copy.py +433 -0
  35. taguette/database/models.py +920 -0
  36. taguette/demodata.py +688 -0
  37. taguette/export.py +500 -0
  38. taguette/extract.py +287 -0
  39. taguette/import_codebook.py +110 -0
  40. taguette/l10n/de_DE/LC_MESSAGES/taguette_javascript.mo +0 -0
  41. taguette/l10n/de_DE/LC_MESSAGES/taguette_main.mo +0 -0
  42. taguette/l10n/el_EL/LC_MESSAGES/taguette_javascript.mo +0 -0
  43. taguette/l10n/el_GR/LC_MESSAGES/taguette_javascript.mo +0 -0
  44. taguette/l10n/el_GR/LC_MESSAGES/taguette_main.mo +0 -0
  45. taguette/l10n/en_US/LC_MESSAGES/taguette_javascript.mo +0 -0
  46. taguette/l10n/en_US/LC_MESSAGES/taguette_main.mo +0 -0
  47. taguette/l10n/es_ES/LC_MESSAGES/taguette_javascript.mo +0 -0
  48. taguette/l10n/es_ES/LC_MESSAGES/taguette_main.mo +0 -0
  49. taguette/l10n/fr_FR/LC_MESSAGES/taguette_javascript.mo +0 -0
  50. taguette/l10n/fr_FR/LC_MESSAGES/taguette_main.mo +0 -0
  51. taguette/l10n/id_ID/LC_MESSAGES/taguette_javascript.mo +0 -0
  52. taguette/l10n/id_ID/LC_MESSAGES/taguette_main.mo +0 -0
  53. taguette/l10n/it_IT/LC_MESSAGES/taguette_javascript.mo +0 -0
  54. taguette/l10n/it_IT/LC_MESSAGES/taguette_main.mo +0 -0
  55. taguette/l10n/nl_NL/LC_MESSAGES/taguette_javascript.mo +0 -0
  56. taguette/l10n/nl_NL/LC_MESSAGES/taguette_main.mo +0 -0
  57. taguette/l10n/pt_PT/LC_MESSAGES/taguette_javascript.mo +0 -0
  58. taguette/l10n/pt_PT/LC_MESSAGES/taguette_main.mo +0 -0
  59. taguette/l10n/sv_SE/LC_MESSAGES/taguette_javascript.mo +0 -0
  60. taguette/l10n/sv_SE/LC_MESSAGES/taguette_main.mo +0 -0
  61. taguette/loki_handler.py +107 -0
  62. taguette/main.py +649 -0
  63. taguette/migrations/env.py +72 -0
  64. taguette/migrations/script.py.mako +24 -0
  65. taguette/migrations/versions/09c662cd9483_make_documents_filename_not_null.py +39 -0
  66. taguette/migrations/versions/1b29e68900c1_add_user_roles.py +55 -0
  67. taguette/migrations/versions/1b96a606551d_update_user_to_have_a_display_name.py +34 -0
  68. taguette/migrations/versions/2467bab602b7_add_color_column_to_tags_table.py +33 -0
  69. taguette/migrations/versions/382db36f20f4_add_user_disabled.py +66 -0
  70. taguette/migrations/versions/43d6c240309d_remove_document_groups.py +81 -0
  71. taguette/migrations/versions/447d636f72c5_cascades.py +40 -0
  72. taguette/migrations/versions/491de2dc7cd7_add_indexes.py +51 -0
  73. taguette/migrations/versions/4c55b3b3ff30_user_login_length.py +58 -0
  74. taguette/migrations/versions/61a4abcc8769_user_with_parent_for_permissions.py +40 -0
  75. taguette/migrations/versions/6489b5f9cfb5_increase_password_hash_size.py +34 -0
  76. taguette/migrations/versions/679f625e6e6a_fix_unique_tags.py +59 -0
  77. taguette/migrations/versions/77c4543a5b9b_add_parent_id_column_to_collection_table.py +41 -0
  78. taguette/migrations/versions/7c94b7a31e69_add_is_archived_column_to_projets.py +39 -0
  79. taguette/migrations/versions/7f56de97e3c8_change_edugain_displayname_length.py +42 -0
  80. taguette/migrations/versions/807c0cc0ecf6_remove_invalid_hltags.py +43 -0
  81. taguette/migrations/versions/80b1cc9d4c22_add_user_email.py +35 -0
  82. taguette/migrations/versions/91ade71ccf4d_add_user_language.py +28 -0
  83. taguette/migrations/versions/955db2e523d1_add_collections_tables_and_associations_.py +60 -0
  84. taguette/migrations/versions/a1b2c3d4e5f6_add_user_hide_code_counts.py +28 -0
  85. taguette/migrations/versions/b23f3b7a638e_add_user_password_change_date.py +36 -0
  86. taguette/migrations/versions/b7c1e2d3f4a5_add_project_compact_layout.py +28 -0
  87. taguette/migrations/versions/bc8e0e0677e9_rename_command_fields.py +126 -0
  88. taguette/migrations/versions/bce44849c2f2_lowercase_logins.py +96 -0
  89. taguette/migrations/versions/ca076140922f_update_user_for_external_logins.py +32 -0
  90. taguette/migrations/versions/d86c45d7aa8a_add_document_direction.py +127 -0
  91. taguette/migrations/versions/db5e31a0233d_remove_blank_highlights.py +88 -0
  92. taguette/migrations/versions/dd37d9027a24_unique_tags.py +68 -0
  93. taguette/migrations/versions/de678cef2a94_add_project_id_to_collections_and_.py +41 -0
  94. taguette/migrations/versions/e459991d960e_add_table_edugainidps.py +39 -0
  95. taguette/migrations/versions/e4cf92942271_add_index.py +29 -0
  96. taguette/migrations/versions/e4e090a5b511_remove_commands_document_id_fk.py +38 -0
  97. taguette/migrations/versions/e9a9f1c77971_add_favorite_column_to_document.py +32 -0
  98. taguette/migrations/versions/ecb4065de575_don_t_use_server_default_now_timezone_.py +44 -0
  99. taguette/migrations/versions/fbdc29566f62_edugain_fix_field_type.py +42 -0
  100. taguette/migrations/versions/fdc6eb54b034_add_parent_id_column_to_tags_table.py +31 -0
  101. taguette/static/README.md +34 -0
  102. taguette/static/alt_templates/.gitkeep +0 -0
  103. taguette/static/assets/test/logos/monsters-university-logo.png +0 -0
  104. taguette/static/css/custom.css +1696 -0
  105. taguette/static/css/fa-all.min.css +5 -0
  106. taguette/static/css/themed.css +3 -0
  107. taguette/static/css/themed.css.map +25 -0
  108. taguette/static/css/vendor/bootstrap-treeview.min.css +1 -0
  109. taguette/static/css/vendor/bootstrap.min.css +6 -0
  110. taguette/static/css/vendor/font/context-menu-icons.eot +0 -0
  111. taguette/static/css/vendor/font/context-menu-icons.ttf +0 -0
  112. taguette/static/css/vendor/font/context-menu-icons.woff +0 -0
  113. taguette/static/css/vendor/font/context-menu-icons.woff2 +0 -0
  114. taguette/static/css/vendor/hierarchy-select.min.css +1 -0
  115. taguette/static/css/vendor/introjs.min.css +2 -0
  116. taguette/static/css/vendor/jquery.contextMenu.min.css +16 -0
  117. taguette/static/favicon.ico +0 -0
  118. taguette/static/js/codebook-viz.js +428 -0
  119. taguette/static/js/collection/context-menu.js +180 -0
  120. taguette/static/js/collection/hierarchy-select.js +71 -0
  121. taguette/static/js/collection/manager.js +217 -0
  122. taguette/static/js/document/context-menu.js +92 -0
  123. taguette/static/js/document/custom-select.js +82 -0
  124. taguette/static/js/document/manager.js +943 -0
  125. taguette/static/js/document/treeview.js +593 -0
  126. taguette/static/js/highlight/README.md +18 -0
  127. taguette/static/js/highlight/crud.js +119 -0
  128. taguette/static/js/highlight/extracts.js +497 -0
  129. taguette/static/js/highlight/loader.js +109 -0
  130. taguette/static/js/highlight/renderer.js +405 -0
  131. taguette/static/js/login.js +15 -0
  132. taguette/static/js/permissions.js +153 -0
  133. taguette/static/js/polling-event.js +145 -0
  134. taguette/static/js/project/intro.js +524 -0
  135. taguette/static/js/project/manager.js +0 -0
  136. taguette/static/js/project/search.js +124 -0
  137. taguette/static/js/tag/context-menu.js +133 -0
  138. taguette/static/js/tag/explore-codes.js +400 -0
  139. taguette/static/js/tag/hierarchy-select.js +69 -0
  140. taguette/static/js/tag/manager.js +545 -0
  141. taguette/static/js/tag/treeview.js +613 -0
  142. taguette/static/js/taguette.js +1790 -0
  143. taguette/static/js/utilities.js +714 -0
  144. taguette/static/js/vendor/bootstrap-treeview-custum.js +421 -0
  145. taguette/static/js/vendor/bootstrap-treeview-custum.min.js +1 -0
  146. taguette/static/js/vendor/bootstrap-treeview-default.min.js +1 -0
  147. taguette/static/js/vendor/bootstrap.bundle.min.js +7 -0
  148. taguette/static/js/vendor/bootstrap.bundle.min.js.map +1 -0
  149. taguette/static/js/vendor/hierarchy-select.min.js +1 -0
  150. taguette/static/js/vendor/introjs.min.js +17 -0
  151. taguette/static/js/vendor/jquery-3.7.1.js +2 -0
  152. taguette/static/js/vendor/jquery.contextMenu.min.js +2 -0
  153. taguette/static/libreqda.ico +0 -0
  154. taguette/static/logo-h.png +0 -0
  155. taguette/static/logo.png +0 -0
  156. taguette/static/logo_blanc.png +0 -0
  157. taguette/static/missing.png +0 -0
  158. taguette/static/robots.txt +2 -0
  159. taguette/static/scss/components/_badge.scss +42 -0
  160. taguette/static/scss/components/_button.scss +169 -0
  161. taguette/static/scss/components/_checkbox.scss +40 -0
  162. taguette/static/scss/components/_context_menu.scss +28 -0
  163. taguette/static/scss/components/_custom_select.scss +150 -0
  164. taguette/static/scss/components/_dropdown.scss +43 -0
  165. taguette/static/scss/components/_form.scss +54 -0
  166. taguette/static/scss/components/_modal.scss +57 -0
  167. taguette/static/scss/components/_project_card.scss +163 -0
  168. taguette/static/scss/components/_tag.scss +308 -0
  169. taguette/static/scss/components/_treeview.scss +76 -0
  170. taguette/static/scss/custom.scss +809 -0
  171. taguette/static/scss/layout/_credits.scss +62 -0
  172. taguette/static/scss/layout/_footer.scss +6 -0
  173. taguette/static/scss/layout/_navigation.scss +165 -0
  174. taguette/static/scss/layout/_sidebar.scss +37 -0
  175. taguette/static/scss/themes/default/_variables.scss +103 -0
  176. taguette/static/scss/themes/default/theme.css +3 -0
  177. taguette/static/scss/themes/default/theme.css.map +26 -0
  178. taguette/static/scss/themes/default/theme.scss +4 -0
  179. taguette/static/webfonts/BadScript-OFL.txt +94 -0
  180. taguette/static/webfonts/BadScript.ttf +0 -0
  181. taguette/static/webfonts/Montserrat-Light.ttf +0 -0
  182. taguette/static/webfonts/Montserrat-OFL.txt +93 -0
  183. taguette/static/webfonts/fa-brands-400.eot +0 -0
  184. taguette/static/webfonts/fa-brands-400.svg +1184 -0
  185. taguette/static/webfonts/fa-brands-400.ttf +0 -0
  186. taguette/static/webfonts/fa-brands-400.woff +0 -0
  187. taguette/static/webfonts/fa-brands-400.woff2 +0 -0
  188. taguette/static/webfonts/fa-regular-400.eot +0 -0
  189. taguette/static/webfonts/fa-regular-400.svg +467 -0
  190. taguette/static/webfonts/fa-regular-400.ttf +0 -0
  191. taguette/static/webfonts/fa-regular-400.woff +0 -0
  192. taguette/static/webfonts/fa-regular-400.woff2 +0 -0
  193. taguette/static/webfonts/fa-solid-900.eot +0 -0
  194. taguette/static/webfonts/fa-solid-900.svg +2618 -0
  195. taguette/static/webfonts/fa-solid-900.ttf +0 -0
  196. taguette/static/webfonts/fa-solid-900.woff +0 -0
  197. taguette/static/webfonts/fa-solid-900.woff2 +0 -0
  198. taguette/templates/alt_templates/.gitkeep +0 -0
  199. taguette/templates/archived_projects.html +50 -0
  200. taguette/templates/auth/account.html +74 -0
  201. taguette/templates/auth/cilogon.login.html +89 -0
  202. taguette/templates/auth/email_reset_password.html +5 -0
  203. taguette/templates/auth/external_account.html +60 -0
  204. taguette/templates/auth/login.html +144 -0
  205. taguette/templates/auth/reset_password.html +27 -0
  206. taguette/templates/auth/token_needed.html +7 -0
  207. taguette/templates/base.html +209 -0
  208. taguette/templates/bug_report.html +158 -0
  209. taguette/templates/cookies_prompt.html +15 -0
  210. taguette/templates/downloads.html +127 -0
  211. taguette/templates/email_bug_report.txt +9 -0
  212. taguette/templates/email_reset_password.txt +5 -0
  213. taguette/templates/error.html +8 -0
  214. taguette/templates/export_codebook.html +20 -0
  215. taguette/templates/export_document.html +20 -0
  216. taguette/templates/export_highlights.html +47 -0
  217. taguette/templates/index.html +347 -0
  218. taguette/templates/maintenance.html +8 -0
  219. taguette/templates/messages.js +74 -0
  220. taguette/templates/modal/change_document.html +68 -0
  221. taguette/templates/modal/create_collection.html +100 -0
  222. taguette/templates/modal/create_document.html +84 -0
  223. taguette/templates/modal/create_highlight.html +38 -0
  224. taguette/templates/modal/create_tag.html +73 -0
  225. taguette/templates/modal/explore_codes.html +58 -0
  226. taguette/templates/modal/manage_collaborators.html +44 -0
  227. taguette/templates/modal/merge_tag.html +49 -0
  228. taguette/templates/new_password.html +32 -0
  229. taguette/templates/partials/alert.html +16 -0
  230. taguette/templates/partials/codebook_viz_modal.html +34 -0
  231. taguette/templates/partials/confirm.html +17 -0
  232. taguette/templates/partials/document_toolbar.html +37 -0
  233. taguette/templates/partials/hlinfo.html +13 -0
  234. taguette/templates/partials/project_card.html +44 -0
  235. taguette/templates/partials/project_card_archived.html +41 -0
  236. taguette/templates/partials/project_sidebar.html +99 -0
  237. taguette/templates/partials/spinner.html +11 -0
  238. taguette/templates/permissions.html +54 -0
  239. taguette/templates/project.html +288 -0
  240. taguette/templates/project_delete.html +17 -0
  241. taguette/templates/project_import.html +122 -0
  242. taguette/templates/project_import_codebook.html +34 -0
  243. taguette/templates/project_import_codebook_confirm.html +49 -0
  244. taguette/templates/project_new.html +31 -0
  245. taguette/templates/tos.html +8 -0
  246. taguette/templates/trans.js +7 -0
  247. taguette/templates/welcome.html +15 -0
  248. taguette/utils.py +177 -0
  249. taguette/validate.py +141 -0
  250. taguette/version_check.py +102 -0
  251. taguette/web/__init__.py +252 -0
  252. taguette/web/api.py +1835 -0
  253. taguette/web/base.py +864 -0
  254. taguette/web/export.py +338 -0
  255. 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))