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/api.py ADDED
@@ -0,0 +1,1835 @@
1
+ import re
2
+ import alembic.util.exc
3
+ import asyncio
4
+ import functools
5
+ import json
6
+ import logging
7
+ import math
8
+ import os
9
+ import prometheus_client
10
+ from sqlalchemy import and_, func, distinct, not_, or_
11
+ from sqlalchemy.exc import IntegrityError, DatabaseError, NoSuchTableError
12
+ from sqlalchemy.orm import aliased, defer, joinedload, subqueryload
13
+ import tempfile
14
+ from tornado.concurrent import Future
15
+ import tornado.log
16
+ from tornado.web import MissingArgumentError, HTTPError
17
+ from urllib.parse import unquote
18
+
19
+ from .. import exact_version
20
+ from .. import convert
21
+ from .. import database
22
+ from .. import extract
23
+ from .. import validate
24
+ from .base import BaseHandler, PromMeasureRequest
25
+ from ..utils import get_path_informations
26
+ from ..demodata import DEMO_DATA
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ PROM_POLLING_CLIENTS = prometheus_client.Gauge(
31
+ 'polling_clients',
32
+ "Number of current polling clients",
33
+ )
34
+
35
+ PROM_REQUESTS = PromMeasureRequest(
36
+ count=prometheus_client.Counter(
37
+ 'api_total',
38
+ "API requests",
39
+ ['name'],
40
+ ),
41
+ time=prometheus_client.Histogram(
42
+ 'api_seconds',
43
+ "API request time",
44
+ ['name'],
45
+ ),
46
+ )
47
+
48
+
49
+ def api_auth(method):
50
+ @functools.wraps(method)
51
+ def wrapper(self, *args, **kwargs):
52
+ if not self.current_user:
53
+ return self.send_error_json(403, self.gettext("Not logged in"))
54
+ return method(self, *args, **kwargs)
55
+
56
+ return wrapper
57
+
58
+
59
+ class CheckUser(BaseHandler):
60
+ @api_auth
61
+ @PROM_REQUESTS.sync('check_user')
62
+ def post(self):
63
+ if not self.application.config['MULTIUSER']:
64
+ raise HTTPError(404)
65
+ data = self.get_json()
66
+ login = data['login']
67
+ permission = data['permission'] if 'permission' in data else False
68
+ try:
69
+ login = validate.fix_user_login(login)
70
+ except validate.InvalidFormat:
71
+ pass
72
+ else:
73
+ user = self.db.query(database.User).get(login)
74
+ return_user = True
75
+ if permission and (not user.role.role_can_be_changed() or
76
+ user.approved_by_user_login is not None):
77
+ return_user = False
78
+ if return_user and user is not None and not user.disabled:
79
+ return self.send_json({'exists': True})
80
+ return self.send_json({'exists': False})
81
+
82
+
83
+ class CreateDemoProject(BaseHandler):
84
+ @api_auth
85
+ @PROM_REQUESTS.sync('create_demo_project')
86
+ def post(self):
87
+ user = self.db.query(database.User).get(self.current_user)
88
+ documents = []
89
+ tags = []
90
+ # Create project
91
+ project = database.Project(
92
+ name=self.gettext("Tutorial - board games"),
93
+ description=self.gettext(
94
+ 'A demo project to showcase LibreQDA'
95
+ ))
96
+ self.db.add(project)
97
+ priv = database.Privileges.TAG
98
+ if user.role.can_create_project():
99
+ priv = database.Privileges.ADMIN
100
+ # Add user as admin
101
+ membership = database.ProjectMember(
102
+ project=project,
103
+ user_login=self.current_user,
104
+ privileges=priv
105
+ )
106
+ self.db.add(membership)
107
+ self.db.flush()
108
+
109
+ # Select language for demo
110
+ lang = user.language
111
+ if lang not in DEMO_DATA.keys():
112
+ lang = self.application.config['DEFAULT_LANGUAGE']
113
+ for document in DEMO_DATA[lang]['documents']:
114
+ d = database.Document(
115
+ project_id=project.id,
116
+ name=document['name'],
117
+ description="",
118
+ filename=document['filename'],
119
+ text_direction=document['text_direction'],
120
+ contents=document['contents'],
121
+ favorite=document['favorite']
122
+ )
123
+ self.db.add(d)
124
+ self.db.flush()
125
+ documents.append(d)
126
+
127
+ for tag in DEMO_DATA[lang]['tags']:
128
+ t = database.Tag(project=project,
129
+ color=tag['color'],
130
+ path=tag['path'],
131
+ description=tag['description'])
132
+ self.db.add(t)
133
+ self.db.flush()
134
+ tags.append(t)
135
+ if tag['childrens']:
136
+ for tag2 in tag['childrens']:
137
+ t2 = database.Tag(project=project,
138
+ color=tag2['color'],
139
+ path=tag2['path'],
140
+ description=tag2['description'],
141
+ parent=t)
142
+ self.db.add(t2)
143
+ self.db.flush()
144
+ tags.append(t2)
145
+
146
+ self.db.commit()
147
+
148
+ for highlight in DEMO_DATA[lang]['highlights']:
149
+ h = database.Highlight(
150
+ document=documents[highlight['document']],
151
+ start_offset=highlight['start_offset'],
152
+ end_offset=highlight['end_offset'],
153
+ snippet=highlight['snippet'])
154
+ self.db.add(h)
155
+ self.db.flush()
156
+ for t in highlight['tags']:
157
+ h.tags.append(tags[t])
158
+ self.db.flush()
159
+ logger.info(h)
160
+ self.db.commit()
161
+
162
+ return self.send_json({
163
+ 'html': self.render_string(
164
+ 'partials/project_card.html',
165
+ project=project,
166
+ demo=True)
167
+ })
168
+
169
+
170
+ class ProjectMeta(BaseHandler):
171
+ @api_auth
172
+ @PROM_REQUESTS.sync('project_meta')
173
+ def post(self, project_id):
174
+ project, privileges = self.get_project(project_id)
175
+ if not privileges.can_edit_project_meta():
176
+ return self.send_error_json(403, self.gettext("Unauthorized"))
177
+ try:
178
+ obj = self.get_json()
179
+ validate.project_name(obj['name'])
180
+ project.name = obj['name']
181
+ validate.description(obj['description'])
182
+ project.description = obj['description']
183
+ logger.info("Updated project: %r %r",
184
+ project.name, project.description)
185
+ cmd = database.Command.project_meta(
186
+ self.current_user,
187
+ project.id,
188
+ obj['name'],
189
+ obj['description'],
190
+ )
191
+ self.db.add(cmd)
192
+ self.db.commit()
193
+ self.db.refresh(cmd)
194
+ self.application.notify_project(project.id, cmd)
195
+ return self.send_json({})
196
+ except validate.InvalidFormat as e:
197
+ logger.info("Error validating ProjectMeta: %r", e)
198
+ return self.send_error_json(400, self.gettext(e.message))
199
+
200
+
201
+ class ProjectDisplay(BaseHandler):
202
+ @api_auth
203
+ @PROM_REQUESTS.sync('project_display')
204
+ def post(self, project_id):
205
+ project, privileges = self.get_project(project_id)
206
+ if not privileges.can_edit_project_meta():
207
+ return self.send_error_json(403, self.gettext("Unauthorized"))
208
+ obj = self.get_json()
209
+ project.compact_layout = bool(obj.get('compact_layout'))
210
+ self.db.commit()
211
+ return self.send_json({})
212
+
213
+
214
+ class DocumentAdd(BaseHandler):
215
+ @api_auth
216
+ @PROM_REQUESTS.async_('document_add')
217
+ async def post(self, project_id):
218
+ project, privileges = self.get_project(project_id)
219
+ if not privileges.can_add_document():
220
+ return await self.send_error_json(403, self.gettext(
221
+ "Unauthorized",
222
+ ))
223
+ try:
224
+ name = self.get_body_argument('name')
225
+ validate.document_name(name)
226
+ description = self.get_body_argument('description')
227
+ validate.description(description)
228
+ try:
229
+ file = self.request.files['file'][0]
230
+ except (KeyError, IndexError):
231
+ raise MissingArgumentError('file')
232
+ content_type = file.content_type
233
+ filename = validate.fix_filename(unquote(file.filename))
234
+ favorite = False
235
+ direction = self.get_body_argument('text_direction',
236
+ 'LEFT_TO_RIGHT')
237
+ try:
238
+ direction = database.TextDirection[direction]
239
+ except KeyError:
240
+ return await self.send_error_json(
241
+ 400,
242
+ "Invalid text direction",
243
+ )
244
+
245
+ # Close DB connection to not overflow the connection pool
246
+ self.close_db_connection()
247
+
248
+ try:
249
+ body = await convert.to_html_chunks(
250
+ file.body, content_type, filename,
251
+ self.application.config,
252
+ )
253
+ except convert.ConversionError as err:
254
+ logger.warning(
255
+ "Error converting document %r %s: %s",
256
+ filename, content_type, err,
257
+ )
258
+ return await self.send_error_json(400, str(err))
259
+ else:
260
+ doc = database.Document(
261
+ name=name,
262
+ description=description,
263
+ filename=filename,
264
+ favorite=favorite,
265
+ project=project,
266
+ text_direction=direction,
267
+ contents=body,
268
+ )
269
+ self.db.add(doc)
270
+ self.db.flush() # Need to flush to get doc.id
271
+
272
+ # Handle existing collections
273
+ collection_ids = self.get_arguments('collection_ids')
274
+ for col_id in collection_ids:
275
+ collection = self.db.query(database.Collection).filter_by(
276
+ id=col_id, project_id=project.id).first()
277
+ if collection:
278
+ doc.collections.append(collection)
279
+
280
+ # Handle new collection creation
281
+ create_collection_name = self.get_body_argument(
282
+ 'create_collection_name', None)
283
+ if create_collection_name:
284
+ create_collection_name = create_collection_name.strip()
285
+ if create_collection_name:
286
+ # Check if collection with this name already exists
287
+ existing_collection = self.db.query(
288
+ database.Collection
289
+ ).filter_by(
290
+ name=create_collection_name,
291
+ project_id=project.id,
292
+ owner_login=self.current_user
293
+ ).first()
294
+
295
+ if existing_collection:
296
+ # Use existing collection
297
+ doc.collections.append(existing_collection)
298
+ logger.info(
299
+ "Added document %r to existing collection %r",
300
+ doc.name, create_collection_name
301
+ )
302
+ else:
303
+ # Create new collection
304
+ new_collection = database.Collection(
305
+ name=create_collection_name,
306
+ description="",
307
+ project_id=project.id,
308
+ owner_login=self.current_user,
309
+ parent_id=None
310
+ )
311
+ self.db.add(new_collection)
312
+ self.db.flush() # Get collection.id
313
+ doc.collections.append(new_collection)
314
+ logger.info(
315
+ "Created new collection %r for document %r",
316
+ create_collection_name, doc.name
317
+ )
318
+
319
+ # Add command for collection creation
320
+ collection_cmd = database.Command.collection_add(
321
+ self.current_user,
322
+ new_collection,
323
+ )
324
+ self.db.add(collection_cmd)
325
+
326
+ cmd = database.Command.document_add(
327
+ self.current_user,
328
+ doc,
329
+ )
330
+ self.db.add(cmd)
331
+ logger.info(
332
+ "Document added to project %r: %r %r (%d bytes)",
333
+ project.id, doc.id, doc.name, len(doc.contents)
334
+ )
335
+ self.db.commit()
336
+ self.db.refresh(cmd)
337
+
338
+ if (create_collection_name and
339
+ create_collection_name.strip() and
340
+ 'collection_cmd' in locals()):
341
+ self.db.refresh(collection_cmd)
342
+ self.application.notify_project(
343
+ project.id, collection_cmd
344
+ )
345
+
346
+ self.application.notify_project(project.id, cmd)
347
+ return await self.send_json({'created': doc.id})
348
+ except validate.InvalidFormat as e:
349
+ logger.info("Error validating DocumentAdd: %r", e)
350
+ return await self.send_error_json(400, self.gettext(e.message))
351
+
352
+
353
+ class Document(BaseHandler):
354
+ @api_auth
355
+ @PROM_REQUESTS.sync('document_info')
356
+ def get(self, project_id, document_id):
357
+ document, _ = self.get_document(project_id, document_id)
358
+
359
+ highlights = (
360
+ self.db.query(database.Highlight)
361
+ .filter(database.Highlight.document_id == document.id)
362
+ .order_by(database.Highlight.start_offset)
363
+ .options(joinedload(database.Highlight.tags))
364
+ .options(defer('tags.highlights_count'))
365
+ ).all()
366
+ return self.send_json({
367
+ 'text_direction': document.text_direction.name,
368
+ 'highlights': [
369
+ {'id': hl.id,
370
+ 'start_offset': hl.start_offset,
371
+ 'end_offset': hl.end_offset,
372
+ 'snippet': hl.snippet,
373
+ 'tags': [t.id for t in hl.tags]}
374
+ for hl in highlights
375
+ ],
376
+ })
377
+
378
+ @api_auth
379
+ @PROM_REQUESTS.sync('document_update')
380
+ def post(self, project_id, document_id):
381
+ document, privileges = self.get_document(project_id, document_id)
382
+ if not privileges.can_edit_document():
383
+ return self.send_error_json(403, self.gettext("Unauthorized"))
384
+ try:
385
+ obj = self.get_json()
386
+ if obj:
387
+ if 'name' in obj:
388
+ validate.document_name(obj['name'])
389
+ document.name = obj['name']
390
+ if 'description' in obj:
391
+ validate.description(obj['description'])
392
+ document.description = obj['description']
393
+ if 'favorite' in obj:
394
+ document.favorite = obj['favorite']
395
+ if 'text_direction' in obj:
396
+ direction = obj['text_direction']
397
+ try:
398
+ direction = database.TextDirection[direction]
399
+ except KeyError:
400
+ return self.send_error_json(
401
+ 400,
402
+ "Invalid text direction",
403
+ )
404
+ document.text_direction = direction
405
+ if 'collections' in obj:
406
+ document.collections = []
407
+ for col_id in obj['collections']:
408
+ collection = (self.db.query(database.Collection)
409
+ .filter_by(
410
+ id=col_id,
411
+ project_id=document.project_id)
412
+ .first())
413
+ if collection:
414
+ document.collections.append(collection)
415
+ cmd = database.Command.document_add(
416
+ self.current_user,
417
+ document,
418
+ )
419
+ self.db.add(cmd)
420
+ self.db.commit()
421
+ self.db.refresh(cmd)
422
+ self.application.notify_project(document.project_id, cmd)
423
+ return self.send_json({'id': document.id})
424
+ except validate.InvalidFormat as e:
425
+ logger.info("Error validating DocumentUpdate: %r", e)
426
+ return self.send_error_json(400, self.gettext(e.message))
427
+
428
+ @api_auth
429
+ @PROM_REQUESTS.sync('document_delete')
430
+ def delete(self, project_id, document_id):
431
+ document, privileges = self.get_document(project_id, document_id)
432
+ if not privileges.can_delete_document():
433
+ return self.send_error_json(403, self.gettext("Unauthorized"))
434
+ self.db.delete(document)
435
+ cmd = database.Command.document_delete(
436
+ self.current_user,
437
+ document,
438
+ )
439
+ self.db.add(cmd)
440
+ self.db.commit()
441
+ self.db.refresh(cmd)
442
+ self.application.notify_project(document.project_id, cmd)
443
+
444
+ self.set_status(204)
445
+ return self.finish()
446
+
447
+
448
+ class DocumentContents(BaseHandler):
449
+ @api_auth
450
+ @PROM_REQUESTS.sync('document_contents')
451
+ def get(self, project_id, document_id):
452
+ # Document contents are immutable. If we ever make a change to the
453
+ # format of this response, change this constant
454
+ version = 1
455
+
456
+ # Still, don't do it in desktop mode, because people might re-create
457
+ # their Taguette database sooner than their browser profile
458
+ if self.application.config['MULTIUSER']:
459
+ # Cache for a long time
460
+ self.set_header(
461
+ 'Cache-Control',
462
+ 'private,max-age=31536000,immutable',
463
+ )
464
+
465
+ # Use a fixed etag
466
+ try:
467
+ document_id = int(document_id)
468
+ except ValueError:
469
+ raise HTTPError(404)
470
+ self.set_header('Etag', '"doc-%d-%d"' % (document_id, version))
471
+
472
+ # Always return 304 if the client has a copy cached
473
+ # This means that access control is not enforced, however:
474
+ # * no content is sent
475
+ # * 304 is sent whether or not the document actually exists
476
+ if self.check_etag_header():
477
+ self.set_status(304)
478
+ self.set_header(
479
+ 'Content-Type',
480
+ 'application/json; charset=utf-8',
481
+ )
482
+ return self.finish()
483
+
484
+ document, _ = self.get_document(project_id, document_id, True)
485
+ return self.send_json({
486
+ 'contents': [
487
+ {'offset': 0, 'contents': document.contents},
488
+ ],
489
+ })
490
+
491
+
492
+ class TagAdd(BaseHandler):
493
+ @api_auth
494
+ @PROM_REQUESTS.sync('tag_add')
495
+ def post(self, project_id):
496
+ project, privileges = self.get_project(project_id)
497
+ if not privileges.can_add_tag():
498
+ return self.send_error_json(403, self.gettext("Unauthorized"))
499
+ try:
500
+ obj = self.get_json()
501
+
502
+ validate.tag_path(obj['path'])
503
+ validate.description(obj['description'])
504
+ validate.color(obj['color'])
505
+
506
+ tag = database.Tag(project=project,
507
+ parent_id=obj['parent_id'],
508
+ color=obj['color'],
509
+ path=obj['path'],
510
+ description=obj['description'])
511
+ try:
512
+ self.db.add(tag)
513
+ self.db.flush() # Need to flush to get tag.id
514
+ except IntegrityError:
515
+ self.db.rollback()
516
+ return self.send_error_json(409, "Conflict")
517
+ cmd = database.Command.tag_add(
518
+ self.current_user,
519
+ tag,
520
+ )
521
+ self.db.add(cmd)
522
+ self.db.commit()
523
+ self.db.refresh(cmd)
524
+ self.application.notify_project(project.id, cmd)
525
+
526
+ return self.send_json({'id': tag.id})
527
+ except validate.InvalidFormat as e:
528
+ logger.info("Error validating TagAdd: %r", e)
529
+ return self.send_error_json(400, self.gettext(e.message))
530
+
531
+
532
+ class TagUpdate(BaseHandler):
533
+ @api_auth
534
+ @PROM_REQUESTS.sync('tag_update')
535
+ def post(self, project_id, tag_id):
536
+ project, privileges = self.get_project(project_id)
537
+ if not privileges.can_update_tag():
538
+ return self.send_error_json(403, self.gettext("Unauthorized"))
539
+ try:
540
+ obj = self.get_json()
541
+ tag = self.db.query(database.Tag).get(int(tag_id))
542
+ if tag is None or tag.project_id != project.id:
543
+ return self.send_error_json(404, self.gettext("No such tag"))
544
+ if obj:
545
+ if 'path' in obj:
546
+ validate.tag_path(obj['path'])
547
+ tag.path = obj['path']
548
+ if 'description' in obj:
549
+ validate.description(obj['description'])
550
+ tag.description = obj['description']
551
+ if 'parent_id' in obj:
552
+ tag.parent_id = obj['parent_id']
553
+ if 'color' in obj:
554
+ validate.color(obj['color'])
555
+ tag.color = obj['color']
556
+ cmd = database.Command.tag_add(
557
+ self.current_user,
558
+ tag,
559
+ )
560
+ try:
561
+ self.db.add(cmd)
562
+ self.db.commit()
563
+ except IntegrityError:
564
+ self.db.rollback()
565
+ return self.send_error_json(409, "Conflict")
566
+ self.db.refresh(cmd)
567
+ self.application.notify_project(project.id, cmd)
568
+
569
+ return self.send_json({'id': tag.id})
570
+ except validate.InvalidFormat as e:
571
+ logger.info("Error validating TagUpdate: %r", e)
572
+ return self.send_error_json(400, self.gettext(e.message))
573
+
574
+ @api_auth
575
+ @PROM_REQUESTS.sync('tag_delete')
576
+ def delete(self, project_id, tag_id):
577
+ project, privileges = self.get_project(project_id)
578
+ if not privileges.can_delete_tag():
579
+ return self.send_error_json(403, self.gettext("Unauthorized"))
580
+ tag = self.db.query(database.Tag).get(int(tag_id))
581
+ if tag is None or tag.project_id != project.id:
582
+ return self.send_error_json(404, self.gettext("No such tag"))
583
+ self.db.delete(tag)
584
+ cmd = database.Command.tag_delete(
585
+ self.current_user,
586
+ project.id,
587
+ tag.id,
588
+ )
589
+ self.db.add(cmd)
590
+ self.db.commit()
591
+ self.db.refresh(cmd)
592
+ self.application.notify_project(project.id, cmd)
593
+
594
+ self.set_status(204)
595
+ return self.finish()
596
+
597
+
598
+ class TagMerge(BaseHandler):
599
+ @api_auth
600
+ @PROM_REQUESTS.sync('tag_merge')
601
+ def post(self, project_id):
602
+ project, privileges = self.get_project(project_id)
603
+ if not privileges.can_merge_tags():
604
+ return self.send_error_json(403, self.gettext("Unauthorized"))
605
+ obj = self.get_json()
606
+ tag_src = self.db.query(database.Tag).get(obj['src'])
607
+ tag_dest = self.db.query(database.Tag).get(obj['dest'])
608
+ if (
609
+ tag_src is None
610
+ or tag_src.project_id != project.id
611
+ or tag_dest is None
612
+ or tag_dest.project_id != project.id
613
+ ):
614
+ return self.send_error_json(404, self.gettext("No such tag"))
615
+
616
+ # Remove tag from tag_src if it's already in tag_dest
617
+ highlights_in_dest = (
618
+ self.db.query(database.highlight_tags.c.highlight_id)
619
+ .filter(database.highlight_tags.c.tag_id == tag_dest.id)
620
+ )
621
+ self.db.execute(
622
+ database.highlight_tags.delete(
623
+ and_(
624
+ database.highlight_tags.c.tag_id == tag_src.id,
625
+ database.highlight_tags.c.highlight_id.in_(
626
+ highlights_in_dest
627
+ ),
628
+ )
629
+ )
630
+ )
631
+ # Update tags that are in tag_src to be in tag_dest
632
+ self.db.execute(
633
+ database.highlight_tags.update()
634
+ .where(database.highlight_tags.c.tag_id == tag_src.id)
635
+ .values(tag_id=tag_dest.id)
636
+ )
637
+ self.db.query(database.Tag).filter(
638
+ database.Tag.parent_id == tag_src.id
639
+ ).update({database.Tag.parent_id: tag_dest.id})
640
+ self.db.delete(tag_src)
641
+
642
+ cmd = database.Command.tag_merge(
643
+ self.current_user,
644
+ project.id,
645
+ tag_src.id,
646
+ tag_dest.id,
647
+ )
648
+ self.db.add(cmd)
649
+ self.db.commit()
650
+ self.db.refresh(cmd)
651
+ self.application.notify_project(project.id, cmd)
652
+
653
+ return self.send_json({'id': tag_dest.id})
654
+
655
+
656
+ def calculate_tag_counts(db_session, tag_in_db, document, is_deletion=False):
657
+
658
+ # Increment or decrement the number of highlights associated with the tag
659
+ highlights_count = tag_in_db.highlights_count
660
+
661
+ # Get the list of associated document IDs, or an empty list if None
662
+ document_ids = tag_in_db.document_ids \
663
+ if tag_in_db.document_ids is not None else []
664
+ documents_count = len(document_ids)
665
+
666
+ if is_deletion:
667
+ highlights_count -= 1
668
+ else:
669
+ highlights_count += 1
670
+
671
+ if is_deletion:
672
+ # Check if there are still any highlights associated with the document
673
+ remaining_highlights = (
674
+ db_session.query(database.Highlight).join(
675
+ database.highlight_tags).filter(
676
+ database.highlight_tags.c.tag_id == tag_in_db.id,
677
+ database.Highlight.document_id == document.id
678
+ ).count())
679
+ if remaining_highlights == 0:
680
+ documents_count -= 1
681
+ else:
682
+ if document.id not in document_ids:
683
+ documents_count += 1
684
+
685
+ return {
686
+ 'highlights_count': highlights_count,
687
+ 'documents_count': documents_count
688
+ }
689
+
690
+
691
+ class HighlightAdd(BaseHandler):
692
+ @api_auth
693
+ @PROM_REQUESTS.sync('highlight_add')
694
+ def post(self, project_id, document_id):
695
+ document, privileges = self.get_document(project_id, document_id, True)
696
+ if not privileges.can_add_highlight():
697
+ return self.send_error_json(403, self.gettext("Unauthorized"))
698
+ obj = self.get_json()
699
+ start, end = obj['start_offset'], obj['end_offset']
700
+ new_tags = set(obj.get('tags', []))
701
+
702
+ # Check the tags exist and are in this project
703
+ tags = (
704
+ self.db.query(database.Tag)
705
+ .filter(database.Tag.id.in_(new_tags))
706
+ .filter(database.Tag.project_id == document.project_id)
707
+ .all()
708
+ )
709
+ snippet = extract.extract(document.contents, start, end)
710
+ if set(tag.id for tag in tags) != new_tags:
711
+ return self.send_error_json(400, self.gettext("No such tag"))
712
+
713
+ if all(c in '\r\n\t' for c in snippet):
714
+ return self.send_error_json(400, self.gettext("Empty highlight"))
715
+
716
+ hl = database.Highlight(document=document,
717
+ start_offset=start,
718
+ end_offset=end,
719
+ snippet=snippet)
720
+ self.db.add(hl)
721
+ self.db.flush() # Need to flush to get hl.id
722
+
723
+ # Insert tags in database
724
+ if new_tags:
725
+ self.db.execute(
726
+ database.highlight_tags.insert(),
727
+ [
728
+ dict(
729
+ highlight_id=hl.id,
730
+ tag_id=tag,
731
+ )
732
+ for tag in sorted(new_tags)
733
+ ],
734
+ )
735
+
736
+ tag_count_changes = {}
737
+ for tag in tags:
738
+ tag_in_db = self.db.query(database.Tag).get(tag.id)
739
+ tag_count_changes[tag.id] = calculate_tag_counts(self.db,
740
+ tag_in_db,
741
+ document)
742
+
743
+ cmd = database.Command.highlight_add(
744
+ self.current_user,
745
+ document,
746
+ hl,
747
+ sorted(new_tags),
748
+ )
749
+ cmd.tag_count_changes = tag_count_changes
750
+ self.db.add(cmd)
751
+ self.db.commit()
752
+ self.db.refresh(cmd)
753
+ self.application.notify_project(document.project_id, cmd)
754
+
755
+ return self.send_json({'id': hl.id})
756
+
757
+
758
+ class HighlightUpdate(BaseHandler):
759
+ @api_auth
760
+ @PROM_REQUESTS.sync('highlight_update')
761
+ def post(self, project_id, document_id, highlight_id):
762
+ document, privileges = self.get_document(project_id, document_id)
763
+ if not privileges.can_add_highlight():
764
+ return self.send_error_json(403, self.gettext("Unauthorized"))
765
+ obj = self.get_json()
766
+ hl = self.db.query(database.Highlight).get(int(highlight_id))
767
+ if hl is None or hl.document_id != document.id:
768
+ return self.send_error_json(404, self.gettext("No such highlight"))
769
+ if obj:
770
+ if 'start_offset' in obj:
771
+ hl.start_offset = obj['start_offset']
772
+ if 'end_offset' in obj:
773
+ hl.end_offset = obj['end_offset']
774
+ if 'tags' in obj:
775
+ # Obtain old tags from database
776
+ old_tags = set(
777
+ row[0]
778
+ for row in self.db.query(database.highlight_tags.c.tag_id)
779
+ .filter(database.highlight_tags.c.highlight_id == hl.id)
780
+ .all()
781
+ )
782
+ new_tags = set(obj['tags'])
783
+
784
+ # Check the tags exist and are in this project
785
+ tags = (
786
+ self.db.query(database.Tag)
787
+ .filter(database.Tag.id.in_(new_tags))
788
+ .filter(database.Tag.project_id == document.project_id)
789
+ .all()
790
+ )
791
+ if set(tag.id for tag in tags) != new_tags:
792
+ return self.send_error_json(
793
+ 400,
794
+ self.gettext("No such tag"),
795
+ )
796
+
797
+ # Update tags in database
798
+ self.db.execute(
799
+ database.highlight_tags.delete(
800
+ database.highlight_tags.c.highlight_id == hl.id
801
+ )
802
+ )
803
+ if new_tags:
804
+ self.db.execute(
805
+ database.highlight_tags.insert(),
806
+ [
807
+ dict(
808
+ highlight_id=hl.id,
809
+ tag_id=tag,
810
+ )
811
+ for tag in sorted(new_tags)
812
+ ],
813
+ )
814
+
815
+ # Compute the change in tag counts
816
+ tag_count_changes = {}
817
+ for tag in old_tags - new_tags:
818
+ tag_in_db = self.db.query(database.Tag).get(tag)
819
+ tag_count_changes[tag] = {
820
+ 'highlights_count': tag_in_db.highlights_count,
821
+ 'documents_count': tag_in_db.documents_count
822
+ }
823
+
824
+ for tag in new_tags - old_tags:
825
+ tag_in_db = self.db.query(database.Tag).get(tag)
826
+ tag_count_changes[tag] = calculate_tag_counts(self.db,
827
+ tag_in_db,
828
+ document)
829
+ else:
830
+ # Obtain old tags from database
831
+ new_tags = set(
832
+ row[0]
833
+ for row in self.db.query(database.highlight_tags.c.tag_id)
834
+ .filter(database.highlight_tags.c.highlight_id == hl.id)
835
+ .all()
836
+ )
837
+ tag_count_changes = {}
838
+
839
+ cmd = database.Command.highlight_add(
840
+ self.current_user,
841
+ document,
842
+ hl,
843
+ sorted(new_tags),
844
+ )
845
+ cmd.tag_count_changes = tag_count_changes
846
+ self.db.add(cmd)
847
+ self.db.commit()
848
+ self.db.refresh(cmd)
849
+ self.application.notify_project(document.project_id, cmd)
850
+
851
+ return self.send_json({'id': hl.id})
852
+
853
+ @api_auth
854
+ @PROM_REQUESTS.sync('highlight_delete')
855
+ def delete(self, project_id, document_id, highlight_id):
856
+ document, privileges = self.get_document(project_id, document_id)
857
+ if not privileges.can_delete_highlight():
858
+ return self.send_error_json(403, self.gettext("Unauthorized"))
859
+ hl = self.db.query(database.Highlight).get(int(highlight_id))
860
+ if hl is None or hl.document_id != document.id:
861
+ return self.send_error_json(404, self.gettext("No such highlight"))
862
+ old_tags = [
863
+ row[0]
864
+ for row in self.db.query(database.highlight_tags.c.tag_id)
865
+ .filter(database.highlight_tags.c.highlight_id == hl.id)
866
+ .all()
867
+ ]
868
+ self.db.delete(hl)
869
+ # Compute the change in tag counts for each old tag
870
+ tag_count_changes = {}
871
+ for tag in old_tags:
872
+ tag_in_db = self.db.query(database.Tag).get(tag)
873
+ tag_count_changes[tag] = calculate_tag_counts(self.db,
874
+ tag_in_db,
875
+ document,
876
+ is_deletion=True)
877
+
878
+ cmd = database.Command.highlight_delete(
879
+ self.current_user,
880
+ document,
881
+ hl.id,
882
+ )
883
+ cmd.tag_count_changes = tag_count_changes
884
+ self.db.add(cmd)
885
+ self.db.commit()
886
+ self.db.refresh(cmd)
887
+ self.application.notify_project(document.project_id, cmd)
888
+
889
+ self.set_status(204)
890
+ return self.finish()
891
+
892
+
893
+ class Highlights(BaseHandler):
894
+ PAGE_SIZE = 50
895
+
896
+ @api_auth
897
+ @PROM_REQUESTS.sync('highlights')
898
+ def get(self, project_id, paths):
899
+ project, _ = self.get_project(project_id)
900
+ page = self.get_query_argument('page', '1')
901
+ try:
902
+ page = int(page, 10) - 1
903
+ except (ValueError, OverflowError):
904
+ page = -1
905
+ if page < 0:
906
+ self.send_error_json(404, "Bad page number")
907
+
908
+ list_of_paths, operator_type, exclude_paths = get_path_informations(
909
+ paths)
910
+
911
+ tag = aliased(database.Tag)
912
+ hl_tag = aliased(database.highlight_tags)
913
+ document = aliased(database.Document)
914
+
915
+ # main query
916
+ query = (
917
+ self.db.query(database.Highlight, document.text_direction)
918
+ .options(joinedload(database.Highlight.tags))
919
+ .join(hl_tag, hl_tag.c.highlight_id == database.Highlight.id)
920
+ .join(tag, hl_tag.c.tag_id == tag.id)
921
+ .join(document, document.id == database.Highlight.document_id)
922
+ .filter(tag.project == project)
923
+ )
924
+
925
+ if any(list_of_paths) and operator_type == 'or':
926
+ query = query.filter(tag.path.in_(list_of_paths))
927
+ elif any(list_of_paths) and operator_type == 'and':
928
+ # Spatial AND: highlight must have or overlap each tag
929
+ query = (
930
+ self.db.query(database.Highlight, document.text_direction)
931
+ .options(subqueryload(database.Highlight.tags))
932
+ .join(document,
933
+ document.id == database.Highlight.document_id)
934
+ .filter(document.project == project)
935
+ )
936
+
937
+ for path in list_of_paths:
938
+ hl2 = aliased(database.Highlight)
939
+ hl_tag2 = aliased(database.highlight_tags)
940
+ tag2 = aliased(database.Tag)
941
+ has_tag = (
942
+ self.db.query(hl_tag.c.highlight_id)
943
+ .join(tag, hl_tag.c.tag_id == tag.id)
944
+ .filter(
945
+ hl_tag.c.highlight_id
946
+ == database.Highlight.id,
947
+ tag.path == path,
948
+ tag.project == project,
949
+ )
950
+ .correlate(database.Highlight)
951
+ .exists()
952
+ )
953
+ overlap_other = (
954
+ self.db.query(hl2.id)
955
+ .join(hl_tag2,
956
+ hl_tag2.c.highlight_id == hl2.id)
957
+ .join(tag2,
958
+ hl_tag2.c.tag_id == tag2.id)
959
+ .filter(
960
+ tag2.path == path,
961
+ tag2.project == project,
962
+ hl2.document_id
963
+ == database.Highlight.document_id,
964
+ hl2.start_offset
965
+ < database.Highlight.end_offset,
966
+ hl2.end_offset
967
+ > database.Highlight.start_offset,
968
+ hl2.id != database.Highlight.id,
969
+ )
970
+ .correlate(database.Highlight)
971
+ .exists()
972
+ )
973
+ query = query.filter(or_(has_tag, overlap_other))
974
+ elif any(list_of_paths) and operator_type == 'mixed':
975
+ or_conditions = []
976
+ # check to remove ! if necessary
977
+ for group in list_of_paths:
978
+ if len(group) == 1:
979
+ or_conditions.append(tag.path == group[0])
980
+ else:
981
+ and_subquery = (self.db.query(hl_tag.c.highlight_id)
982
+ .join(tag, hl_tag.c.tag_id == tag.id)
983
+ .filter(tag.project == project)
984
+ .filter(tag.path.in_(group))
985
+ .group_by(hl_tag.c.highlight_id)
986
+ .having(
987
+ func.count(
988
+ distinct(tag.id)) == len(group))
989
+ .subquery())
990
+ or_conditions.append(database.Highlight.id.in_(
991
+ self.db.query(and_subquery.c.highlight_id)))
992
+ if or_conditions:
993
+ query = query.filter(or_(*or_conditions))
994
+ else:
995
+ # Special case to select all highlights: we also need to select
996
+ # highlights that have no tag at all
997
+ query = (
998
+ self.db.query(database.Highlight, document.text_direction)
999
+ .options(joinedload(database.Highlight.tags))
1000
+ .join(document, document.id == database.Highlight.document_id)
1001
+ .filter(document.project == project)
1002
+ )
1003
+
1004
+ # Handle exclusions
1005
+ if any(exclude_paths):
1006
+ excl_tag = aliased(database.Tag)
1007
+ excl_hl_tag = database.highlight_tags.alias()
1008
+ exclude_subquery = (
1009
+ self.db.query(excl_hl_tag.c.highlight_id)
1010
+ .join(excl_tag,
1011
+ excl_hl_tag.c.tag_id == excl_tag.id)
1012
+ .filter(excl_tag.path.in_(exclude_paths),
1013
+ excl_tag.project == project)
1014
+ .subquery())
1015
+ query = query.filter(
1016
+ not_(database.Highlight.id.in_(exclude_subquery)))
1017
+
1018
+ query = query.order_by(
1019
+ database.Highlight.document_id,
1020
+ database.Highlight.start_offset
1021
+ )
1022
+
1023
+ # Deduplicate results
1024
+ all_results = query.all()
1025
+ seen_ids = set()
1026
+ unique_results = []
1027
+ for hl, direction in all_results:
1028
+ if hl.id not in seen_ids:
1029
+ seen_ids.add(hl.id)
1030
+ unique_results.append((hl, direction))
1031
+
1032
+ total = len(unique_results)
1033
+ highlights = unique_results[
1034
+ page * self.PAGE_SIZE:(page + 1) * self.PAGE_SIZE
1035
+ ]
1036
+
1037
+ # Group tags from identical, contained, or containing highlights
1038
+ result_highlights = []
1039
+ grouped_hl_ids = set()
1040
+ excluded = set(exclude_paths or [])
1041
+ for hl, direction in highlights:
1042
+ # Skip if already grouped with a previous highlight
1043
+ if hl.id in grouped_hl_ids:
1044
+ continue
1045
+ all_tag_ids = set(t.id for t in hl.tags if t.path not in excluded)
1046
+
1047
+ # Find related highlights: any overlap
1048
+ # (identical, contained, containing, or partial)
1049
+ related_highlights = (
1050
+ self.db.query(database.Highlight)
1051
+ .options(joinedload(database.Highlight.tags))
1052
+ .filter(
1053
+ database.Highlight.document_id == hl.document_id,
1054
+ database.Highlight.id != hl.id,
1055
+ database.Highlight.start_offset < hl.end_offset,
1056
+ database.Highlight.end_offset > hl.start_offset,
1057
+ )
1058
+ .all()
1059
+ )
1060
+
1061
+ # Skip if any overlapping highlight carries an excluded tag
1062
+ if excluded and any(
1063
+ t.path in excluded
1064
+ for related_hl in related_highlights
1065
+ for t in related_hl.tags
1066
+ ):
1067
+ for related_hl in related_highlights:
1068
+ grouped_hl_ids.add(related_hl.id)
1069
+ continue
1070
+
1071
+ for related_hl in related_highlights:
1072
+ all_tag_ids.update(
1073
+ t.id for t in related_hl.tags if t.path not in excluded
1074
+ )
1075
+ grouped_hl_ids.add(related_hl.id)
1076
+
1077
+ # Find the union range across all overlapping highlights
1078
+ all_highlights = [hl] + list(related_highlights)
1079
+ union_start = min(h.start_offset for h in all_highlights)
1080
+ union_end = max(h.end_offset for h in all_highlights)
1081
+
1082
+ # Try to find a single highlight that covers the full union
1083
+ largest_hl = None
1084
+ for h in all_highlights:
1085
+ if h.start_offset == union_start and h.end_offset == union_end:
1086
+ largest_hl = h
1087
+ break
1088
+
1089
+ if largest_hl is not None:
1090
+ content = largest_hl.snippet
1091
+ else:
1092
+ # Partial overlap: extract union content from the document
1093
+ doc = self.db.query(database.Document).get(hl.document_id)
1094
+ if doc and doc.contents:
1095
+ content = extract.extract(doc.contents,
1096
+ union_start, union_end)
1097
+ else:
1098
+ # Fallback to the current highlight's snippet
1099
+ content = hl.snippet
1100
+
1101
+ # Build tag_ranges: for each highlight, compute relative offsets
1102
+ tag_ranges = []
1103
+ tag_color_map = {}
1104
+ for h in all_highlights:
1105
+ for t in h.tags:
1106
+ if t.path in excluded:
1107
+ continue
1108
+ tag_color_map[t.id] = t.color
1109
+ tag_ranges.append({
1110
+ 'tag_id': t.id,
1111
+ 'color': t.color,
1112
+ 'start': h.start_offset - union_start,
1113
+ 'end': h.end_offset - union_start,
1114
+ })
1115
+
1116
+ # Deduplicate tag_ranges (same tag_id + start + end)
1117
+ seen = set()
1118
+ unique_tag_ranges = []
1119
+ for tr in tag_ranges:
1120
+ key = (tr['tag_id'], tr['start'], tr['end'])
1121
+ if key not in seen:
1122
+ seen.add(key)
1123
+ unique_tag_ranges.append(tr)
1124
+
1125
+ result_highlights.append({
1126
+ 'id': hl.id,
1127
+ 'document_id': hl.document_id,
1128
+ 'content': content,
1129
+ 'tags': sorted(all_tag_ids),
1130
+ 'text_direction': direction.name,
1131
+ 'tag_ranges': sorted(
1132
+ unique_tag_ranges,
1133
+ key=lambda tr: tr['tag_id'],
1134
+ ),
1135
+ })
1136
+
1137
+ return self.send_json({
1138
+ 'highlights': result_highlights,
1139
+ 'pages': math.ceil(total / self.PAGE_SIZE),
1140
+ })
1141
+
1142
+
1143
+ class Search(BaseHandler):
1144
+ PAGE_SIZE = 5
1145
+
1146
+ @api_auth
1147
+ @PROM_REQUESTS.sync('search')
1148
+ def get(self, project_id, search_text):
1149
+ project, _ = self.get_project(project_id)
1150
+ page = self.get_query_argument('page', '1')
1151
+ try:
1152
+ page = int(page, 10) - 1
1153
+ except (ValueError, OverflowError):
1154
+ page = -1
1155
+ if page < 0:
1156
+ self.send_error_json(404, self.gettext("Bad page number"))
1157
+
1158
+ search_text_escaped = f"%{search_text}%"
1159
+ count_total = self.db.execute(
1160
+ 'SELECT count(d.id) as count '
1161
+ 'FROM documents as d '
1162
+ 'WHERE d.project_id = :project_id AND '
1163
+ 'LOWER(d.contents) like :search_text_escaped',
1164
+ {
1165
+ 'project_id': project_id,
1166
+ 'search_text_escaped': search_text_escaped,
1167
+ })
1168
+ total = (count_total.first())['count']
1169
+ # TODO: find a way to do this within SQLALchemy..
1170
+ char_length = 'CHAR_LENGTH'
1171
+ if self.application.config['DATABASE'].startswith('sqlite:'):
1172
+ char_length = 'LENGTH'
1173
+ result = self.db.execute(
1174
+ 'SELECT d.id, d.name, d.contents, '
1175
+ f'(({char_length}(d.contents) - '
1176
+ f'{char_length}(REPLACE(LOWER(d.contents), :search_text, \'\'))) '
1177
+ f'/ {char_length}(:search_text)) as occurences '
1178
+ 'FROM documents as d '
1179
+ 'WHERE d.project_id = :project_id AND '
1180
+ 'LOWER(d.contents) like :search_text_escaped '
1181
+ 'ORDER BY occurences DESC '
1182
+ 'LIMIT :per_page OFFSET :start', {
1183
+ 'project_id': project_id,
1184
+ 'search_text': search_text,
1185
+ 'search_text_escaped': search_text_escaped,
1186
+ 'start': page * self.PAGE_SIZE,
1187
+ 'per_page': self.PAGE_SIZE
1188
+ })
1189
+ return self.send_json({
1190
+ 'documents': [
1191
+ {
1192
+ 'id': d['id'],
1193
+ 'name': d['name'],
1194
+ 'occurences': d['occurences'],
1195
+ 'contents': re.sub('<[^<]+?>', '', d['contents']),
1196
+ }
1197
+ for d in result
1198
+ ],
1199
+ 'nb_results': total,
1200
+ 'pages': math.ceil(total / self.PAGE_SIZE),
1201
+ 'search_text': search_text
1202
+ })
1203
+
1204
+
1205
+ class PermissionsUpdate(BaseHandler):
1206
+ @api_auth
1207
+ @PROM_REQUESTS.sync('permissions_update')
1208
+ def patch(self):
1209
+ user = self.db.query(database.User).get(self.current_user)
1210
+ if not user.role.can_change_role():
1211
+ return self.send_error_json(403, self.gettext(
1212
+ "Unauthorized",
1213
+ ))
1214
+ if not self.application.config['MULTIUSER']:
1215
+ raise HTTPError(404)
1216
+ obj = self.get_json()
1217
+
1218
+ for login, user_info in obj.items():
1219
+ try:
1220
+ login = validate.fix_user_login(login)
1221
+ except validate.InvalidFormat as e:
1222
+ return self.send_error_json(400, self.gettext(e.message))
1223
+
1224
+ try:
1225
+ role = database.Roles[user_info['privileges']]
1226
+ if role not in (database.Roles['TEAM_MEMBER'],
1227
+ database.Roles['PROJECT_OWNER']):
1228
+ return self.send_error_json(400, self.gettext(
1229
+ "Bad request",
1230
+ ))
1231
+ except KeyError:
1232
+ return self.send_error_json(
1233
+ 400,
1234
+ "Invalid role %r" % user_info.get('privileges'),
1235
+ )
1236
+ user_to_update = self.db.query(database.User).get(login)
1237
+ user_to_update.role = role
1238
+ if role == database.Roles.TEAM_MEMBER:
1239
+ user_to_update.approved_by_user_login = None
1240
+ else:
1241
+ user_to_update.approved_by_user_login = self.current_user
1242
+
1243
+ self.db.commit()
1244
+ self.set_status(204)
1245
+ return self.finish()
1246
+
1247
+
1248
+ class MembersUpdate(BaseHandler):
1249
+ @api_auth
1250
+ @PROM_REQUESTS.sync('members_update')
1251
+ def patch(self, project_id):
1252
+ if not self.application.config['MULTIUSER']:
1253
+ raise HTTPError(404)
1254
+ obj = self.get_json()
1255
+ project, privileges = self.get_project(project_id)
1256
+
1257
+ if (obj.keys() == {self.current_user} and
1258
+ not obj[self.current_user]):
1259
+ # Special case: you are always allowed to remove yourself
1260
+ pass
1261
+ elif not privileges.can_edit_members():
1262
+ return self.send_error_json(403, self.gettext("Unauthorized"))
1263
+
1264
+ # Get all members
1265
+ members = (
1266
+ self.db.query(database.ProjectMember)
1267
+ .filter(database.ProjectMember.project_id == project.id)
1268
+ ).all()
1269
+ members = {member.user_login: member for member in members}
1270
+
1271
+ # Go over the JSON patch and update
1272
+ commands = []
1273
+ for login, user_info in obj.items():
1274
+ try:
1275
+ login = validate.fix_user_login(login)
1276
+ except validate.InvalidFormat as e:
1277
+ return self.send_error_json(400, self.gettext(e.message))
1278
+ if not user_info:
1279
+ if login in members:
1280
+ logger.info("Removing member %r from project %d (%s)",
1281
+ login, project.id, members[login].privileges)
1282
+ self.db.delete(members.pop(login))
1283
+ cmd = database.Command.member_remove(
1284
+ self.current_user, project.id,
1285
+ login,
1286
+ )
1287
+ self.db.add(cmd)
1288
+ commands.append(cmd)
1289
+ else:
1290
+ try:
1291
+ privileges = database.Privileges[user_info['privileges']]
1292
+ except KeyError:
1293
+ return self.send_error_json(
1294
+ 400,
1295
+ "Invalid privileges %r" % user_info.get('privileges'),
1296
+ )
1297
+ if login in members:
1298
+ logger.info("Changing member %r in project %d: %s -> %s",
1299
+ login, project.id, members[login].privileges,
1300
+ privileges)
1301
+ members[login].privileges = privileges
1302
+ else:
1303
+ logger.info("Adding member %r to project %d (%s)",
1304
+ login, project.id, privileges)
1305
+ member = database.ProjectMember(project=project,
1306
+ user_login=login,
1307
+ privileges=privileges)
1308
+ members[login] = member
1309
+ self.db.add(member)
1310
+ cmd = database.Command.member_add(
1311
+ self.current_user, project.id,
1312
+ login, privileges,
1313
+ )
1314
+ self.db.add(cmd)
1315
+ commands.append(cmd)
1316
+
1317
+ # Check that there are still admins
1318
+ for member in members.values():
1319
+ if member.privileges == database.Privileges.ADMIN:
1320
+ break
1321
+ else:
1322
+ self.db.rollback()
1323
+ return self.send_error_json(
1324
+ 400,
1325
+ self.gettext("There must be one admin"),
1326
+ )
1327
+
1328
+ self.db.commit()
1329
+ for cmd in commands:
1330
+ self.db.refresh(cmd)
1331
+ self.application.notify_project(project.id, cmd)
1332
+
1333
+ self.set_status(204)
1334
+ return self.finish()
1335
+
1336
+
1337
+ class ProjectImport(BaseHandler):
1338
+ def prepare(self):
1339
+ user = self.db.query(database.User).get(self.current_user)
1340
+ if (not self.application.config['SQLITE3_IMPORT_ENABLED'] or
1341
+ not user.role.can_create_project()):
1342
+ raise HTTPError(403)
1343
+
1344
+ @api_auth
1345
+ @PROM_REQUESTS.sync('project_import')
1346
+ async def post(self):
1347
+ try:
1348
+ file = self.request.files['file'][0]
1349
+ except (KeyError, IndexError):
1350
+ raise MissingArgumentError('file')
1351
+
1352
+ with tempfile.TemporaryDirectory(
1353
+ prefix='taguette_export_',
1354
+ ) as tmp_dir:
1355
+ filename = os.path.join(tmp_dir, 'db.sqlite3')
1356
+
1357
+ # Write the database to temporary file
1358
+ with open(filename, 'wb') as fp:
1359
+ fp.write(file.body)
1360
+
1361
+ project_id = self.get_body_argument('project_id', None)
1362
+ if project_id is None:
1363
+ return await self._list_projects(filename)
1364
+ else:
1365
+ try:
1366
+ project_id = int(project_id)
1367
+ except ValueError:
1368
+ self.set_status(400)
1369
+ return await self.send_json({
1370
+ 'error': "Invalid project ID",
1371
+ })
1372
+ return await self._import_project(filename, project_id)
1373
+
1374
+ async def _list_projects(self, filename):
1375
+ # Connect to the database
1376
+ try:
1377
+ src_db = database.connect(
1378
+ 'sqlite:///%s' % filename,
1379
+ external=True,
1380
+ )()
1381
+ except database.UnknownVersion:
1382
+ return self.send_error_json(400, self.gettext(
1383
+ "This database is an unknown version. It might have been "
1384
+ + "written by a more recent version of Taguette, please check "
1385
+ + "for updates",
1386
+ ))
1387
+ except alembic.util.exc.CommandError:
1388
+ return self.send_error_json(500, self.gettext(
1389
+ "This database could not be updated to the current version",
1390
+ ))
1391
+ except (DatabaseError, NoSuchTableError):
1392
+ return self.send_error_json(400, self.gettext(
1393
+ "This is not a Taguette project file",
1394
+ ))
1395
+
1396
+ # List projects
1397
+ projects = src_db.execute(database.Project.__table__.select())
1398
+ output = False
1399
+ for i, row in enumerate(projects):
1400
+ if not output:
1401
+ self.set_header(
1402
+ 'Content-Type', 'application/json; charset=utf-8',
1403
+ )
1404
+ self.write('{"projects": [')
1405
+ output = True
1406
+ else:
1407
+ self.write(',')
1408
+ self.write(json.dumps({
1409
+ 'id': row['id'],
1410
+ 'name': row['name'],
1411
+ }))
1412
+ if i == 100:
1413
+ await self.flush()
1414
+ if not output:
1415
+ self.write('{"projects": [')
1416
+ return await self.finish(']}')
1417
+
1418
+ async def _import_project(self, filename, project_id):
1419
+ # Connect to the database
1420
+ try:
1421
+ src_db = database.connect(
1422
+ 'sqlite:///%s' % filename,
1423
+ external=True,
1424
+ )()
1425
+ except (DatabaseError, NoSuchTableError):
1426
+ return self.send_error_json(400, self.gettext(
1427
+ "This is not a Taguette project file",
1428
+ ))
1429
+
1430
+ # Copy data
1431
+ new_project_id = database.copy_project(
1432
+ src_db, self.db,
1433
+ project_id, self.current_user,
1434
+ )
1435
+ src_db.close()
1436
+
1437
+ # Insert a command for the import
1438
+ self.db.add(
1439
+ database.Command.project_import(self.current_user, new_project_id)
1440
+ )
1441
+
1442
+ self.db.commit()
1443
+
1444
+ return await self.send_json({'project_id': new_project_id})
1445
+
1446
+
1447
+ class TooManyCommands(Exception):
1448
+ """There are too many commands, have the client reload instead.
1449
+ """
1450
+
1451
+
1452
+ class ProjectEvents(BaseHandler):
1453
+ response_cancelled = False
1454
+ polling_clients = set()
1455
+ PROM_POLLING_CLIENTS.set_function(
1456
+ lambda: len(ProjectEvents.polling_clients)
1457
+ )
1458
+
1459
+ wait_future = None
1460
+
1461
+ @api_auth
1462
+ @PROM_REQUESTS.async_('events')
1463
+ async def get(self, project_id):
1464
+ ProjectEvents.polling_clients.add(self.request.remote_ip)
1465
+ tornado.log.access_log.info(
1466
+ "started %s %s (%s) (%s)",
1467
+ self.request.method,
1468
+ self.request.uri,
1469
+ self.request.remote_ip,
1470
+ self.current_user,
1471
+ )
1472
+
1473
+ client_version = self.get_query_argument('version', None)
1474
+ if client_version != exact_version():
1475
+ return await self.send_json({'reload': True})
1476
+
1477
+ from_id = int(self.get_query_argument('from'))
1478
+ project, _ = self.get_project(project_id)
1479
+ self.project_id = int(project_id)
1480
+
1481
+ try:
1482
+ cmds_json = await self._get_commands(project.id, from_id)
1483
+ except TooManyCommands:
1484
+ return await self.send_json({'reload': True})
1485
+ except asyncio.CancelledError:
1486
+ return
1487
+
1488
+ # Remove 'project_id' from each event
1489
+ def _change_cmd_json(old):
1490
+ new = dict(old)
1491
+ new.pop('project_id')
1492
+ return new
1493
+
1494
+ cmds_json = [_change_cmd_json(cmd) for cmd in cmds_json]
1495
+
1496
+ tornado.log.access_log.info(cmds_json)
1497
+
1498
+ return await self.send_json({'events': cmds_json})
1499
+
1500
+ async def _get_commands(self, project_id, from_id):
1501
+ # Limit over which we won't send update but rather reload the frontend
1502
+ LIMIT = 20
1503
+
1504
+ # Check for immediate update
1505
+ cmds = (
1506
+ self.db.query(database.Command)
1507
+ .filter(database.Command.id > from_id)
1508
+ .filter(database.Command.project_id == project_id)
1509
+ .limit(LIMIT)
1510
+ ).all()
1511
+
1512
+ if len(cmds) >= LIMIT:
1513
+ raise TooManyCommands
1514
+
1515
+ if cmds:
1516
+ # Convert to JSON, return
1517
+ return [cmd.to_json() for cmd in cmds]
1518
+
1519
+ # Subscribe for events (which come as JSON)
1520
+ self.wait_future = Future()
1521
+ recheck = await self.application.observe_project(
1522
+ project_id,
1523
+ self.wait_future,
1524
+ )
1525
+
1526
+ # Check again, if necessary
1527
+ if recheck:
1528
+ cmds = (
1529
+ self.db.query(database.Command)
1530
+ .filter(database.Command.id > from_id)
1531
+ .filter(database.Command.project_id == project_id)
1532
+ .limit(LIMIT)
1533
+ ).all()
1534
+ if cmds:
1535
+ # Events happened while we were subscribing:
1536
+ # Cancel subscription, return them
1537
+ self.wait_future.cancel()
1538
+ self.application.unobserve_project(
1539
+ self.project_id,
1540
+ self.wait_future,
1541
+ )
1542
+ self.wait_future = None
1543
+ return [cmd.to_json() for cmd in cmds]
1544
+
1545
+ self.db.expire_all()
1546
+
1547
+ # Close DB connection to not overflow the connection pool
1548
+ self.close_db_connection()
1549
+
1550
+ return [await self.wait_future]
1551
+
1552
+ def on_connection_close(self):
1553
+ self.response_cancelled = True
1554
+ if self.wait_future:
1555
+ self.wait_future.cancel()
1556
+ self.application.unobserve_project(self.project_id, self.wait_future)
1557
+
1558
+ def on_finish(self):
1559
+ super(ProjectEvents, self).on_finish()
1560
+ ProjectEvents.polling_clients.discard(self.request.remote_ip)
1561
+
1562
+ def _log(self):
1563
+ if not self.response_cancelled:
1564
+ self.application.log_request(self)
1565
+ else:
1566
+ tornado.log.access_log.info(
1567
+ "aborted %s %s (%s) %.2fms",
1568
+ self.request.method,
1569
+ self.request.uri,
1570
+ self.request.remote_ip,
1571
+ 1000.0 * self.request.request_time(),
1572
+ )
1573
+
1574
+
1575
+ class CreateCollection(BaseHandler):
1576
+ @api_auth
1577
+ @PROM_REQUESTS.sync('create_collection')
1578
+ def post(self, project_id):
1579
+ project, privileges = self.get_project(project_id)
1580
+ if not privileges.can_add_document():
1581
+ return self.send_error_json(403, self.gettext("Unauthorized"))
1582
+ try:
1583
+ obj = self.get_json()
1584
+ name = obj.get('name', '').strip()
1585
+ description = obj.get('description', '').strip(
1586
+ ) if 'description' in obj else ''
1587
+ parent_id = obj.get('parent_id', None)
1588
+
1589
+ if not name:
1590
+ return self.send_error_json(
1591
+ 400,
1592
+ self.gettext("Collection name is required"))
1593
+
1594
+ if parent_id is not None:
1595
+ parent_collection = (self.db.query(database.Collection)
1596
+ .filter_by(
1597
+ id=parent_id,
1598
+ project_id=project.id)
1599
+ .first())
1600
+ if not parent_collection:
1601
+ return self.send_error_json(
1602
+ 400,
1603
+ self.gettext("Parent collection not found"))
1604
+
1605
+ collection = database.Collection(
1606
+ name=name,
1607
+ description=description,
1608
+ project_id=project.id,
1609
+ owner_login=self.current_user,
1610
+ parent_id=parent_id
1611
+ )
1612
+ self.db.add(collection)
1613
+ self.db.flush()
1614
+ document_ids = obj.get('document_ids', [])
1615
+ for doc_id in document_ids:
1616
+ document = self.db.query(database.Document).filter_by(
1617
+ id=doc_id, project_id=project.id).first()
1618
+ if document:
1619
+ document.collections.clear()
1620
+ collection.documents.append(document)
1621
+
1622
+ cmd = database.Command.collection_add(
1623
+ self.current_user,
1624
+ collection,
1625
+ )
1626
+ self.db.add(cmd)
1627
+ self.db.commit()
1628
+ self.db.refresh(cmd)
1629
+ self.application.notify_project(project.id, cmd)
1630
+ self.db.refresh(collection)
1631
+ return self.send_json({
1632
+ 'id': collection.id,
1633
+ 'name': collection.name,
1634
+ 'description': collection.description,
1635
+ 'created': collection.created.isoformat(),
1636
+ })
1637
+ except Exception as e:
1638
+ logger.exception("Error creating collection")
1639
+ return self.send_error_json(
1640
+ 500, self.gettext("Internal error: ") + str(e))
1641
+
1642
+
1643
+ class CollectionUpdate(BaseHandler):
1644
+ @api_auth
1645
+ @PROM_REQUESTS.sync('collection_update')
1646
+ def post(self, project_id, collection_id):
1647
+ project, privileges = self.get_project(project_id)
1648
+ if not privileges.can_edit_document():
1649
+ return self.send_error_json(403, self.gettext("Unauthorized"))
1650
+ try:
1651
+ obj = self.get_json()
1652
+ collection = (self.db.query(database.Collection)
1653
+ .get(int(collection_id)))
1654
+ if collection is None or collection.project_id != project.id:
1655
+ return self.send_error_json(
1656
+ 404,
1657
+ self.gettext("No such collection"))
1658
+ if obj:
1659
+ if 'name' in obj:
1660
+ if not obj['name'].strip():
1661
+ return self.send_error_json(
1662
+ 400,
1663
+ self.gettext("Collection name is required"))
1664
+ collection.name = obj['name'].strip()
1665
+ if 'description' in obj:
1666
+ validate.description(obj['description'])
1667
+ collection.description = obj['description']
1668
+ if 'parent_id' in obj:
1669
+ parent_id = obj['parent_id']
1670
+ if parent_id is not None:
1671
+ parent_collection = (self.db.query(database.Collection)
1672
+ .filter_by(
1673
+ id=parent_id,
1674
+ project_id=project.id)
1675
+ .first())
1676
+ if not parent_collection:
1677
+ return self.send_error_json(
1678
+ 400,
1679
+ self.gettext("Parent collection not found"))
1680
+ if parent_id == collection.id:
1681
+ return self.send_error_json(
1682
+ 400,
1683
+ self.gettext("Collection cannot be its own "
1684
+ "parent"))
1685
+ if self._would_create_circular_reference(
1686
+ collection, parent_id):
1687
+ return self.send_error_json(
1688
+ 400,
1689
+ self.gettext("Would create circular "
1690
+ "reference"))
1691
+ collection.parent_id = parent_id
1692
+ if 'document_ids' in obj:
1693
+ collection.documents = []
1694
+ for doc_id in obj['document_ids']:
1695
+ document = (self.db.query(database.Document)
1696
+ .filter_by(
1697
+ id=doc_id,
1698
+ project_id=project.id)
1699
+ .first())
1700
+ if document:
1701
+ collection.documents.append(document)
1702
+
1703
+ cmd = database.Command.collection_update(
1704
+ self.current_user,
1705
+ collection,
1706
+ )
1707
+ self.db.add(cmd)
1708
+ self.db.commit()
1709
+ self.db.refresh(cmd)
1710
+ self.application.notify_project(project.id, cmd)
1711
+ self.db.refresh(collection)
1712
+
1713
+ return self.send_json({
1714
+ 'id': collection.id,
1715
+ 'name': collection.name,
1716
+ 'description': collection.description,
1717
+ 'parent_id': collection.parent_id,
1718
+ 'document_ids': [d.id for d in collection.documents]
1719
+ })
1720
+ except validate.InvalidFormat as e:
1721
+ logger.info("Error validating CollectionUpdate: %r", e)
1722
+ return self.send_error_json(400, self.gettext(e.message))
1723
+ except Exception as e:
1724
+ logger.exception("Error updating collection")
1725
+ return self.send_error_json(
1726
+ 500, self.gettext("Internal error: ") + str(e))
1727
+
1728
+ def _would_create_circular_reference(self, collection, new_parent_id):
1729
+ # Prevent loops: A → B → C → A
1730
+ current_id = new_parent_id
1731
+ while current_id is not None:
1732
+ if current_id == collection.id: # Loop detected!
1733
+ return True
1734
+ parent_collection = (self.db.query(database.Collection)
1735
+ .filter_by(
1736
+ id=current_id,
1737
+ project_id=collection.project_id)
1738
+ .first())
1739
+ if not parent_collection:
1740
+ break
1741
+ current_id = parent_collection.parent_id # Go up hierarchy
1742
+ return False
1743
+
1744
+ @api_auth
1745
+ @PROM_REQUESTS.sync('collection_delete')
1746
+ def delete(self, project_id, collection_id):
1747
+ project, privileges = self.get_project(project_id)
1748
+ if not privileges.can_edit_document():
1749
+ return self.send_error_json(403, self.gettext("Unauthorized"))
1750
+ collection = (self.db.query(database.Collection)
1751
+ .get(int(collection_id)))
1752
+ if collection is None or collection.project_id != project.id:
1753
+ return self.send_error_json(404,
1754
+ self.gettext("No such collection"))
1755
+
1756
+ delete_mode = self.get_argument('delete_mode', default=None)
1757
+
1758
+ children = (self.db.query(database.Collection)
1759
+ .options(joinedload(database.Collection.documents))
1760
+ .filter_by(parent_id=collection.id)
1761
+ .all())
1762
+
1763
+ if children and delete_mode is None:
1764
+ return self.send_error_json(
1765
+ 400, self.gettext("Cannot delete collection with "
1766
+ "subcollections. "
1767
+ "Please specify delete_mode."))
1768
+
1769
+ commands_to_notify = []
1770
+
1771
+ if delete_mode == 'cascade':
1772
+ commands_to_notify = self._delete_collection_cascade(collection)
1773
+ elif delete_mode == 'orphan':
1774
+ for child in children:
1775
+ child.parent_id = None
1776
+ self.db.add(child)
1777
+
1778
+ self.db.flush()
1779
+
1780
+ for child in children:
1781
+ self.db.refresh(child)
1782
+ update_cmd = database.Command.collection_update(
1783
+ self.current_user,
1784
+ child,
1785
+ )
1786
+ self.db.add(update_cmd)
1787
+ commands_to_notify.append(update_cmd)
1788
+
1789
+ cmd = database.Command.collection_delete(
1790
+ self.current_user,
1791
+ collection,
1792
+ )
1793
+ self.db.add(cmd)
1794
+ self.db.delete(collection)
1795
+ commands_to_notify.append(cmd)
1796
+ else:
1797
+ cmd = database.Command.collection_delete(
1798
+ self.current_user,
1799
+ collection,
1800
+ )
1801
+ self.db.add(cmd)
1802
+ self.db.delete(collection)
1803
+ commands_to_notify = [cmd]
1804
+
1805
+ self.db.commit()
1806
+
1807
+ for cmd in commands_to_notify:
1808
+ self.db.refresh(cmd)
1809
+ self.application.notify_project(project.id, cmd)
1810
+
1811
+ self.set_status(204)
1812
+ return self.finish()
1813
+
1814
+ def _delete_collection_cascade(self, collection):
1815
+
1816
+ commands = []
1817
+
1818
+ children = (self.db.query(database.Collection)
1819
+ .filter_by(parent_id=collection.id)
1820
+ .all())
1821
+
1822
+ for child in children:
1823
+ child_commands = self._delete_collection_cascade(child)
1824
+ commands.extend(child_commands)
1825
+
1826
+ cmd = database.Command.collection_delete(
1827
+ self.current_user,
1828
+ collection,
1829
+ )
1830
+ self.db.add(cmd)
1831
+ commands.append(cmd)
1832
+
1833
+ self.db.delete(collection)
1834
+
1835
+ return commands