libreqda 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CHANGELOG.md +481 -0
- libreqda-1.2.0.dist-info/LICENSE.txt +28 -0
- libreqda-1.2.0.dist-info/METADATA +131 -0
- libreqda-1.2.0.dist-info/RECORD +255 -0
- libreqda-1.2.0.dist-info/WHEEL +4 -0
- libreqda-1.2.0.dist-info/entry_points.txt +4 -0
- scripts/babelrc +8 -0
- scripts/dist/LICENSE.txt +33 -0
- scripts/dist/RELEASE.txt +23 -0
- scripts/dist/macos/libreqda.icns +0 -0
- scripts/dist/macos/macos.spec +43 -0
- scripts/dist/macos/plist.patch +10 -0
- scripts/dist/macos/taguette.icns +0 -0
- scripts/dist/macos/taguette_console_wrapper +4 -0
- scripts/dist/macos.sh +32 -0
- scripts/dist/pyinstaller_entrypoint.py +19 -0
- scripts/dist/windows/windows-installer.iss +51 -0
- scripts/dist/windows/windows.spec +39 -0
- scripts/edugain/worker_update_edugain_idps.py +89 -0
- scripts/edugain/worker_update_edugain_idps.sh +9 -0
- scripts/make_password_reset_link.py +56 -0
- scripts/make_theme.py +99 -0
- scripts/new_db_revision.sh +8 -0
- scripts/register_user.py +76 -0
- scripts/update_pot.sh +33 -0
- scripts/update_translations.sh +17 -0
- scripts/validate.py +140 -0
- scripts/wipe_and_disable_user.py +90 -0
- taguette/__init__.py +13 -0
- taguette/access_control.py +145 -0
- taguette/convert.py +623 -0
- taguette/database/__init__.py +177 -0
- taguette/database/base.py +9 -0
- taguette/database/copy.py +433 -0
- taguette/database/models.py +920 -0
- taguette/demodata.py +688 -0
- taguette/export.py +500 -0
- taguette/extract.py +287 -0
- taguette/import_codebook.py +110 -0
- taguette/l10n/de_DE/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/de_DE/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/el_EL/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/el_GR/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/el_GR/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/en_US/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/en_US/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/es_ES/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/es_ES/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/fr_FR/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/fr_FR/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/id_ID/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/id_ID/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/it_IT/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/it_IT/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/nl_NL/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/nl_NL/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/pt_PT/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/pt_PT/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/sv_SE/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/sv_SE/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/loki_handler.py +107 -0
- taguette/main.py +649 -0
- taguette/migrations/env.py +72 -0
- taguette/migrations/script.py.mako +24 -0
- taguette/migrations/versions/09c662cd9483_make_documents_filename_not_null.py +39 -0
- taguette/migrations/versions/1b29e68900c1_add_user_roles.py +55 -0
- taguette/migrations/versions/1b96a606551d_update_user_to_have_a_display_name.py +34 -0
- taguette/migrations/versions/2467bab602b7_add_color_column_to_tags_table.py +33 -0
- taguette/migrations/versions/382db36f20f4_add_user_disabled.py +66 -0
- taguette/migrations/versions/43d6c240309d_remove_document_groups.py +81 -0
- taguette/migrations/versions/447d636f72c5_cascades.py +40 -0
- taguette/migrations/versions/491de2dc7cd7_add_indexes.py +51 -0
- taguette/migrations/versions/4c55b3b3ff30_user_login_length.py +58 -0
- taguette/migrations/versions/61a4abcc8769_user_with_parent_for_permissions.py +40 -0
- taguette/migrations/versions/6489b5f9cfb5_increase_password_hash_size.py +34 -0
- taguette/migrations/versions/679f625e6e6a_fix_unique_tags.py +59 -0
- taguette/migrations/versions/77c4543a5b9b_add_parent_id_column_to_collection_table.py +41 -0
- taguette/migrations/versions/7c94b7a31e69_add_is_archived_column_to_projets.py +39 -0
- taguette/migrations/versions/7f56de97e3c8_change_edugain_displayname_length.py +42 -0
- taguette/migrations/versions/807c0cc0ecf6_remove_invalid_hltags.py +43 -0
- taguette/migrations/versions/80b1cc9d4c22_add_user_email.py +35 -0
- taguette/migrations/versions/91ade71ccf4d_add_user_language.py +28 -0
- taguette/migrations/versions/955db2e523d1_add_collections_tables_and_associations_.py +60 -0
- taguette/migrations/versions/a1b2c3d4e5f6_add_user_hide_code_counts.py +28 -0
- taguette/migrations/versions/b23f3b7a638e_add_user_password_change_date.py +36 -0
- taguette/migrations/versions/b7c1e2d3f4a5_add_project_compact_layout.py +28 -0
- taguette/migrations/versions/bc8e0e0677e9_rename_command_fields.py +126 -0
- taguette/migrations/versions/bce44849c2f2_lowercase_logins.py +96 -0
- taguette/migrations/versions/ca076140922f_update_user_for_external_logins.py +32 -0
- taguette/migrations/versions/d86c45d7aa8a_add_document_direction.py +127 -0
- taguette/migrations/versions/db5e31a0233d_remove_blank_highlights.py +88 -0
- taguette/migrations/versions/dd37d9027a24_unique_tags.py +68 -0
- taguette/migrations/versions/de678cef2a94_add_project_id_to_collections_and_.py +41 -0
- taguette/migrations/versions/e459991d960e_add_table_edugainidps.py +39 -0
- taguette/migrations/versions/e4cf92942271_add_index.py +29 -0
- taguette/migrations/versions/e4e090a5b511_remove_commands_document_id_fk.py +38 -0
- taguette/migrations/versions/e9a9f1c77971_add_favorite_column_to_document.py +32 -0
- taguette/migrations/versions/ecb4065de575_don_t_use_server_default_now_timezone_.py +44 -0
- taguette/migrations/versions/fbdc29566f62_edugain_fix_field_type.py +42 -0
- taguette/migrations/versions/fdc6eb54b034_add_parent_id_column_to_tags_table.py +31 -0
- taguette/static/README.md +34 -0
- taguette/static/alt_templates/.gitkeep +0 -0
- taguette/static/assets/test/logos/monsters-university-logo.png +0 -0
- taguette/static/css/custom.css +1696 -0
- taguette/static/css/fa-all.min.css +5 -0
- taguette/static/css/themed.css +3 -0
- taguette/static/css/themed.css.map +25 -0
- taguette/static/css/vendor/bootstrap-treeview.min.css +1 -0
- taguette/static/css/vendor/bootstrap.min.css +6 -0
- taguette/static/css/vendor/font/context-menu-icons.eot +0 -0
- taguette/static/css/vendor/font/context-menu-icons.ttf +0 -0
- taguette/static/css/vendor/font/context-menu-icons.woff +0 -0
- taguette/static/css/vendor/font/context-menu-icons.woff2 +0 -0
- taguette/static/css/vendor/hierarchy-select.min.css +1 -0
- taguette/static/css/vendor/introjs.min.css +2 -0
- taguette/static/css/vendor/jquery.contextMenu.min.css +16 -0
- taguette/static/favicon.ico +0 -0
- taguette/static/js/codebook-viz.js +428 -0
- taguette/static/js/collection/context-menu.js +180 -0
- taguette/static/js/collection/hierarchy-select.js +71 -0
- taguette/static/js/collection/manager.js +217 -0
- taguette/static/js/document/context-menu.js +92 -0
- taguette/static/js/document/custom-select.js +82 -0
- taguette/static/js/document/manager.js +943 -0
- taguette/static/js/document/treeview.js +593 -0
- taguette/static/js/highlight/README.md +18 -0
- taguette/static/js/highlight/crud.js +119 -0
- taguette/static/js/highlight/extracts.js +497 -0
- taguette/static/js/highlight/loader.js +109 -0
- taguette/static/js/highlight/renderer.js +405 -0
- taguette/static/js/login.js +15 -0
- taguette/static/js/permissions.js +153 -0
- taguette/static/js/polling-event.js +145 -0
- taguette/static/js/project/intro.js +524 -0
- taguette/static/js/project/manager.js +0 -0
- taguette/static/js/project/search.js +124 -0
- taguette/static/js/tag/context-menu.js +133 -0
- taguette/static/js/tag/explore-codes.js +400 -0
- taguette/static/js/tag/hierarchy-select.js +69 -0
- taguette/static/js/tag/manager.js +545 -0
- taguette/static/js/tag/treeview.js +613 -0
- taguette/static/js/taguette.js +1790 -0
- taguette/static/js/utilities.js +714 -0
- taguette/static/js/vendor/bootstrap-treeview-custum.js +421 -0
- taguette/static/js/vendor/bootstrap-treeview-custum.min.js +1 -0
- taguette/static/js/vendor/bootstrap-treeview-default.min.js +1 -0
- taguette/static/js/vendor/bootstrap.bundle.min.js +7 -0
- taguette/static/js/vendor/bootstrap.bundle.min.js.map +1 -0
- taguette/static/js/vendor/hierarchy-select.min.js +1 -0
- taguette/static/js/vendor/introjs.min.js +17 -0
- taguette/static/js/vendor/jquery-3.7.1.js +2 -0
- taguette/static/js/vendor/jquery.contextMenu.min.js +2 -0
- taguette/static/libreqda.ico +0 -0
- taguette/static/logo-h.png +0 -0
- taguette/static/logo.png +0 -0
- taguette/static/logo_blanc.png +0 -0
- taguette/static/missing.png +0 -0
- taguette/static/robots.txt +2 -0
- taguette/static/scss/components/_badge.scss +42 -0
- taguette/static/scss/components/_button.scss +169 -0
- taguette/static/scss/components/_checkbox.scss +40 -0
- taguette/static/scss/components/_context_menu.scss +28 -0
- taguette/static/scss/components/_custom_select.scss +150 -0
- taguette/static/scss/components/_dropdown.scss +43 -0
- taguette/static/scss/components/_form.scss +54 -0
- taguette/static/scss/components/_modal.scss +57 -0
- taguette/static/scss/components/_project_card.scss +163 -0
- taguette/static/scss/components/_tag.scss +308 -0
- taguette/static/scss/components/_treeview.scss +76 -0
- taguette/static/scss/custom.scss +809 -0
- taguette/static/scss/layout/_credits.scss +62 -0
- taguette/static/scss/layout/_footer.scss +6 -0
- taguette/static/scss/layout/_navigation.scss +165 -0
- taguette/static/scss/layout/_sidebar.scss +37 -0
- taguette/static/scss/themes/default/_variables.scss +103 -0
- taguette/static/scss/themes/default/theme.css +3 -0
- taguette/static/scss/themes/default/theme.css.map +26 -0
- taguette/static/scss/themes/default/theme.scss +4 -0
- taguette/static/webfonts/BadScript-OFL.txt +94 -0
- taguette/static/webfonts/BadScript.ttf +0 -0
- taguette/static/webfonts/Montserrat-Light.ttf +0 -0
- taguette/static/webfonts/Montserrat-OFL.txt +93 -0
- taguette/static/webfonts/fa-brands-400.eot +0 -0
- taguette/static/webfonts/fa-brands-400.svg +1184 -0
- taguette/static/webfonts/fa-brands-400.ttf +0 -0
- taguette/static/webfonts/fa-brands-400.woff +0 -0
- taguette/static/webfonts/fa-brands-400.woff2 +0 -0
- taguette/static/webfonts/fa-regular-400.eot +0 -0
- taguette/static/webfonts/fa-regular-400.svg +467 -0
- taguette/static/webfonts/fa-regular-400.ttf +0 -0
- taguette/static/webfonts/fa-regular-400.woff +0 -0
- taguette/static/webfonts/fa-regular-400.woff2 +0 -0
- taguette/static/webfonts/fa-solid-900.eot +0 -0
- taguette/static/webfonts/fa-solid-900.svg +2618 -0
- taguette/static/webfonts/fa-solid-900.ttf +0 -0
- taguette/static/webfonts/fa-solid-900.woff +0 -0
- taguette/static/webfonts/fa-solid-900.woff2 +0 -0
- taguette/templates/alt_templates/.gitkeep +0 -0
- taguette/templates/archived_projects.html +50 -0
- taguette/templates/auth/account.html +74 -0
- taguette/templates/auth/cilogon.login.html +89 -0
- taguette/templates/auth/email_reset_password.html +5 -0
- taguette/templates/auth/external_account.html +60 -0
- taguette/templates/auth/login.html +144 -0
- taguette/templates/auth/reset_password.html +27 -0
- taguette/templates/auth/token_needed.html +7 -0
- taguette/templates/base.html +209 -0
- taguette/templates/bug_report.html +158 -0
- taguette/templates/cookies_prompt.html +15 -0
- taguette/templates/downloads.html +127 -0
- taguette/templates/email_bug_report.txt +9 -0
- taguette/templates/email_reset_password.txt +5 -0
- taguette/templates/error.html +8 -0
- taguette/templates/export_codebook.html +20 -0
- taguette/templates/export_document.html +20 -0
- taguette/templates/export_highlights.html +47 -0
- taguette/templates/index.html +347 -0
- taguette/templates/maintenance.html +8 -0
- taguette/templates/messages.js +74 -0
- taguette/templates/modal/change_document.html +68 -0
- taguette/templates/modal/create_collection.html +100 -0
- taguette/templates/modal/create_document.html +84 -0
- taguette/templates/modal/create_highlight.html +38 -0
- taguette/templates/modal/create_tag.html +73 -0
- taguette/templates/modal/explore_codes.html +58 -0
- taguette/templates/modal/manage_collaborators.html +44 -0
- taguette/templates/modal/merge_tag.html +49 -0
- taguette/templates/new_password.html +32 -0
- taguette/templates/partials/alert.html +16 -0
- taguette/templates/partials/codebook_viz_modal.html +34 -0
- taguette/templates/partials/confirm.html +17 -0
- taguette/templates/partials/document_toolbar.html +37 -0
- taguette/templates/partials/hlinfo.html +13 -0
- taguette/templates/partials/project_card.html +44 -0
- taguette/templates/partials/project_card_archived.html +41 -0
- taguette/templates/partials/project_sidebar.html +99 -0
- taguette/templates/partials/spinner.html +11 -0
- taguette/templates/permissions.html +54 -0
- taguette/templates/project.html +288 -0
- taguette/templates/project_delete.html +17 -0
- taguette/templates/project_import.html +122 -0
- taguette/templates/project_import_codebook.html +34 -0
- taguette/templates/project_import_codebook_confirm.html +49 -0
- taguette/templates/project_new.html +31 -0
- taguette/templates/tos.html +8 -0
- taguette/templates/trans.js +7 -0
- taguette/templates/welcome.html +15 -0
- taguette/utils.py +177 -0
- taguette/validate.py +141 -0
- taguette/version_check.py +102 -0
- taguette/web/__init__.py +252 -0
- taguette/web/api.py +1835 -0
- taguette/web/base.py +864 -0
- taguette/web/export.py +338 -0
- taguette/web/views.py +1618 -0
taguette/web/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
|