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