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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import alembic.command
|
|
2
|
+
import alembic.config
|
|
3
|
+
from alembic.runtime.migration import MigrationContext
|
|
4
|
+
from alembic.script import ScriptDirectory
|
|
5
|
+
import logging
|
|
6
|
+
import opentelemetry.trace
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from sqlalchemy import engine_from_config
|
|
10
|
+
import sqlalchemy.engine
|
|
11
|
+
import sqlalchemy.event
|
|
12
|
+
from sqlalchemy.exc import NoSuchTableError
|
|
13
|
+
from sqlalchemy.orm import sessionmaker
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import taguette
|
|
17
|
+
from .base import PROM_DATABASE_VERSION
|
|
18
|
+
from .copy import copy_project # noqa: F401
|
|
19
|
+
from .models import Base, JSON, User, Project, Privileges, ProjectMember, \
|
|
20
|
+
TextDirection, Document, Command, Highlight, Tag, EdugainIdps, \
|
|
21
|
+
highlight_tags, Roles, Collection # noqa: F401
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
tracer = opentelemetry.trace.get_tracer(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UnknownVersion(ValueError):
|
|
29
|
+
"""Unknown database version"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def set_sqlite_pragma(dbapi_connection, connection_record):
|
|
33
|
+
cursor = dbapi_connection.cursor()
|
|
34
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
35
|
+
cursor.close()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def connect(db_url, *, external=False, create_tables=None):
|
|
39
|
+
"""Connect to the database using an environment variable.
|
|
40
|
+
"""
|
|
41
|
+
if create_tables is None:
|
|
42
|
+
create_tables = not external
|
|
43
|
+
|
|
44
|
+
if isinstance(db_url, dict):
|
|
45
|
+
db_dict = db_url
|
|
46
|
+
db_url = db_dict['url']
|
|
47
|
+
else:
|
|
48
|
+
db_dict = {'url': db_url}
|
|
49
|
+
|
|
50
|
+
logger.info("Connecting to SQL database %r", db_url)
|
|
51
|
+
if db_url.startswith('sqlite:'):
|
|
52
|
+
db_dict['connect_args'] = {'check_same_thread': False}
|
|
53
|
+
engine = engine_from_config(db_dict, prefix='')
|
|
54
|
+
# logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
|
|
55
|
+
|
|
56
|
+
if db_url.startswith('sqlite:'):
|
|
57
|
+
sqlalchemy.event.listen(
|
|
58
|
+
engine,
|
|
59
|
+
"connect",
|
|
60
|
+
set_sqlite_pragma,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# https://www.sqlite.org/security.html#untrusted_sqlite_database_files
|
|
64
|
+
if external:
|
|
65
|
+
logger.info("Database is not trusted")
|
|
66
|
+
|
|
67
|
+
@sqlalchemy.event.listens_for(engine, "connect")
|
|
68
|
+
def secure(dbapi_connection, connection_record):
|
|
69
|
+
cursor = dbapi_connection.cursor()
|
|
70
|
+
cursor.execute("PRAGMA trusted_schema=OFF")
|
|
71
|
+
cursor.execute('PRAGMA cell_size_check=ON')
|
|
72
|
+
cursor.execute('PRAGMA mmap_size=0')
|
|
73
|
+
cursor.close()
|
|
74
|
+
|
|
75
|
+
conn = engine.connect()
|
|
76
|
+
conn.exec_driver_sql('PRAGMA quick_check')
|
|
77
|
+
conn.close()
|
|
78
|
+
|
|
79
|
+
alembic_cfg = alembic.config.Config()
|
|
80
|
+
alembic_cfg.set_main_option('script_location', 'taguette:migrations')
|
|
81
|
+
alembic_cfg.set_main_option('sqlalchemy.url', db_url)
|
|
82
|
+
|
|
83
|
+
with engine.connect() as conn:
|
|
84
|
+
if engine.dialect.has_table(conn, Project.__tablename__):
|
|
85
|
+
# Check that alembic version is there
|
|
86
|
+
if not engine.dialect.has_table(conn, 'alembic_version'):
|
|
87
|
+
raise NoSuchTableError('alembic_version')
|
|
88
|
+
|
|
89
|
+
# Perform Alembic migrations if needed
|
|
90
|
+
_auto_upgrade_db(db_url, conn, alembic_cfg, external)
|
|
91
|
+
|
|
92
|
+
# Check that all tables are here
|
|
93
|
+
for table in Base.metadata.sorted_tables:
|
|
94
|
+
if not engine.dialect.has_table(conn, table.name):
|
|
95
|
+
raise NoSuchTableError(table.name)
|
|
96
|
+
elif create_tables:
|
|
97
|
+
# Set SQLite's auto_vacuum mode
|
|
98
|
+
if db_url.startswith('sqlite:'):
|
|
99
|
+
conn.exec_driver_sql(
|
|
100
|
+
"PRAGMA auto_vacuum=1;"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
logger.warning("The tables don't seem to exist; creating")
|
|
104
|
+
Base.metadata.create_all(bind=engine)
|
|
105
|
+
|
|
106
|
+
# Mark this as the most recent Alembic version
|
|
107
|
+
alembic.command.stamp(alembic_cfg, "head")
|
|
108
|
+
|
|
109
|
+
# Set SQLite's "application ID"
|
|
110
|
+
if db_url.startswith('sqlite:'):
|
|
111
|
+
conn.exec_driver_sql(
|
|
112
|
+
"PRAGMA application_id=0x54677474;" # 'Tgtt'
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
raise NoSuchTableError('projects')
|
|
116
|
+
|
|
117
|
+
# Record to Prometheus
|
|
118
|
+
conn = engine.connect()
|
|
119
|
+
revision = MigrationContext.configure(conn).get_current_revision()
|
|
120
|
+
PROM_DATABASE_VERSION.labels(revision).set(1)
|
|
121
|
+
|
|
122
|
+
DBSession = sessionmaker(bind=engine)
|
|
123
|
+
|
|
124
|
+
return DBSession
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _auto_upgrade_db(db_url, conn, alembic_cfg, external):
|
|
128
|
+
context = MigrationContext.configure(conn)
|
|
129
|
+
current_rev = context.get_current_revision()
|
|
130
|
+
scripts = ScriptDirectory.from_config(alembic_cfg)
|
|
131
|
+
if [current_rev] != scripts.get_heads():
|
|
132
|
+
logger.warning("Database schema is out of date: %s", current_rev)
|
|
133
|
+
_ = taguette.trans.gettext
|
|
134
|
+
if db_url.startswith('sqlite:'):
|
|
135
|
+
if not external:
|
|
136
|
+
print(_(
|
|
137
|
+
"\n The database schema used by Taguette has "
|
|
138
|
+
"changed! We will try to\n update your workspace "
|
|
139
|
+
"automatically.\n"), file=sys.stderr, flush=True)
|
|
140
|
+
assert db_url.startswith('sqlite:///')
|
|
141
|
+
assert os.path.exists(db_url[10:])
|
|
142
|
+
backup = db_url[10:] + '.bak'
|
|
143
|
+
shutil.copy2(db_url[10:], backup)
|
|
144
|
+
logger.warning(
|
|
145
|
+
"Performing automated update, backup file: %s", backup,
|
|
146
|
+
)
|
|
147
|
+
print(
|
|
148
|
+
_("\n A backup copy of your database file has been "
|
|
149
|
+
"created. If the update\n goes horribly wrong, "
|
|
150
|
+
"make sure to keep that file, and let us know:\n "
|
|
151
|
+
"%(backup)s\n") % dict(backup=backup),
|
|
152
|
+
file=sys.stderr, flush=True,
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
scripts.get_revision(current_rev)
|
|
156
|
+
except alembic.util.exc.CommandError as e:
|
|
157
|
+
raise UnknownVersion from e
|
|
158
|
+
alembic.command.upgrade(alembic_cfg, 'head')
|
|
159
|
+
else:
|
|
160
|
+
print(_("\n The database schema used by Taguette has "
|
|
161
|
+
"changed! Because you are not using\n SQLite, we "
|
|
162
|
+
"will not attempt a migration automatically; back up "
|
|
163
|
+
"your data and\n use `taguette --database=%(url)s "
|
|
164
|
+
"migrate` if you want to proceed.") % dict(url=db_url),
|
|
165
|
+
file=sys.stderr, flush=True)
|
|
166
|
+
sys.exit(3)
|
|
167
|
+
else:
|
|
168
|
+
logger.info("Database is up to date: %s", current_rev)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def migrate(db_url, revision):
|
|
172
|
+
alembic_cfg = alembic.config.Config()
|
|
173
|
+
alembic_cfg.set_main_option('script_location', 'taguette:migrations')
|
|
174
|
+
alembic_cfg.set_main_option('sqlalchemy.url', db_url)
|
|
175
|
+
|
|
176
|
+
logger.warning("Performing database upgrade")
|
|
177
|
+
alembic.command.upgrade(alembic_cfg, revision)
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
import logging
|
|
3
|
+
import opentelemetry.trace
|
|
4
|
+
|
|
5
|
+
from .models import (Project, Privileges, ProjectMember, TextDirection,
|
|
6
|
+
Document, Command, Highlight, Tag, highlight_tags,
|
|
7
|
+
Collection, collection_documents)
|
|
8
|
+
from .. import convert
|
|
9
|
+
from ..utils import DefaultMap
|
|
10
|
+
from .. import validate
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
tracer = opentelemetry.trace.get_tracer(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@tracer.start_as_current_span('taguette/copy_project')
|
|
17
|
+
def copy_project(
|
|
18
|
+
src_db, dest_db,
|
|
19
|
+
project_id, user_login,
|
|
20
|
+
):
|
|
21
|
+
def copy(
|
|
22
|
+
table, pkey, fkeys, size,
|
|
23
|
+
*, condition=None, transform=None, validators=None
|
|
24
|
+
):
|
|
25
|
+
return copy_table(
|
|
26
|
+
src_db, dest_db,
|
|
27
|
+
table, pkey, fkeys,
|
|
28
|
+
batch_size=size,
|
|
29
|
+
condition=condition, transform=transform, validators=validators,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def insert(table, values):
|
|
33
|
+
ins = dest_db.execute(
|
|
34
|
+
table.insert(),
|
|
35
|
+
values,
|
|
36
|
+
)
|
|
37
|
+
return ins
|
|
38
|
+
|
|
39
|
+
# Copy project
|
|
40
|
+
project = src_db.execute(
|
|
41
|
+
Project.__table__.select().where(Project.id == project_id)
|
|
42
|
+
).fetchone()
|
|
43
|
+
if project is None:
|
|
44
|
+
raise KeyError("project ID not found")
|
|
45
|
+
validate.project_name(project['name'])
|
|
46
|
+
validate.description(project['description'])
|
|
47
|
+
project = dict(project)
|
|
48
|
+
project.pop('id')
|
|
49
|
+
project['is_archived'] = False
|
|
50
|
+
new_project_id, = insert(Project.__table__, project).inserted_primary_key
|
|
51
|
+
mapping_project = {project_id: new_project_id}
|
|
52
|
+
|
|
53
|
+
# Add member
|
|
54
|
+
insert(
|
|
55
|
+
ProjectMember.__table__,
|
|
56
|
+
dict(
|
|
57
|
+
project_id=new_project_id,
|
|
58
|
+
user_login=user_login,
|
|
59
|
+
privileges=Privileges.ADMIN,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Copy documents
|
|
64
|
+
mapping_document = copy(
|
|
65
|
+
Document.__table__, 'id',
|
|
66
|
+
dict(project_id=mapping_project),
|
|
67
|
+
2,
|
|
68
|
+
condition=Document.project_id == project_id,
|
|
69
|
+
validators=dict(
|
|
70
|
+
name=validate.document_name,
|
|
71
|
+
description=validate.description,
|
|
72
|
+
filename=validate.filename,
|
|
73
|
+
contents=convert.is_html_safe,
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Update parent_id with new pk
|
|
78
|
+
def transform_tag(row, mapping):
|
|
79
|
+
if 'parent_id' in row and row['parent_id'] in mapping:
|
|
80
|
+
row['parent_id'] = mapping[row['parent_id']]
|
|
81
|
+
return row
|
|
82
|
+
|
|
83
|
+
def transform_collection(row, mapping):
|
|
84
|
+
if 'parent_id' in row and row['parent_id'] in mapping:
|
|
85
|
+
row['parent_id'] = mapping[row['parent_id']]
|
|
86
|
+
return {**row, 'owner_login': None}
|
|
87
|
+
|
|
88
|
+
# Copy tags
|
|
89
|
+
mapping_tags = copy(
|
|
90
|
+
Tag.__table__, 'id',
|
|
91
|
+
dict(project_id=mapping_project),
|
|
92
|
+
50,
|
|
93
|
+
condition=Tag.project_id == project_id,
|
|
94
|
+
validators=dict(
|
|
95
|
+
path=validate.tag_path,
|
|
96
|
+
description=validate.description,
|
|
97
|
+
color=validate.color
|
|
98
|
+
),
|
|
99
|
+
transform=transform_tag
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Copy highlights
|
|
103
|
+
mapping_highlights = copy(
|
|
104
|
+
Highlight.__table__, 'id',
|
|
105
|
+
dict(document_id=mapping_document),
|
|
106
|
+
50,
|
|
107
|
+
condition=Highlight.document_id.in_(mapping_document.keys()),
|
|
108
|
+
validators=dict(
|
|
109
|
+
start_offset=lambda v: isinstance(v, int) and v >= 0,
|
|
110
|
+
end_offset=lambda v: isinstance(v, int) and v > 0,
|
|
111
|
+
snippet=convert.is_html_safe,
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Copy highlight tags
|
|
116
|
+
copy(
|
|
117
|
+
highlight_tags, None,
|
|
118
|
+
dict(highlight_id=mapping_highlights, tag_id=mapping_tags),
|
|
119
|
+
200,
|
|
120
|
+
condition=highlight_tags.c.tag_id.in_(mapping_tags.keys()),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Copy collections
|
|
124
|
+
mapping_collections = copy(
|
|
125
|
+
Collection.__table__, 'id',
|
|
126
|
+
dict(project_id=mapping_project),
|
|
127
|
+
50,
|
|
128
|
+
condition=Collection.project_id == project_id,
|
|
129
|
+
validators=dict(
|
|
130
|
+
name=lambda v: isinstance(v, str) and len(v.strip()) > 0,
|
|
131
|
+
description=lambda v: v is None or isinstance(v, str),
|
|
132
|
+
),
|
|
133
|
+
transform=transform_collection
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Copy collection documents association
|
|
137
|
+
copy(
|
|
138
|
+
collection_documents, None,
|
|
139
|
+
dict(collection_id=mapping_collections, document_id=mapping_document),
|
|
140
|
+
200,
|
|
141
|
+
condition=collection_documents.c.collection_id.in_(
|
|
142
|
+
mapping_collections.keys()),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def validate_text_direction(direction):
|
|
146
|
+
try:
|
|
147
|
+
TextDirection[direction]
|
|
148
|
+
except KeyError:
|
|
149
|
+
raise ValueError("Invalid text direction")
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
# Copy commands
|
|
153
|
+
def transform_command(cmd, mapping):
|
|
154
|
+
payload = cmd['payload']
|
|
155
|
+
if payload['type'] not in Command.TYPES:
|
|
156
|
+
raise ValueError("Unknown command %r" % payload['type'])
|
|
157
|
+
|
|
158
|
+
method = getattr(Command, payload['type'])
|
|
159
|
+
expected_columns = (
|
|
160
|
+
set(method.columns)
|
|
161
|
+
| {'date', 'user_login', 'payload'}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Keep expected_payload_fields = set(method.payload_fields) | {'type'}
|
|
165
|
+
|
|
166
|
+
# Check that the right columns are set
|
|
167
|
+
if {k for k, v in cmd.items() if v is not None} != expected_columns:
|
|
168
|
+
raise ValueError("Command doesn't have expected columns")
|
|
169
|
+
|
|
170
|
+
# Remove check that the right JSON fields are set to allow importation
|
|
171
|
+
# if payload.keys() != expected_payload_fields:
|
|
172
|
+
# raise ValueError("Command doesn't have expected fields")
|
|
173
|
+
|
|
174
|
+
# Map an ID, using negative ID if it's unknown
|
|
175
|
+
def mv(mapping, value):
|
|
176
|
+
return mapping.get(value, -abs(value))
|
|
177
|
+
|
|
178
|
+
field_validators = dict(
|
|
179
|
+
type=lambda v: True, # Already checked above
|
|
180
|
+
description=validate.description,
|
|
181
|
+
project_name=validate.project_name,
|
|
182
|
+
document_name=validate.document_name,
|
|
183
|
+
text_direction=validate_text_direction,
|
|
184
|
+
highlight_id=lambda v: isinstance(v, int),
|
|
185
|
+
start_offset=lambda v: isinstance(v, int) and v >= 0,
|
|
186
|
+
end_offset=lambda v: isinstance(v, int) and v > 0,
|
|
187
|
+
tags=lambda v: (
|
|
188
|
+
isinstance(v, list)
|
|
189
|
+
and all(isinstance(e, int) for e in v)
|
|
190
|
+
),
|
|
191
|
+
tag_parent=lambda v: (
|
|
192
|
+
v is None or isinstance(v, int)
|
|
193
|
+
),
|
|
194
|
+
favorite=lambda v: (
|
|
195
|
+
v is None or isinstance(v, bool)
|
|
196
|
+
),
|
|
197
|
+
tag_id=lambda v: isinstance(v, int),
|
|
198
|
+
tag_path=validate.tag_path,
|
|
199
|
+
tag_color=validate.color,
|
|
200
|
+
src_tag_id=lambda v: isinstance(v, int),
|
|
201
|
+
dest_tag_id=lambda v: isinstance(v, int),
|
|
202
|
+
member=validate.user_login,
|
|
203
|
+
privileges=lambda v: v in Privileges.__members__,
|
|
204
|
+
# Collections fields
|
|
205
|
+
collections=lambda v: (
|
|
206
|
+
isinstance(v, list)
|
|
207
|
+
and all(isinstance(e, int) for e in v)
|
|
208
|
+
),
|
|
209
|
+
collection_id=lambda v: isinstance(v, int),
|
|
210
|
+
collection_name=lambda v: isinstance(
|
|
211
|
+
v, str) and len(v.strip()) > 0,
|
|
212
|
+
parent_id=lambda v: (
|
|
213
|
+
v is None or isinstance(v, int) or (
|
|
214
|
+
isinstance(v, str) and v.isdigit())
|
|
215
|
+
),
|
|
216
|
+
document_ids=lambda v: (
|
|
217
|
+
isinstance(v, list)
|
|
218
|
+
and all(isinstance(e, int) for e in v)
|
|
219
|
+
),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
field_transformers = dict(
|
|
223
|
+
highlight_id=lambda v: mv(mapping_highlights, v),
|
|
224
|
+
tag_id=lambda v: mv(mapping_tags, v),
|
|
225
|
+
src_tag_id=lambda v: mv(mapping_tags, v),
|
|
226
|
+
tags=lambda tags: [mv(mapping_tags, t) for t in tags],
|
|
227
|
+
# Collections transformers
|
|
228
|
+
collections=lambda collections: [
|
|
229
|
+
mv(mapping_collections, c) for c in collections],
|
|
230
|
+
collection_id=lambda v: mv(mapping_collections, v),
|
|
231
|
+
parent_id=lambda v: mv(mapping_collections, int(
|
|
232
|
+
v)) if v is not None else None,
|
|
233
|
+
document_ids=lambda document_ids: [
|
|
234
|
+
mv(mapping_document, d) for d in document_ids],
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Map JSON fields
|
|
238
|
+
for field, value in list(payload.items()):
|
|
239
|
+
try:
|
|
240
|
+
if (field in field_validators and
|
|
241
|
+
not field_validators[field](value)):
|
|
242
|
+
print(field, value)
|
|
243
|
+
if field == 'parent_id':
|
|
244
|
+
print("mapping_collections:", mapping_collections)
|
|
245
|
+
print("validation result:",
|
|
246
|
+
field_validators[field](value))
|
|
247
|
+
print("value type:", type(value))
|
|
248
|
+
raise ValueError("Invalid field %r in command" % field)
|
|
249
|
+
except validate.InvalidFormat:
|
|
250
|
+
raise ValueError("Invalid field %r in command" % field)
|
|
251
|
+
if field in field_transformers:
|
|
252
|
+
payload[field] = field_transformers[field](value)
|
|
253
|
+
|
|
254
|
+
return dict(cmd.items(), payload=payload)
|
|
255
|
+
|
|
256
|
+
copy(
|
|
257
|
+
Command.__table__, 'id',
|
|
258
|
+
dict(
|
|
259
|
+
# Map all users to the importing user
|
|
260
|
+
user_login=DefaultMap(lambda key: user_login, {}),
|
|
261
|
+
project_id=mapping_project,
|
|
262
|
+
# Map None to None and unknown keys (deleted documents) to negative
|
|
263
|
+
document_id=DefaultMap(
|
|
264
|
+
lambda key: None if key is None else -abs(key),
|
|
265
|
+
mapping_document,
|
|
266
|
+
),
|
|
267
|
+
),
|
|
268
|
+
100,
|
|
269
|
+
condition=Command.project_id == project_id,
|
|
270
|
+
transform=transform_command,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
dest_db.commit()
|
|
274
|
+
|
|
275
|
+
return new_project_id
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def copy_table(
|
|
279
|
+
src_db, dest_db, table,
|
|
280
|
+
pkey, fkeys,
|
|
281
|
+
*, batch_size=1000, condition=None, transform=None, validators=None
|
|
282
|
+
):
|
|
283
|
+
"""Copy all data in a table across database connections.
|
|
284
|
+
|
|
285
|
+
:param src_db: The SQLAlchemy ``Connection`` or ``Session`` to copy from.
|
|
286
|
+
:param dest_db: The SQLAlchemy ``Connection`` or ``Session`` to copy into.
|
|
287
|
+
:param table: The SQLAlchemy ``Table`` we are copying. It should exist on
|
|
288
|
+
both the source and destination databases.
|
|
289
|
+
:param pkey: The field in the table that is the primary key and should be
|
|
290
|
+
reset during the copy (so new values get generated when inserting in
|
|
291
|
+
the destination and no conflict occurs).
|
|
292
|
+
:param fkeys: A dictionary associating the name of the fields that are
|
|
293
|
+
foreign keys to a dictionary mapping the keys.
|
|
294
|
+
:param transform: Function to apply on each row.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
from .models import Tag
|
|
298
|
+
from sqlalchemy import func
|
|
299
|
+
|
|
300
|
+
query = table.select()
|
|
301
|
+
if pkey is not None:
|
|
302
|
+
query = query.order_by(pkey)
|
|
303
|
+
if condition is not None:
|
|
304
|
+
query = query.where(condition)
|
|
305
|
+
|
|
306
|
+
if table == Tag.__table__ or table == Collection.__table__:
|
|
307
|
+
count_query = src_db.execute(
|
|
308
|
+
func.count().select().select_from(query.alias()))
|
|
309
|
+
total_rows = count_query.scalar()
|
|
310
|
+
batch_size = total_rows + 1
|
|
311
|
+
query = src_db.execute(query)
|
|
312
|
+
assert pkey is None or pkey in query.keys()
|
|
313
|
+
assert all(field in query.keys() for field in fkeys)
|
|
314
|
+
mapping = {}
|
|
315
|
+
while True:
|
|
316
|
+
batch = query.fetchmany(batch_size)
|
|
317
|
+
if not batch:
|
|
318
|
+
break
|
|
319
|
+
|
|
320
|
+
rows = [dict(row) for row in batch]
|
|
321
|
+
|
|
322
|
+
if table == Tag.__table__ or table == Collection.__table__:
|
|
323
|
+
roots, hierarchies = build_hierarchies(rows)
|
|
324
|
+
insert_hierarchically(
|
|
325
|
+
dest_db,
|
|
326
|
+
roots,
|
|
327
|
+
hierarchies,
|
|
328
|
+
mapping,
|
|
329
|
+
table,
|
|
330
|
+
pkey,
|
|
331
|
+
fkeys,
|
|
332
|
+
transform,
|
|
333
|
+
validators
|
|
334
|
+
)
|
|
335
|
+
else:
|
|
336
|
+
insert_rows(dest_db, rows, table, pkey, fkeys,
|
|
337
|
+
transform, validators, mapping)
|
|
338
|
+
|
|
339
|
+
return mapping
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def build_hierarchies(items):
|
|
343
|
+
"""Build a hierarchy for items with parent_id relationships."""
|
|
344
|
+
hierarchies = defaultdict(list)
|
|
345
|
+
roots = []
|
|
346
|
+
for item in items:
|
|
347
|
+
if item['parent_id'] is None:
|
|
348
|
+
roots.append(item)
|
|
349
|
+
else:
|
|
350
|
+
hierarchies[item['parent_id']].append(item)
|
|
351
|
+
return roots, hierarchies
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def validate_item(item, validators):
|
|
355
|
+
"""Validate an item against the provided validators."""
|
|
356
|
+
if validators:
|
|
357
|
+
for key, value in item.items():
|
|
358
|
+
if key in validators:
|
|
359
|
+
if not validators[key](value):
|
|
360
|
+
raise ValueError(
|
|
361
|
+
f"Data failed validation (column {key})")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def prepare_item_for_insertion(item, pkey, fkeys, transform, mapping):
|
|
365
|
+
"""Prepare an item for insertion by removing pkey, mapping foreign keys,
|
|
366
|
+
and applying transform."""
|
|
367
|
+
item_id = item[pkey]
|
|
368
|
+
# Get primary key, remove it
|
|
369
|
+
if pkey is not None:
|
|
370
|
+
item.pop(pkey)
|
|
371
|
+
# Map foreign keys
|
|
372
|
+
for field, fkey_map in fkeys.items():
|
|
373
|
+
item[field] = fkey_map[item[field]]
|
|
374
|
+
# Generic transform
|
|
375
|
+
if transform is not None:
|
|
376
|
+
item = transform(item, mapping)
|
|
377
|
+
return item_id, item
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def insert_hierarchically(dest_db, roots, hierarchies, mapping,
|
|
381
|
+
table, pkey, fkeys, transform, validators):
|
|
382
|
+
"""Insert items recursively to respect parent-child relationships."""
|
|
383
|
+
for item in roots:
|
|
384
|
+
item_id, prepared_item = prepare_item_for_insertion(
|
|
385
|
+
item, pkey, fkeys, transform, mapping)
|
|
386
|
+
# Validate
|
|
387
|
+
validate_item(prepared_item, validators)
|
|
388
|
+
# Have to insert one-by-one for inserted_primary_key
|
|
389
|
+
ins = dest_db.execute(table.insert(), prepared_item)
|
|
390
|
+
# Store new primary key
|
|
391
|
+
if pkey is not None:
|
|
392
|
+
mapping[item_id], = ins.inserted_primary_key
|
|
393
|
+
if item_id in hierarchies:
|
|
394
|
+
insert_hierarchically(
|
|
395
|
+
dest_db,
|
|
396
|
+
hierarchies[item_id],
|
|
397
|
+
hierarchies,
|
|
398
|
+
mapping,
|
|
399
|
+
table,
|
|
400
|
+
pkey,
|
|
401
|
+
fkeys,
|
|
402
|
+
transform,
|
|
403
|
+
validators)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def insert_rows(dest_db,
|
|
407
|
+
rows,
|
|
408
|
+
table,
|
|
409
|
+
pkey,
|
|
410
|
+
fkeys,
|
|
411
|
+
transform,
|
|
412
|
+
validators,
|
|
413
|
+
mapping):
|
|
414
|
+
"""Insert rows into the destination database."""
|
|
415
|
+
for row in rows:
|
|
416
|
+
orig_pkey = 0
|
|
417
|
+
# Get primary key, remove it
|
|
418
|
+
if pkey is not None:
|
|
419
|
+
orig_pkey = row[pkey]
|
|
420
|
+
row.pop(pkey)
|
|
421
|
+
# Map foreign keys
|
|
422
|
+
for field, fkey_map in fkeys.items():
|
|
423
|
+
row[field] = fkey_map[row[field]]
|
|
424
|
+
# Generic transform
|
|
425
|
+
if transform is not None:
|
|
426
|
+
row = transform(row, mapping)
|
|
427
|
+
# Validate
|
|
428
|
+
validate_item(row, validators)
|
|
429
|
+
# Have to insert one-by-one for inserted_primary_key
|
|
430
|
+
ins = dest_db.execute(table.insert(), row)
|
|
431
|
+
# Store new primary key
|
|
432
|
+
if pkey is not None:
|
|
433
|
+
mapping[orig_pkey], = ins.inserted_primary_key
|