libreqda 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. CHANGELOG.md +481 -0
  2. libreqda-1.2.0.dist-info/LICENSE.txt +28 -0
  3. libreqda-1.2.0.dist-info/METADATA +131 -0
  4. libreqda-1.2.0.dist-info/RECORD +255 -0
  5. libreqda-1.2.0.dist-info/WHEEL +4 -0
  6. libreqda-1.2.0.dist-info/entry_points.txt +4 -0
  7. scripts/babelrc +8 -0
  8. scripts/dist/LICENSE.txt +33 -0
  9. scripts/dist/RELEASE.txt +23 -0
  10. scripts/dist/macos/libreqda.icns +0 -0
  11. scripts/dist/macos/macos.spec +43 -0
  12. scripts/dist/macos/plist.patch +10 -0
  13. scripts/dist/macos/taguette.icns +0 -0
  14. scripts/dist/macos/taguette_console_wrapper +4 -0
  15. scripts/dist/macos.sh +32 -0
  16. scripts/dist/pyinstaller_entrypoint.py +19 -0
  17. scripts/dist/windows/windows-installer.iss +51 -0
  18. scripts/dist/windows/windows.spec +39 -0
  19. scripts/edugain/worker_update_edugain_idps.py +89 -0
  20. scripts/edugain/worker_update_edugain_idps.sh +9 -0
  21. scripts/make_password_reset_link.py +56 -0
  22. scripts/make_theme.py +99 -0
  23. scripts/new_db_revision.sh +8 -0
  24. scripts/register_user.py +76 -0
  25. scripts/update_pot.sh +33 -0
  26. scripts/update_translations.sh +17 -0
  27. scripts/validate.py +140 -0
  28. scripts/wipe_and_disable_user.py +90 -0
  29. taguette/__init__.py +13 -0
  30. taguette/access_control.py +145 -0
  31. taguette/convert.py +623 -0
  32. taguette/database/__init__.py +177 -0
  33. taguette/database/base.py +9 -0
  34. taguette/database/copy.py +433 -0
  35. taguette/database/models.py +920 -0
  36. taguette/demodata.py +688 -0
  37. taguette/export.py +500 -0
  38. taguette/extract.py +287 -0
  39. taguette/import_codebook.py +110 -0
  40. taguette/l10n/de_DE/LC_MESSAGES/taguette_javascript.mo +0 -0
  41. taguette/l10n/de_DE/LC_MESSAGES/taguette_main.mo +0 -0
  42. taguette/l10n/el_EL/LC_MESSAGES/taguette_javascript.mo +0 -0
  43. taguette/l10n/el_GR/LC_MESSAGES/taguette_javascript.mo +0 -0
  44. taguette/l10n/el_GR/LC_MESSAGES/taguette_main.mo +0 -0
  45. taguette/l10n/en_US/LC_MESSAGES/taguette_javascript.mo +0 -0
  46. taguette/l10n/en_US/LC_MESSAGES/taguette_main.mo +0 -0
  47. taguette/l10n/es_ES/LC_MESSAGES/taguette_javascript.mo +0 -0
  48. taguette/l10n/es_ES/LC_MESSAGES/taguette_main.mo +0 -0
  49. taguette/l10n/fr_FR/LC_MESSAGES/taguette_javascript.mo +0 -0
  50. taguette/l10n/fr_FR/LC_MESSAGES/taguette_main.mo +0 -0
  51. taguette/l10n/id_ID/LC_MESSAGES/taguette_javascript.mo +0 -0
  52. taguette/l10n/id_ID/LC_MESSAGES/taguette_main.mo +0 -0
  53. taguette/l10n/it_IT/LC_MESSAGES/taguette_javascript.mo +0 -0
  54. taguette/l10n/it_IT/LC_MESSAGES/taguette_main.mo +0 -0
  55. taguette/l10n/nl_NL/LC_MESSAGES/taguette_javascript.mo +0 -0
  56. taguette/l10n/nl_NL/LC_MESSAGES/taguette_main.mo +0 -0
  57. taguette/l10n/pt_PT/LC_MESSAGES/taguette_javascript.mo +0 -0
  58. taguette/l10n/pt_PT/LC_MESSAGES/taguette_main.mo +0 -0
  59. taguette/l10n/sv_SE/LC_MESSAGES/taguette_javascript.mo +0 -0
  60. taguette/l10n/sv_SE/LC_MESSAGES/taguette_main.mo +0 -0
  61. taguette/loki_handler.py +107 -0
  62. taguette/main.py +649 -0
  63. taguette/migrations/env.py +72 -0
  64. taguette/migrations/script.py.mako +24 -0
  65. taguette/migrations/versions/09c662cd9483_make_documents_filename_not_null.py +39 -0
  66. taguette/migrations/versions/1b29e68900c1_add_user_roles.py +55 -0
  67. taguette/migrations/versions/1b96a606551d_update_user_to_have_a_display_name.py +34 -0
  68. taguette/migrations/versions/2467bab602b7_add_color_column_to_tags_table.py +33 -0
  69. taguette/migrations/versions/382db36f20f4_add_user_disabled.py +66 -0
  70. taguette/migrations/versions/43d6c240309d_remove_document_groups.py +81 -0
  71. taguette/migrations/versions/447d636f72c5_cascades.py +40 -0
  72. taguette/migrations/versions/491de2dc7cd7_add_indexes.py +51 -0
  73. taguette/migrations/versions/4c55b3b3ff30_user_login_length.py +58 -0
  74. taguette/migrations/versions/61a4abcc8769_user_with_parent_for_permissions.py +40 -0
  75. taguette/migrations/versions/6489b5f9cfb5_increase_password_hash_size.py +34 -0
  76. taguette/migrations/versions/679f625e6e6a_fix_unique_tags.py +59 -0
  77. taguette/migrations/versions/77c4543a5b9b_add_parent_id_column_to_collection_table.py +41 -0
  78. taguette/migrations/versions/7c94b7a31e69_add_is_archived_column_to_projets.py +39 -0
  79. taguette/migrations/versions/7f56de97e3c8_change_edugain_displayname_length.py +42 -0
  80. taguette/migrations/versions/807c0cc0ecf6_remove_invalid_hltags.py +43 -0
  81. taguette/migrations/versions/80b1cc9d4c22_add_user_email.py +35 -0
  82. taguette/migrations/versions/91ade71ccf4d_add_user_language.py +28 -0
  83. taguette/migrations/versions/955db2e523d1_add_collections_tables_and_associations_.py +60 -0
  84. taguette/migrations/versions/a1b2c3d4e5f6_add_user_hide_code_counts.py +28 -0
  85. taguette/migrations/versions/b23f3b7a638e_add_user_password_change_date.py +36 -0
  86. taguette/migrations/versions/b7c1e2d3f4a5_add_project_compact_layout.py +28 -0
  87. taguette/migrations/versions/bc8e0e0677e9_rename_command_fields.py +126 -0
  88. taguette/migrations/versions/bce44849c2f2_lowercase_logins.py +96 -0
  89. taguette/migrations/versions/ca076140922f_update_user_for_external_logins.py +32 -0
  90. taguette/migrations/versions/d86c45d7aa8a_add_document_direction.py +127 -0
  91. taguette/migrations/versions/db5e31a0233d_remove_blank_highlights.py +88 -0
  92. taguette/migrations/versions/dd37d9027a24_unique_tags.py +68 -0
  93. taguette/migrations/versions/de678cef2a94_add_project_id_to_collections_and_.py +41 -0
  94. taguette/migrations/versions/e459991d960e_add_table_edugainidps.py +39 -0
  95. taguette/migrations/versions/e4cf92942271_add_index.py +29 -0
  96. taguette/migrations/versions/e4e090a5b511_remove_commands_document_id_fk.py +38 -0
  97. taguette/migrations/versions/e9a9f1c77971_add_favorite_column_to_document.py +32 -0
  98. taguette/migrations/versions/ecb4065de575_don_t_use_server_default_now_timezone_.py +44 -0
  99. taguette/migrations/versions/fbdc29566f62_edugain_fix_field_type.py +42 -0
  100. taguette/migrations/versions/fdc6eb54b034_add_parent_id_column_to_tags_table.py +31 -0
  101. taguette/static/README.md +34 -0
  102. taguette/static/alt_templates/.gitkeep +0 -0
  103. taguette/static/assets/test/logos/monsters-university-logo.png +0 -0
  104. taguette/static/css/custom.css +1696 -0
  105. taguette/static/css/fa-all.min.css +5 -0
  106. taguette/static/css/themed.css +3 -0
  107. taguette/static/css/themed.css.map +25 -0
  108. taguette/static/css/vendor/bootstrap-treeview.min.css +1 -0
  109. taguette/static/css/vendor/bootstrap.min.css +6 -0
  110. taguette/static/css/vendor/font/context-menu-icons.eot +0 -0
  111. taguette/static/css/vendor/font/context-menu-icons.ttf +0 -0
  112. taguette/static/css/vendor/font/context-menu-icons.woff +0 -0
  113. taguette/static/css/vendor/font/context-menu-icons.woff2 +0 -0
  114. taguette/static/css/vendor/hierarchy-select.min.css +1 -0
  115. taguette/static/css/vendor/introjs.min.css +2 -0
  116. taguette/static/css/vendor/jquery.contextMenu.min.css +16 -0
  117. taguette/static/favicon.ico +0 -0
  118. taguette/static/js/codebook-viz.js +428 -0
  119. taguette/static/js/collection/context-menu.js +180 -0
  120. taguette/static/js/collection/hierarchy-select.js +71 -0
  121. taguette/static/js/collection/manager.js +217 -0
  122. taguette/static/js/document/context-menu.js +92 -0
  123. taguette/static/js/document/custom-select.js +82 -0
  124. taguette/static/js/document/manager.js +943 -0
  125. taguette/static/js/document/treeview.js +593 -0
  126. taguette/static/js/highlight/README.md +18 -0
  127. taguette/static/js/highlight/crud.js +119 -0
  128. taguette/static/js/highlight/extracts.js +497 -0
  129. taguette/static/js/highlight/loader.js +109 -0
  130. taguette/static/js/highlight/renderer.js +405 -0
  131. taguette/static/js/login.js +15 -0
  132. taguette/static/js/permissions.js +153 -0
  133. taguette/static/js/polling-event.js +145 -0
  134. taguette/static/js/project/intro.js +524 -0
  135. taguette/static/js/project/manager.js +0 -0
  136. taguette/static/js/project/search.js +124 -0
  137. taguette/static/js/tag/context-menu.js +133 -0
  138. taguette/static/js/tag/explore-codes.js +400 -0
  139. taguette/static/js/tag/hierarchy-select.js +69 -0
  140. taguette/static/js/tag/manager.js +545 -0
  141. taguette/static/js/tag/treeview.js +613 -0
  142. taguette/static/js/taguette.js +1790 -0
  143. taguette/static/js/utilities.js +714 -0
  144. taguette/static/js/vendor/bootstrap-treeview-custum.js +421 -0
  145. taguette/static/js/vendor/bootstrap-treeview-custum.min.js +1 -0
  146. taguette/static/js/vendor/bootstrap-treeview-default.min.js +1 -0
  147. taguette/static/js/vendor/bootstrap.bundle.min.js +7 -0
  148. taguette/static/js/vendor/bootstrap.bundle.min.js.map +1 -0
  149. taguette/static/js/vendor/hierarchy-select.min.js +1 -0
  150. taguette/static/js/vendor/introjs.min.js +17 -0
  151. taguette/static/js/vendor/jquery-3.7.1.js +2 -0
  152. taguette/static/js/vendor/jquery.contextMenu.min.js +2 -0
  153. taguette/static/libreqda.ico +0 -0
  154. taguette/static/logo-h.png +0 -0
  155. taguette/static/logo.png +0 -0
  156. taguette/static/logo_blanc.png +0 -0
  157. taguette/static/missing.png +0 -0
  158. taguette/static/robots.txt +2 -0
  159. taguette/static/scss/components/_badge.scss +42 -0
  160. taguette/static/scss/components/_button.scss +169 -0
  161. taguette/static/scss/components/_checkbox.scss +40 -0
  162. taguette/static/scss/components/_context_menu.scss +28 -0
  163. taguette/static/scss/components/_custom_select.scss +150 -0
  164. taguette/static/scss/components/_dropdown.scss +43 -0
  165. taguette/static/scss/components/_form.scss +54 -0
  166. taguette/static/scss/components/_modal.scss +57 -0
  167. taguette/static/scss/components/_project_card.scss +163 -0
  168. taguette/static/scss/components/_tag.scss +308 -0
  169. taguette/static/scss/components/_treeview.scss +76 -0
  170. taguette/static/scss/custom.scss +809 -0
  171. taguette/static/scss/layout/_credits.scss +62 -0
  172. taguette/static/scss/layout/_footer.scss +6 -0
  173. taguette/static/scss/layout/_navigation.scss +165 -0
  174. taguette/static/scss/layout/_sidebar.scss +37 -0
  175. taguette/static/scss/themes/default/_variables.scss +103 -0
  176. taguette/static/scss/themes/default/theme.css +3 -0
  177. taguette/static/scss/themes/default/theme.css.map +26 -0
  178. taguette/static/scss/themes/default/theme.scss +4 -0
  179. taguette/static/webfonts/BadScript-OFL.txt +94 -0
  180. taguette/static/webfonts/BadScript.ttf +0 -0
  181. taguette/static/webfonts/Montserrat-Light.ttf +0 -0
  182. taguette/static/webfonts/Montserrat-OFL.txt +93 -0
  183. taguette/static/webfonts/fa-brands-400.eot +0 -0
  184. taguette/static/webfonts/fa-brands-400.svg +1184 -0
  185. taguette/static/webfonts/fa-brands-400.ttf +0 -0
  186. taguette/static/webfonts/fa-brands-400.woff +0 -0
  187. taguette/static/webfonts/fa-brands-400.woff2 +0 -0
  188. taguette/static/webfonts/fa-regular-400.eot +0 -0
  189. taguette/static/webfonts/fa-regular-400.svg +467 -0
  190. taguette/static/webfonts/fa-regular-400.ttf +0 -0
  191. taguette/static/webfonts/fa-regular-400.woff +0 -0
  192. taguette/static/webfonts/fa-regular-400.woff2 +0 -0
  193. taguette/static/webfonts/fa-solid-900.eot +0 -0
  194. taguette/static/webfonts/fa-solid-900.svg +2618 -0
  195. taguette/static/webfonts/fa-solid-900.ttf +0 -0
  196. taguette/static/webfonts/fa-solid-900.woff +0 -0
  197. taguette/static/webfonts/fa-solid-900.woff2 +0 -0
  198. taguette/templates/alt_templates/.gitkeep +0 -0
  199. taguette/templates/archived_projects.html +50 -0
  200. taguette/templates/auth/account.html +74 -0
  201. taguette/templates/auth/cilogon.login.html +89 -0
  202. taguette/templates/auth/email_reset_password.html +5 -0
  203. taguette/templates/auth/external_account.html +60 -0
  204. taguette/templates/auth/login.html +144 -0
  205. taguette/templates/auth/reset_password.html +27 -0
  206. taguette/templates/auth/token_needed.html +7 -0
  207. taguette/templates/base.html +209 -0
  208. taguette/templates/bug_report.html +158 -0
  209. taguette/templates/cookies_prompt.html +15 -0
  210. taguette/templates/downloads.html +127 -0
  211. taguette/templates/email_bug_report.txt +9 -0
  212. taguette/templates/email_reset_password.txt +5 -0
  213. taguette/templates/error.html +8 -0
  214. taguette/templates/export_codebook.html +20 -0
  215. taguette/templates/export_document.html +20 -0
  216. taguette/templates/export_highlights.html +47 -0
  217. taguette/templates/index.html +347 -0
  218. taguette/templates/maintenance.html +8 -0
  219. taguette/templates/messages.js +74 -0
  220. taguette/templates/modal/change_document.html +68 -0
  221. taguette/templates/modal/create_collection.html +100 -0
  222. taguette/templates/modal/create_document.html +84 -0
  223. taguette/templates/modal/create_highlight.html +38 -0
  224. taguette/templates/modal/create_tag.html +73 -0
  225. taguette/templates/modal/explore_codes.html +58 -0
  226. taguette/templates/modal/manage_collaborators.html +44 -0
  227. taguette/templates/modal/merge_tag.html +49 -0
  228. taguette/templates/new_password.html +32 -0
  229. taguette/templates/partials/alert.html +16 -0
  230. taguette/templates/partials/codebook_viz_modal.html +34 -0
  231. taguette/templates/partials/confirm.html +17 -0
  232. taguette/templates/partials/document_toolbar.html +37 -0
  233. taguette/templates/partials/hlinfo.html +13 -0
  234. taguette/templates/partials/project_card.html +44 -0
  235. taguette/templates/partials/project_card_archived.html +41 -0
  236. taguette/templates/partials/project_sidebar.html +99 -0
  237. taguette/templates/partials/spinner.html +11 -0
  238. taguette/templates/permissions.html +54 -0
  239. taguette/templates/project.html +288 -0
  240. taguette/templates/project_delete.html +17 -0
  241. taguette/templates/project_import.html +122 -0
  242. taguette/templates/project_import_codebook.html +34 -0
  243. taguette/templates/project_import_codebook_confirm.html +49 -0
  244. taguette/templates/project_new.html +31 -0
  245. taguette/templates/tos.html +8 -0
  246. taguette/templates/trans.js +7 -0
  247. taguette/templates/welcome.html +15 -0
  248. taguette/utils.py +177 -0
  249. taguette/validate.py +141 -0
  250. taguette/version_check.py +102 -0
  251. taguette/web/__init__.py +252 -0
  252. taguette/web/api.py +1835 -0
  253. taguette/web/base.py +864 -0
  254. taguette/web/export.py +338 -0
  255. taguette/web/views.py +1618 -0
@@ -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,9 @@
1
+ import prometheus_client
2
+
3
+
4
+ PROM_DATABASE_VERSION = prometheus_client.Gauge('database_version',
5
+ "Database version",
6
+ ['version'])
7
+ PROM_COMMAND = prometheus_client.Counter('commands_total',
8
+ "Number of commands",
9
+ ['type'])
@@ -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