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/views.py ADDED
@@ -0,0 +1,1618 @@
1
+ import asyncio
2
+ from datetime import timedelta, datetime
3
+ from email.message import EmailMessage
4
+ import io
5
+ import itertools
6
+ import json
7
+ import logging
8
+ from markupsafe import Markup
9
+ import prometheus_client
10
+ from sqlalchemy.exc import IntegrityError
11
+ from sqlalchemy.orm import aliased
12
+ import time
13
+ import tornado.locale
14
+ from tornado.web import authenticated, HTTPError
15
+ from urllib.parse import urlunparse
16
+ from collections import defaultdict
17
+ import re
18
+
19
+ from .. import database
20
+ from .. import import_codebook
21
+ from ..utils import _f
22
+ from .. import validate
23
+ from .base import BaseHandler, PromMeasureRequest
24
+ from ..demodata import DEMO_DATA
25
+
26
+ # For CILogon
27
+ from oauth2client.client import OAuth2WebServerFlow
28
+ import base64
29
+ import requests
30
+ from tornado.escape import url_escape
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ PROM_REQUESTS = PromMeasureRequest(
35
+ count=prometheus_client.Counter(
36
+ "pages_total",
37
+ "Page requests",
38
+ ["name"],
39
+ ),
40
+ time=prometheus_client.Histogram(
41
+ "page_seconds",
42
+ "Page request time",
43
+ ["name"],
44
+ ),
45
+ )
46
+
47
+ PROM_USER_CONNECTIONS = prometheus_client.Counter(
48
+ "user_connections_total", "User connection total"
49
+ )
50
+
51
+ PROM_ACTIVE_SESSIONS = prometheus_client.Gauge(
52
+ "active_sessions", "Number of currently active user sessions"
53
+ )
54
+
55
+ PROM_DOWNLOADS = prometheus_client.Counter(
56
+ "downloads_total", "Download link clicks", ["os"]
57
+ )
58
+ for _os in ('windows', 'macos'):
59
+ PROM_DOWNLOADS.labels(_os).inc(0)
60
+
61
+
62
+ def get_idps(db):
63
+ idps = [
64
+ {
65
+ "entityid": "http://google.com/accounts/o8/id",
66
+ "name": "Google"
67
+ },
68
+ {
69
+ "entityid": "http://login.microsoftonline.com/"
70
+ "common/oauth2/v2.0/authorize",
71
+ "name": "Microsoft"
72
+ }
73
+ ]
74
+
75
+ idps_query = (
76
+ db.query(database.EdugainIdps)
77
+ .filter(database.EdugainIdps.code == "CAF")
78
+ .order_by(database.EdugainIdps.e_displayname)
79
+ ).all()
80
+ for idp in idps_query:
81
+ displayname = idp.e_displayname.split(";")
82
+ if len(displayname) > 1:
83
+ displayname = displayname[1]
84
+ else:
85
+ displayname = displayname[0]
86
+ displayname = re.sub(r"==[a-z]{2}$", "", displayname)
87
+ idps.append({"entityid": idp.entityid, "name": displayname})
88
+
89
+ return idps
90
+
91
+
92
+ class Index(BaseHandler):
93
+ """Index page, shows welcome message and user's projects."""
94
+
95
+ @PROM_REQUESTS.sync("index")
96
+ def get(self):
97
+ if self.current_user is not None:
98
+ if self.get_query_argument("token", None):
99
+ # Remove the token from the URL
100
+ return self.redirect(self.reverse_url("index"))
101
+ user = self.db.query(database.User).get(self.current_user)
102
+ user_display_name = user.login
103
+ if user.displayname is not None and user.displayname != "":
104
+ user_display_name = user.displayname
105
+
106
+ is_demo = "false" if len(user.project_memberships) else "true"
107
+ _ = self.xsrf_token # Make sure XSRF cookie is set
108
+ return self.render(
109
+ "index.html",
110
+ user=user,
111
+ user_login=Markup(json.dumps(self.current_user)),
112
+ user_display_name=user_display_name,
113
+ projects=[
114
+ m.project for m in user.project_memberships
115
+ if not m.project.is_archived
116
+ ],
117
+ can_create_project=user.role.can_create_project(),
118
+ can_change_role=user.role.can_change_role(),
119
+ can_import_project=(
120
+ user.role.can_create_project()
121
+ and self.application.config["SQLITE3_IMPORT_ENABLED"]
122
+ ),
123
+ demo=False,
124
+ is_demo=Markup(is_demo),
125
+ is_multiuser=self.application.config["MULTIUSER"],
126
+ show_credits=self.application.config["SHOW_CREDITS"]
127
+ )
128
+ elif not self.application.config["MULTIUSER"]:
129
+ token = self.get_query_argument("token", None)
130
+ if (self.application.config.get('UNIX_SOCKET') or
131
+ (token and token == self.application.single_user_token)):
132
+ self.login("admin")
133
+ return self.redirect(self.reverse_url("index"))
134
+ elif token:
135
+ return self.redirect(self.reverse_url("index"))
136
+ else:
137
+ return self.render('auth/token_needed.html')
138
+ return self.render('welcome.html')
139
+
140
+ def post(self):
141
+ language = self.get_body_argument('language')
142
+ url = self.get_body_argument('url')
143
+ self.set_cookie('language', language)
144
+ return self.redirect(url)
145
+
146
+ def check_xsrf_cookie(self):
147
+ pass
148
+
149
+ head = get
150
+
151
+
152
+ class CookiesPrompt(BaseHandler):
153
+ @PROM_REQUESTS.sync("cookies_prompt")
154
+ def get(self):
155
+ return self.render(
156
+ "cookies_prompt.html",
157
+ next=self.get_argument("next", ""),
158
+ )
159
+
160
+ @PROM_REQUESTS.sync("cookies_prompt")
161
+ def post(self):
162
+ self.set_cookie("cookies_accepted", "yes", dont_check=True)
163
+ next_ = self.get_argument("next", "")
164
+ if not next_:
165
+ next_ = self.reverse_url("index")
166
+ return self.redirect(next_)
167
+
168
+ def check_xsrf_cookie(self):
169
+ pass
170
+
171
+
172
+ class BrokenBasePath(BaseHandler):
173
+ def get(self):
174
+ self.set_header("Content-Type", "text/plain")
175
+ return self.finish(
176
+ (
177
+ "Error: BASE_PATH is set to {0} so you shouldn't be"
178
+ "reaching this page.\n"
179
+ "\n"
180
+ "You might need to fix your reverse proxy's configuration"
181
+ "or LibreQDA's BASE_PATH setting.\n"
182
+ ).format(self.application.config["BASE_PATH"])
183
+ )
184
+
185
+
186
+ class Login(BaseHandler):
187
+ @PROM_REQUESTS.sync("login")
188
+ def get(self):
189
+ if not self.application.config["MULTIUSER"]:
190
+ raise HTTPError(404)
191
+ if self.current_user:
192
+ return self._go_to_next()
193
+ else:
194
+ if not self.application.config["ALLOW_EXTERNAL_LOGIN"] or (
195
+ self.application.config["ALLOW_EXTERNAL_LOGIN_BYPASS"]
196
+ and self.get_argument("bypass", None) is not None
197
+ ):
198
+ return self.render(
199
+ "auth/login.html",
200
+ register=False,
201
+ next=self.get_argument("next", ""),
202
+ )
203
+ else:
204
+ error = self.get_argument("error", "")
205
+ error_msg = self.get_argument("error_msg", "")
206
+ return self.render(
207
+ "auth/cilogon.login.html",
208
+ next=self.get_argument("next", ""),
209
+ error=error,
210
+ error_msg=error_msg,
211
+ idps=get_idps(self.db),
212
+ entity_id=self.get_cookie("entity_id", None),
213
+ )
214
+
215
+ @PROM_REQUESTS.async_("login")
216
+ async def post(self):
217
+ if not self.application.config["MULTIUSER"]:
218
+ raise HTTPError(404)
219
+ if (
220
+ self.application.config["ALLOW_EXTERNAL_LOGIN"]
221
+ and not self.application.config["ALLOW_EXTERNAL_LOGIN_BYPASS"]
222
+ ):
223
+ raise HTTPError(404)
224
+ login = self.get_body_argument("login")
225
+ try:
226
+ login = validate.fix_user_login(login)
227
+ except validate.InvalidFormat:
228
+ logger.info("Login: invalid login")
229
+ else:
230
+ password = self.get_body_argument("password")
231
+ user = self.db.query(database.User).get(login)
232
+ if user is None or user.disabled:
233
+ logger.info("Login: non-existent user")
234
+ elif not await user.check_password(password):
235
+ logger.info("Login: invalid password for %r", user.login)
236
+ else:
237
+ self.login(user.login)
238
+ PROM_USER_CONNECTIONS.inc()
239
+ PROM_ACTIVE_SESSIONS.inc()
240
+ return self._go_to_next()
241
+
242
+ return self.render(
243
+ "auth/login.html",
244
+ register=False,
245
+ next=self.get_argument("next", ""),
246
+ login_error=self.gettext("Invalid login or password"),
247
+ )
248
+
249
+ def _go_to_next(self):
250
+ next_ = self.get_argument("next", "")
251
+ if not next_:
252
+ next_ = self.reverse_url("index")
253
+ return self.redirect(next_)
254
+
255
+
256
+ class Logout(BaseHandler):
257
+ @PROM_REQUESTS.sync("logout")
258
+ def get(self):
259
+ if not self.application.config["MULTIUSER"]:
260
+ raise HTTPError(404)
261
+ self.logout()
262
+ current_value = PROM_ACTIVE_SESSIONS._value._value
263
+ if current_value > 0:
264
+ PROM_ACTIVE_SESSIONS.dec()
265
+ return self.redirect(self.reverse_url("index"))
266
+
267
+
268
+ class Register(BaseHandler):
269
+ @PROM_REQUESTS.sync("register")
270
+ def get(self):
271
+ if not self.application.config["MULTIUSER"]:
272
+ raise HTTPError(404)
273
+ if not self.application.config["REGISTRATION_ENABLED"]:
274
+ raise HTTPError(403)
275
+ if self.current_user:
276
+ return self.redirect(self.reverse_url("index"))
277
+ else:
278
+ if not self.application.config["ALLOW_EXTERNAL_LOGIN"] or (
279
+ self.application.config["ALLOW_EXTERNAL_LOGIN_BYPASS"]
280
+ and self.get_argument("bypass", None) is not None
281
+ ):
282
+ return self.render("auth/login.html", register=True)
283
+ else:
284
+ return self.render(
285
+ "auth/cilogon.login.html",
286
+ idps=get_idps(self.db),
287
+ entity_id=self.get_cookie("entity_id", None),
288
+ )
289
+
290
+ @PROM_REQUESTS.async_("register")
291
+ async def post(self):
292
+ if not self.application.config["MULTIUSER"]:
293
+ raise HTTPError(404)
294
+ if (
295
+ self.application.config["ALLOW_EXTERNAL_LOGIN"]
296
+ and not self.application.config["ALLOW_EXTERNAL_LOGIN_BYPASS"]
297
+ ):
298
+ raise HTTPError(404)
299
+ if not self.application.config["REGISTRATION_ENABLED"]:
300
+ raise HTTPError(403)
301
+ try:
302
+ login = self.get_body_argument("login")
303
+ password1 = self.get_body_argument("password1")
304
+ password2 = self.get_body_argument("password2")
305
+ login = validate.fix_user_login(login, new=True)
306
+ validate.user_password(password1)
307
+ email = self.get_body_argument("email", "")
308
+ if email:
309
+ validate.user_email(email)
310
+ if password1 != password2:
311
+ raise validate.InvalidFormat(_f("Passwords do not match"))
312
+ if self.db.query(database.User).get(login) is not None:
313
+ raise validate.InvalidFormat(_f("User name is taken"))
314
+ if (
315
+ email
316
+ and self.db.query(database.User)
317
+ .filter(database.User.email == email)
318
+ .count()
319
+ > 0
320
+ ):
321
+ raise validate.InvalidFormat(
322
+ _f("Email address is already used")
323
+ )
324
+ user = database.User(login=login)
325
+ user.role = "PROJECT_ADMIN"
326
+ await user.set_password(password1)
327
+ if email:
328
+ user.email = email
329
+ if (
330
+ self.application.terms_of_service is not None
331
+ and self.get_body_argument("tos", None) != "accepted"
332
+ ):
333
+ raise validate.InvalidFormat(
334
+ _f(
335
+ "Terms of Service must be accepted",
336
+ )
337
+ )
338
+ self.db.add(user)
339
+ self.db.commit()
340
+ logger.info("User registered: %r", login)
341
+ self.set_secure_cookie("user", login)
342
+ return self.redirect(self.reverse_url("index"))
343
+ except validate.InvalidFormat as e:
344
+ logger.info("Error validating Register: %r", e)
345
+ return await self.render(
346
+ "auth/login.html",
347
+ register=True,
348
+ register_error=self.gettext(e.message),
349
+ )
350
+
351
+
352
+ class UserPermissions(BaseHandler):
353
+ def prepare(self):
354
+ if not self.application.config["MULTIUSER"]:
355
+ raise HTTPError(404)
356
+ user = self.db.query(database.User).get(self.current_user)
357
+ if not user.role.can_change_role():
358
+ raise HTTPError(403)
359
+
360
+ @PROM_REQUESTS.sync("permissions")
361
+ def get(self):
362
+ user = self.db.query(database.User).get(self.current_user)
363
+ members = (
364
+ self.db.query(database.User).filter(
365
+ database.User.approved_by_user_login == self.current_user
366
+ )
367
+ ).all()
368
+ members_json = Markup(
369
+ json.dumps(
370
+ {
371
+ user.login: {"privileges": user.role.name}
372
+ for user in members
373
+ }
374
+ )
375
+ )
376
+ _ = self.xsrf_token # Make sure XSRF cookie is set
377
+ return self.render(
378
+ "permissions.html",
379
+ members=members_json,
380
+ user=user,
381
+ user_login=Markup(json.dumps(self.current_user)),
382
+ )
383
+
384
+
385
+ class Cilogon(BaseHandler):
386
+ flow_cilogon = None
387
+
388
+ def flow_func_cilogon(self):
389
+ d = {}
390
+
391
+ def get_flow():
392
+ if "FLOW" in d:
393
+ return d["FLOW"]
394
+
395
+ # Notes:
396
+ # The first in the list of selected_idp will be the
397
+ # default choice of idp
398
+ f = OAuth2WebServerFlow(
399
+ auth_uri=self.application.config["OAUTH_AUTH_URI"],
400
+ token_uri=self.application.config["OAUTH_TOKEN_URI"],
401
+ client_id=self.application.config["OAUTH_CLIENT_ID"],
402
+ client_secret=self.application.config["OAUTH_CLIENT_SECRET"],
403
+ scope=self.application.config["OAUTH_SCOPE"],
404
+ redirect_uri=self.application.config["OAUTH_CALLBACK_URL"],
405
+ )
406
+ f.params["access_type"] = "offline" # offline access
407
+ f.params["include_granted_scopes"] = "true" # incremental auth
408
+
409
+ d["FLOW"] = f
410
+ return f
411
+
412
+ return get_flow
413
+
414
+ def login_cilogon(self, entity_id=None, appredirecturl="None"):
415
+ state_encoded_str = str(
416
+ base64.b64encode(str(appredirecturl).encode("utf-8")), "utf-8"
417
+ )
418
+ auth_uri = self.flow_cilogon().step1_get_authorize_url(
419
+ state=state_encoded_str
420
+ )
421
+ if entity_id is not None:
422
+ auth_uri += f"&idphint={entity_id}"
423
+ return self.redirect(auth_uri, status=301)
424
+
425
+ @PROM_REQUESTS.sync("cilogon")
426
+ def get(self):
427
+ entity_id = self.get_argument("entity_id", '')
428
+ self.set_cookie("entity_id", entity_id)
429
+ self.flow_cilogon = self.flow_func_cilogon()
430
+ self.login_cilogon(entity_id=entity_id)
431
+
432
+
433
+ class CilogonCallback(BaseHandler):
434
+ @PROM_REQUESTS.sync("ci_callback")
435
+ def get(self):
436
+ error = self.get_argument("error", None)
437
+ error_description = self.get_argument("error_description", "")
438
+ if error:
439
+ logger.error(
440
+ f"CILogon : ERROR : {error}, MESSAGE: {error_description}"
441
+ )
442
+ error = url_escape(error)
443
+ error_description = url_escape(error_description)
444
+ return self.redirect(
445
+ self.reverse_url("login")
446
+ + f"?error={error}&error_msg={error_description}"
447
+ )
448
+ else:
449
+ logger.info("CILogon : Success")
450
+ auth_code = self.get_argument("code", None)
451
+ self.cilogon_callback(auth_code)
452
+
453
+ def get_ci_token(self, params):
454
+ if self.application.config["OAUTH_TOKEN_METHOD"].lower() == "get":
455
+ return requests.get(
456
+ self.application.config["OAUTH_TOKEN_URI"], params=params
457
+ )
458
+ else:
459
+ return requests.post(
460
+ self.application.config["OAUTH_TOKEN_URI"], params=params
461
+ )
462
+
463
+ def get_ci_user_infos(self, access_token):
464
+ return requests.get(
465
+ self.application.config["OAUTH_USERINFO_URI"],
466
+ params={"access_token": access_token},
467
+ )
468
+
469
+ def cilogon_callback(self, auth_code=""):
470
+ if not auth_code:
471
+ login_url = self.reverse_url("login")
472
+ error_description = url_escape("Invalid authorization code")
473
+ return self.redirect(
474
+ f"{login_url}?error=error&error_msg={error_description}"
475
+ )
476
+ #######################################################################
477
+ # Get user informations from CILogon
478
+ params = {
479
+ "grant_type": "authorization_code",
480
+ "code": auth_code,
481
+ "client_id": self.application.config["OAUTH_CLIENT_ID"],
482
+ "client_secret": self.application.config["OAUTH_CLIENT_SECRET"],
483
+ "redirect_uri": self.application.config["OAUTH_CALLBACK_URL"],
484
+ }
485
+ error = False
486
+ cil_user_infos = {}
487
+
488
+ tok = self.get_ci_token(params)
489
+ if tok.status_code == 200:
490
+ tok = tok.json()
491
+ r = self.get_ci_user_infos(tok["access_token"])
492
+ logger.info(r)
493
+ if r.status_code == 200:
494
+ cil_user_infos = r.json()
495
+ logger.info(cil_user_infos)
496
+ else:
497
+ error = True
498
+ else:
499
+ error = True
500
+
501
+ if error:
502
+ login_url = self.reverse_url("login")
503
+ error_description = url_escape("Invalid CI Logon response")
504
+ return self.redirect(
505
+ f"{login_url}?error=error&error_msg={error_description}"
506
+ )
507
+
508
+ #######################################################################
509
+ # Set user provenances.
510
+ # We use local cached list of Edugain IDPs (updated weekly)
511
+ cil_user_infos["idp"] = cil_user_infos["idp"] \
512
+ if "idp" in cil_user_infos else ""
513
+ edugain_idp = (
514
+ self.db.query(database.EdugainIdps)
515
+ .filter(database.EdugainIdps.entityid == cil_user_infos["idp"])
516
+ .first()
517
+ )
518
+ cil_user_infos["edugain.code"] = edugain_idp.code \
519
+ if edugain_idp is not None else ''
520
+ email = cil_user_infos.get('email', '')
521
+
522
+ # Check access rules (whitelist/blacklist) if configured
523
+ access_rules = self.application.config.get('_access_rules')
524
+ cil_user_infos["force_role"] = None
525
+ if access_rules:
526
+ from ..access_control import check_access, get_role_from_rule
527
+ result = check_access(access_rules, cil_user_infos)
528
+ cil_user_infos["force_role"] = (
529
+ get_role_from_rule(result['matched_rule']))
530
+ if not result['allowed']:
531
+ logger.warning(
532
+ "Access denied for %s: %s", email, result['reason']
533
+ )
534
+ error_msg = url_escape(
535
+ "Restricted access. Your email is not authorized."
536
+ )
537
+ return self.redirect(
538
+ self.reverse_url("login")
539
+ + f"?error=error&error_msg={error_msg}"
540
+ )
541
+
542
+ if email:
543
+ logger.info("OK CILogon provided email")
544
+ else:
545
+ logger.error(
546
+ "LOGIN FAILED : Using CILogon : "
547
+ "CILogon user email= !!! Email not provided !!!"
548
+ )
549
+ logger.error("Dump CILogon information")
550
+ logger.error(json.dumps(cil_user_infos, indent=3))
551
+ error_msg = url_escape("No email provided from CI Logon")
552
+ return self.redirect(
553
+ self.reverse_url("login")
554
+ + f"?error=error&error_msg={error_msg}"
555
+ )
556
+
557
+ self.init_session(cil_user_infos)
558
+
559
+ # Log information
560
+ logger.info(
561
+ f"LOGIN SUCCESS : Using CILogon : email={cil_user_infos['email']}"
562
+ )
563
+
564
+ def init_session(self, user_info):
565
+ login = user_info["email"].lower()
566
+ user = self.db.query(database.User).get(login)
567
+ if user is None:
568
+ logger.info(
569
+ f"CILogon Login: User login not found: {login}, "
570
+ "searching email."
571
+ )
572
+ user = (
573
+ self.db.query(database.User)
574
+ .filter(database.User.email == login)
575
+ .first()
576
+ )
577
+ if user:
578
+ logger.info(
579
+ f"CILogon Login: Updating user info for existing "
580
+ f"user: {user.login} -> {login}."
581
+ )
582
+ user.login = login
583
+ user.displayname = user_info["name"] if "name" in user_info \
584
+ else self.generateUserDisplayNameFromEmail(
585
+ user_info["email"])
586
+ user.external = True
587
+ self.db.commit()
588
+ if user is None:
589
+ logger.info(
590
+ f"CILogon Login: non-existent user, create user {login}"
591
+ )
592
+ user = database.User(login=login)
593
+ user.displayname = user_info["name"] if "name" in user_info \
594
+ else self.generateUserDisplayNameFromEmail(user_info["email"])
595
+ user.login = login
596
+ user.email = login
597
+ user.external = True
598
+ user.disabled = False
599
+ user.role = user_info["force_role"] \
600
+ if ("force_role" in user_info
601
+ and user_info["force_role"] is not None) \
602
+ else "PROJECT_ADMIN"
603
+ self.db.add(user)
604
+ self.db.commit()
605
+ self.set_secure_cookie("user", login)
606
+ PROM_USER_CONNECTIONS.inc()
607
+ PROM_ACTIVE_SESSIONS.inc()
608
+ return self.redirect(self.reverse_url("index"))
609
+ else:
610
+ logger.info("CILogon Login: Logging in..")
611
+ self.login(user.login)
612
+ PROM_USER_CONNECTIONS.inc()
613
+ PROM_ACTIVE_SESSIONS.inc()
614
+ return self._go_to_next()
615
+
616
+ def generateUserDisplayNameFromEmail(self, email):
617
+ displayname = re.sub(r"[._]", " ", email.split('@', 1)[0])
618
+ return displayname
619
+
620
+ def _go_to_next(self):
621
+ next_ = self.get_argument("next", "")
622
+ if not next_:
623
+ next_ = self.reverse_url("index")
624
+ return self.redirect(next_)
625
+
626
+
627
+ class TermsOfService(BaseHandler):
628
+ @PROM_REQUESTS.sync("tos")
629
+ def get(self):
630
+ if self.application.terms_of_service:
631
+ return self.render(
632
+ "tos.html",
633
+ tos_text=Markup(self.application.terms_of_service),
634
+ )
635
+ else:
636
+ raise HTTPError(404)
637
+
638
+
639
+ class Downloads(BaseHandler):
640
+ @PROM_REQUESTS.sync("downloads")
641
+ def get(self):
642
+ from .. import version_check
643
+ version_check.get_outdated_version()
644
+ latest_tag = version_check._cache.get('latest')
645
+ latest_version = None
646
+ if latest_tag:
647
+ match = version_check._VERSION_RE.search(latest_tag)
648
+ if match:
649
+ latest_version = match.group(1)
650
+ return self.render(
651
+ "downloads.html",
652
+ latest_version=latest_version,
653
+ windows_url=self.application.config.get('INSTALLER_URL'),
654
+ macos_url=self.application.config.get('MACOS_INSTALLER_URL'),
655
+ )
656
+
657
+
658
+ class DownloadTracker(BaseHandler):
659
+ _OS_TO_CONFIG = {
660
+ 'windows': 'INSTALLER_URL',
661
+ 'macos': 'MACOS_INSTALLER_URL',
662
+ }
663
+
664
+ @PROM_REQUESTS.sync("download_track")
665
+ def get(self, os_name):
666
+ target = self.application.config.get(
667
+ self._OS_TO_CONFIG.get(os_name, ''))
668
+ if not target:
669
+ raise HTTPError(404)
670
+ PROM_DOWNLOADS.labels(os_name).inc()
671
+ return self.redirect(target)
672
+
673
+
674
+ class Account(BaseHandler):
675
+ def get_languages(self):
676
+ locale_list = [
677
+ (
678
+ loc_code,
679
+ tornado.locale.get(loc_code).name
680
+ if tornado.locale.get(loc_code).name != "Unknown"
681
+ else loc_code,
682
+ )
683
+ for loc_code in tornado.locale.get_supported_locales()
684
+ ]
685
+ # locale_list.append(('en', self.gettext("English")))
686
+ return locale_list
687
+
688
+ @authenticated
689
+ @PROM_REQUESTS.sync("account")
690
+ def get(self):
691
+ if not self.application.config["MULTIUSER"]:
692
+ raise HTTPError(404)
693
+ user = self.db.query(database.User).get(self.current_user)
694
+ if not self.application.config['ALLOW_EXTERNAL_LOGIN']:
695
+ return self.render(
696
+ "auth/account.html",
697
+ user=user,
698
+ current_language=user.language,
699
+ is_external=self.application.config["ALLOW_EXTERNAL_LOGIN"],
700
+ )
701
+ else:
702
+ return self.render('auth/external_account.html',
703
+ user=user,
704
+ current_language=user.language)
705
+
706
+ @authenticated
707
+ @PROM_REQUESTS.async_("account")
708
+ async def post(self):
709
+ if not self.application.config["MULTIUSER"]:
710
+ raise HTTPError(404)
711
+ user = self.db.query(database.User).get(self.current_user)
712
+ try:
713
+ displayname = self.get_body_argument("displayname", None)
714
+ email = self.get_body_argument("email", None)
715
+ language = self.get_body_argument("language", None)
716
+ hide_code_counts = self.get_body_argument(
717
+ "hide_code_counts", None,
718
+ )
719
+ password1 = self.get_body_argument("password1", None)
720
+ password2 = self.get_body_argument("password2", None)
721
+ if displayname is not None:
722
+ user.displayname = displayname
723
+ if displayname == "":
724
+ user.displayname = None
725
+ if email is not None:
726
+ if email:
727
+ validate.user_email(email)
728
+ user.email = email.lower() or None
729
+ if not self.application.config["ALLOW_EXTERNAL_LOGIN"]:
730
+ if password1 or password2:
731
+ validate.user_password(password1)
732
+ if password1 != password2:
733
+ raise validate.InvalidFormat(
734
+ _f("Passwords do not match")
735
+ )
736
+ await user.set_password(password1)
737
+ if language not in tornado.locale.get_supported_locales():
738
+ language = None
739
+ user.language = language
740
+ user.hide_code_counts = hide_code_counts is not None
741
+ self.db.commit()
742
+ return self.redirect(self.reverse_url("account"))
743
+ except validate.InvalidFormat as e:
744
+ logger.info("Error validating Account: %r", e)
745
+ template = "auth/account.html"
746
+ if self.application.config["ALLOW_EXTERNAL_LOGIN"]:
747
+ template = "auth/external_account.html"
748
+
749
+ return await self.render(
750
+ template,
751
+ user=user,
752
+ current_language=user.language,
753
+ error=self.gettext(e.message),
754
+ )
755
+
756
+
757
+ class AskResetPassword(BaseHandler):
758
+ @PROM_REQUESTS.sync("reset_password")
759
+ def get(self):
760
+ if not self.application.config["MULTIUSER"]:
761
+ raise HTTPError(404)
762
+ if self.application.config["ALLOW_EXTERNAL_LOGIN"]:
763
+ raise HTTPError(404)
764
+ return self.render("auth/reset_password.html")
765
+
766
+ @PROM_REQUESTS.async_("reset_password")
767
+ async def post(self):
768
+ if not self.application.config["MULTIUSER"]:
769
+ raise HTTPError(404)
770
+ if self.application.config["ALLOW_EXTERNAL_LOGIN"]:
771
+ raise HTTPError(404)
772
+ email = self.get_body_argument("email")
773
+ user = (
774
+ self.db.query(database.User)
775
+ .filter(database.User.email == email)
776
+ .filter(database.User.disabled == False) # noqa: E712
777
+ ).one_or_none()
778
+ if user is None:
779
+ return await self.render(
780
+ "auth/reset_password.html",
781
+ error=self.gettext(
782
+ "This email address is not associated with any user"
783
+ ),
784
+ )
785
+ elif (
786
+ user.email_sent is None
787
+ or user.email_sent + timedelta(days=1) < datetime.utcnow()
788
+ ):
789
+ # Generate a signed token
790
+ reset_token = "%s|%s|%s" % (
791
+ int(time.time()),
792
+ user.login,
793
+ user.email,
794
+ )
795
+ reset_token = self.create_signed_value("reset_token", reset_token)
796
+ reset_token = reset_token.decode("utf-8")
797
+
798
+ # Reset link
799
+ path = self.request.path
800
+ if path.endswith("/reset_password"):
801
+ path = path[:-15]
802
+ path = path + "/new_password"
803
+ reset_link = urlunparse(
804
+ [
805
+ "https",
806
+ self.request.host,
807
+ path,
808
+ "",
809
+ "reset_token=" + reset_token,
810
+ "",
811
+ ]
812
+ )
813
+
814
+ msg = EmailMessage()
815
+ msg["Subject"] = self.gettext("Password reset for Taguette")
816
+ msg["From"] = self.application.config["EMAIL"]
817
+ msg["To"] = "{} <{}>".format(user.login, user.email)
818
+ msg.set_content(
819
+ self.render_string(
820
+ "email_reset_password.txt",
821
+ link=reset_link,
822
+ login=user.login,
823
+ )
824
+ )
825
+ msg.add_alternative(
826
+ self.render_string(
827
+ "auth/email_reset_password.html",
828
+ link=reset_link,
829
+ login=user.login,
830
+ ),
831
+ subtype="html",
832
+ )
833
+
834
+ logger.warning(
835
+ "Sending reset password email to %s %s", user.login, user.email
836
+ )
837
+ await asyncio.get_event_loop().run_in_executor(
838
+ None,
839
+ lambda: self.application.send_mail(msg),
840
+ )
841
+ user.email_sent = datetime.utcnow()
842
+ self.db.commit()
843
+ else:
844
+ logger.warning(
845
+ "NOT Sending reset password email to %s %s (rate limited)",
846
+ user.login,
847
+ user.email,
848
+ )
849
+ return await self.render(
850
+ "auth/reset_password.html", message=self.gettext("Email sent!")
851
+ )
852
+
853
+
854
+ class SetNewPassword(BaseHandler):
855
+ def decode_reset_token(self, reset_token):
856
+ reset_token_clear = self.get_secure_cookie(
857
+ "reset_token",
858
+ reset_token,
859
+ min_version=2,
860
+ max_age_days=1,
861
+ )
862
+ if reset_token_clear is None:
863
+ raise HTTPError(403, _f("Invalid token"))
864
+ ts, login, email = reset_token_clear.decode("utf-8").split("|", 2)
865
+ user = self.db.query(database.User).get(login)
866
+ if not user or user.disabled or user.email != email:
867
+ raise HTTPError(403, _f("No user associated with that token"))
868
+ if user.password_set_date >= datetime.utcfromtimestamp(int(ts)):
869
+ # Password has been changed after the reset token was created
870
+ raise HTTPError(403, _f("Password has already been changed"))
871
+ return user
872
+
873
+ @PROM_REQUESTS.sync("new_password")
874
+ def get(self):
875
+ if not self.application.config["MULTIUSER"]:
876
+ raise HTTPError(404)
877
+ if self.application.config["ALLOW_EXTERNAL_LOGIN"]:
878
+ raise HTTPError(404)
879
+ reset_token = self.get_query_argument("reset_token")
880
+ try:
881
+ self.decode_reset_token(reset_token)
882
+ except HTTPError as e:
883
+ self.set_status(403)
884
+ return self.render(
885
+ "auth/login.html",
886
+ register=False,
887
+ login_error=self.gettext(e.log_message),
888
+ )
889
+ return self.render("new_password.html", reset_token=reset_token)
890
+
891
+ @PROM_REQUESTS.async_("new_password")
892
+ async def post(self):
893
+ if not self.application.config["MULTIUSER"]:
894
+ raise HTTPError(404)
895
+ if self.application.config["ALLOW_EXTERNAL_LOGIN"]:
896
+ raise HTTPError(404)
897
+ reset_token = self.get_body_argument("reset_token")
898
+ try:
899
+ user = self.decode_reset_token(reset_token)
900
+ except HTTPError as e:
901
+ self.set_status(403)
902
+ return await self.render(
903
+ "auth/login.html",
904
+ register=False,
905
+ login_error=self.gettext(e.log_message),
906
+ )
907
+ try:
908
+ password1 = self.get_body_argument("password1")
909
+ password2 = self.get_body_argument("password2")
910
+ validate.user_password(password1)
911
+ if password1 != password2:
912
+ raise validate.InvalidFormat(_f("Passwords do not match"))
913
+ logger.info("Password reset: changing password for %r", user.login)
914
+ await user.set_password(password1)
915
+ self.db.commit()
916
+ return self.redirect(self.reverse_url("index"))
917
+ except validate.InvalidFormat as e:
918
+ logger.info("Error validating SetNewPassword: %r", e)
919
+ return await self.render(
920
+ "new_password.html",
921
+ reset_token=reset_token,
922
+ error=self.gettext(e.message),
923
+ )
924
+
925
+
926
+ class BugReport(BaseHandler):
927
+ def _user_projects(self):
928
+ if not self.current_user:
929
+ return []
930
+ user = self.db.query(database.User).get(self.current_user)
931
+ projects = [
932
+ m.project for m in user.project_memberships
933
+ if not m.project.is_archived
934
+ ]
935
+ projects.sort(key=lambda p: (p.name or "").lower())
936
+ return projects
937
+
938
+ @PROM_REQUESTS.sync("bug_report")
939
+ def get(self):
940
+ if self.application.config["MULTIUSER"] and not self.current_user:
941
+ raise HTTPError(404)
942
+ email = ""
943
+ if self.current_user:
944
+ user = self.db.query(database.User).get(self.current_user)
945
+ email = user.email or ""
946
+ project_id = self.get_query_argument("project_id", "").strip()
947
+ return self.render(
948
+ "bug_report.html",
949
+ description="",
950
+ email=email,
951
+ project_id=project_id,
952
+ projects=self._user_projects(),
953
+ )
954
+
955
+ @PROM_REQUESTS.async_("bug_report")
956
+ async def post(self):
957
+ if self.application.config["MULTIUSER"] and not self.current_user:
958
+ raise HTTPError(404)
959
+ description = self.get_body_argument("description")
960
+ email = self.get_body_argument("email", "")
961
+ project_id = self.get_body_argument("project_id", "").strip()
962
+ screenshots = self.request.files.get("screenshot", [])
963
+ projects = self._user_projects()
964
+
965
+ rate_limit_key = self.current_user or self.request.remote_ip
966
+ history = self.application.bug_reports[rate_limit_key]
967
+ cutoff = datetime.utcnow() - timedelta(hours=24)
968
+ history[:] = [sent for sent in history if sent > cutoff]
969
+ if len(history) >= 10:
970
+ return await self.render(
971
+ "bug_report.html",
972
+ description=description,
973
+ email=email,
974
+ project_id=project_id,
975
+ projects=projects,
976
+ error=self.gettext(
977
+ "You've reached the limit of 10 bug reports per 24"
978
+ " hours. Please try again later."
979
+ ),
980
+ )
981
+
982
+ if not project_id:
983
+ return await self.render(
984
+ "bug_report.html",
985
+ description=description,
986
+ email=email,
987
+ project_id=project_id,
988
+ projects=projects,
989
+ error=self.gettext("Please provide the project ID"),
990
+ )
991
+ if len(screenshots) > 5:
992
+ return await self.render(
993
+ "bug_report.html",
994
+ description=description,
995
+ email=email,
996
+ project_id=project_id,
997
+ projects=projects,
998
+ error=self.gettext("You can attach at most 5 images"),
999
+ )
1000
+ for screenshot in screenshots:
1001
+ if not screenshot["content_type"].startswith("image/"):
1002
+ return await self.render(
1003
+ "bug_report.html",
1004
+ description=description,
1005
+ email=email,
1006
+ project_id=project_id,
1007
+ projects=projects,
1008
+ error=self.gettext("Attachments must be images"),
1009
+ )
1010
+ if len(screenshot["body"]) > 15 * 1024 * 1024:
1011
+ return await self.render(
1012
+ "bug_report.html",
1013
+ description=description,
1014
+ email=email,
1015
+ project_id=project_id,
1016
+ projects=projects,
1017
+ error=self.gettext("Each image must be under 15MB"),
1018
+ )
1019
+
1020
+ msg = EmailMessage()
1021
+ msg["Subject"] = self.gettext("Bug report from LibreQDA")
1022
+ msg["From"] = self.application.config["EMAIL"]
1023
+ msg["To"] = (
1024
+ self.application.config.get("BUG_REPORT_EMAIL_TO")
1025
+ or self.application.config["EMAIL"]
1026
+ )
1027
+ if email:
1028
+ msg["Reply-To"] = email
1029
+ msg.set_content(
1030
+ self.render_string(
1031
+ "email_bug_report.txt",
1032
+ description=description,
1033
+ login=self.current_user,
1034
+ email=email,
1035
+ project_id=project_id,
1036
+ )
1037
+ )
1038
+ for screenshot in screenshots:
1039
+ maintype, _, subtype = screenshot["content_type"].partition("/")
1040
+ msg.add_attachment(
1041
+ screenshot["body"],
1042
+ maintype=maintype,
1043
+ subtype=subtype,
1044
+ filename=screenshot["filename"],
1045
+ )
1046
+
1047
+ logger.info("Sending bug report email from %r", self.current_user)
1048
+ try:
1049
+ await asyncio.get_event_loop().run_in_executor(
1050
+ None,
1051
+ lambda: self.application.send_mail(msg),
1052
+ )
1053
+ except Exception:
1054
+ logger.exception("Failed to send bug report email")
1055
+ return await self.render(
1056
+ "bug_report.html",
1057
+ description=description,
1058
+ email=email,
1059
+ project_id=project_id,
1060
+ projects=projects,
1061
+ error=self.gettext(
1062
+ "Sorry, we couldn't send your report right now."
1063
+ " Please try again in a few minutes."
1064
+ ),
1065
+ )
1066
+ history.append(datetime.utcnow())
1067
+ return await self.render(
1068
+ "bug_report.html",
1069
+ description="",
1070
+ email=email,
1071
+ project_id="",
1072
+ projects=projects,
1073
+ message=self.gettext(
1074
+ "Your bug report has been sent. Thank you!"
1075
+ ),
1076
+ )
1077
+
1078
+
1079
+ class ProjectAdd(BaseHandler):
1080
+ def prepare(self):
1081
+ user = self.db.query(database.User).get(self.current_user)
1082
+ if not user.role.can_create_project():
1083
+ raise HTTPError(403)
1084
+
1085
+ @authenticated
1086
+ @PROM_REQUESTS.sync("new_project")
1087
+ def get(self):
1088
+ return self.render("project_new.html")
1089
+
1090
+ @authenticated
1091
+ @PROM_REQUESTS.sync("new_project")
1092
+ def post(self):
1093
+ name = self.get_body_argument("name", "")
1094
+ description = self.get_body_argument("description", "")
1095
+ try:
1096
+ validate.project_name(name)
1097
+ validate.description(description)
1098
+
1099
+ # Create project
1100
+ project = database.Project(name=name, description=description)
1101
+ self.db.add(project)
1102
+ # Add user as admin
1103
+ membership = database.ProjectMember(
1104
+ project=project,
1105
+ user_login=self.current_user,
1106
+ privileges=database.Privileges.ADMIN,
1107
+ )
1108
+ self.db.add(membership)
1109
+ # Add default tags
1110
+ self.db.add(
1111
+ database.Tag(
1112
+ project=project,
1113
+ # TRANSLATORS: Default tag name
1114
+ path=self.gettext("interesting"),
1115
+ # TRANSLATORS: Default tag color
1116
+ color=self.application.tag_default_color,
1117
+ # TRANSLATORS: Default tag description
1118
+ description=self.gettext("Further review required"),
1119
+ ),
1120
+ )
1121
+
1122
+ self.db.commit()
1123
+ return self.redirect(self.reverse_url("project", project.id))
1124
+ except validate.InvalidFormat as e:
1125
+ logger.info("Error validating ProjectAdd: %r", e)
1126
+ return self.render(
1127
+ "project_new.html",
1128
+ name=name,
1129
+ description=description,
1130
+ error=self.gettext(e.message),
1131
+ )
1132
+
1133
+ def render(self, template_name, **kwargs):
1134
+ for name in ("name", "description", "error"):
1135
+ kwargs.setdefault(name, "")
1136
+ super(ProjectAdd, self).render(template_name, **kwargs)
1137
+
1138
+
1139
+ class ProjectImport(BaseHandler):
1140
+ def prepare(self):
1141
+ user = self.db.query(database.User).get(self.current_user)
1142
+ if (
1143
+ not self.application.config["SQLITE3_IMPORT_ENABLED"]
1144
+ or not user.role.can_create_project()
1145
+ ):
1146
+ raise HTTPError(403)
1147
+
1148
+ @authenticated
1149
+ @PROM_REQUESTS.sync("import_project")
1150
+ def get(self):
1151
+ user = self.db.query(database.User).get(self.current_user)
1152
+ return self.render("project_import.html", user=user)
1153
+
1154
+
1155
+ class ProjectDelete(BaseHandler):
1156
+ @authenticated
1157
+ @PROM_REQUESTS.sync("delete_project")
1158
+ def get(self, project_id):
1159
+ project, privileges = self.get_project(project_id)
1160
+ if not privileges.can_delete_project():
1161
+ self.set_status(403)
1162
+ return self.finish(
1163
+ self.gettext(
1164
+ "You don't have permission to delete this project",
1165
+ )
1166
+ )
1167
+ doc = aliased(database.Document)
1168
+ highlights = (
1169
+ self.db.query(database.Highlight)
1170
+ .join(doc, database.Highlight.document_id == doc.id)
1171
+ .filter(doc.project_id == project.id)
1172
+ ).count()
1173
+ return self.render(
1174
+ "project_delete.html",
1175
+ project=project,
1176
+ documents=len(project.documents),
1177
+ tags=len(project.tags),
1178
+ highlights=highlights,
1179
+ )
1180
+
1181
+ @authenticated
1182
+ @PROM_REQUESTS.sync("delete_project")
1183
+ def post(self, project_id):
1184
+ project, privileges = self.get_project(project_id)
1185
+ if not privileges.can_delete_project():
1186
+ raise HTTPError(403)
1187
+ logger.warning(
1188
+ "Deleting project %d %r user=%r",
1189
+ project.id,
1190
+ project.name,
1191
+ self.current_user,
1192
+ )
1193
+ self.db.delete(project)
1194
+ self.db.commit()
1195
+ return self.redirect(self.reverse_url("index"))
1196
+
1197
+
1198
+ class ProjectArchive(BaseHandler):
1199
+ @authenticated
1200
+ @PROM_REQUESTS.sync("archive_project")
1201
+ def post(self, project_id):
1202
+ project, privileges = self.get_project(project_id)
1203
+ if not privileges.can_delete_project():
1204
+ raise HTTPError(403)
1205
+ logger.info(
1206
+ "Archiving project %d %r user=%r",
1207
+ project.id,
1208
+ project.name,
1209
+ self.current_user,
1210
+ )
1211
+ project.is_archived = True
1212
+ self.db.commit()
1213
+ return self.redirect(self.reverse_url("index"))
1214
+
1215
+
1216
+ class ProjectUnarchive(BaseHandler):
1217
+ @authenticated
1218
+ @PROM_REQUESTS.sync("unarchive_project")
1219
+ def post(self, project_id):
1220
+ project, privileges = self.get_project(project_id)
1221
+ if not privileges.can_delete_project():
1222
+ raise HTTPError(403)
1223
+ logger.info(
1224
+ "Unarchiving project %d %r user=%r",
1225
+ project.id,
1226
+ project.name,
1227
+ self.current_user,
1228
+ )
1229
+ project.is_archived = False
1230
+ self.db.commit()
1231
+ return self.redirect(self.reverse_url("archived_projects"))
1232
+
1233
+
1234
+ class ArchivedProjects(BaseHandler):
1235
+ @authenticated
1236
+ @PROM_REQUESTS.sync("archived_projects")
1237
+ def get(self):
1238
+ user = self.db.query(database.User).get(self.current_user)
1239
+ projects = [
1240
+ m.project for m in user.project_memberships
1241
+ if m.project.is_archived
1242
+ ]
1243
+ return self.render(
1244
+ "archived_projects.html",
1245
+ user=user,
1246
+ projects=projects,
1247
+ )
1248
+
1249
+
1250
+ class ImportCodebook(BaseHandler):
1251
+ @authenticated
1252
+ @PROM_REQUESTS.sync("import_codebook")
1253
+ def get(self, project_id):
1254
+ project, privileges = self.get_project(project_id)
1255
+ if not privileges.can_import_codebook():
1256
+ self.set_status(403)
1257
+ return self.finish(
1258
+ self.gettext(
1259
+ "You don't have permission to import a codebook",
1260
+ )
1261
+ )
1262
+ return self.render("project_import_codebook.html", project=project)
1263
+
1264
+ def _show_confirmation_form(self, project, tags, errors=[]):
1265
+ # Load tags from project, to set whether they exist
1266
+ project_tags = set(tag.path for tag in project.tags)
1267
+ for tag in tags:
1268
+ tag["exists"] = tag["path"] in project_tags
1269
+
1270
+ return self.render(
1271
+ "project_import_codebook_confirm.html",
1272
+ project=project,
1273
+ tags=tags,
1274
+ errors=errors,
1275
+ )
1276
+
1277
+ @authenticated
1278
+ @PROM_REQUESTS.sync("import_codebook")
1279
+ def post(self, project_id):
1280
+ project, privileges = self.get_project(project_id)
1281
+ if not privileges.can_import_codebook():
1282
+ self.set_status(403)
1283
+ return self.render(
1284
+ "project_import_codebook.html",
1285
+ project=project,
1286
+ error=self.gettext(
1287
+ "You don't have permission to import a codebook",
1288
+ ),
1289
+ )
1290
+
1291
+ if "file" in self.request.files and len(self.request.files) >= 1:
1292
+ uploaded_file = self.request.files["file"][0]
1293
+
1294
+ reader = io.BytesIO(uploaded_file.body)
1295
+ try:
1296
+ tags = import_codebook.list_tags(reader)
1297
+ except import_codebook.InvalidCodebook as e:
1298
+ logger.warning(
1299
+ "Invalid codebook: %s%s",
1300
+ e.message,
1301
+ (" (row %d)" % e.row) if e.row is not None else "",
1302
+ )
1303
+ msg = self.gettext(e.message)
1304
+ if e.row is not None:
1305
+ msg = self.gettext("Row %d: %s") % (e.row, msg)
1306
+ self.set_status(400)
1307
+ return self.render(
1308
+ "project_import_codebook.html",
1309
+ project=project,
1310
+ error=msg,
1311
+ )
1312
+
1313
+ # Disable duplicates
1314
+ seen_paths = set()
1315
+ for tag in tags:
1316
+ tag["enabled"] = tag["path"] not in seen_paths
1317
+ seen_paths.add(tag["path"])
1318
+
1319
+ # Sort by path, keeping the enabled one first
1320
+ tags = sorted(tags, key=lambda t: (t["path"], not t["enabled"]))
1321
+
1322
+ return self._show_confirmation_form(project, tags)
1323
+ elif self.get_body_argument("tag0-path", None):
1324
+ # Accumulate validation errors
1325
+ errors = []
1326
+ # What to insert/update into database
1327
+ replace_tags = {}
1328
+ insert_tags = []
1329
+ # List of tags to show the confirmation form again on conflict
1330
+ tags = []
1331
+
1332
+ for i in itertools.count():
1333
+ path = self.get_body_argument("tag%d-path" % i, None)
1334
+ parent = self.get_body_argument("tag%d-parent" % i, None)
1335
+ color = self.get_body_argument("tag%d-color" % i, "")
1336
+ description = self.get_body_argument(
1337
+ "tag%d-description" % i,
1338
+ "",
1339
+ )
1340
+ if path is None:
1341
+ break
1342
+
1343
+ replace = enabled = True
1344
+ if self.get_body_argument("tag%d-replace" % i, ""):
1345
+ replace = True
1346
+ elif self.get_body_argument("tag%d-import" % i, ""):
1347
+ replace = False
1348
+ else:
1349
+ enabled = False
1350
+
1351
+ try:
1352
+ validate.tag_path(path)
1353
+ validate.description(description)
1354
+ validate.color(color)
1355
+ except validate.InvalidFormat as e:
1356
+ errors.append(self.gettext(e.message))
1357
+ continue
1358
+
1359
+ tags.append(
1360
+ {
1361
+ "path": path,
1362
+ "description": description,
1363
+ "color": color,
1364
+ "parent": parent,
1365
+ "enabled": enabled,
1366
+ }
1367
+ )
1368
+ if not enabled:
1369
+ continue
1370
+
1371
+ if replace:
1372
+ replace_tags[path] = {
1373
+ "description": description,
1374
+ "parent": parent,
1375
+ "color": color,
1376
+ }
1377
+ else: # Insert
1378
+ insert_tags.append(
1379
+ {
1380
+ "path": path,
1381
+ "description": description,
1382
+ "color": color,
1383
+ "parent": parent,
1384
+ }
1385
+ )
1386
+
1387
+ if errors:
1388
+ self.set_status(400)
1389
+ return self.render(
1390
+ "project_import_codebook_confirm.html",
1391
+ project=project,
1392
+ errors=errors,
1393
+ )
1394
+
1395
+ try:
1396
+ changed_tags = []
1397
+
1398
+ # Do updates
1399
+ for tag in project.tags:
1400
+ try:
1401
+ tag_update = replace_tags.pop(tag.path)
1402
+ except KeyError:
1403
+ pass
1404
+ else:
1405
+ tag.description = tag_update["description"]
1406
+ tag.color = tag_update["color"]
1407
+ tag_parent = (
1408
+ self.db.query(database.Tag)
1409
+ .filter_by(
1410
+ path=tag_update["parent"],
1411
+ project_id=project.id,
1412
+ )
1413
+ .first()
1414
+ )
1415
+ tag.parent_id = tag_parent.id if tag_parent else None
1416
+ changed_tags.append(tag)
1417
+ if replace_tags:
1418
+ raise KeyError
1419
+
1420
+ # Do inserts
1421
+ for tag_insert in insert_tags:
1422
+ tag_parent = (
1423
+ self.db.query(database.Tag)
1424
+ .filter_by(
1425
+ path=tag_insert["parent"], project_id=project.id
1426
+ )
1427
+ .first()
1428
+ )
1429
+ tag = database.Tag(
1430
+ project_id=project.id,
1431
+ path=tag_insert["path"],
1432
+ description=tag_insert["description"],
1433
+ color=tag_insert["color"],
1434
+ parent_id=tag_parent.id if tag_parent else None,
1435
+ )
1436
+ self.db.add(tag)
1437
+ changed_tags.append(tag)
1438
+ self.db.flush()
1439
+ commands = []
1440
+ for tag in changed_tags:
1441
+ cmd = database.Command.tag_add(
1442
+ self.current_user,
1443
+ tag,
1444
+ )
1445
+ self.db.add(cmd)
1446
+ commands.append(cmd)
1447
+ self.db.flush()
1448
+
1449
+ self.db.commit()
1450
+ for cmd in commands:
1451
+ self.application.notify_project(project.id, cmd)
1452
+ return self.redirect(self.reverse_url("project", project.id))
1453
+ except (IntegrityError, KeyError):
1454
+ logger.warning("Error importing codebook", exc_info=True)
1455
+ self.db.rollback()
1456
+ error = self.gettext(
1457
+ "Error importing tags, concurrent changes caused a "
1458
+ + "conflict",
1459
+ )
1460
+ self.set_status(409)
1461
+ return self._show_confirmation_form(
1462
+ project,
1463
+ tags,
1464
+ errors=[error],
1465
+ )
1466
+ else:
1467
+ self.set_status(400)
1468
+ return self.render(
1469
+ "project_import_codebook.html",
1470
+ project=project,
1471
+ error=self.gettext("No file provided"),
1472
+ )
1473
+
1474
+
1475
+ class Project(BaseHandler):
1476
+ @authenticated
1477
+ @PROM_REQUESTS.sync("project")
1478
+ def get(self, project_id):
1479
+ project, privileges = self.get_project(project_id)
1480
+ if project.is_archived:
1481
+ return self.redirect(self.reverse_url('archived_projects'))
1482
+ is_demo = self.get_argument("demo", None)
1483
+ demo_highlights_location = []
1484
+ user = self.db.query(database.User).get(self.current_user)
1485
+ if is_demo is not None:
1486
+ is_demo = "true"
1487
+ lang = user.language
1488
+ if lang not in DEMO_DATA.keys():
1489
+ lang = self.application.config["DEFAULT_LANGUAGE"]
1490
+ demo_highlights_location = DEMO_DATA[lang]["tutorial_highlights"]
1491
+ else:
1492
+ is_demo = "false"
1493
+
1494
+ # Group tags by their parent IDs
1495
+ grouped_tags = defaultdict(list)
1496
+
1497
+ for tag in project.tags:
1498
+ tag_parent_id = tag.parent.id if tag.parent else None
1499
+ grouped_tags[tag_parent_id].append(tag)
1500
+
1501
+ # Function to build the tree structure
1502
+ def build_tree(parent_id):
1503
+ children = []
1504
+ for child_tag in grouped_tags[parent_id]:
1505
+ child_id = child_tag.id
1506
+ parent_id = child_tag.parent.id if child_tag.parent else None
1507
+ color = (
1508
+ child_tag.color
1509
+ if child_tag.color
1510
+ else self.application.tag_default_color
1511
+ )
1512
+ child_info = {
1513
+ "id": child_tag.id,
1514
+ "path": child_tag.path,
1515
+ "description": child_tag.description,
1516
+ "color": color,
1517
+ "parent": parent_id,
1518
+ "count": child_tag.highlights_count,
1519
+ "count_document": child_tag.documents_count,
1520
+ "children": build_tree(child_id),
1521
+ }
1522
+ children.append(child_info)
1523
+ return children
1524
+
1525
+ # Build the tree for tags with parent_id=None (root level)
1526
+ result_tree = build_tree(None)
1527
+
1528
+ # Convert the result to JSON
1529
+ tags_json = Markup(json.dumps(result_tree, sort_keys=True))
1530
+
1531
+ # Group collections by their parent IDs
1532
+ grouped_collections = defaultdict(list)
1533
+
1534
+ for collection in project.collections:
1535
+ collection_parent_id = (
1536
+ collection.parent.id if collection.parent else None
1537
+ )
1538
+ grouped_collections[collection_parent_id].append(collection)
1539
+
1540
+ # Function to build the collection tree structure
1541
+ def build_collection_tree(parent_id):
1542
+ children = []
1543
+ for child_collection in grouped_collections[parent_id]:
1544
+ child_id = child_collection.id
1545
+ collection_parent_id = (
1546
+ child_collection.parent.id
1547
+ if child_collection.parent
1548
+ else None
1549
+ )
1550
+ child_info = {
1551
+ "id": child_collection.id,
1552
+ "name": child_collection.name,
1553
+ "description": child_collection.description,
1554
+ "parent": collection_parent_id,
1555
+ "document_ids": [d.id for d in child_collection.documents],
1556
+ "children": build_collection_tree(child_id),
1557
+ }
1558
+ children.append(child_info)
1559
+ return children
1560
+
1561
+ # Build the tree for collections with parent_id=None (root level)
1562
+ collections_tree = build_collection_tree(None)
1563
+
1564
+ # Convert the result to JSON
1565
+ collections_json = Markup(json.dumps(collections_tree, sort_keys=True))
1566
+
1567
+ documents_json = Markup(
1568
+ json.dumps(
1569
+ {
1570
+ str(doc.id): {
1571
+ "id": doc.id,
1572
+ "name": doc.name,
1573
+ "description": doc.description,
1574
+ "favorite": doc.favorite,
1575
+ "text_direction": doc.text_direction.name,
1576
+ "collections": [c.id for c in doc.collections],
1577
+ "highlights_count": doc.highlights_count,
1578
+ }
1579
+ for doc in project.documents
1580
+ },
1581
+ sort_keys=True,
1582
+ )
1583
+ )
1584
+
1585
+ members = (
1586
+ self.db.query(database.ProjectMember).filter(
1587
+ database.ProjectMember.project_id == project_id
1588
+ )
1589
+ ).all()
1590
+ can_import_codebook = privileges.can_import_codebook()
1591
+ can_delete_project = privileges.can_delete_project()
1592
+ members_json = Markup(
1593
+ json.dumps(
1594
+ {
1595
+ member.user_login: {"privileges": member.privileges.name}
1596
+ for member in members
1597
+ }
1598
+ )
1599
+ )
1600
+ _ = self.xsrf_token # Make sure XSRF cookie is set
1601
+ return self.render(
1602
+ "project.html",
1603
+ project=project,
1604
+ last_event=(
1605
+ project.last_event if project.last_event is not None else -1
1606
+ ),
1607
+ collections=collections_json,
1608
+ documents=documents_json,
1609
+ user_login=Markup(json.dumps(self.current_user)),
1610
+ tags=tags_json,
1611
+ members=members_json,
1612
+ can_import_codebook=can_import_codebook,
1613
+ can_delete_project=can_delete_project,
1614
+ is_external_login=self.application.config["ALLOW_EXTERNAL_LOGIN"],
1615
+ is_demo=Markup(is_demo),
1616
+ demo_highlights_location=Markup(demo_highlights_location),
1617
+ user=user,
1618
+ )