582 Commits

Author SHA1 Message Date
Patrick Jentsch
56844e0898 Update Terms of Use Modal and fix message flashing. 2025-06-03 13:51:28 +02:00
Patrick Jentsch
c28d534942 Fix redirect loop 2025-05-23 11:38:32 +02:00
Patrick Jentsch
80604bf8de enhance modals and terms of use html 2024-12-23 14:49:06 +01:00
Patrick Jentsch
d4cd313940 Implement /admin using flask-admin. Overall cleanup 2024-12-16 15:37:19 +01:00
Patrick Jentsch
c405061574 Restructure corpora blueprint 2024-12-16 11:39:54 +01:00
Patrick Jentsch
6c1f48eb2f Update corpora package 2024-12-16 10:09:54 +01:00
Patrick Jentsch
cda28910f5 Update settings 2024-12-16 10:07:21 +01:00
Patrick Jentsch
9a805b9d14 Add value to submit button 2024-12-16 09:57:18 +01:00
Patrick Jentsch
16bf891654 fix wtf macros 2024-12-16 09:45:19 +01:00
Patrick Jentsch
cb53b27ebf Rename function 2024-12-12 15:55:39 +01:00
Patrick Jentsch
6684257bc4 added job inputs/results blueprints 2024-12-12 15:32:56 +01:00
Patrick Jentsch
0d1805fb76 Update Job Blueprint package 2024-12-12 15:26:01 +01:00
Patrick Jentsch
bb60a2ba67 Move jobs namespace back to http routes 2024-12-12 10:32:08 +01:00
Patrick Jentsch
328f85ba52 Implement corpora endpoint/socket.io namespace 2024-12-09 16:12:49 +01:00
Patrick Jentsch
93344c9573 Add job namespace and remove old json_routes logic 2024-12-06 11:41:36 +01:00
Patrick Jentsch
1372c86609 Add api and administration links to navigations 2024-12-06 10:14:45 +01:00
Patrick Jentsch
713a7645db Bump nopaque version 2024-12-05 15:34:11 +01:00
Patrick Jentsch
0c64c07925 Update corpus analysis loading modal 2024-12-05 15:33:15 +01:00
Patrick Jentsch
a6ddf4c980 Remove import corpus button 2024-12-05 15:12:53 +01:00
Patrick Jentsch
cab5f7ea05 More js enhancements 2024-12-05 15:07:13 +01:00
Patrick Jentsch
07f09cdbd9 fix cqi_over_socketio 2024-12-05 15:07:03 +01:00
Patrick Jentsch
c97b2a886e Further js refactoring 2024-12-05 14:26:05 +01:00
Patrick Jentsch
df2bffe0fd implement first version of jobs socketio namespace 2024-12-03 16:09:14 +01:00
Patrick Jentsch
aafb3ca3ec Update javascript app structure 2024-12-03 15:59:08 +01:00
Patrick Jentsch
12a3ac1d5d Update JS code structure 2024-12-02 09:34:17 +01:00
Patrick Jentsch
a2904caea2 Update cqpserver image version 2024-11-28 10:02:27 +01:00
Patrick Jentsch
e325552100 Update corpus analysis tabs to look the same as before base template update 2024-11-28 10:02:00 +01:00
Patrick Jentsch
e269156925 fix socketio emits from database event listeners 2024-11-27 15:46:54 +01:00
Patrick Jentsch
9c9de242ca Remove unsed css 2024-11-27 11:35:51 +01:00
Patrick Jentsch
ec54fdc3bb Restore service scheme on pages 2024-11-27 11:34:21 +01:00
Patrick Jentsch
2263a8d27d codestyle enhancements in base template 2024-11-21 11:22:57 +01:00
Patrick Jentsch
143cdd91f9 update workspace settings 2024-11-21 11:22:46 +01:00
Patrick Jentsch
b5f7478e14 Update templates 2024-11-21 11:12:11 +01:00
Patrick Jentsch
a95b8d979d Fix forms 2024-11-20 15:56:48 +01:00
Patrick Jentsch
18d5ab160e Optimize jinja wtf macros 2024-11-20 15:56:29 +01:00
Patrick Jentsch
7439edacef Add background color to job list entries 2024-11-20 15:55:59 +01:00
Patrick Jentsch
99d7a8bdfc Some fixes and improve jinja2 template performance by reducing include statements 2024-11-19 15:28:43 +01:00
Patrick Jentsch
54c4295bf7 Fixes and more descriptions 2024-11-18 13:32:55 +01:00
Patrick Jentsch
1e5c26b8e3 Reorganize Socket.IO code 2024-11-18 12:36:37 +01:00
Patrick Jentsch
9f56647cf7 highlight active items in top navbar 2024-11-18 12:35:53 +01:00
Patrick Jentsch
460257294d Use relative import for sub blueprints 2024-11-18 11:08:28 +01:00
Patrick Jentsch
2c43333c94 Check tos accepted in registration form 2024-11-18 11:03:29 +01:00
Patrick Jentsch
fc8b11fa66 update auth package 2024-11-15 16:07:29 +01:00
Patrick Jentsch
a8ab1bee71 Move some blueprints and rename routes 2024-11-15 15:59:08 +01:00
Patrick Jentsch
ee7f64f5be Design update 2024-11-15 15:21:26 +01:00
Patrick Jentsch
6aacac2419 flatten the contributions blueprint 2024-11-14 14:36:18 +01:00
Patrick Jentsch
ce253f4a65 Make the header span over the complete width 2024-11-13 16:08:18 +01:00
Patrick Jentsch
7b604ce4f2 Remove manual-modal references 2024-11-11 14:51:17 +01:00
Patrick Jentsch
98b20e5cab Remove colors from social area 2024-11-11 13:38:47 +01:00
Patrick Jentsch
a322ffb2f1 Fix README 2024-11-11 12:05:03 +01:00
Patrick Jentsch
29365984a3 fix some namespace responses 2024-11-11 08:45:16 +01:00
Patrick Jentsch
bd0a9c60f8 strictly use socket.io class based namespaces 2024-11-07 12:12:42 +01:00
Patrick Jentsch
d41ebc6efe Fix project vscode settings 2024-11-07 10:51:35 +01:00
Patrick Jentsch
63690222ed Rename cqi extensions file 2024-11-07 10:44:27 +01:00
Patrick Jentsch
b4faa1c695 Code enhancements in vrt file normalizer module 2024-11-07 10:40:25 +01:00
Patrick Jentsch
909b130285 Fix wrong import 2024-11-07 09:48:40 +01:00
Patrick Jentsch
c223f07289 Codestyle enhancements 2024-11-07 08:57:32 +01:00
Patrick Jentsch
fcb49025e9 remove unused socketio event handlers 2024-11-07 08:51:49 +01:00
Patrick Jentsch
191d7813a7 prefix extension name with "nopaque_" 2024-11-07 08:35:02 +01:00
Patrick Jentsch
f255fef631 Remove debug print statement 2024-11-07 08:32:20 +01:00
Patrick Jentsch
76171f306d Remove debug print statements 2024-11-07 08:31:52 +01:00
Patrick Jentsch
5ea6d45f46 Reset all corpora on deploy cli command 2024-11-07 08:31:31 +01:00
Patrick Jentsch
289a551122 Create dedicated '/users' Socket.IO Namespace 2024-11-06 13:04:30 +01:00
Patrick Jentsch
2a28f19660 Move Socket.IO Namespaces to dedicated directory 2024-11-06 12:27:49 +01:00
Patrick Jentsch
fc2ace4b9e Remove unused Socket.IO AdminNamespace 2024-11-05 14:55:48 +01:00
Patrick Jentsch
a174bf968f Remove unused config entry 2024-11-05 14:02:45 +01:00
Patrick Jentsch
551b928dca Add typehints to email code 2024-11-05 09:05:31 +01:00
Patrick Jentsch
eeb5a280b3 move blueprints in dedicated folder 2024-09-30 13:30:13 +02:00
Patrick Jentsch
5fc3015bf1 rename functions to indicate that they should not be imported directly 2024-09-26 15:34:52 +02:00
Patrick Jentsch
5f05cedf5e Make the "daemon" (now tasks) more understandable 2024-09-26 15:33:32 +02:00
Patrick Jentsch
aabea234fe More simplification 2024-09-26 14:45:05 +02:00
Patrick Jentsch
492fdc9d28 modernize type hinting 2024-09-25 17:46:53 +02:00
Patrick Jentsch
02e6c7c16c various updates 2024-09-25 12:08:20 +02:00
Patrick Jentsch
c7ca674b2f Streamline setup process and init code 2024-09-25 10:45:53 +02:00
Patrick Jentsch
81c6f32a35 Simplify logging configuration 2024-08-01 16:29:06 +02:00
Patrick Jentsch
94548ac30c Move sheduler start logic 2024-08-01 12:10:33 +02:00
Patrick Jentsch
158190de1a Codesstyle enhancements 2024-08-01 12:00:52 +02:00
Patrick Jentsch
13e4d461c7 Update .env.tpl 2024-08-01 12:00:34 +02:00
Patrick Jentsch
e51dcafa6f Update vscode settings.json 2024-08-01 11:59:50 +02:00
Patrick Jentsch
f79c6d48b2 Going back to vanilla css 2024-07-01 15:37:34 +02:00
Patrick Jentsch
5ee9edef9f Fix multiple db event listener registrations 2024-06-03 11:08:21 +02:00
Patrick Jentsch
f1ccda6ad7 Fix colors in corpus analysis 2024-06-03 11:03:57 +02:00
Patrick Jentsch
a65b1ff578 remove manual modal 2024-05-27 16:58:51 +02:00
Patrick Jentsch
fe0fcb0e10 fix job status notifications 2024-05-27 09:24:40 +02:00
Patrick Jentsch
32fa632961 move anonymous user to seperate file 2024-05-27 09:23:08 +02:00
Patrick Jentsch
562b8d5ce0 Move clearfix to helpers.scss 2024-05-27 08:58:04 +02:00
Patrick Jentsch
cbd0a41bce style and compatibility update 2024-05-21 10:29:12 +02:00
Patrick Jentsch
c68286e010 more flexible navbar code in base template 2024-05-08 14:01:01 +02:00
Patrick Jentsch
4a29a52f2a Remove implementation of FileSize validator 2024-05-04 17:17:06 +02:00
Patrick Jentsch
991810cff5 Use HTML comments in navbar.html.j2 2024-05-04 17:04:25 +02:00
Patrick Jentsch
6025a4a606 add API back 2024-05-04 15:14:21 +02:00
Patrick Jentsch
e1cfd394fa move socketio decorators 2024-05-04 14:55:05 +02:00
Patrick Jentsch
882987ba68 fixes 2024-05-04 14:41:40 +02:00
Patrick Jentsch
a03b5918d9 More Streamlining 2024-05-03 15:08:57 +02:00
Patrick Jentsch
43b38b2216 better large screen handling 2024-04-30 16:21:23 +02:00
Patrick Jentsch
543276d766 A lot of generalization for better scaling and overview 2024-04-30 16:00:06 +02:00
Patrick Jentsch
485a0155c6 Bump dependencies. Some parts needed to be deactivated for that. They need to be reimplemented.
- breadcrumbs (!flask-breadcrumbs)
- manual modal button
- api blueprint (!flask-marshmallow/!marshmallow-sqlalchemy)
2024-04-30 08:41:29 +02:00
Patrick Jentsch
c29c50feb9 new event system first draft 2024-04-18 15:37:17 +02:00
Patrick Jentsch
c191e7bd4a rename extension extras 2024-04-18 15:35:41 +02:00
Patrick Jentsch
8f960cf359 explicitly set permissions to false for anonymous users 2024-04-11 15:46:58 +02:00
Patrick Jentsch
ccf484c9bc make is_administrator a property, add back db events 2024-04-11 14:33:47 +02:00
Patrick Jentsch
d0d2a8abd6 Move external static content into external directory 2024-04-11 14:13:04 +02:00
Patrick Jentsch
03876f6a39 Self host external css/fonts/js 2024-04-11 11:08:37 +02:00
Patrick Jentsch
cdf6f9fcfd declutter by using default filenames 2024-04-10 13:52:33 +02:00
Patrick Jentsch
268da220d2 Enhance code structure 2024-04-10 13:34:48 +02:00
Patrick Jentsch
84e1755a57 Remove version declaration in docker compose files. 2024-04-10 13:34:14 +02:00
Patrick Jentsch
82d6f6003f Restructure Dockerfile for better caching 2024-03-13 12:58:39 +01:00
Patrick Jentsch
9da74c1c6f Use pathlib where possible 2024-03-07 15:49:04 +01:00
Patrick Jentsch
ec23bd94ee add missing import 2024-03-06 14:46:25 +01:00
Patrick Jentsch
55a62053b0 Make models in package work 2024-03-06 13:20:02 +01:00
Patrick Jentsch
a1e5bd61e0 move models in seperate modules 2024-03-05 16:02:23 +01:00
Patrick Jentsch
cf8c164d60 allow .whl files for spacy nlp pipeline contributions 2024-02-14 14:24:21 +01:00
Patrick Jentsch
05ab204e5a Merge branch 'master' into development 2024-02-14 13:43:45 +01:00
Patrick Jentsch
9f188afd16 Bump nopaque version 2024-02-14 13:42:20 +01:00
Patrick Jentsch
dc77ac7b76 Add new spaCy NLP Pipeline version 2024-02-14 13:40:49 +01:00
Inga Kirschnick
84276af322 Merge branch 'development' 2024-01-23 14:38:24 +01:00
Inga Kirschnick
d9d4067536 Set new version in config 2024-01-23 14:38:10 +01:00
Inga Kirschnick
ba65cf5911 Merge branch 'development' 2024-01-23 14:19:06 +01:00
Patrick Jentsch
69a1edc51e fix eventlet version 2024-01-23 13:31:15 +01:00
Inga Kirschnick
32ad8c7359 News + user avatar fix 2024-01-22 10:58:52 +01:00
Patrick Jentsch
8c0843d2d0 Merge branch 'development' 2023-12-21 14:28:10 +01:00
Patrick Jentsch
d4c9ab5821 Add user reste cli command 2023-12-21 14:27:50 +01:00
Patrick Jentsch
518a245133 Merge branch 'development' 2023-12-21 13:54:25 +01:00
Inga Kirschnick
b6864b355a Bug fixes 2023-12-21 13:03:58 +01:00
Inga Kirschnick
0a45e1bb65 Bug fixes 2023-12-21 12:48:50 +01:00
Inga Kirschnick
08ca938333 Corrections in Terms of Use 2023-12-21 09:31:42 +01:00
Inga Kirschnick
cfdef8d1fa New terms of use + privacy statement 2023-12-20 15:37:59 +01:00
Inga Kirschnick
5dce269736 Version number + original slogan font 2023-12-18 12:49:30 +01:00
Patrick Jentsch
13369296d3 rename docker-entrypoint.sh to docker-nopaque-entrypoint.sh 2023-12-15 13:56:03 +01:00
Patrick Jentsch
4f6e1c121f Add nopaque version config variable 2023-12-15 08:47:59 +01:00
Patrick Jentsch
438a257fe3 Update CI script 2023-12-15 08:47:46 +01:00
Patrick Jentsch
2e88d7d035 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-12-15 08:37:02 +01:00
Patrick Jentsch
b338c33d42 Bump cwb version 2023-12-15 08:36:50 +01:00
Gloria Glinphratum
d6cebddd92 Updated query builder gifs and instructions 2023-12-12 14:56:08 +01:00
Gloria Glinphratum
07fda0e95a Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-12-07 22:35:41 +01:00
Gloria Glinphratum
3927d9e4cd Edits in structural attributes section and others 2023-12-07 22:34:00 +01:00
Inga Kirschnick
8f5d5ffdec Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-12-07 12:46:48 +01:00
Inga Kirschnick
f02d1619e2 Try to implement anchor tags 2023-12-07 12:46:37 +01:00
Patrick Jentsch
892f1f799e Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-12-05 15:00:49 +01:00
Patrick Jentsch
f5e98ae655 Add badges to README 2023-12-05 15:00:21 +01:00
Gloria Glinphratum
f790106e0e Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-12-05 14:54:05 +01:00
Gloria Glinphratum
c57acc73d2 Manual changes 2023-12-05 14:42:38 +01:00
Patrick Jentsch
678a0767b7 Change Manual icon 2023-11-30 11:21:39 +01:00
Patrick Jentsch
17a9338d9f Fix job deletion from job page 2023-11-29 16:11:14 +01:00
Patrick Jentsch
a7cbce1eda Fix wrong spacy-nlp-pipeline version number 2023-11-29 10:45:35 +01:00
Inga Kirschnick
fa28c875e1 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-11-28 12:40:05 +01:00
Inga Kirschnick
0927edcceb Bug Fixes 2023-11-28 12:39:54 +01:00
Patrick Jentsch
9c22370eea Implement force download parameter in model insert_defaults methods 2023-11-28 12:10:55 +01:00
Patrick Jentsch
bdcc80a66f Add new tesseract-ocr-pipeline version. Remove redundant spacy-nlp-pipeline version. 2023-11-28 10:34:30 +01:00
Patrick Jentsch
9be5ce6014 link logo to homepage 2023-11-23 13:32:54 +01:00
Patrick Jentsch
00e4c3ade3 Add logo to sidenav 2023-11-23 13:26:19 +01:00
Patrick Jentsch
79a16cae83 Add links to my profile page 2023-11-23 13:16:21 +01:00
Inga Kirschnick
c5aea0be94 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-11-22 12:50:18 +01:00
Inga Kirschnick
afcb890ccf Element Target drag&drop + small improvements 2023-11-22 12:50:08 +01:00
Patrick Jentsch
9627708950 rename manual files to fit new naming convention 2023-11-21 12:31:10 +01:00
Patrick Jentsch
1bb1408988 make the workshops package fit the new file scheme 2023-11-21 10:11:49 +01:00
Patrick Jentsch
79bafdea89 Switch back to older settings and extension .vscode setup 2023-11-20 15:26:22 +01:00
Patrick Jentsch
a2d617718b Update .vscode directory contents 2023-11-20 11:05:56 +01:00
Inga Kirschnick
691b2de5b2 Bug Fix: lock chips after switch to QB 2023-11-20 09:48:06 +01:00
Patrick Jentsch
eb0e7c9ba1 Fix error on not authenticated users 2023-11-20 09:35:53 +01:00
Patrick Jentsch
ab132746e7 Add TODO in migration scripts 2023-11-17 10:42:55 +01:00
Inga Kirschnick
ae5646512d Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-11-17 10:15:50 +01:00
Inga Kirschnick
fc66327920 Make double quotation marks escapable again 2023-11-17 10:15:39 +01:00
Patrick Jentsch
9bfc96ad41 minor codestyle fix 2023-11-16 17:22:07 +01:00
Patrick Jentsch
008938b46b Avatar in top right corner 2023-11-16 15:57:27 +01:00
Inga Kirschnick
4f24e9f9da Erase meta data logic from struc attribute builder 2023-11-14 09:48:38 +01:00
Inga Kirschnick
d0fe4360bb Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-11-13 15:37:26 +01:00
Patrick Jentsch
1c18806c9c Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-11-13 15:53:17 +01:00
Patrick Jentsch
9487aa7a60 Restructure modals and base template 2023-11-13 15:53:14 +01:00
Inga Kirschnick
6559051fd5 Delete condition logic in token builder 2023-11-13 15:37:19 +01:00
Inga Kirschnick
0882e085a3 Function renaming 2023-11-13 14:46:19 +01:00
Patrick Jentsch
ff1bcb40f3 update query builder code to fit the new style 2023-11-13 14:20:19 +01:00
Patrick Jentsch
d298b200dc Move javascript files to fit new style 2023-11-13 12:59:36 +01:00
Patrick Jentsch
660d7ebc99 Fix sidenav profile entries 2023-11-13 12:46:48 +01:00
Patrick Jentsch
df33c7b36d Fix old Utils references in js 2023-11-13 10:30:24 +01:00
Inga Kirschnick
bf8b22fb58 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-11-13 09:43:03 +01:00
Inga Kirschnick
b216ad8a40 QB parts as extensions 2023-11-13 09:42:56 +01:00
Patrick Jentsch
4822f6ec02 integrate js cqi into corpus_analysis package 2023-11-10 10:27:39 +01:00
Patrick Jentsch
61be3345be some javascript fixes after namespace implementation 2023-11-09 15:51:00 +01:00
Patrick Jentsch
e9ddb85f03 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-11-09 14:30:10 +01:00
Patrick Jentsch
e3166ca54c Use a single js namespace as parent for all other nopaque namespaces. 2023-11-09 14:29:01 +01:00
Inga Kirschnick
0565f309f8 Split QB back to mult. classes, as far as possible 2023-11-08 15:46:53 +01:00
Inga Kirschnick
1f40002249 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-11-07 13:24:11 +01:00
Inga Kirschnick
1ff9c8bfe3 Query Builder in one class 2023-11-07 13:24:01 +01:00
Patrick Jentsch
e8fe67d290 Some code cleanup 2023-10-30 11:36:28 +01:00
Inga Kirschnick
fbb32ef580 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-10-26 15:18:32 +02:00
Inga Kirschnick
985e9b406f Editing nested token queries and bug fixes 2023-10-26 15:18:03 +02:00
Patrick Jentsch
0abfe65afa Bring back community update 2/x 2023-10-25 16:21:30 +02:00
Patrick Jentsch
f4d3415c11 First work to bring back Community Update functionality 2023-10-24 16:11:08 +02:00
Patrick Jentsch
965f2854b2 Add comments to JavaScript and some restructuring 2023-10-24 15:09:20 +02:00
Patrick Jentsch
f101a742a9 Fix broken dependency with Flask-Assets >2.0 2023-10-24 13:18:46 +02:00
Inga Kirschnick
c046fbfb1e Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-10-19 10:21:23 +02:00
Inga Kirschnick
8997d3ad67 New condition section token builder 2023-10-19 10:21:12 +02:00
Patrick Jentsch
bf249193af integrate all js files into assets 2023-10-12 14:13:47 +02:00
Patrick Jentsch
c40e428eb2 add more constants and type hints to cqi package 2023-10-12 10:27:28 +02:00
Patrick Jentsch
4daf3359b9 move constants in cqi package into seperate file 2023-10-12 10:03:12 +02:00
Patrick Jentsch
d875623a8c Remove clickable class from not clickable elements 2023-10-11 16:23:10 +02:00
Patrick Jentsch
067318bb89 Huge List class update 2023-10-11 16:20:17 +02:00
Patrick Jentsch
a9203cc409 Fix forms and displays 2023-10-11 14:26:07 +02:00
Patrick Jentsch
78dd375ef8 Performance update for the docker entrypoint script 2023-10-10 15:28:10 +02:00
Patrick Jentsch
82cd384e5f Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-10-10 11:06:50 +02:00
Patrick Jentsch
c7dab5e502 intermediate update on displays and forms 1/2 2023-10-10 11:06:44 +02:00
Inga Kirschnick
d3cfd2cfaf Editing Meta Data and Tokens 2023-10-09 16:30:46 +02:00
Inga Kirschnick
14c10aeab1 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-10-09 15:42:10 +02:00
Inga Kirschnick
2dec17b1b9 Editing Entity Type 2023-10-09 15:42:01 +02:00
Patrick Jentsch
9fe38fab52 Remove debug messages 2023-10-09 14:34:06 +02:00
Patrick Jentsch
e20dd01710 Better auto initialization method for forms and resource displays 2023-10-09 14:21:31 +02:00
Patrick Jentsch
1b974f0bbc Fix Requests usage again 2023-10-06 15:04:36 +02:00
Patrick Jentsch
c6be72d0a7 Update broken requests 2023-10-06 11:55:31 +02:00
Patrick Jentsch
d3f2d5648e Further javascript improvements 2023-10-05 16:08:04 +02:00
Patrick Jentsch
7cae84ffdc Make the joblist clickable again 2023-10-05 14:19:46 +02:00
Patrick Jentsch
1d6834302d Change js structure for displays 2023-10-05 14:11:17 +02:00
Patrick Jentsch
53f4400731 rename Requests namespace to requests 2023-10-04 14:07:39 +02:00
Patrick Jentsch
f36600f06c downgrade python to v3.10.13 2023-10-04 13:48:32 +02:00
Patrick Jentsch
068211a72b add missing semicolons 2023-10-04 13:48:10 +02:00
Patrick Jentsch
f566e276a1 use better js naming conventions 2023-10-04 12:32:27 +02:00
Patrick Jentsch
c605613d86 Fix path 2023-09-26 15:08:22 +02:00
Patrick Jentsch
d1fc425f48 Update docker compose file examples 2023-09-26 15:02:02 +02:00
Inga Kirschnick
b8ae221987 Expert Mode - Query Builder Switch Parser v1 2023-09-25 14:40:39 +02:00
Patrick Jentsch
b50147a66a Use IP instead of Hostname again... 2023-09-25 13:43:17 +02:00
Patrick Jentsch
18311c8c9c Use Hostname for cqpserver again... 2023-09-25 13:39:39 +02:00
Patrick Jentsch
2dc54f4258 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-09-25 13:28:51 +02:00
Patrick Jentsch
bcdc3721ef Don't use container name as host for cqiclient 2023-09-25 13:28:48 +02:00
Inga Kirschnick
60bcaa9e01 Corpus Analysis Asset update 2023-09-25 12:42:10 +02:00
Patrick Jentsch
af89a5776f add missing dot 2023-09-25 10:18:19 +02:00
Patrick Jentsch
fcbf9c8cb6 Set default values in docker compose 2023-09-25 10:17:06 +02:00
Patrick Jentsch
cc6ce6e1f3 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-09-25 10:11:51 +02:00
Patrick Jentsch
4581367d04 Restructure startup procedure 2023-09-25 10:11:11 +02:00
Inga Kirschnick
d7f00f6337 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-09-18 17:05:10 +02:00
Inga Kirschnick
86947e2cf8 First parser text to query Chip 2023-09-18 17:05:01 +02:00
Patrick Jentsch
4a9a03e648 Update cwb image 2023-09-15 11:42:37 +02:00
Inga Kirschnick
45369d4c84 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-09-12 16:42:38 +02:00
Inga Kirschnick
f56e951b71 Locking end-tag of structural attributes, ... 2023-09-12 16:42:28 +02:00
Patrick Jentsch
d776e11fe5 Bump python version 2023-09-11 14:39:05 +02:00
Patrick Jentsch
9200837e63 Use new cqi version. No chunking needed anymore 2023-09-08 11:12:43 +02:00
Patrick Jentsch
aad347caa0 Fix problems with Flask-Breadcrumbs (use fixed Flask-Menu versio) 2023-09-06 13:59:39 +02:00
Inga Kirschnick
9ccab8657a Query Builder: Incidence Modifier for tokens 2023-08-29 17:06:10 +02:00
Inga Kirschnick
fe7f69d596 QB form update + incidence modifier 2023-08-21 07:26:54 +02:00
Inga Kirschnick
8a5c94f448 Bug fix 2023-08-11 14:38:18 +02:00
Inga Kirschnick
3d38e550a0 Update Positional Attribute Modal Query Builder 2023-08-11 13:55:41 +02:00
Patrick Jentsch
1387d80a26 Update cqi utils 2023-08-11 13:50:56 +02:00
Patrick Jentsch
5c00c5740e upgrade cqi to 0.1.6 2023-08-11 10:49:40 +02:00
Patrick Jentsch
04575b78cf Codestyle enhancements 2023-08-10 15:48:49 +02:00
Patrick Jentsch
2951fc6966 fix id issues 2023-08-08 16:08:58 +02:00
Inga Kirschnick
bf0213edbc Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-08-08 16:00:34 +02:00
Inga Kirschnick
c843fbb437 changing id_prefix 2023-08-08 16:00:30 +02:00
Patrick Jentsch
1dc7d2a1c6 remove prefix stuff 2023-08-08 16:00:05 +02:00
Patrick Jentsch
173aea7df4 Fix id reference errors 2023-08-08 14:33:07 +02:00
Inga Kirschnick
f1962b3b47 add id_prefix to query builder macro 2023-08-08 14:19:50 +02:00
Inga Kirschnick
dd04623278 Merge branch 'query-builder' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into query-builder 2023-08-08 14:15:44 +02:00
Inga Kirschnick
5e8008399d First Query Builder/Expert Mode implementation 2023-08-08 14:12:07 +02:00
Patrick Jentsch
0d92f221cb rename ui-form 2023-08-08 14:07:07 +02:00
Patrick Jentsch
766c5ba27d Update corpus analysis extensions to use dynamic id prefixes for elements 2023-08-08 12:21:47 +02:00
Patrick Jentsch
661ac7c509 Fix macro problems in corpus analysis 2023-08-08 11:28:10 +02:00
Patrick Jentsch
3b390858ff Use macros for html generation instead of variables 2023-08-08 10:48:36 +02:00
Inga Kirschnick
ae8e383085 First rearrangement Query Builder 2023-08-02 14:14:46 +02:00
Inga Kirschnick
9ac626c64d Merge branch 'development' 2023-07-26 20:44:54 +02:00
Inga Kirschnick
d0c6b2b9e5 Merge branch 'development' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into development 2023-07-26 20:37:25 +02:00
Inga Kirschnick
8277e60689 Merge branch 'visualizations-update' into development 2023-07-26 20:36:57 +02:00
Inga Kirschnick
8b887d79ef Workshop Aufgaben update 2023-07-26 20:36:24 +02:00
Patrick Jentsch
c9ad538bee Merge branch 'development' 2023-07-26 11:35:39 +02:00
Patrick Jentsch
983400b925 Merge branch 'visualizations-update' into development 2023-07-26 11:32:11 +02:00
Inga Kirschnick
37f9e1281d Merge branch 'visualizations-update' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into visualizations-update 2023-07-26 11:25:37 +02:00
Inga Kirschnick
5eef2292e7 bug fixes 2023-07-26 11:25:32 +02:00
Patrick Jentsch
351da5d4e9 Fix admin delete user in AdminUserList.js 2023-07-26 10:53:34 +02:00
Inga Kirschnick
27fe4a95e4 Add "(beta)" to Static Visualization + small fixes 2023-07-26 09:03:36 +02:00
Inga Kirschnick
0627b27ec7 Merge branch 'visualizations-update' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into visualizations-update 2023-07-25 17:16:25 +02:00
Inga Kirschnick
adfd229e66 FGHO Sommerschule 2023 Aufgaben 2023-07-25 17:16:20 +02:00
Patrick Jentsch
ae6a7cb86d Add vorbereitungen section for workshop fgho 2023 2023-07-25 16:04:45 +02:00
Patrick Jentsch
2dd6015ba6 Merge branch 'visualizations-update' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into visualizations-update 2023-07-25 15:20:23 +02:00
Patrick Jentsch
f80b635ca3 Add workshops package 2023-07-25 15:18:57 +02:00
Inga Kirschnick
0e8a87d34e Query Builder fixes 2023-07-25 14:56:07 +02:00
Patrick Jentsch
ccf7f449dd Bump cqi version 2023-07-24 15:12:05 +02:00
Patrick Jentsch
dd05657362 Fix wrong pagination handling in concordance 2023-07-24 13:48:01 +02:00
Patrick Jentsch
cef82d9001 Merge branch 'visualizations-update' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into visualizations-update 2023-07-24 10:02:44 +02:00
Patrick Jentsch
656eef17db unify get_user event via socketio 2023-07-24 10:02:35 +02:00
Inga Kirschnick
104c2fe468 Merge branch 'visualizations-update' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into visualizations-update 2023-07-21 13:14:39 +02:00
Inga Kirschnick
d08f95e944 dynamic token visualization 2023-07-21 13:14:29 +02:00
Patrick Jentsch
87e2c2b484 Bump Flask-Hashids version 2023-07-19 11:10:50 +02:00
Patrick Jentsch
7a925b6a19 Better error handling in CorpusAnalysisApp 2023-07-18 17:18:04 +02:00
Inga Kirschnick
e4f435c5ee small fix 2023-07-18 16:07:06 +02:00
Inga Kirschnick
7721926d6c Merge branch 'visualizations-update' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into visualizations-update 2023-07-18 16:05:09 +02:00
Inga Kirschnick
691d4757ff Token list first implementation+ query builder fix 2023-07-18 16:01:31 +02:00
Inga Kirschnick
6c744fc3ba Query Builder fix 2023-07-18 14:05:11 +02:00
Patrick Jentsch
e46f0032bd Show static visualizations only on overview page 2023-07-17 12:35:53 +02:00
Patrick Jentsch
9da1a6e987 Add status text to corpus analysis app startup modal 2023-07-17 10:40:34 +02:00
Patrick Jentsch
8182cccecd Update cqi to v0.1.4 2023-07-14 12:59:27 +02:00
Patrick Jentsch
d898cd8516 Some Codestyle enhancements 2023-07-13 15:27:49 +02:00
Patrick Jentsch
4ae4b88a44 Cleanup in cqi over socketio 2023-07-13 12:42:47 +02:00
Patrick Jentsch
b7483af8e9 Bump Socket.IO client version 2023-07-13 12:40:37 +02:00
Patrick Jentsch
41d8dbad5d simplify decompression and decoding 2023-07-12 11:25:58 +02:00
Patrick Jentsch
203faa4257 Remove debug messages and move data inflation into api fn 2023-07-12 11:11:15 +02:00
Patrick Jentsch
960f36c740 Fix BrokenPipeError handling in cqi_over_socketio 2023-07-12 10:54:52 +02:00
Inga Kirschnick
c3834ca400 Outsource Static Viz to extensions logic 2023-07-11 15:52:44 +02:00
Inga Kirschnick
572fdf3a00 Small updates custom stopword list 2023-07-11 13:40:20 +02:00
Inga Kirschnick
22b43a689f Merge branch 'visualizations-update' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into visualizations-update 2023-07-11 09:33:22 +02:00
Inga Kirschnick
deec9e8a76 Custom Stopword List Settings 2023-07-11 09:33:11 +02:00
Patrick Jentsch
688b96ffee remove debug messages and increase chunk size in cqi 2023-07-07 11:47:34 +02:00
Patrick Jentsch
a9973e9c8e Add compression to static corpus data, use chunked computation, hide read corpus ids in corpus analysis 2023-07-06 13:02:22 +02:00
Patrick Jentsch
413b6111df Implement fast boundary computation for ent and s s_attrs 2023-07-03 15:31:28 +02:00
Patrick Jentsch
a9f05fffdf Fix Error handling in corpus analysis app 2023-07-03 13:28:52 +02:00
Patrick Jentsch
7936ac270b Merge branch 'visualizations-update' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into visualizations-update 2023-07-03 11:17:13 +02:00
Patrick Jentsch
1eabf18b13 Remove timeout in cqi js 2023-07-03 11:17:07 +02:00
Inga Kirschnick
94dc25750c Merge branch 'visualizations-update' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into visualizations-update 2023-07-03 11:06:59 +02:00
Inga Kirschnick
beb157092e New visualizations for frequencies 2023-07-03 11:06:43 +02:00
Patrick Jentsch
1cd9540e5b Improve cqi extension structure 2023-06-30 15:36:45 +02:00
Patrick Jentsch
912bd7da07 Remove TODOs that are done 2023-06-30 14:18:07 +02:00
Patrick Jentsch
e21ef2422d cqi-js: implement timeout 2023-06-30 14:13:34 +02:00
Patrick Jentsch
c52c966863 Better marking for non standard cqi additions ins cqi js 2023-06-30 12:55:32 +02:00
Patrick Jentsch
a7a948908f Small fixes and remove old cqi_over_socketio interface 2023-06-30 12:19:18 +02:00
Patrick Jentsch
3a97b1a07a Remove mention of old cqi client 2023-06-30 12:11:17 +02:00
Patrick Jentsch
315b538c30 Replace the old js CQiClient with fully featured new one 2023-06-30 12:10:17 +02:00
Patrick Jentsch
07103ee4e5 Fix issues in cqi_over_sio 2023-06-29 13:17:29 +02:00
Patrick Jentsch
efa8712cd9 Add a full featured cqi Javascript client for cqi_over_sio 2023-06-29 12:09:28 +02:00
Patrick Jentsch
e816a2fb15 implement missing cqi over socketio functions 2023-06-23 09:22:34 +02:00
Inga Kirschnick
6c31788402 Visualization fix for real data 2023-06-22 16:38:06 +02:00
Inga Kirschnick
1c98c5070a Merge branch 'visualizations-update' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into visualizations-update 2023-06-22 16:23:55 +02:00
Patrick Jentsch
1e33366820 fix cache loading string instead of parsing json 2023-06-22 16:44:29 +02:00
Patrick Jentsch
71013f1dc5 Add missing data and data cache to vis data generator function 2023-06-22 16:42:28 +02:00
Inga Kirschnick
142c82cc36 New data structure implementation 2023-06-22 16:23:46 +02:00
Patrick Jentsch
f84ac48975 Add test snippet for fast cpos boundary calculation for s_attrs 2023-06-22 14:19:14 +02:00
Patrick Jentsch
2739dc4b4f Remove debug code 2023-06-22 13:21:19 +02:00
Patrick Jentsch
eb2abf8282 Merge branch 'visualizations-update' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into visualizations-update 2023-06-22 12:46:36 +02:00
Patrick Jentsch
529c778772 codestyle 2023-06-22 12:45:33 +02:00
Patrick Jentsch
be51044059 Fix cqi_over_socketio not handling cqi status correctly 2023-06-22 12:45:23 +02:00
Inga Kirschnick
e194ce7541 Add token category selection for freqs graph 2023-06-21 17:05:36 +02:00
Inga Kirschnick
19e01d6709 Script fix + small optical changes 2023-06-21 08:46:08 +02:00
Inga Kirschnick
d6e17e1554 Update for real data visualization 2023-06-19 18:33:36 +02:00
Inga Kirschnick
11b697145b Merge branch 'visualizations-update' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into visualizations-update 2023-06-19 13:42:05 +02:00
Inga Kirschnick
11e1789d83 visualization testing 2023-06-19 13:41:56 +02:00
Patrick Jentsch
f037c31b88 Add more visualization data 2023-06-19 13:22:20 +02:00
Patrick Jentsch
972f514e6b Implementation of visdata v2 2023-06-16 17:35:54 +02:00
Patrick Jentsch
e6d8d72e52 Add visualization data method to cqi over socketio 2023-06-14 14:50:04 +02:00
Inga Kirschnick
91e68360ac Analysis Overview fix 2023-06-14 13:57:58 +02:00
Inga Kirschnick
c35b2f8674 Sidenav User Field height fix 2023-06-14 10:28:05 +02:00
Inga Kirschnick
71359523ba Sort Mechanics Text Info List 2023-06-13 17:18:00 +02:00
Inga Kirschnick
cc508cf4eb Visualization Testing Corpus Analysis 2023-06-13 15:41:34 +02:00
Patrick Jentsch
b7ca2a2cf6 Add new cqi method to get data for the new corpus overview 2023-06-12 13:01:27 +02:00
Inga Kirschnick
21d6072f6f Query Builder input fix 2023-06-12 11:53:51 +02:00
Inga Kirschnick
baf70750e8 Merge branch 'development' 2023-06-07 15:14:57 +02:00
Inga Kirschnick
b8bcb159a2 Hide Community Update code 2023-06-07 15:13:47 +02:00
Inga Kirschnick
15e7fa6dd3 Rename function 2023-06-07 14:34:29 +02:00
Inga Kirschnick
589c4a6c56 print deletion 2023-06-07 13:51:12 +02:00
Inga Kirschnick
9ffc41a133 update clickable list item 2023-06-07 13:49:07 +02:00
Inga Kirschnick
4944d31dd5 getUser back to CorpusList 2023-06-07 13:39:04 +02:00
Inga Kirschnick
62d20409ea Sleep Timer fix CorpusList 2023-06-07 11:29:00 +02:00
Inga Kirschnick
8aeafc33bd Admin page Corpus List fix 2023-06-07 11:23:59 +02:00
Inga Kirschnick
a54ff2e35a Update OCR-Models list + small fix for empty list 2023-06-07 10:35:16 +02:00
Inga Kirschnick
e93449ba73 Reload Dashboard after deletion/unfollow corpus 2023-06-06 15:47:21 +02:00
Inga Kirschnick
c2471e1848 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-06-06 15:39:53 +02:00
Inga Kirschnick
4c277cd685 Corpus List update 2023-06-06 15:39:47 +02:00
Patrick Jentsch
d6789a0388 Remove Anonymous from cfr selection in follow link gen 2023-06-06 13:47:24 +02:00
Patrick Jentsch
793de849ef Allow to change role by using a corpus follow link 2023-06-06 13:44:02 +02:00
Patrick Jentsch
f4b30433e6 Add back community update code 2023-06-06 11:48:58 +02:00
Patrick Jentsch
c2a6b9d746 comment out community update code 2023-06-05 16:52:20 +02:00
Inga Kirschnick
59950aba5b Typo fix 2023-06-05 15:55:12 +02:00
Inga Kirschnick
d619795815 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-06-05 15:49:19 +02:00
Inga Kirschnick
0e786803ee List Selection expansion & select all fix 2023-06-05 15:49:11 +02:00
Patrick Jentsch
ac2f27150b typo fixes 2023-06-05 14:02:53 +02:00
Patrick Jentsch
526fd1769e First works on adding select to joblist 2023-06-05 13:44:07 +02:00
Patrick Jentsch
86318b9a7d disable role select for self 2023-06-05 13:01:14 +02:00
Patrick Jentsch
e326e1ab81 remove debug follower list 2023-06-05 11:27:08 +02:00
Patrick Jentsch
2f3ddc6b81 Remove all cqi inga function code fragments 2023-06-05 10:42:52 +02:00
Patrick Jentsch
44d98dfa46 Add inga method to cqi_over_socketio 2023-06-01 11:24:09 +02:00
Inga Kirschnick
6648b3548b Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-05-31 16:10:15 +02:00
Inga Kirschnick
367c2ce5e0 Fix corpus follower on user page + optgroup models 2023-05-31 16:10:05 +02:00
Patrick Jentsch
5cc07f2e13 Some more events (unregistered for now) 2023-05-17 12:30:13 +02:00
Inga Kirschnick
fab259522e Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-05-15 15:18:54 +02:00
Inga Kirschnick
b795cfa891 Small update Corpus File List Selection 2023-05-15 15:18:44 +02:00
Patrick Jentsch
184e78ef0b Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-05-15 14:28:49 +02:00
Patrick Jentsch
202700b129 fix cqi over socketio corpus.update_db command 2023-05-15 14:28:27 +02:00
Inga Kirschnick
49ff1aa284 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-05-15 14:01:49 +02:00
Inga Kirschnick
ce32b03f4a Update Public Corpus Page 2023-05-15 14:01:41 +02:00
Patrick Jentsch
fbf663fee3 fix corpus reset cli command 2023-05-15 12:22:38 +02:00
Patrick Jentsch
66a54dae10 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-05-15 12:00:19 +02:00
Patrick Jentsch
60a59383c7 A better application structure 2023-05-15 12:00:13 +02:00
Inga Kirschnick
0cf955bd2f CorpusFile selection+restore public_corpus page 2023-05-12 13:43:38 +02:00
Patrick Jentsch
1c47d2a346 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-05-11 16:33:28 +02:00
Patrick Jentsch
336bbc39e4 Move cli interface code in package 2023-05-11 16:33:21 +02:00
Patrick Jentsch
595bda98ef Fix wrong admin check 2023-05-09 15:32:09 +02:00
Patrick Jentsch
91e42d6d92 Revert changes and fix some typos 2023-05-09 14:18:59 +02:00
Patrick Jentsch
8c935820e8 Better and more live updated on corpus follower actions 2023-05-08 15:20:42 +02:00
Patrick Jentsch
e4593d5922 Fix issue with admin view of Corpus Lists 2023-05-08 11:34:10 +02:00
Patrick Jentsch
c3306563f0 Change logic for colorization of followed corpora in corpus Lists 2023-05-08 11:32:28 +02:00
Inga Kirschnick
47b9a90cb6 Typo fix 2023-05-05 08:45:27 +02:00
Inga Kirschnick
b07addc5c3 (Public-)Corpus List fix+highligting owner status 2023-05-05 08:41:14 +02:00
Inga Kirschnick
8a85dd9e61 Live status for follower 2023-05-02 11:47:29 +02:00
Inga Kirschnick
c6db277436 Corpus Follower List Fix 2023-04-28 13:24:30 +02:00
Inga Kirschnick
6c76d27a32 Update corpus page 2023-04-27 15:11:18 +02:00
Patrick Jentsch
817a13dfff First take on cleaning up the corpus analysis template code 2023-04-21 15:00:54 +02:00
Inga Kirschnick
078f88d4ec Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-04-18 15:59:13 +02:00
Patrick Jentsch
2768a96133 Rename contributions create templates 2023-04-18 16:11:24 +02:00
Inga Kirschnick
69b5e9b48b Link fix 2023-04-18 15:55:53 +02:00
Patrick Jentsch
a844cdb45b Rename routes and templates in corpora package 2023-04-18 11:32:04 +02:00
Inga Kirschnick
8dd3669af4 checking terms of use confirmation update 2023-04-17 09:43:12 +02:00
Inga Kirschnick
e67dc49976 Database update 2023-04-14 11:29:49 +02:00
Inga Kirschnick
8538fc705f Terms of use confirmation 2023-04-13 16:08:07 +02:00
Inga Kirschnick
144bb38d75 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-04-12 16:49:14 +02:00
Inga Kirschnick
ac47a9e57f New default avatar, placeholder texts removed 2023-04-12 16:49:04 +02:00
Patrick Jentsch
0de14ea5db cleanup CorpusFollowerPermissions 2023-04-12 12:45:41 +02:00
Patrick Jentsch
98b1c15aa0 Fix some incompatible requirements 2023-04-12 09:31:01 +02:00
Patrick Jentsch
8e7d44ec57 Settings ui polish 2023-04-11 15:03:12 +02:00
Patrick Jentsch
4ca2c0c873 Fix typos and simplification 2023-04-11 13:30:38 +02:00
Patrick Jentsch
2fd7e35b99 Add missing imports 2023-04-11 12:39:37 +02:00
Patrick Jentsch
3a2295487c Fix some privacy issues 2023-04-11 11:46:33 +02:00
Patrick Jentsch
77fc8a42f1 Move the last bits of the settings package to the user package 2023-04-06 08:42:21 +02:00
Patrick Jentsch
6abf119c0c Fix some things in admin backend 2023-04-05 13:59:31 +02:00
Patrick Jentsch
719e6da9c8 deactivate automatic python venv activation in vscode terminal 2023-04-05 12:27:32 +02:00
Patrick Jentsch
ca25ea0b80 Change logic to check whether a user folder is empty or not 2023-04-05 12:05:34 +02:00
Patrick Jentsch
477e583be9 fix settings page delete user function 2023-04-04 09:14:32 +02:00
Patrick Jentsch
ee82dafb7c Fix admin user settings template 2023-04-04 09:01:51 +02:00
Patrick Jentsch
423709b4eb Add a prefix for nopaque's data within the application context 2023-04-04 08:56:19 +02:00
Patrick Jentsch
a27caaa8a2 Use a better redirect mechanic in the proxied settings route 2023-04-04 08:44:25 +02:00
Patrick Jentsch
87798f4781 completly move settings logic to users package 2023-04-03 16:34:03 +02:00
Patrick Jentsch
6e6fa49f79 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-04-03 15:25:59 +02:00
Patrick Jentsch
f1d8b81923 move settings related json routes to users package 2023-04-03 15:25:55 +02:00
Inga Kirschnick
c3d429ed83 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-31 14:28:18 +02:00
Inga Kirschnick
4d2d4fcc40 Add followed corpora to Corpus List 2023-03-31 14:28:11 +02:00
Patrick Jentsch
2289106ac7 Fix last seen not set error on admin user page 2023-03-31 14:17:08 +02:00
Inga Kirschnick
451a0a3955 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-31 11:47:52 +02:00
Inga Kirschnick
9167bffa61 New user avatar 2023-03-31 11:46:17 +02:00
Patrick Jentsch
491e24f0a3 Fix jobinputlist 2023-03-31 09:32:59 +02:00
Patrick Jentsch
43751b44ac Fix missing job status value in toast notification 2023-03-31 09:25:13 +02:00
Patrick Jentsch
cff4b2c588 Fix problems with new forms 2023-03-31 09:14:21 +02:00
Patrick Jentsch
cca0185500 Remove latest extension recommendation 2023-03-31 09:13:55 +02:00
Patrick Jentsch
5c776e0fb6 Move delete user method in users package 2023-03-30 13:36:11 +02:00
Patrick Jentsch
9ce5ff8cba add extension and setting recommendations 2023-03-30 13:07:02 +02:00
Patrick Jentsch
35b239877a fix missing username pattern 2023-03-29 14:32:52 +02:00
Patrick Jentsch
e4a8ad911f Update admin user settings 2023-03-29 14:32:35 +02:00
Patrick Jentsch
9b2353105e Add NopaqueForm as a base for all others 2023-03-29 09:25:08 +02:00
Patrick Jentsch
9de09519d6 more normalization 2023-03-28 14:19:37 +02:00
Patrick Jentsch
b41436c844 normalize template parameters from database 2023-03-28 14:11:46 +02:00
Patrick Jentsch
09b3afc880 Update message in request 2023-03-27 14:01:56 +02:00
Patrick Jentsch
df870c1c7d Update settings page 2023-03-27 13:56:24 +02:00
Patrick Jentsch
020de69e45 settings update 2023-03-27 10:22:43 +02:00
Patrick Jentsch
9e58578761 fix wrong link in request 2023-03-24 13:57:47 +01:00
Patrick Jentsch
5b6eae7645 Cleanup 2023-03-23 17:42:51 +01:00
Patrick Jentsch
57813b4bc2 Fix link issues 2023-03-22 12:06:33 +01:00
Patrick Jentsch
622d32fa45 UI enhancements 2023-03-21 10:50:29 +01:00
Patrick Jentsch
a676475b55 Fix JobList delete function 2023-03-21 09:14:16 +01:00
Patrick Jentsch
f2bbcdc441 Rename manual modal template 2023-03-20 16:02:12 +01:00
Patrick Jentsch
685db81c85 Fix TesseractOCRPipelineModelList.js 2023-03-20 16:00:54 +01:00
Patrick Jentsch
575eeae94a Fix errors from settings move 2023-03-20 16:00:25 +01:00
Patrick Jentsch
3d4403e997 Move breadcrumbs 2023-03-20 15:33:49 +01:00
Patrick Jentsch
333e6b268e Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-20 15:32:45 +01:00
Patrick Jentsch
ff4406aaae Move elements in navigation bars 2023-03-20 15:31:25 +01:00
Inga Kirschnick
41096445a6 small update settings page+new package 'settings' 2023-03-17 15:56:37 +01:00
Patrick Jentsch
823e42faf0 Replace roadmap with manual 2023-03-16 10:31:58 +01:00
Patrick Jentsch
faf5a61808 Update sidenav 2023-03-16 09:58:56 +01:00
Patrick Jentsch
f8e94a721f Change how the user avatar is exchanged between client und server 2023-03-16 09:54:48 +01:00
Patrick Jentsch
7ea7b6d7a7 Rework sidenav 2023-03-16 09:29:57 +01:00
Inga Kirschnick
268d00ce72 Icon + public profile section update on edit page 2023-03-15 12:46:17 +01:00
Inga Kirschnick
666397046d Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-15 09:42:13 +01:00
Inga Kirschnick
8d21bfe434 Small fix edit profile page 2023-03-15 09:42:07 +01:00
Patrick Jentsch
743ed52fd6 Align carets on the right 2023-03-15 09:37:36 +01:00
Inga Kirschnick
0f8c1b1cb4 New profile page 2023-03-15 09:24:09 +01:00
Inga Kirschnick
bd86a71222 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-15 09:19:14 +01:00
Inga Kirschnick
464ae8ecc3 New user Settings page 2023-03-15 09:18:11 +01:00
Patrick Jentsch
fac6ba11ed Move settings 2023-03-14 15:41:23 +01:00
Patrick Jentsch
0520caeddd fix 2023-03-14 14:27:41 +01:00
Patrick Jentsch
6e9a6fa5a1 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-14 14:25:10 +01:00
Patrick Jentsch
4c97929b1b Move contributions to dashboard 2023-03-14 14:25:05 +01:00
Patrick Jentsch
2efc9533ec Add breadcrumbs to admin package 2023-03-14 14:24:52 +01:00
Inga Kirschnick
f1be57e509 Avatar condition and breadcrumb update 2023-03-14 14:21:23 +01:00
Inga Kirschnick
777151b2bf Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-14 13:32:42 +01:00
Inga Kirschnick
c14abf5200 Javascript Changes users 2023-03-14 13:32:32 +01:00
Patrick Jentsch
b8e63d2342 Add Icons for users breadcrumbs 2023-03-14 12:20:29 +01:00
Patrick Jentsch
8dba78c474 Return default user avatar if not set 2023-03-14 12:12:05 +01:00
Patrick Jentsch
8aebe27aa8 Fix wrong route decorator 2023-03-14 11:58:06 +01:00
Patrick Jentsch
a9767bf3c3 Add breadcrumbs to users package 2023-03-14 11:44:15 +01:00
Patrick Jentsch
c91004d6ba Implement flask-breadcrumbs everywhere 2023-03-14 11:13:35 +01:00
Patrick Jentsch
bac526b927 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-13 16:23:51 +01:00
Patrick Jentsch
90ac30bba3 Implement Flask-Breadcrumbs 2023-03-13 16:22:42 +01:00
Inga Kirschnick
4c05c6cc18 merge settings into users route 2023-03-13 15:04:44 +01:00
Inga Kirschnick
018805a1b6 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-13 13:29:08 +01:00
Inga Kirschnick
646f735ab2 Reviewed User package and invite user optical fix 2023-03-13 13:29:01 +01:00
Patrick Jentsch
eec056e010 Change template base variable name 2023-03-13 12:05:35 +01:00
Patrick Jentsch
f348d1ed23 Add missing method to route 2023-03-13 11:24:01 +01:00
Patrick Jentsch
e03b5258ef Codestyle enhancements 2023-03-13 09:49:59 +01:00
Patrick Jentsch
ca53974e50 Update the generic error handling again. Added type hints 2023-03-13 08:36:51 +01:00
Patrick Jentsch
5c2225c43e Let the generic error handler generate json again 2023-03-13 08:20:09 +01:00
Patrick Jentsch
a1af3e34d2 Remove unused print statement 2023-03-13 07:58:33 +01:00
Inga Kirschnick
7e54d56ed5 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-10 17:03:48 +01:00
Inga Kirschnick
bda18e64c8 Small Dashboard Job List fix 2023-03-10 17:03:37 +01:00
Patrick Jentsch
b6f155a06b Fix error_handler 2023-03-10 15:17:24 +01:00
Patrick Jentsch
ecb577628b Remove debug comments 2023-03-10 14:55:10 +01:00
Patrick Jentsch
6ba3f9c849 Add settings to project vscode settings.yml 2023-03-10 12:56:09 +01:00
Patrick Jentsch
2529dfeb62 remove testroute 2023-03-10 12:07:18 +01:00
Patrick Jentsch
c589fd1f78 Remove unused function in utils 2023-03-10 10:34:46 +01:00
Patrick Jentsch
21819bfd9b Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-10 10:33:16 +01:00
Patrick Jentsch
aaf14a9952 Rework corpora package 2023-03-10 10:33:11 +01:00
Inga Kirschnick
57a598ed20 Reviewed Job Package 2023-03-10 08:47:03 +01:00
Inga Kirschnick
3789f61ca4 scripts fix 2023-03-09 15:10:55 +01:00
Inga Kirschnick
42b421c2e0 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-09 14:56:44 +01:00
Inga Kirschnick
d6fcaa97bf rework jobs package 1/2 2023-03-09 14:55:52 +01:00
Patrick Jentsch
465a7dc0af Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-09 14:55:12 +01:00
Patrick Jentsch
59de68e6fa More rework 2023-03-09 14:55:08 +01:00
Inga Kirschnick
5f27ce2801 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-09 13:18:47 +01:00
Inga Kirschnick
92cc2cd419 pagination fix, 1st example implemantation corpora 2023-03-09 13:18:39 +01:00
Patrick Jentsch
fdad10991c More restructuring 2023-03-09 12:07:16 +01:00
Patrick Jentsch
53bba2afb0 Restructure Contributions with nested blueprints 2023-03-08 15:22:40 +01:00
Patrick Jentsch
6bb4594937 Restructure javascript 2023-03-08 11:42:53 +01:00
Patrick Jentsch
0d7fca9b0b Fix logic error 2023-03-08 10:41:54 +01:00
Patrick Jentsch
0e7e5933cc Better exception handling in json-routes 2023-03-08 10:34:46 +01:00
Patrick Jentsch
09fdad2162 Standardize code for reference 2023-03-07 16:34:49 +01:00
Patrick Jentsch
9272150212 combine content_negotiation related decorators 2023-03-07 16:32:15 +01:00
Patrick Jentsch
cb830a6f9b Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-06 15:04:11 +01:00
Patrick Jentsch
7770d4d478 Restructure toggle ispublic requests 2023-03-06 15:04:06 +01:00
Patrick Jentsch
cfa4fa68f2 Remove dev route 2023-03-06 15:03:33 +01:00
Patrick Jentsch
b98e30022e restructure corpus routes file and add new decorators 2023-03-06 15:03:13 +01:00
Patrick Jentsch
4fb5f2f2dc Add content negotiation related route decorators 2023-03-06 15:02:46 +01:00
Inga Kirschnick
fecbb50d39 bug fixes and unfollow_corpus update 2023-03-06 12:27:24 +01:00
Inga Kirschnick
8a55ce902e color SCSS update social area+ decorator fix 2023-03-02 15:08:50 +01:00
Inga Kirschnick
b364480de6 Public Page update 2023-03-02 09:59:47 +01:00
Inga Kirschnick
e11b2e3c1a Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-02 09:57:59 +01:00
Inga Kirschnick
2dc7efbc8d Update follow corpus by token method 2023-03-02 09:57:43 +01:00
Patrick Jentsch
c01068e96b cleanup imports 2023-03-01 16:33:29 +01:00
Patrick Jentsch
73cb566db2 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-03-01 16:33:03 +01:00
Patrick Jentsch
145b80356d Redesign corpus page and add possibility to add followers by username for owner 2023-03-01 16:31:41 +01:00
Inga Kirschnick
ed195af6a2 corpus follower permission decorator update 2023-03-01 15:14:51 +01:00
Inga Kirschnick
b1586b3679 social-area page and profile page update 2023-03-01 14:09:15 +01:00
Inga Kirschnick
ec6d0a6477 corpus permission decorator + sidenav update 2023-02-28 10:27:10 +01:00
Inga Kirschnick
d2828cabbe Explanation update and profile link button 2023-02-24 15:46:44 +01:00
Inga Kirschnick
8b01777318 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-02-24 15:22:33 +01:00
Inga Kirschnick
e2ddbf26f1 Update User Card and Share link with specific role 2023-02-24 15:22:26 +01:00
Patrick Jentsch
3147bed90a codestyle enhancements 2023-02-24 10:34:42 +01:00
Patrick Jentsch
c565b08f9c Found a better solution for default role assignments 2023-02-24 10:30:42 +01:00
Patrick Jentsch
ff3ac3658f Yet another small fix 2023-02-24 10:02:28 +01:00
Inga Kirschnick
17ec3e292a Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-02-24 09:47:18 +01:00
Patrick Jentsch
122cce98a1 another small fix 2023-02-24 09:46:35 +01:00
Patrick Jentsch
cb31afe723 small fix 2023-02-24 09:44:09 +01:00
Inga Kirschnick
d0b369efaf Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-02-24 09:30:43 +01:00
Inga Kirschnick
b27a1051af import share link token generation to models.py 2023-02-24 09:30:29 +01:00
Patrick Jentsch
0609e2cd72 Fix follow corpus mechanics 2023-02-24 09:27:20 +01:00
Patrick Jentsch
1d85e96d3a Let the Corpus owner change Roles of followers 2023-02-23 15:18:53 +01:00
Patrick Jentsch
132875bb34 Remove unused import 2023-02-23 13:06:06 +01:00
Patrick Jentsch
9e5bb7ad90 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-02-23 13:05:11 +01:00
Patrick Jentsch
38d09a3490 Integrate CorpusFollowerRoles 2023-02-23 13:05:04 +01:00
Inga Kirschnick
a459d6607a Update Share Link 2023-02-23 09:50:09 +01:00
Inga Kirschnick
5881588160 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-02-22 16:00:11 +01:00
Inga Kirschnick
3ad942f17b Share link implementation for followers 2023-02-22 16:00:04 +01:00
Patrick Jentsch
1be8a449fe cleanup in models file 2023-02-22 09:35:19 +01:00
Patrick Jentsch
288014969a Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-02-21 16:23:14 +01:00
Patrick Jentsch
68dc8de476 Add function to dynamically add followers to CorpusFollowerList 2023-02-21 16:23:10 +01:00
Inga Kirschnick
4fab75f0e2 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-02-21 16:18:58 +01:00
Inga Kirschnick
726e781692 share link generator update 2023-02-21 16:18:04 +01:00
Patrick Jentsch
d699fd09e5 Add live data updates for corpus follower lists 2023-02-21 13:59:11 +01:00
Patrick Jentsch
ff238cd823 Change style of contributionlists 2023-02-21 11:05:23 +01:00
Patrick Jentsch
8d70e93856 Update CorpusFollowerAssociation table 2023-02-21 11:05:09 +01:00
Patrick Jentsch
8168a2384f Add unfollow and view function to corpusfollowerlist 2023-02-20 16:15:17 +01:00
Patrick Jentsch
2dc41fd387 Add CorpusFollowerList and functions 2023-02-20 10:40:33 +01:00
Patrick Jentsch
5837e05024 Add routes for CorpusFollower permission management 2023-02-15 16:17:25 +01:00
Patrick Jentsch
525723818e Merge branch 'development' 2023-02-15 11:37:09 +01:00
Patrick Jentsch
112d1ec020 Merge branch 'public-corpus' into development 2023-02-15 11:32:44 +01:00
Patrick Jentsch
fc8517649f Remove debug messages and more leftovers 2023-02-15 11:31:47 +01:00
Patrick Jentsch
dcdb71ac74 Remove more UI leftovers from community update 2023-02-15 11:30:27 +01:00
Patrick Jentsch
23cdfb1441 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-02-15 11:00:10 +01:00
Patrick Jentsch
3e8bc5214c Remove public share mechanisms until legal matters are clarified 2023-02-15 10:56:54 +01:00
Patrick Jentsch
df1862b0a4 Bump Flask-Hashids version 2023-02-15 10:42:55 +01:00
Inga Kirschnick
1f7baa6390 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-02-13 10:17:05 +01:00
Inga Kirschnick
8595e2a203 Share link expiration update and small fixes 2023-02-13 10:16:44 +01:00
Patrick Jentsch
874c963cc4 Fix some list problems 2023-02-10 14:51:47 +01:00
Patrick Jentsch
1767ceee01 Remove function call of missing function 2023-02-10 09:53:59 +01:00
Inga Kirschnick
dabf0d1344 Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-02-10 09:43:05 +01:00
Inga Kirschnick
33a54b6206 Corpus first share link 2023-02-10 09:37:31 +01:00
Patrick Jentsch
428400df3f Add public switch without form 2023-02-09 16:22:37 +01:00
Patrick Jentsch
1f9ff9884d Merge branch 'public-corpus' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque into public-corpus 2023-02-09 15:25:13 +01:00
Patrick Jentsch
5a4464ebb3 Change sqlalchemy version 2023-02-09 15:24:33 +01:00
Inga Kirschnick
e03b54cfcd update public corpora list on dashboard 2023-02-09 14:03:03 +01:00
Patrick Jentsch
91a800f7fd Add analyse button in public corpus pages 2023-02-09 12:10:37 +01:00
Patrick Jentsch
52fa23ff65 Fix some problems after merge 2023-02-09 12:02:00 +01:00
Inga Kirschnick
66fbf4d57b update corpus public page 2023-02-09 11:56:02 +01:00
Inga Kirschnick
4d227415d9 Merge branch 'development' into public-corpus 2023-02-09 11:54:58 +01:00
Inga Kirschnick
f06508b412 New Public Corpus Page 2023-02-09 11:21:03 +01:00
Patrick Jentsch
20c0678d3e Merge branch 'master' of gitlab.ub.uni-bielefeld.de:sfb1288inf/nopaque 2023-02-06 15:51:41 +01:00
Patrick Jentsch
c323c53f37 Merge branch 'development' 2023-02-06 15:51:13 +01:00
Inga Kirschnick
2d8cef64e8 Merge branch 'development' 2023-02-06 12:40:07 +01:00
Patrick Jentsch
9b9edf501d Merge branch 'development' 2022-11-25 10:47:15 +01:00
Patrick Jentsch
903310c17f Merge branch 'development' 2022-11-24 12:29:28 +01:00
Patrick Jentsch
bc92fd249f Merge branch 'development' 2022-11-18 16:02:52 +01:00
Patrick Jentsch
422415065d Merge branch 'development' 2022-10-28 13:21:29 +02:00
Patrick Jentsch
07ec01ae2e Merge branch 'development' 2022-10-11 11:34:19 +02:00
593 changed files with 1587134 additions and 12057 deletions

View File

@@ -5,8 +5,9 @@
!app
!migrations
!tests
!.flaskenv
!boot.sh
!config.py
!nopaque.py
!docker-nopaque-entrypoint.sh
!requirements.txt
!requirements.freezed.txt
!wsgi.py

204
.env.tpl
View File

@@ -1,204 +1,20 @@
################################################################################
# Docker #
################################################################################
# DEFAULT: ./data
# NOTE: Use `.` as <project-basedir>
# HOST_DATA_DIR=
# Example: 1000
##############################################################################
# Environment variables used by Docker Compose config files. #
##############################################################################
# HINT: Use this bash command `id -u`
# NOTE: 0 (= root user) is not allowed
HOST_UID=
# Example: 1000
# HINT: Use this bash command `id -g`
# NOTE: 0 (= root group) is not allowed
HOST_GID=
# Example: 999
# HINT: Use this bash command `getent group docker | cut -d: -f3`
HOST_DOCKER_GID=
# DEFAULT: ./logs
# NOTES: Use `.` as <project-basedir>
# HOST_LOG_DIR=
# DEFAULT: nopaque
NOPAQUE_DOCKER_NETWORK_NAME=nopaque
# DEFAULT: nopaque_default
# DOCKER_NETWORK_NAME=
################################################################################
# Flask #
# https://flask.palletsprojects.com/en/1.1.x/config/ #
################################################################################
# CHOOSE ONE: http, https
# DEFAULT: http
# PREFERRED_URL_SCHEME=
# DEFAULT: hard to guess string
# HINT: Use this bash command `python -c "import uuid; print(uuid.uuid4().hex)"`
# SECRET_KEY=
# DEFAULT: localhost:5000
# Example: nopaque.example.com/nopaque.example.com:5000
# HINT: If your instance is publicly available on a different Port then 80/443,
# you will have to add this to the server name
# SERVER_NAME=
# CHOOSE ONE: False, True
# DEFAULT: False
# HINT: Set to true if you redirect http to https
# SESSION_COOKIE_SECURE=
################################################################################
# Flask-Assets #
# https://webassets.readthedocs.io/en/latest/ #
################################################################################
# CHOOSE ONE: False, True
# DEFAULT: False
# ASSETS_DEBUG=
################################################################################
# Flask-Hashids #
# https://github.com/Pevtrick/Flask-Hashids #
################################################################################
# DEFAULT: 16
# HASHIDS_MIN_LENGTH=
# NOTE: Use this bash command `python -c "import uuid; print(uuid.uuid4().hex)"`
# It is strongly recommended that this is NEVER the same as the SECRET_KEY
HASHIDS_SALT=
################################################################################
# Flask-Login #
# https://flask-login.readthedocs.io/en/latest/ #
################################################################################
# CHOOSE ONE: False, True
# DEFAULT: False
# HINT: Set to true if you redirect http to https
# REMEMBER_COOKIE_SECURE=
################################################################################
# Flask-Mail #
# https://pythonhosted.org/Flask-Mail/ #
################################################################################
# EXAMPLE: nopaque Admin <nopaque@example.com>
MAIL_DEFAULT_SENDER=
MAIL_PASSWORD=
# EXAMPLE: smtp.example.com
MAIL_SERVER=
# EXAMPLE: 587
MAIL_PORT=
# CHOOSE ONE: False, True
# DEFAULT: False
# MAIL_USE_SSL=
# CHOOSE ONE: False, True
# DEFAULT: False
# MAIL_USE_TLS=
# EXAMPLE: nopaque@example.com
MAIL_USERNAME=
################################################################################
# Flask-SQLAlchemy #
# https://flask-sqlalchemy.palletsprojects.com/en/2.x/config/ #
################################################################################
# DEFAULT: 'sqlite:///<nopaque-basedir>/data.sqlite'
# NOTE: Use `.` as <nopaque-basedir>,
# Don't use a SQLite database when using Docker
# SQLALCHEMY_DATABASE_URI=
################################################################################
# nopaque #
################################################################################
# An account is registered with this email adress gets automatically assigned
# the administrator role.
# EXAMPLE: admin.nopaque@example.com
NOPAQUE_ADMIN=
# DEFAULT: /mnt/nopaque
# NOTE: This must be a network share and it must be available on all Docker
# Swarm nodes
# NOPAQUE_DATA_DIR=
# CHOOSE ONE: False, True
# DEFAULT: True
# NOPAQUE_IS_PRIMARY_INSTANCE=
# transport://[userid:password]@hostname[:port]/[virtual_host]
NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI=
# NOTE: Get these from the nopaque development team
NOPAQUE_DOCKER_REGISTRY_USERNAME=
NOPAQUE_DOCKER_REGISTRY_PASSWORD=
# DEFAULT: %Y-%m-%d %H:%M:%S
# NOPAQUE_LOG_DATE_FORMAT=
# DEFAULT: [%(asctime)s] %(levelname)s in %(pathname)s (function: %(funcName)s, line: %(lineno)d): %(message)s
# NOPAQUE_LOG_FORMAT=
# DEFAULT: INFO
# CHOOSE ONE: CRITICAL, ERROR, WARNING, INFO, DEBUG
# NOPAQUE_LOG_LEVEL=
# CHOOSE ONE: False, True
# DEFAULT: True
# NOPAQUE_LOG_FILE_ENABLED=
# DEFAULT: <nopaque-basedir>/logs
# NOTE: Use `.` as <nopaque-basedir>
# NOPAQUE_LOG_FILE_DIR=
# DEFAULT: NOPAQUE_LOG_LEVEL
# CHOOSE ONE: CRITICAL, ERROR, WARNING, INFO, DEBUG
# NOPAQUE_LOG_FILE_LEVEL=
# CHOOSE ONE: False, True
# DEFAULT: False
# NOPAQUE_LOG_STDERR_ENABLED=
# CHOOSE ONE: CRITICAL, ERROR, WARNING, INFO, DEBUG
# DEFAULT: NOPAQUE_LOG_LEVEL
# NOPAQUE_LOG_STDERR_LEVEL=
# CHOOSE ONE: False, True
# DEFAULT: False
# HINT: Set this to True only if you are using a proxy in front of nopaque
# NOPAQUE_PROXY_FIX_ENABLED=
# DEFAULT: 0
# Number of values to trust for X-Forwarded-For
# NOPAQUE_PROXY_FIX_X_FOR=
# DEFAULT: 0
# Number of values to trust for X-Forwarded-Host
# NOPAQUE_PROXY_FIX_X_HOST=
# DEFAULT: 0
# Number of values to trust for X-Forwarded-Port
# NOPAQUE_PROXY_FIX_X_PORT=
# DEFAULT: 0
# Number of values to trust for X-Forwarded-Prefix
# NOPAQUE_PROXY_FIX_X_PREFIX=
# DEFAULT: 0
# Number of values to trust for X-Forwarded-Proto
# NOPAQUE_PROXY_FIX_X_PROTO=
# CHOOSE ONE: False, True
# DEFAULT: False
# NOPAQUE_TRANSKRIBUS_ENABLED=
# READ-COOP account data: https://readcoop.eu/
# NOPAQUE_READCOOP_USERNAME=
# NOPAQUE_READCOOP_PASSWORD=
# NOTE: This must be a network share and it must be available on all
# Docker Swarm nodes, mounted to the same path.
HOST_NOPAQUE_DATA_PATH=/mnt/nopaque

View File

@@ -1 +0,0 @@
FLASK_APP=nopaque.py

6
.gitignore vendored
View File

@@ -1,11 +1,11 @@
# nopaque specifics
app/static/gen/
data/
volumes/
docker-compose.override.yml
logs/
!logs/dummy
*.env
*.pjentsch-testing
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]

84
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,84 @@
include:
- template: Security/Container-Scanning.gitlab-ci.yml
##############################################################################
# Pipeline stages in order of execution #
##############################################################################
stages:
- build
- publish
- sca
##############################################################################
# Pipeline behavior #
##############################################################################
workflow:
rules:
# Run the pipeline on commits to the default branch
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
# Set the Docker image tag to `latest`
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:latest
when: always
# Run the pipeline on tag creation
- if: $CI_COMMIT_TAG
variables:
# Set the Docker image tag to the Git tag name
DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
when: always
# Don't run the pipeline on all other occasions
- when: never
##############################################################################
# Default values for pipeline jobs #
##############################################################################
default:
image: docker:24.0.6
services:
- docker:24.0.6-dind
tags:
- docker
##############################################################################
# CI/CD variables for all jobs in the pipeline #
##############################################################################
variables:
DOCKER_TLS_CERTDIR: /certs
DOCKER_BUILD_PATH: .
DOCKERFILE: Dockerfile
##############################################################################
# Pipeline jobs #
##############################################################################
build:
stage: build
script:
- docker build --tag $DOCKER_IMAGE --file $DOCKERFILE $DOCKER_BUILD_PATH
- docker save $DOCKER_IMAGE > docker_image.tar
artifacts:
paths:
- docker_image.tar
publish:
stage: publish
before_script:
- docker login --username gitlab-ci-token --password $CI_JOB_TOKEN $CI_REGISTRY
script:
- docker load --input docker_image.tar
- docker push $DOCKER_IMAGE
after_script:
- docker logout $CI_REGISTRY
container_scanning:
stage: sca
rules:
# Run the job on commits to the default branch
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: always
# Run the job on tag creation
- if: $CI_COMMIT_TAG
when: always
# Don't run the job on all other occasions
- when: never
variables:
CS_IMAGE: $DOCKER_IMAGE

View File

@@ -1,7 +1,8 @@
{
"recommendations": [
"samuelcolvin.jinjahtml",
"irongeek.vscode-env",
"ms-azuretools.vscode-docker",
"ms-python.python"
"ms-python.python",
"samuelcolvin.jinjahtml"
]
}

25
.vscode/settings.json vendored
View File

@@ -1 +1,24 @@
{}
{
"editor.rulers": [79],
"editor.tabSize": 4,
"emmet.includeLanguages": {
"jinja-html": "html"
},
"files.associations": {
".flaskenv": "env",
"*.env.tpl": "env",
"*.txt.j2": "jinja"
},
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
"files.trimTrailingWhitespace": true,
"[html]": {
"editor.tabSize": 2
},
"[javascript]": {
"editor.tabSize": 2
},
"[jinja-html]": {
"editor.tabSize": 2
}
}

View File

@@ -1,50 +1,57 @@
FROM python:3.8.10-slim-buster
FROM python:3.10.13-slim-bookworm
LABEL authors="Patrick Jentsch <p.jentsch@uni-bielefeld.de>"
ARG DOCKER_GID
ARG UID
ARG GID
# Set environment variables
ENV LANG="C.UTF-8"
ENV PYTHONDONTWRITEBYTECODE="1"
ENV PYTHONUNBUFFERED="1"
# Install system dependencies
RUN apt-get update \
&& apt-get install --no-install-recommends --yes \
build-essential \
gosu \
libpq-dev \
&& rm --recursive /var/lib/apt/lists/*
RUN groupadd --gid "${DOCKER_GID}" docker \
&& groupadd --gid "${GID}" nopaque \
&& useradd --create-home --gid nopaque --groups "${DOCKER_GID}" --no-log-init --uid "${UID}" nopaque
# Create a non-root user
RUN useradd --create-home --no-log-init nopaque \
&& groupadd docker \
&& usermod --append --groups docker nopaque
USER nopaque
WORKDIR /home/nopaque
ENV PYTHON3_VENV_PATH="/home/nopaque/venv"
RUN python3 -m venv "${PYTHON3_VENV_PATH}"
ENV PATH="${PYTHON3_VENV_PATH}/bin:${PATH}"
# Create a Python virtual environment
ENV NOPAQUE_PYTHON3_VENV_PATH="/home/nopaque/.venv"
RUN python3 -m venv "${NOPAQUE_PYTHON3_VENV_PATH}"
ENV PATH="${NOPAQUE_PYTHON3_VENV_PATH}/bin:${PATH}"
COPY --chown=nopaque:nopaque requirements.txt .
RUN python3 -m pip install --requirement requirements.txt \
&& rm requirements.txt
# Install Python dependencies
COPY --chown=nopaque:nopaque requirements.freezed.txt requirements.freezed.txt
RUN python3 -m pip install --requirement requirements.freezed.txt \
&& rm requirements.freezed.txt
# Install the application
COPY docker-nopaque-entrypoint.sh /usr/local/bin/
COPY --chown=nopaque:nopaque app app
COPY --chown=nopaque:nopaque migrations migrations
COPY --chown=nopaque:nopaque tests tests
COPY --chown=nopaque:nopaque .flaskenv boot.sh config.py nopaque.py ./
COPY --chown=nopaque:nopaque boot.sh config.py wsgi.py ./
EXPOSE 5000
ENTRYPOINT ["./boot.sh"]
USER root
ENTRYPOINT ["docker-nopaque-entrypoint.sh"]

View File

@@ -1,5 +1,8 @@
# nopaque
![release badge](https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque/-/badges/release.svg)
![pipeline badge](https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque/badges/master/pipeline.svg?ignore_skipped=true)
nopaque bundles various tools and services that provide humanities scholars with DH methods and thus can support their various individual research processes. Using nopaque, researchers can subject digitized sources to Optical Character Recognition (OCR). The resulting text files can then be used as a data basis for Natural Language Processing (NLP). The texts are automatically subjected to various linguistic annotations. The data processed via NLP can then be summarized in the web application as corpora and analyzed by means of an information retrieval system through complex search queries. The range of functions of the web application will be successively extended according to the needs of the researchers.
## Prerequisites and requirements
@@ -32,7 +35,7 @@ username@hostname:~$ sudo mount --types cifs --options gid=${USER},password=nopa
# Clone the nopaque repository
username@hostname:~$ git clone https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque.git
# Create data directories
username@hostname:~$ mkdir data/{db,logs,mq}
username@hostname:~$ mkdir -p volumes/{db,mq}
username@hostname:~$ cp db.env.tpl db.env
username@hostname:~$ cp .env.tpl .env
# Fill out the variables within these files.

View File

@@ -2,6 +2,8 @@ from apifairy import APIFairy
from config import Config
from docker import DockerClient
from flask import Flask
from flask.logging import default_handler
from flask_admin import Admin
from flask_apscheduler import APScheduler
from flask_assets import Environment
from flask_login import LoginManager
@@ -12,79 +14,143 @@ from flask_paranoid import Paranoid
from flask_socketio import SocketIO
from flask_sqlalchemy import SQLAlchemy
from flask_hashids import Hashids
from logging import Formatter, StreamHandler
from werkzeug.middleware.proxy_fix import ProxyFix
from .extensions.nopaque_flask_admin_views import AdminIndexView, ModelView
docker_client = DockerClient.from_env()
admin = Admin()
apifairy = APIFairy()
assets = Environment()
db = SQLAlchemy()
docker_client = DockerClient()
hashids = Hashids()
login = LoginManager()
login.login_view = 'auth.login'
login.login_message = 'Please log in to access this page.'
ma = Marshmallow()
mail = Mail()
migrate = Migrate(compare_type=True)
paranoid = Paranoid()
paranoid.redirect_view = '/'
scheduler = APScheduler()
socketio = SocketIO()
def create_app(config: Config = Config) -> Flask:
''' Creates an initialized Flask (WSGI Application) object. '''
app: Flask = Flask(__name__)
''' Creates an initialized Flask object. '''
app = Flask(__name__)
app.config.from_object(config)
config.init_app(app)
# region Logging
log_formatter = Formatter(
fmt=app.config['NOPAQUE_LOG_FORMAT'],
datefmt=app.config['NOPAQUE_LOG_DATE_FORMAT']
)
log_handler = StreamHandler()
log_handler.setFormatter(log_formatter)
log_handler.setLevel(app.config['NOPAQUE_LOG_LEVEL'])
app.logger.setLevel('DEBUG')
app.logger.removeHandler(default_handler)
app.logger.addHandler(log_handler)
# endregion Logging
# region Middlewares
if app.config['NOPAQUE_PROXY_FIX_ENABLED']:
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=app.config['NOPAQUE_PROXY_FIX_X_FOR'],
x_host=app.config['NOPAQUE_PROXY_FIX_X_HOST'],
x_port=app.config['NOPAQUE_PROXY_FIX_X_PORT'],
x_prefix=app.config['NOPAQUE_PROXY_FIX_X_PREFIX'],
x_proto=app.config['NOPAQUE_PROXY_FIX_X_PROTO']
)
# endregion Middlewares
# region Extensions
docker_client.login(
app.config['NOPAQUE_DOCKER_REGISTRY_USERNAME'],
password=app.config['NOPAQUE_DOCKER_REGISTRY_PASSWORD'],
registry=app.config['NOPAQUE_DOCKER_REGISTRY']
)
from .models import AnonymousUser, User
admin.init_app(app, index_view=AdminIndexView())
apifairy.init_app(app)
assets.init_app(app)
db.init_app(app)
hashids.init_app(app)
login.init_app(app)
login.anonymous_user = AnonymousUser
login.login_view = 'auth.login'
login.user_loader(lambda user_id: User.query.get(int(user_id)))
ma.init_app(app)
mail.init_app(app)
migrate.init_app(app, db)
paranoid.init_app(app)
paranoid.redirect_view = '/'
scheduler.init_app(app)
socketio.init_app(app, message_queue=app.config['NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI']) # noqa
socketio.init_app(app, message_queue=app.config['NOPAQUE_SOCKETIO_MESSAGE_QUEUE_URI'])
# endregion Extensions
from .admin import bp as admin_blueprint
app.register_blueprint(admin_blueprint, url_prefix='/admin')
from .api import bp as api_blueprint
# region Blueprints
from .blueprints.api import bp as api_blueprint
app.register_blueprint(api_blueprint, url_prefix='/api')
from .auth import bp as auth_blueprint
app.register_blueprint(auth_blueprint, url_prefix='/auth')
from .blueprints.auth import bp as auth_blueprint
app.register_blueprint(auth_blueprint)
from .contributions import bp as contributions_blueprint
from .blueprints.contributions import bp as contributions_blueprint
app.register_blueprint(contributions_blueprint, url_prefix='/contributions')
from .corpora import bp as corpora_blueprint
app.register_blueprint(corpora_blueprint, url_prefix='/corpora')
from .blueprints.corpora import bp as corpora_blueprint
app.register_blueprint(corpora_blueprint, cli_group='corpus', url_prefix='/corpora')
from .errors import bp as errors_blueprint
app.register_blueprint(errors_blueprint)
from .blueprints.errors import bp as errors_bp
app.register_blueprint(errors_bp)
from .jobs import bp as jobs_blueprint
from .blueprints.jobs import bp as jobs_blueprint
app.register_blueprint(jobs_blueprint, url_prefix='/jobs')
from .main import bp as main_blueprint
app.register_blueprint(main_blueprint, url_prefix='/')
from .blueprints.main import bp as main_blueprint
app.register_blueprint(main_blueprint, cli_group=None)
from .services import bp as services_blueprint
from .blueprints.services import bp as services_blueprint
app.register_blueprint(services_blueprint, url_prefix='/services')
from .settings import bp as settings_blueprint
from .blueprints.settings import bp as settings_blueprint
app.register_blueprint(settings_blueprint, url_prefix='/settings')
from .users import bp as users_blueprint
app.register_blueprint(users_blueprint, url_prefix='/users')
from .blueprints.users import bp as users_blueprint
app.register_blueprint(users_blueprint, cli_group='user', url_prefix='/users')
from .blueprints.workshops import bp as workshops_blueprint
app.register_blueprint(workshops_blueprint, url_prefix='/workshops')
from .models import _models
for model in _models:
admin.add_view(ModelView(model, db.session, category='Database'))
# endregion Blueprints
# region SocketIO Namespaces
from .namespaces.cqi_over_sio import CQiOverSocketIONamespace
socketio.on_namespace(CQiOverSocketIONamespace('/cqi_over_sio'))
# endregion SocketIO Namespaces
# region Database event Listeners
from .models.event_listeners import register_event_listeners
register_event_listeners()
# endregion Database event Listeners
# region Add scheduler jobs
if app.config['NOPAQUE_IS_PRIMARY_INSTANCE']:
from .jobs import handle_corpora
scheduler.add_job('handle_corpora', handle_corpora, seconds=3, trigger='interval')
from .jobs import handle_jobs
scheduler.add_job('handle_jobs', handle_jobs, seconds=3, trigger='interval')
# endregion Add scheduler jobs
return app

View File

@@ -1,13 +0,0 @@
from app.models import Role
from flask_wtf import FlaskForm
from wtforms import BooleanField, SelectField, SubmitField
class AdminEditUserForm(FlaskForm):
confirmed = BooleanField('Confirmed')
role = SelectField('Role')
submit = SubmitField('Submit')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.role.choices = [(x.hashid, x.name) for x in Role.query.all()]

View File

@@ -1,111 +0,0 @@
from flask import current_app, flash, redirect, render_template, url_for
from flask_login import login_required
from threading import Thread
from app import db, hashids
from app.decorators import admin_required
from app.models import Role, User, UserSettingJobStatusMailNotificationLevel
from app.settings.forms import (
EditNotificationSettingsForm
)
from app.users.forms import EditProfileSettingsForm
from . import bp
from .forms import AdminEditUserForm
@bp.before_request
@login_required
@admin_required
def before_request():
'''
Ensures that the routes in this package can be visited only by users with
administrator privileges (login_required and admin_required).
'''
pass
@bp.route('')
def index():
return redirect(url_for('.users'))
@bp.route('/users')
def users():
users = [x.to_json_serializeable(backrefs=True) for x in User.query.all()]
return render_template(
'admin/users.html.j2',
users=users,
title='Users'
)
@bp.route('/users/<hashid:user_id>')
def user(user_id):
user = User.query.get_or_404(user_id)
return render_template('admin/user.html.j2', title='User', user=user)
@bp.route('/users/<hashid:user_id>/edit', methods=['GET', 'POST'])
def edit_user(user_id):
user = User.query.get_or_404(user_id)
admin_edit_user_form = AdminEditUserForm(
data={'confirmed': user.confirmed, 'role': user.role.hashid},
prefix='admin-edit-user-form'
)
edit_profile_settings_form = EditProfileSettingsForm(
user,
data=user.to_json_serializeable(),
prefix='edit-profile-settings-form'
)
edit_notification_settings_form = EditNotificationSettingsForm(
data=user.to_json_serializeable(),
prefix='edit-notification-settings-form'
)
if (admin_edit_user_form.submit.data
and admin_edit_user_form.validate()):
user.confirmed = admin_edit_user_form.confirmed.data
role_id = hashids.decode(admin_edit_user_form.role.data)
user.role = Role.query.get(role_id)
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.edit_user', user_id=user.id))
if (edit_profile_settings_form.submit.data
and edit_profile_settings_form.validate()):
user.email = edit_profile_settings_form.email.data
user.username = edit_profile_settings_form.username.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.edit_user', user_id=user.id))
if (edit_notification_settings_form.submit.data
and edit_notification_settings_form.validate()):
user.setting_job_status_mail_notification_level = \
UserSettingJobStatusMailNotificationLevel[
edit_notification_settings_form.job_status_mail_notification_level.data # noqa
]
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.edit_user', user_id=user.id))
return render_template(
'admin/edit_user.html.j2',
admin_edit_user_form=admin_edit_user_form,
edit_profile_settings_form=edit_profile_settings_form,
edit_notification_settings_form=edit_notification_settings_form,
title='Edit user',
user=user
)
@bp.route('/users/<hashid:user_id>/delete', methods=['DELETE'])
def delete_user(user_id):
def _delete_user(app, user_id):
with app.app_context():
user = User.query.get(user_id)
user.delete()
db.session.commit()
User.query.get_or_404(user_id)
thread = Thread(
target=_delete_user,
args=(current_app._get_current_object(), user_id)
)
thread.start()
return {}, 202

View File

@@ -1,8 +0,0 @@
from flask import Blueprint
USERNAME_REGEX = '^[A-Za-zÄÖÜäöüß0-9_.]*$'
bp = Blueprint('auth', __name__)
from . import routes

View File

@@ -5,8 +5,8 @@ from flask import abort, Blueprint
from werkzeug.exceptions import InternalServerError
from app import db, hashids
from app.models import Job, JobInput, JobStatus, TesseractOCRPipelineModel
from .schemas import EmptySchema, JobSchema, SpaCyNLPPipelineJobSchema, TesseractOCRPipelineJobSchema, TesseractOCRPipelineModelSchema
from .auth import auth_error_responses, token_auth
from .schemas import EmptySchema, JobSchema, SpaCyNLPPipelineJobSchema, TesseractOCRPipelineJobSchema, TesseractOCRPipelineModelSchema
bp = Blueprint('jobs', __name__)
@@ -77,7 +77,7 @@ def delete_job(job_id):
job = Job.query.get(job_id)
if job is None:
abort(404)
if not (job.user == current_user or current_user.is_administrator()):
if not (job.user == current_user or current_user.is_administrator):
abort(403)
try:
job.delete()
@@ -97,6 +97,6 @@ def get_job(job_id):
job = Job.query.get(job_id)
if job is None:
abort(404)
if not (job.user == current_user or current_user.is_administrator()):
if not (job.user == current_user or current_user.is_administrator):
abort(403)
return job

View File

@@ -2,7 +2,6 @@ from apifairy.fields import FileField
from marshmallow import validate, validates, ValidationError
from marshmallow.decorators import post_dump
from app import ma
from app.auth import USERNAME_REGEX
from app.models import (
Job,
JobStatus,
@@ -11,7 +10,7 @@ from app.models import (
User,
UserSettingJobStatusMailNotificationLevel
)
from app.services import SERVICES
from app.blueprints.services import SERVICES
@@ -142,7 +141,10 @@ class UserSchema(ma.SQLAlchemySchema):
username = ma.auto_field(
validate=[
validate.Length(min=1, max=64),
validate.Regexp(USERNAME_REGEX, error='Usernames must have only letters, numbers, dots or underscores')
validate.Regexp(
User.username_pattern,
error='Usernames must have only letters, numbers, dots or underscores'
)
]
)
email = ma.auto_field(validate=validate.Email())

View File

@@ -3,11 +3,11 @@ from apifairy import authenticate, body, response
from apifairy.decorators import other_responses
from flask import abort, Blueprint
from werkzeug.exceptions import InternalServerError
from app import db
from app.email import create_message, send
from app import db
from app.models import User
from .schemas import EmptySchema, UserSchema
from .auth import auth_error_responses, token_auth
from .schemas import EmptySchema, UserSchema
bp = Blueprint('users', __name__)
@@ -60,7 +60,7 @@ def delete_user(user_id):
user = User.query.get(user_id)
if user is None:
abort(404)
if not (user == current_user or current_user.is_administrator()):
if not (user == current_user or current_user.is_administrator):
abort(403)
user.delete()
db.session.commit()
@@ -78,7 +78,7 @@ def get_user(user_id):
user = User.query.get(user_id)
if user is None:
abort(404)
if not (user == current_user or current_user.is_administrator()):
if not (user == current_user or current_user.is_administrator):
abort(403)
return user
@@ -94,6 +94,6 @@ def get_user_by_username(username):
user = User.query.filter(User.username == username).first()
if user is None:
abort(404)
if not (user == current_user or current_user.is_administrator()):
if not (user == current_user or current_user.is_administrator):
abort(403)
return user

View File

@@ -0,0 +1,27 @@
from flask import Blueprint, redirect, request, url_for
from flask_login import current_user
from app import db
bp = Blueprint('auth', __name__)
@bp.before_app_request
def before_request():
if not current_user.is_authenticated:
return
current_user.ping()
db.session.commit()
if (
not current_user.confirmed
and request.endpoint
and request.blueprint != 'auth'
and request.endpoint != 'static'
and request.endpoint != 'main.accept_terms_of_use'
):
return redirect(url_for('auth.unconfirmed'))
from . import routes

View File

@@ -8,7 +8,6 @@ from wtforms import (
)
from wtforms.validators import InputRequired, Email, EqualTo, Length, Regexp
from app.models import User
from . import USERNAME_REGEX
class RegistrationForm(FlaskForm):
@@ -22,7 +21,7 @@ class RegistrationForm(FlaskForm):
InputRequired(),
Length(max=64),
Regexp(
USERNAME_REGEX,
User.username_pattern,
message=(
'Usernames must have only letters, numbers, dots or '
'underscores'
@@ -44,15 +43,28 @@ class RegistrationForm(FlaskForm):
EqualTo('password', message='Passwords must match')
]
)
terms_of_use_accepted = BooleanField(
'I have read and accept the terms of use',
validators=[InputRequired()]
)
submit = SubmitField()
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'registration-form'
super().__init__(*args, **kwargs)
def validate_email(self, field):
if User.query.filter_by(email=field.data.lower()).first():
raise ValidationError('Email already registered')
def validate_username(self, field):
if User.query.filter_by(username=field.data).first():
raise ValidationError('Username already in use')
raise ValidationError('Username already registered')
def validate_terms_of_use_accepted(self, field):
if not field.data:
raise ValidationError('Terms of Use not accepted')
class LoginForm(FlaskForm):
@@ -61,11 +73,21 @@ class LoginForm(FlaskForm):
remember_me = BooleanField('Keep me logged in')
submit = SubmitField()
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'login-form'
super().__init__(*args, **kwargs)
class ResetPasswordRequestForm(FlaskForm):
email = StringField('Email', validators=[InputRequired(), Email()])
submit = SubmitField()
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'reset-password-request-form'
super().__init__(*args, **kwargs)
class ResetPasswordForm(FlaskForm):
password = PasswordField(
@@ -83,3 +105,8 @@ class ResetPasswordForm(FlaskForm):
]
)
submit = SubmitField()
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'reset-password-form'
super().__init__(*args, **kwargs)

View File

@@ -1,11 +1,4 @@
from flask import (
abort,
flash,
redirect,
render_template,
request,
url_for
)
from flask import abort, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_user, login_required, logout_user
from app import db
from app.email import create_message, send
@@ -19,33 +12,18 @@ from .forms import (
)
@bp.before_app_request
def before_request():
"""
Checks if a user is unconfirmed when visiting specific sites. Redirects to
unconfirmed view if user is unconfirmed.
"""
if current_user.is_authenticated:
current_user.ping()
db.session.commit()
if (not current_user.confirmed
and request.endpoint
and request.blueprint != 'auth'
and request.endpoint != 'static'):
return redirect(url_for('auth.unconfirmed'))
@bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = RegistrationForm(prefix='registration-form')
form = RegistrationForm()
if form.validate_on_submit():
try:
user = User.create(
email=form.email.data.lower(),
password=form.password.data,
username=form.username.data
username=form.username.data,
terms_of_use_accepted=form.terms_of_use_accepted.data
)
except OSError:
flash('Internal Server Error', category='error')
@@ -65,8 +43,8 @@ def register():
return redirect(url_for('.login'))
return render_template(
'auth/register.html.j2',
form=form,
title='Register'
title='Register',
form=form
)
@@ -74,7 +52,7 @@ def register():
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = LoginForm(prefix='login-form')
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first()
if user and user.verify_password(form.password.data):
@@ -85,7 +63,11 @@ def login():
flash('You have been logged in')
return redirect(next)
flash('Invalid email/username or password', category='error')
return render_template('auth/login.html.j2', form=form, title='Log in')
return render_template(
'auth/login.html.j2',
title='Log in',
form=form
)
@bp.route('/logout')
@@ -101,10 +83,13 @@ def logout():
def unconfirmed():
if current_user.confirmed:
return redirect(url_for('main.dashboard'))
return render_template('auth/unconfirmed.html.j2', title='Unconfirmed')
return render_template(
'auth/unconfirmed.html.j2',
title='Unconfirmed'
)
@bp.route('/confirm')
@bp.route('/confirm-request')
@login_required
def confirm_request():
if current_user.confirmed:
@@ -135,11 +120,11 @@ def confirm(token):
return redirect(url_for('.unconfirmed'))
@bp.route('/reset_password', methods=['GET', 'POST'])
@bp.route('/reset-password-request', methods=['GET', 'POST'])
def reset_password_request():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = ResetPasswordRequestForm(prefix='reset-password-request-form')
form = ResetPasswordRequestForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.lower()).first()
if user is not None:
@@ -159,16 +144,16 @@ def reset_password_request():
return redirect(url_for('.login'))
return render_template(
'auth/reset_password_request.html.j2',
form=form,
title='Password Reset'
title='Password Reset',
form=form
)
@bp.route('/reset_password/<token>', methods=['GET', 'POST'])
@bp.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = ResetPasswordForm(prefix='reset-password-form')
form = ResetPasswordForm()
if form.validate_on_submit():
if User.reset_password(token, form.password.data):
db.session.commit()
@@ -177,7 +162,7 @@ def reset_password(token):
return redirect(url_for('main.index'))
return render_template(
'auth/reset_password.html.j2',
form=form,
title='Password Reset',
form=form,
token=token
)

View File

@@ -0,0 +1,25 @@
from flask import Blueprint
from flask_login import login_required
bp = Blueprint('contributions', __name__)
@bp.before_request
@login_required
def before_request():
'''
Ensures that the routes in this package can only be visited by users that
are logged in.
'''
pass
from . import routes
from .spacy_nlp_pipeline_models import bp as spacy_nlp_pipeline_models_bp
bp.register_blueprint(spacy_nlp_pipeline_models_bp, url_prefix='/spacy-nlp-pipeline-models')
from .tesseract_ocr_pipeline_models import bp as tesseract_ocr_pipeline_models_bp
bp.register_blueprint(tesseract_ocr_pipeline_models_bp, url_prefix='/tesseract-ocr-pipeline-models')

View File

@@ -0,0 +1,47 @@
from flask_wtf import FlaskForm
from wtforms import (
StringField,
SubmitField,
SelectMultipleField,
IntegerField
)
from wtforms.validators import InputRequired, Length
class ContributionBaseForm(FlaskForm):
title = StringField(
'Title',
validators=[InputRequired(), Length(max=64)]
)
description = StringField(
'Description',
validators=[InputRequired(), Length(max=255)]
)
version = StringField(
'Version',
validators=[InputRequired(), Length(max=16)]
)
publisher = StringField(
'Publisher',
validators=[InputRequired(), Length(max=128)]
)
publisher_url = StringField(
'Publisher URL',
validators=[InputRequired(), Length(max=512)]
)
publishing_url = StringField(
'Publishing URL',
validators=[InputRequired(), Length(max=512)]
)
publishing_year = IntegerField(
'Publishing year',
validators=[InputRequired()]
)
compatible_service_versions = SelectMultipleField(
'Compatible service versions'
)
submit = SubmitField()
class UpdateContributionBaseForm(ContributionBaseForm):
pass

View File

@@ -0,0 +1,7 @@
from flask import render_template
from . import bp
@bp.route('')
def index():
return render_template('contributions/index.html.j2', title='Contributions')

View File

@@ -0,0 +1,18 @@
from flask import current_app, Blueprint
from flask_login import login_required
bp = Blueprint('spacy_nlp_pipeline_models', __name__)
@bp.before_request
@login_required
def before_request():
'''
Ensures that the routes in this package can only be visited by users that
are logged in.
'''
pass
from . import routes, json_routes

View File

@@ -0,0 +1,48 @@
from flask_wtf.file import FileField, FileRequired
from wtforms import StringField, ValidationError
from wtforms.validators import InputRequired, Length
from app.blueprints.services import SERVICES
from ..forms import ContributionBaseForm, UpdateContributionBaseForm
class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm):
spacy_model_file = FileField(
'File',
validators=[FileRequired()]
)
pipeline_name = StringField(
'Pipeline name',
validators=[InputRequired(), Length(max=64)]
)
def validate_spacy_model_file(self, field):
if not field.data.filename.lower().endswith(('.tar.gz', ('.whl'))):
raise ValidationError('.tar.gz or .whl files only!')
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-spacy-nlp-pipeline-model-form'
super().__init__(*args, **kwargs)
service_manifest = SERVICES['spacy-nlp-pipeline']
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''
class UpdateSpaCyNLPPipelineModelForm(UpdateContributionBaseForm):
pipeline_name = StringField(
'Pipeline name',
validators=[InputRequired(), Length(max=64)]
)
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'edit-spacy-nlp-pipeline-model-form'
super().__init__(*args, **kwargs)
service_manifest = SERVICES['spacy-nlp-pipeline']
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''

View File

@@ -0,0 +1,53 @@
from flask import abort, current_app, request
from flask_login import current_user, login_required
from threading import Thread
from app import db
from app.decorators import content_negotiation, permission_required
from app.models import SpaCyNLPPipelineModel
from . import bp
@bp.route('/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
@login_required
@content_negotiation(produces='application/json')
def delete_spacy_model(spacy_nlp_pipeline_model_id):
def _delete_spacy_model(app, spacy_nlp_pipeline_model_id):
with app.app_context():
snpm = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
snpm.delete()
db.session.commit()
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (snpm.user == current_user or current_user.is_administrator):
abort(403)
thread = Thread(
target=_delete_spacy_model,
args=(current_app._get_current_object(), snpm.id)
)
thread.start()
response_data = {
'message': \
f'SpaCy NLP Pipeline Model "{snpm.title}" marked for deletion'
}
return response_data, 202
@bp.route('/<hashid:spacy_nlp_pipeline_model_id>/is_public', methods=['PUT'])
@permission_required('CONTRIBUTE')
@content_negotiation(consumes='application/json', produces='application/json')
def update_spacy_nlp_pipeline_model_is_public(spacy_nlp_pipeline_model_id):
is_public = request.json
if not isinstance(is_public, bool):
abort(400)
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (snpm.user == current_user or current_user.is_administrator):
abort(403)
snpm.is_public = is_public
db.session.commit()
response_data = {
'message': (
f'SpaCy NLP Pipeline Model "{snpm.title}"'
f' is now {"public" if is_public else "private"}'
)
}
return response_data, 200

View File

@@ -0,0 +1,70 @@
from flask import abort, flash, redirect, render_template, url_for
from flask_login import current_user, login_required
from app import db
from app.models import SpaCyNLPPipelineModel
from . import bp
from .forms import (
CreateSpaCyNLPPipelineModelForm,
UpdateSpaCyNLPPipelineModelForm
)
@bp.route('/')
@login_required
def index():
return redirect(url_for('contributions.index', _anchor='spacy-nlp-pipeline-models'))
@bp.route('/create', methods=['GET', 'POST'])
@login_required
def create():
form = CreateSpaCyNLPPipelineModelForm()
if form.is_submitted():
if not form.validate():
return {'errors': form.errors}, 400
try:
snpm = SpaCyNLPPipelineModel.create(
form.spacy_model_file.data,
compatible_service_versions=form.compatible_service_versions.data,
description=form.description.data,
pipeline_name=form.pipeline_name.data,
publisher=form.publisher.data,
publisher_url=form.publisher_url.data,
publishing_url=form.publishing_url.data,
publishing_year=form.publishing_year.data,
is_public=False,
title=form.title.data,
version=form.version.data,
user=current_user
)
except OSError:
abort(500)
db.session.commit()
flash(f'SpaCy NLP Pipeline model "{snpm.title}" created')
return {}, 201, {'Location': url_for('.index')}
return render_template(
'contributions/spacy_nlp_pipeline_models/create.html.j2',
title='Create SpaCy NLP Pipeline Model',
form=form
)
@bp.route('/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
@login_required
def entity(spacy_nlp_pipeline_model_id):
snpm = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (snpm.user == current_user or current_user.is_administrator):
abort(403)
form = UpdateSpaCyNLPPipelineModelForm(data=snpm.to_json_serializeable())
if form.validate_on_submit():
form.populate_obj(snpm)
if db.session.is_modified(snpm):
flash(f'SpaCy NLP Pipeline model "{snpm.title}" updated')
db.session.commit()
return redirect(url_for('.index'))
return render_template(
'contributions/spacy_nlp_pipeline_models/entity.html.j2',
title=f'{snpm.title} {snpm.version}',
form=form,
spacy_nlp_pipeline_model=snpm
)

View File

@@ -0,0 +1,18 @@
from flask import Blueprint
from flask_login import login_required
bp = Blueprint('tesseract_ocr_pipeline_models', __name__)
@bp.before_request
@login_required
def before_request():
'''
Ensures that the routes in this package can only be visited by users that
are logged in.
'''
pass
from . import json_routes, routes

View File

@@ -0,0 +1,39 @@
from flask_wtf.file import FileField, FileRequired
from wtforms import ValidationError
from app.blueprints.services import SERVICES
from ..forms import ContributionBaseForm, UpdateContributionBaseForm
class CreateTesseractOCRPipelineModelForm(ContributionBaseForm):
tesseract_model_file = FileField(
'File',
validators=[FileRequired()]
)
def validate_tesseract_model_file(self, field):
if not field.data.filename.lower().endswith('.traineddata'):
raise ValidationError('traineddata files only!')
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-tesseract-ocr-pipeline-model-form'
service_manifest = SERVICES['tesseract-ocr-pipeline']
super().__init__(*args, **kwargs)
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''
class UpdateTesseractOCRPipelineModelForm(UpdateContributionBaseForm):
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'edit-tesseract-ocr-pipeline-model-form'
service_manifest = SERVICES['tesseract-ocr-pipeline']
super().__init__(*args, **kwargs)
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''

View File

@@ -0,0 +1,52 @@
from flask import abort, current_app, request
from flask_login import current_user
from threading import Thread
from app import db
from app.decorators import content_negotiation, permission_required
from app.models import TesseractOCRPipelineModel
from . import bp
@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
@content_negotiation(produces='application/json')
def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id):
with app.app_context():
topm = TesseractOCRPipelineModel.query.get(tesseract_ocr_pipeline_model_id)
topm.delete()
db.session.commit()
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (topm.user == current_user or current_user.is_administrator):
abort(403)
thread = Thread(
target=_delete_tesseract_ocr_pipeline_model,
args=(current_app._get_current_object(), topm.id)
)
thread.start()
response_data = {
'message': \
f'Tesseract OCR Pipeline Model "{topm.title}" marked for deletion'
}
return response_data, 202
@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>/is_public', methods=['PUT'])
@permission_required('CONTRIBUTE')
@content_negotiation(consumes='application/json', produces='application/json')
def update_tesseract_ocr_pipeline_model_is_public(tesseract_ocr_pipeline_model_id):
is_public = request.json
if not isinstance(is_public, bool):
abort(400)
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (topm.user == current_user or current_user.is_administrator):
abort(403)
topm.is_public = is_public
db.session.commit()
response_data = {
'message': (
f'Tesseract OCR Pipeline Model "{topm.title}"'
f' is now {"public" if is_public else "private"}'
)
}
return response_data, 200

View File

@@ -0,0 +1,66 @@
from flask import abort, flash, redirect, render_template, url_for
from flask_login import current_user
from app import db
from app.models import TesseractOCRPipelineModel
from . import bp
from .forms import (
CreateTesseractOCRPipelineModelForm,
UpdateTesseractOCRPipelineModelForm
)
@bp.route('/')
def index():
return redirect(url_for('contributions.index', _anchor='tesseract-ocr-pipeline-models'))
@bp.route('/create', methods=['GET', 'POST'])
def create():
form = CreateTesseractOCRPipelineModelForm()
if form.is_submitted():
if not form.validate():
return {'errors': form.errors}, 400
try:
topm = TesseractOCRPipelineModel.create(
form.tesseract_model_file.data,
compatible_service_versions=form.compatible_service_versions.data,
description=form.description.data,
publisher=form.publisher.data,
publisher_url=form.publisher_url.data,
publishing_url=form.publishing_url.data,
publishing_year=form.publishing_year.data,
is_public=False,
title=form.title.data,
version=form.version.data,
user=current_user
)
except OSError:
abort(500)
db.session.commit()
flash(f'Tesseract OCR Pipeline model "{topm.title}" created')
return {}, 201, {'Location': url_for('.index')}
return render_template(
'contributions/tesseract_ocr_pipeline_models/create.html.j2',
title='Create Tesseract OCR Pipeline Model',
form=form
)
@bp.route('/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
def entity(tesseract_ocr_pipeline_model_id):
topm = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (topm.user == current_user or current_user.is_administrator):
abort(403)
form = UpdateTesseractOCRPipelineModelForm(data=topm.to_json_serializeable())
if form.validate_on_submit():
form.populate_obj(topm)
if db.session.is_modified(topm):
flash(f'Tesseract OCR Pipeline model "{topm.title}" updated')
db.session.commit()
return redirect(url_for('.index'))
return render_template(
'contributions/tesseract_ocr_pipeline_models/entity.html.j2',
title=f'{topm.title} {topm.version}',
form=form,
tesseract_ocr_pipeline_model=topm
)

View File

@@ -0,0 +1,19 @@
from flask import Blueprint
from flask_login import login_required
bp = Blueprint('corpora', __name__)
bp.cli.short_help = 'Corpus commands.'
@bp.before_request
@login_required
def before_request():
'''
Ensures that the routes in this package can only be visited by users that
are logged in.
'''
pass
from . import cli, files, followers, routes

View File

@@ -0,0 +1,34 @@
from flask import current_app
import shutil
from app import db
from app.models import Corpus, CorpusStatus
from . import bp
@bp.cli.command('reset')
def reset():
''' Reset built corpora. '''
status = [
CorpusStatus.QUEUED,
CorpusStatus.BUILDING,
CorpusStatus.BUILT,
CorpusStatus.STARTING_ANALYSIS_SESSION,
CorpusStatus.RUNNING_ANALYSIS_SESSION,
CorpusStatus.CANCELING_ANALYSIS_SESSION
]
for corpus in [x for x in Corpus.query.all() if x.status in status]:
print(f'Resetting corpus {corpus}')
corpus_cwb_dir = corpus.path / 'cwb'
corpus_cwb_data_dir = corpus_cwb_dir / 'data'
corpus_cwb_registry_dir = corpus_cwb_dir / 'registry'
try:
shutil.rmtree(corpus.path / 'cwb', ignore_errors=True)
corpus_cwb_dir.mkdir()
corpus_cwb_data_dir.mkdir()
corpus_cwb_registry_dir.mkdir()
except OSError as e:
current_app.logger.error(e)
raise
corpus.status = CorpusStatus.UNPREPARED
corpus.num_analysis_sessions = 0
db.session.commit()

View File

@@ -0,0 +1,33 @@
from flask import abort
from flask_login import current_user
from functools import wraps
from app.models import Corpus, CorpusFollowerAssociation
def corpus_follower_permission_required(*permissions):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
corpus_id = kwargs.get('corpus_id')
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator):
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first()
if cfa is None:
abort(403)
if not all([cfa.role.has_permission(p) for p in permissions]):
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
def corpus_owner_or_admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
corpus_id = kwargs.get('corpus_id')
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator):
abort(403)
return f(*args, **kwargs)
return decorated_function

View File

@@ -0,0 +1,2 @@
from .. import bp
from . import json_routes, routes

View File

@@ -1,39 +1,14 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired
from wtforms import (
BooleanField,
StringField,
SubmitField,
TextAreaField,
ValidationError,
IntegerField
)
from wtforms.validators import InputRequired, Length
class CorpusBaseForm(FlaskForm):
description = TextAreaField(
'Description',
validators=[InputRequired(), Length(max=255)]
)
title = StringField('Title', validators=[InputRequired(), Length(max=32)])
submit = SubmitField()
class CreateCorpusForm(CorpusBaseForm):
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-corpus-form'
super().__init__(*args, **kwargs)
class UpdateCorpusForm(CorpusBaseForm):
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-corpus-form'
super().__init__(*args, **kwargs)
class CorpusFileBaseForm(FlaskForm):
author = StringField(
'Author',
@@ -62,25 +37,18 @@ class CorpusFileBaseForm(FlaskForm):
class CreateCorpusFileForm(CorpusFileBaseForm):
vrt = FileField('File', validators=[FileRequired()])
def validate_vrt(self, field):
if not field.data.filename.lower().endswith('.vrt'):
raise ValidationError('VRT files only!')
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-corpus-file-form'
super().__init__(*args, **kwargs)
def validate_vrt(self, field):
if not field.data.filename.lower().endswith('.vrt'):
raise ValidationError('VRT files only!')
class UpdateCorpusFileForm(CorpusFileBaseForm):
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-corpus-file-form'
super().__init__(*args, **kwargs)
class ChangeCorpusSettingsForm(FlaskForm):
is_public = BooleanField('Public Corpus')
submit = SubmitField()
class ImportCorpusForm(FlaskForm):
pass

View File

@@ -0,0 +1,30 @@
from flask import current_app
from threading import Thread
from app.decorators import content_negotiation
from app import db
from app.models import CorpusFile
from ..decorators import corpus_follower_permission_required
from . import bp
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['DELETE'])
@corpus_follower_permission_required('MANAGE_FILES')
@content_negotiation(produces='application/json')
def delete_corpus_file(corpus_id, corpus_file_id):
def _delete_corpus_file(app, corpus_file_id):
with app.app_context():
corpus_file = CorpusFile.query.get(corpus_file_id)
corpus_file.delete()
db.session.commit()
corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
thread = Thread(
target=_delete_corpus_file,
args=(current_app._get_current_object(), corpus_file.id)
)
thread.start()
response_data = {
'message': f'Corpus File "{corpus_file.title}" marked for deletion',
'category': 'corpus'
}
return response_data, 202

View File

@@ -0,0 +1,93 @@
from flask import (
abort,
flash,
redirect,
render_template,
send_from_directory,
url_for
)
from app import db
from app.models import Corpus, CorpusFile, CorpusStatus
from ..decorators import corpus_follower_permission_required
from . import bp
from .forms import CreateCorpusFileForm, UpdateCorpusFileForm
@bp.route('/<hashid:corpus_id>/files')
def corpus_files(corpus_id):
return redirect(url_for('.corpus', _anchor='files', corpus_id=corpus_id))
@bp.route('/<hashid:corpus_id>/files/create', methods=['GET', 'POST'])
@corpus_follower_permission_required('MANAGE_FILES')
def create_corpus_file(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
form = CreateCorpusFileForm()
if form.is_submitted():
if not form.validate():
response = {'errors': form.errors}
return response, 400
try:
corpus_file = CorpusFile.create(
form.vrt.data,
address=form.address.data,
author=form.author.data,
booktitle=form.booktitle.data,
chapter=form.chapter.data,
editor=form.editor.data,
institution=form.institution.data,
journal=form.journal.data,
pages=form.pages.data,
publisher=form.publisher.data,
publishing_year=form.publishing_year.data,
school=form.school.data,
title=form.title.data,
mimetype='application/vrt+xml',
corpus=corpus
)
except (AttributeError, OSError):
abort(500)
corpus.status = CorpusStatus.UNPREPARED
db.session.commit()
flash(f'Corpus File "{corpus_file.filename}" added', category='corpus')
return '', 201, {'Location': corpus.url}
return render_template(
'corpora/files/create.html.j2',
title='Add corpus file',
form=form,
corpus=corpus
)
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['GET', 'POST'])
@corpus_follower_permission_required('MANAGE_FILES')
def corpus_file(corpus_id, corpus_file_id):
corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
form = UpdateCorpusFileForm(data=corpus_file.to_json_serializeable())
if form.validate_on_submit():
form.populate_obj(corpus_file)
if db.session.is_modified(corpus_file):
corpus_file.corpus.status = CorpusStatus.UNPREPARED
db.session.commit()
flash(f'Corpus file "{corpus_file.filename}" updated', category='corpus')
return redirect(corpus_file.corpus.url)
return render_template(
'corpora/files/corpus_file.html.j2',
title='Edit corpus file',
form=form,
corpus=corpus_file.corpus,
corpus_file=corpus_file
)
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/download')
@corpus_follower_permission_required('VIEW')
def download_corpus_file(corpus_id, corpus_file_id):
corpus_file = CorpusFile.query.filter_by(corpus_id=corpus_id, id=corpus_file_id).first_or_404()
return send_from_directory(
corpus_file.path.parent,
corpus_file.path.name,
as_attachment=True,
download_name=corpus_file.filename,
mimetype=corpus_file.mimetype
)

View File

@@ -0,0 +1,2 @@
from .. import bp
from . import json_routes

View File

@@ -0,0 +1,76 @@
from flask import abort, flash, jsonify, make_response, request
from flask_login import current_user
from app import db
from app.decorators import content_negotiation
from app.models import (
Corpus,
CorpusFollowerAssociation,
CorpusFollowerRole,
User
)
from ..decorators import corpus_follower_permission_required
from . import bp
@bp.route('/<hashid:corpus_id>/followers', methods=['POST'])
@corpus_follower_permission_required('MANAGE_FOLLOWERS')
@content_negotiation(consumes='application/json', produces='application/json')
def create_corpus_followers(corpus_id):
usernames = request.json
if not (isinstance(usernames, list) or all(isinstance(u, str) for u in usernames)):
abort(400)
corpus = Corpus.query.get_or_404(corpus_id)
for username in usernames:
user = User.query.filter_by(username=username, is_public=True).first_or_404()
user.follow_corpus(corpus)
db.session.commit()
response_data = {
'message': f'Users are now following "{corpus.title}"',
'category': 'corpus'
}
return response_data, 200
@bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>/role', methods=['PUT'])
@corpus_follower_permission_required('MANAGE_FOLLOWERS')
@content_negotiation(consumes='application/json', produces='application/json')
def update_corpus_follower_role(corpus_id, follower_id):
role_name = request.json
if not isinstance(role_name, str):
abort(400)
cfr = CorpusFollowerRole.query.filter_by(name=role_name).first()
if cfr is None:
abort(400)
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404()
cfa.role = cfr
db.session.commit()
response_data = {
'message': f'User "{cfa.follower.username}" is now {cfa.role.name}',
'category': 'corpus'
}
return response_data, 200
@bp.route('/<hashid:corpus_id>/followers/<hashid:follower_id>', methods=['DELETE'])
def delete_corpus_follower(corpus_id, follower_id):
cfa = CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=follower_id).first_or_404()
if not (
current_user.id == follower_id
or current_user == cfa.corpus.user
or CorpusFollowerAssociation.query.filter_by(corpus_id=corpus_id, follower_id=current_user.id).first().role.has_permission('MANAGE_FOLLOWERS')
or current_user.is_administrator):
abort(403)
if current_user.id == follower_id:
flash(f'You are no longer following "{cfa.corpus.title}"', 'corpus')
response = make_response()
response.status_code = 204
else:
response_data = {
'message': f'"{cfa.follower.username}" is not following "{cfa.corpus.title}" anymore',
'category': 'corpus'
}
response = jsonify(response_data)
response.status_code = 200
cfa.follower.unfollow_corpus(cfa.corpus)
db.session.commit()
return response

View File

@@ -0,0 +1,33 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField
from wtforms.validators import InputRequired, Length
class CorpusBaseForm(FlaskForm):
description = TextAreaField(
'Description',
validators=[InputRequired(), Length(max=255)]
)
title = StringField('Title', validators=[InputRequired(), Length(max=32)])
submit = SubmitField()
class CreateCorpusForm(CorpusBaseForm):
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-corpus-form'
super().__init__(*args, **kwargs)
class UpdateCorpusForm(CorpusBaseForm):
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-corpus-form'
super().__init__(*args, **kwargs)
class ImportCorpusForm(FlaskForm):
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'import-corpus-form'
super().__init__(*args, **kwargs)

View File

@@ -0,0 +1,299 @@
from datetime import datetime
from flask import (
abort,
current_app,
flash,
Flask,
jsonify,
redirect,
request,
render_template,
url_for
)
from flask_login import current_user
from string import punctuation
from threading import Thread
import nltk
from app import db
from app.models import (
Corpus,
CorpusFollowerAssociation,
CorpusFollowerRole,
User
)
from . import bp
from .decorators import corpus_follower_permission_required
from .forms import CreateCorpusForm
def _delete_corpus(app: Flask, corpus_id: int):
with app.app_context():
corpus: Corpus = Corpus.query.get(corpus_id)
corpus.delete()
db.session.commit()
def _build_corpus(app: Flask, corpus_id: int):
with app.app_context():
corpus = Corpus.query.get(corpus_id)
corpus.build()
db.session.commit()
@bp.route('')
def corpora():
return redirect(url_for('main.dashboard', _anchor='corpora'))
@bp.route('/create', methods=['GET', 'POST'])
def create_corpus():
form = CreateCorpusForm()
if form.validate_on_submit():
try:
corpus = Corpus.create(
title=form.title.data,
description=form.description.data,
user=current_user
)
except OSError:
abort(500)
db.session.commit()
flash(f'Corpus "{corpus.title}" created', 'corpus')
return redirect(corpus.url)
return render_template(
'corpora/create.html.j2',
title='Create corpus',
form=form
)
@bp.route('/<hashid:corpus_id>')
def corpus(corpus_id: int):
corpus = Corpus.query.get_or_404(corpus_id)
cfa = CorpusFollowerAssociation.query.filter_by(
corpus_id=corpus_id,
follower_id=current_user.id
).first()
if cfa is None:
if corpus.user == current_user or current_user.is_administrator:
cfr = CorpusFollowerRole.query.filter_by(name='Administrator').first()
else:
cfr = CorpusFollowerRole.query.filter_by(name='Anonymous').first()
else:
cfr = cfa.role
cfrs = CorpusFollowerRole.query.all()
# TODO: Better solution for filtering admin
users = User.query.filter(
User.is_public == True,
User.id != current_user.id,
User.id != corpus.user.id,
User.role_id < 4
).all()
if (
corpus.user == current_user
or current_user.is_administrator
):
return render_template(
'corpora/corpus.html.j2',
title=corpus.title,
corpus=corpus,
cfr=cfr,
cfrs=cfrs,
users=users
)
if (
current_user.is_following_corpus(corpus)
or corpus.is_public
):
cfas = CorpusFollowerAssociation.query.filter(
Corpus.id == corpus_id,
CorpusFollowerAssociation.follower_id != corpus.user.id
).all()
return render_template(
'corpora/public_corpus.html.j2',
title=corpus.title,
corpus=corpus,
cfrs=cfrs,
cfr=cfr,
cfas=cfas,
users=users
)
abort(403)
@bp.route('/<hashid:corpus_id>', methods=['DELETE'])
def delete_corpus(corpus_id: int):
corpus = Corpus.query.get_or_404(corpus_id)
if not (
corpus.user == current_user
or current_user.is_administrator
):
abort(403)
thread = Thread(
target=_delete_corpus,
args=(current_app._get_current_object(), corpus.id)
)
thread.start()
return jsonify(f'Corpus "{corpus.title}" marked for deletion.'), 202
@bp.route('/<hashid:corpus_id>/build', methods=['POST'])
def build_corpus(corpus_id: int):
corpus = Corpus.query.get_or_404(corpus_id)
cfa = CorpusFollowerAssociation.query.filter_by(
corpus_id=corpus_id,
follower_id=current_user.id
).first()
if not (
cfa is not None and cfa.role.has_permission('MANAGE_FILES')
or corpus.user == current_user
or current_user.is_administrator
):
abort(403)
if len(corpus.files.all()) == 0:
abort(409)
thread = Thread(
target=_build_corpus,
args=(current_app._get_current_object(), corpus.id)
)
thread.start()
return jsonify(f'Corpus "{corpus.title}" marked for building.'), 202
@bp.route('/<hashid:corpus_id>/create-share-link', methods=['POST'])
def create_share_link(corpus_id: int):
data = request.json
expiration_date = data['expiration_date']
if not isinstance(expiration_date, str):
abort(400)
role_name = data['role_name']
if not isinstance(role_name, str):
abort(400)
corpus = Corpus.query.get_or_404(corpus_id)
cfa = CorpusFollowerAssociation.query.filter_by(
corpus_id=corpus_id,
follower_id=current_user.id
).first()
if not (
cfa is not None and cfa.role.has_permission('MANAGE_FOLLOWERS')
or corpus.user == current_user
or current_user.is_administrator
):
abort(403)
_expiration_date = datetime.strptime(expiration_date, '%b %d, %Y')
cfr = CorpusFollowerRole.query.filter_by(name=role_name).first()
if cfr is None:
abort(400)
token = current_user.generate_follow_corpus_token(
corpus.hashid,
role_name,
_expiration_date
)
corpus_share_link = url_for(
'corpora.follow_corpus',
corpus_id=corpus_id,
token=token,
_external=True
)
return jsonify(corpus_share_link)
@bp.route('/<hashid:corpus_id>/analysis')
@corpus_follower_permission_required('VIEW')
def analysis(corpus_id: int):
corpus = Corpus.query.get_or_404(corpus_id)
return render_template(
'corpora/analysis.html.j2',
corpus=corpus,
title=f'Analyse Corpus {corpus.title}'
)
@bp.route('/<hashid:corpus_id>/analysis/stopwords')
def get_stopwords(corpus_id: int):
languages = [
'german',
'english',
'catalan',
'greek',
'spanish',
'french',
'italian',
'russian',
'chinese'
]
nltk.download('stopwords', quiet=True)
stopwords = {
language: nltk.corpus.stopwords.words(language)
for language in languages
}
stopwords['punctuation'] = list(punctuation)
stopwords['punctuation'] += ['', '|', '', '', '', '--']
stopwords['user_stopwords'] = []
return jsonify(stopwords)
@bp.route('/<hashid:corpus_id>/follow/<token>')
def follow_corpus(corpus_id: int, token: str):
corpus = Corpus.query.get_or_404(corpus_id)
if not current_user.follow_corpus_by_token(token):
abort(403)
db.session.commit()
flash(f'You are following "{corpus.title}" now', category='corpus')
return redirect(corpus.url)
@bp.route('/<hashid:corpus_id>/is-public', methods=['PUT'])
def update_is_public(corpus_id):
new_value = request.json
if not isinstance(new_value, bool):
abort(400)
corpus = Corpus.query.get_or_404(corpus_id)
if not (
corpus.user == current_user
or current_user.is_administrator
):
abort(403)
corpus.is_public = new_value
db.session.commit()
return jsonify(f'Corpus "{corpus.title}" is now {"public" if new_value else "private"}'), 200

View File

@@ -0,0 +1,20 @@
from flask import jsonify, render_template, request
from werkzeug.exceptions import HTTPException
from . import bp
@bp.app_errorhandler(HTTPException)
def handle_http_exception(e: HTTPException):
''' Generic HTTP exception handler '''
accept_json = request.accept_mimetypes.accept_json
accept_html = request.accept_mimetypes.accept_html
if accept_json and not accept_html:
error = {
'code': e.code,
'name': e.name,
'description': e.description
}
return jsonify(error), e.code
return render_template('errors/error.html.j2', error=e), e.code

View File

@@ -0,0 +1,13 @@
from flask import Blueprint
bp = Blueprint('jobs', __name__)
from . import routes
from .inputs import bp as inputs_bp
bp.register_blueprint(inputs_bp, url_prefix='/<hashid:job_id>/inputs')
from .results import bp as results_bp
bp.register_blueprint(results_bp, url_prefix='/<hashid:job_id>/results')

View File

@@ -1,5 +1,7 @@
from flask import Blueprint
bp = Blueprint('main', __name__)
bp = Blueprint('inputs', __name__)
from . import routes

View File

@@ -0,0 +1,27 @@
from flask import abort, send_from_directory
from flask_login import current_user, login_required
from app.models import JobInput
from . import bp
@bp.route('/<hashid:job_input_id>/download')
@login_required
def download_job_input(job_id: int, job_input_id: int):
job_input = JobInput.query.filter_by(
job_id=job_id,
id=job_input_id
).first_or_404()
if not (
job_input.job.user == current_user
or current_user.is_administrator
):
abort(403)
return send_from_directory(
job_input.path.parent,
job_input.path.name,
as_attachment=True,
download_name=job_input.filename,
mimetype=job_input.mimetype
)

View File

@@ -1,5 +1,7 @@
from flask import Blueprint
bp = Blueprint('admin', __name__)
bp = Blueprint('results', __name__)
from . import routes

View File

@@ -0,0 +1,27 @@
from flask import abort, send_from_directory
from flask_login import current_user, login_required
from app.models import JobResult
from . import bp
@bp.route('/<hashid:job_result_id>/download')
@login_required
def download_job_result(job_id: int, job_result_id: int):
job_result = JobResult.query.filter_by(
job_id=job_id,
id=job_result_id
).first_or_404()
if not (
job_result.job.user == current_user
or current_user.is_administrator
):
abort(403)
return send_from_directory(
job_result.path.parent,
job_result.path.name,
as_attachment=True,
download_name=job_result.filename,
mimetype=job_result.mimetype
)

View File

@@ -0,0 +1,111 @@
from flask import (
abort,
current_app,
Flask,
jsonify,
redirect,
render_template,
url_for
)
from flask_login import current_user, login_required
from threading import Thread
from app import db
from app.decorators import admin_required
from app.models import Job, JobStatus
from . import bp
@bp.route('')
@login_required
def index():
return redirect(url_for('main.dashboard', _anchor='jobs'))
@bp.route('/<hashid:job_id>')
@login_required
def job(job_id: int):
job = Job.query.get_or_404(job_id)
if not (
job.user == current_user
or current_user.is_administrator
):
abort(403)
return render_template(
'jobs/job.html.j2',
title='Job',
job=job
)
def _delete_job(app: Flask, job_id: int):
with app.app_context():
job = Job.query.get(job_id)
job.delete()
db.session.commit()
@bp.route('/<hashid:job_id>', methods=['DELETE'])
@login_required
def delete_job(job_id: int):
job = Job.query.get_or_404(job_id)
if not (
job.user == current_user
or current_user.is_administrator
):
abort(403)
thread = Thread(
target=_delete_job,
args=(current_app._get_current_object(), job.id)
)
thread.start()
return jsonify(f'Job "{job.title}" marked for deletion.'), 202
@bp.route('/<hashid:job_id>/log')
@admin_required
def job_log(job_id: int):
job = Job.query.get_or_404(job_id)
if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:
abort(409)
log_file_path = job.path / 'pipeline_data' / 'logs' / 'pyflow_log.txt'
with log_file_path.open() as log_file:
log = log_file.read()
return jsonify(log)
def _restart_job(app: Flask, job_id: int):
with app.app_context():
job = Job.query.get(job_id)
job.restart()
db.session.commit()
@bp.route('/<hashid:job_id>/restart', methods=['POST'])
@login_required
def restart_job(job_id: int):
job = Job.query.get_or_404(job_id)
if not (
job.user == current_user
or current_user.is_administrator
):
abort(403)
if job.status != JobStatus.FAILED:
abort(409)
thread = Thread(
target=_restart_job,
args=(current_app._get_current_object(), job.id)
)
thread.start()
return jsonify(f'Job "{job.title}" marked for restarting.'), 202

View File

@@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('main', __name__, cli_group=None)
from . import cli, routes

View File

@@ -0,0 +1,51 @@
from flask import current_app
from flask_migrate import upgrade
from pathlib import Path
from app import db
from app.models import (
Corpus,
CorpusFollowerRole,
Role,
SpaCyNLPPipelineModel,
TesseractOCRPipelineModel,
User
)
from . import bp
@bp.cli.command('deploy')
def deploy():
''' Run deployment tasks. '''
print('Make default directories')
base_dir = current_app.config['NOPAQUE_DATA_DIR']
default_dirs: list[Path] = [
base_dir / 'tmp',
base_dir / 'users'
]
for default_dir in default_dirs:
if not default_dir.exists():
default_dir.mkdir()
if not default_dir.is_dir():
raise NotADirectoryError(f'{default_dir} is not a directory')
print('Migrate database to latest revision')
upgrade()
print('Insert/Update default Roles')
Role.insert_defaults()
print('Insert/Update default Users')
User.insert_defaults()
print('Insert/Update default CorpusFollowerRoles')
CorpusFollowerRole.insert_defaults()
print('Insert/Update default SpaCyNLPPipelineModels')
SpaCyNLPPipelineModel.insert_defaults()
print('Insert/Update default TesseractOCRPipelineModels')
TesseractOCRPipelineModel.insert_defaults()
print('Stop running analysis sessions')
for corpus in Corpus.query.all():
corpus.num_analysis_sessions = 0
db.session.commit()
# TODO: Implement checks for if the nopaque network exists

View File

@@ -0,0 +1,96 @@
from flask import abort, flash, jsonify, redirect, render_template, url_for
from flask_login import current_user, login_required, login_user
from app.blueprints.auth.forms import LoginForm
from app.models import Corpus, User
from . import bp
from app import db
@bp.route('/', methods=['GET', 'POST'])
def index():
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first()
if user and user.verify_password(form.password.data):
login_user(user, form.remember_me.data)
flash('You have been logged in')
return redirect(url_for('.dashboard'))
flash('Invalid email/username or password', category='error')
redirect(url_for('.index'))
return render_template(
'main/index.html.j2',
title='nopaque',
form=form
)
@bp.route('/faq')
def faq():
return render_template(
'main/faq.html.j2',
title='Frequently Asked Questions'
)
@bp.route('/dashboard')
@login_required
def dashboard():
return render_template(
'main/dashboard.html.j2',
title='Dashboard'
)
@bp.route('/manual')
def manual():
return render_template(
'main/manual.html.j2',
title='Manual'
)
@bp.route('/news')
def news():
return render_template(
'main/news.html.j2',
title='News'
)
@bp.route('/privacy-policy')
def privacy_policy():
return render_template(
'main/privacy_policy.html.j2',
title='Privacy statement (GDPR)'
)
@bp.route('/terms-of-use')
def terms_of_use():
return render_template(
'main/terms_of_use.html.j2',
title='Terms of use'
)
@bp.route('/accept-terms-of-use', methods=['POST'])
@login_required
def accept_terms_of_use():
current_user.terms_of_use_accepted = True
db.session.commit()
return jsonify('You accepted the terms of use'), 202
@bp.route('/social')
@login_required
def social():
corpora = Corpus.query.filter(Corpus.is_public == True, Corpus.user != current_user).all()
users = User.query.filter(User.is_public == True, User.id != current_user.id).all()
return render_template(
'main/social.html.j2',
title='Social',
corpora=corpora,
users=users
)

View File

@@ -0,0 +1,24 @@
from flask import Blueprint
from flask_login import login_required
from pathlib import Path
import yaml
services_file = Path(__file__).parent / 'services.yml'
with services_file.open('r') as f:
SERVICES = yaml.safe_load(f)
bp = Blueprint('services', __name__)
@bp.before_request
@login_required
def before_request():
'''
Ensures that the routes in this package can only be visited by users that
are logged in.
'''
pass
from . import routes # noqa

View File

@@ -1,12 +1,17 @@
from flask_login import current_user
from flask_wtf import FlaskForm
from flask_login import current_user
from flask_wtf.file import FileField, FileRequired
from wtforms import (BooleanField, DecimalRangeField, MultipleFileField,
SelectField, StringField, SubmitField, ValidationError)
from wtforms import (
BooleanField,
DecimalRangeField,
MultipleFileField,
SelectField,
StringField,
SubmitField,
ValidationError
)
from wtforms.validators import InputRequired, Length
from app.models import SpaCyNLPPipelineModel, TesseractOCRPipelineModel
from . import SERVICES
@@ -33,6 +38,8 @@ class CreateFileSetupPipelineJobForm(CreateJobBaseForm):
raise ValidationError('JPEG, PNG and TIFF files only!')
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-file-setup-pipeline-job-form'
service_manifest = SERVICES['file-setup-pipeline']
version = kwargs.pop('version', service_manifest['latest_version'])
super().__init__(*args, **kwargs)
@@ -54,12 +61,14 @@ class CreateTesseractOCRPipelineJobForm(CreateJobBaseForm):
if field.data:
if not('methods' in service_info and 'binarization' in service_info['methods']):
raise ValidationError('Binarization is not available')
def validate_pdf(self, field):
if field.data.mimetype != 'application/pdf':
raise ValidationError('PDF files only!')
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-tesseract-ocr-pipeline-job-form'
service_manifest = SERVICES['tesseract-ocr-pipeline']
version = kwargs.pop('version', service_manifest['latest_version'])
super().__init__(*args, **kwargs)
@@ -75,12 +84,18 @@ class CreateTesseractOCRPipelineJobForm(CreateJobBaseForm):
del self.binarization.render_kw['disabled']
if 'ocropus_nlbin_threshold' in service_info['methods']:
del self.ocropus_nlbin_threshold.render_kw['disabled']
user_models = [
x for x in current_user.tesseract_ocr_pipeline_models.order_by(TesseractOCRPipelineModel.title).all()
]
models = [
x for x in TesseractOCRPipelineModel.query.order_by(TesseractOCRPipelineModel.title).all()
if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user)
]
self.model.choices = [('', 'Choose your option')]
self.model.choices += [(x.hashid, f'{x.title} [{x.version}]') for x in models]
self.model.choices = {
'': [('', 'Choose your option')],
'Your models': [(x.hashid, f'{x.title} [{x.version}]') for x in user_models] if user_models else [(0, 'Nothing here yet...')],
'Public models': [(x.hashid, f'{x.title} [{x.version}]') for x in models]
}
self.model.default = ''
self.version.choices = [(x, x) for x in service_manifest['versions']]
self.version.data = version
@@ -106,6 +121,8 @@ class CreateTranskribusHTRPipelineJobForm(CreateJobBaseForm):
raise ValidationError('PDF files only!')
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-transkribus-htr-pipeline-job-form'
transkribus_htr_pipeline_models = kwargs.pop('transkribus_htr_pipeline_models', [])
service_manifest = SERVICES['transkribus-htr-pipeline']
version = kwargs.pop('version', service_manifest['latest_version'])
@@ -129,7 +146,7 @@ class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm):
encoding_detection = BooleanField('Encoding detection', render_kw={'disabled': True})
txt = FileField('File', validators=[FileRequired()])
model = SelectField('Model', validators=[InputRequired()])
def validate_encoding_detection(self, field):
service_info = SERVICES['spacy-nlp-pipeline']['versions'][self.version.data]
if field.data:
@@ -144,23 +161,30 @@ class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm):
raise ValidationError('Plain text files only!')
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'create-spacy-nlp-pipeline-job-form'
service_manifest = SERVICES['spacy-nlp-pipeline']
version = kwargs.pop('version', service_manifest['latest_version'])
super().__init__(*args, **kwargs)
service_info = service_manifest['versions'][version]
print(service_info)
if self.encoding_detection.render_kw is None:
self.encoding_detection.render_kw = {}
self.encoding_detection.render_kw['disabled'] = True
if 'methods' in service_info:
if 'encoding_detection' in service_info['methods']:
del self.encoding_detection.render_kw['disabled']
models = [
x for x in SpaCyNLPPipelineModel.query.order_by(SpaCyNLPPipelineModel.title).all()
if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user)
user_models = [
x for x in current_user.spacy_nlp_pipeline_models.order_by(SpaCyNLPPipelineModel.title).all()
]
self.model.choices = [('', 'Choose your option')]
self.model.choices += [(x.hashid, f'{x.title} [{x.version}]') for x in models]
models = [
x for x in SpaCyNLPPipelineModel.query.filter(SpaCyNLPPipelineModel.user != current_user, SpaCyNLPPipelineModel.is_public == True).order_by(SpaCyNLPPipelineModel.title).all()
if version in x.compatible_service_versions
]
self.model.choices = {
'': [('', 'Choose your option')],
'Your models': [(x.hashid, f'{x.title} [{x.version}]') for x in user_models] if user_models else [(0, 'Nothing here yet...')],
'Public models': [(x.hashid, f'{x.title} [{x.version}]') for x in models]
}
self.model.default = ''
self.version.choices = [(x, x) for x in service_manifest['versions']]
self.version.data = version

View File

@@ -1,5 +1,5 @@
from flask import abort, current_app, flash, make_response, Markup, render_template, request
from flask_login import current_user, login_required
from flask import abort, current_app, flash, redirect, render_template, request, url_for
from flask_login import current_user
import requests
from app import db, hashids
from app.models import (
@@ -18,8 +18,12 @@ from .forms import (
)
@bp.route('/services')
def services():
return redirect(url_for('main.dashboard'))
@bp.route('/file-setup-pipeline', methods=['GET', 'POST'])
@login_required
def file_setup_pipeline():
service = 'file-setup-pipeline'
service_manifest = SERVICES[service]
@@ -49,18 +53,17 @@ def file_setup_pipeline():
abort(500)
job.status = JobStatus.SUBMITTED
db.session.commit()
message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
message = f'Job "<a href="{job.url}">{job.title}</a>" created'
flash(message, 'job')
return {}, 201, {'Location': job.url}
return render_template(
'services/file_setup_pipeline.html.j2',
form=form,
title=service_manifest['name']
title=service_manifest['name'],
form=form
)
@bp.route('/tesseract-ocr-pipeline', methods=['GET', 'POST'])
@login_required
def tesseract_ocr_pipeline():
service_name = 'tesseract-ocr-pipeline'
service_manifest = SERVICES[service_name]
@@ -93,23 +96,24 @@ def tesseract_ocr_pipeline():
abort(500)
job.status = JobStatus.SUBMITTED
db.session.commit()
message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
message = f'Job "<a href="{job.url}">{job.title}</a>" created'
flash(message, 'job')
return {}, 201, {'Location': job.url}
tesseract_ocr_pipeline_models = [
x for x in TesseractOCRPipelineModel.query.all()
if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user)
]
user_tesseract_ocr_pipeline_models_count = len(current_user.tesseract_ocr_pipeline_models.all())
return render_template(
'services/tesseract_ocr_pipeline.html.j2',
title=service_manifest['name'],
form=form,
tesseract_ocr_pipeline_models=tesseract_ocr_pipeline_models,
title=service_manifest['name']
user_tesseract_ocr_pipeline_models_count=user_tesseract_ocr_pipeline_models_count
)
@bp.route('/transkribus-htr-pipeline', methods=['GET', 'POST'])
@login_required
def transkribus_htr_pipeline():
if not current_app.config.get('NOPAQUE_TRANSKRIBUS_ENABLED'):
abort(404)
@@ -126,10 +130,9 @@ def transkribus_htr_pipeline():
abort(500)
transkribus_htr_pipeline_models = r.json()['trpModelMetadata']
transkribus_htr_pipeline_models.append({'modelId': 48513, 'name': 'Caroline Minuscle', 'language': 'lat', 'isoLanguages': ['lat']})
print(transkribus_htr_pipeline_models[len(transkribus_htr_pipeline_models)-1])
form = CreateTranskribusHTRPipelineJobForm(
transkribus_htr_pipeline_models=transkribus_htr_pipeline_models,
prefix='create-job-form',
transkribus_htr_pipeline_models=transkribus_htr_pipeline_models,
version=version
)
if form.is_submitted():
@@ -156,19 +159,18 @@ def transkribus_htr_pipeline():
abort(500)
job.status = JobStatus.SUBMITTED
db.session.commit()
message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
message = f'Job "<a href="{job.url}">{job.title}</a>" created'
flash(message, 'job')
return {}, 201, {'Location': job.url}
return render_template(
'services/transkribus_htr_pipeline.html.j2',
form=form,
title=service_manifest['name'],
form=form,
transkribus_htr_pipeline_models=transkribus_htr_pipeline_models
)
@bp.route('/spacy-nlp-pipeline', methods=['GET', 'POST'])
@login_required
def spacy_nlp_pipeline():
service = 'spacy-nlp-pipeline'
service_manifest = SERVICES[service]
@@ -177,6 +179,7 @@ def spacy_nlp_pipeline():
abort(404)
form = CreateSpacyNLPPipelineJobForm(prefix='create-job-form', version=version)
spacy_nlp_pipeline_models = SpaCyNLPPipelineModel.query.all()
user_spacy_nlp_pipeline_models_count = len(current_user.spacy_nlp_pipeline_models.all())
if form.is_submitted():
if not form.validate():
response = {'errors': form.errors}
@@ -201,21 +204,21 @@ def spacy_nlp_pipeline():
abort(500)
job.status = JobStatus.SUBMITTED
db.session.commit()
message = Markup(f'Job "<a href="{job.url}">{job.title}</a>" created')
message = f'Job "<a href="{job.url}">{job.title}</a>" created'
flash(message, 'job')
return {}, 201, {'Location': job.url}
return render_template(
'services/spacy_nlp_pipeline.html.j2',
title=service_manifest['name'],
form=form,
spacy_nlp_pipeline_models=spacy_nlp_pipeline_models,
title=service_manifest['name']
user_spacy_nlp_pipeline_models_count=user_spacy_nlp_pipeline_models_count
)
@bp.route('/corpus-analysis')
@login_required
def corpus_analysis():
return render_template(
'services/corpus_analysis.html.j2',
title='Corpus analysis'
title='Corpus Analysis'
)

View File

@@ -10,7 +10,7 @@ file-setup-pipeline:
tesseract-ocr-pipeline:
name: 'Tesseract OCR Pipeline'
publisher: 'Bielefeld University - CRC 1288 - INF'
latest_version: '0.1.1'
latest_version: '0.1.2'
versions:
0.1.0:
methods:
@@ -23,6 +23,12 @@ tesseract-ocr-pipeline:
- 'ocropus_nlbin_threshold'
publishing_year: 2022
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/tesseract-ocr-pipeline/-/releases/v0.1.1'
0.1.2:
methods:
- 'binarization'
- 'ocropus_nlbin_threshold'
publishing_year: 2023
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/tesseract-ocr-pipeline/-/releases/v0.1.2'
transkribus-htr-pipeline:
name: 'Transkribus HTR Pipeline'
publisher: 'Bielefeld University - CRC 1288 - INF'
@@ -41,7 +47,7 @@ transkribus-htr-pipeline:
spacy-nlp-pipeline:
name: 'SpaCy NLP Pipeline'
publisher: 'Bielefeld University - CRC 1288 - INF'
latest_version: '0.1.2'
latest_version: '0.1.1'
versions:
0.1.0:
methods:
@@ -56,5 +62,5 @@ spacy-nlp-pipeline:
0.1.2:
methods:
- 'encoding_detection'
publishing_year: 2022
publishing_year: 2024
url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/spacy-nlp-pipeline/-/releases/v0.1.2'

View File

@@ -2,4 +2,6 @@ from flask import Blueprint
bp = Blueprint('settings', __name__)
from . import routes # noqa
from . import routes

View File

@@ -0,0 +1,160 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired, FileSize
from wtforms import (
PasswordField,
SelectField,
StringField,
SubmitField,
TextAreaField,
ValidationError
)
from wtforms.validators import (
DataRequired,
Email,
EqualTo,
Length,
Regexp
)
from app.models import User, UserSettingJobStatusMailNotificationLevel
class UpdateAccountInformationForm(FlaskForm):
email = StringField(
'E-Mail',
validators=[DataRequired(), Length(max=254), Email()]
)
username = StringField(
'Username',
validators=[
DataRequired(),
Length(max=64),
Regexp(
User.username_pattern,
message=(
'Usernames must have only letters, numbers, dots or '
'underscores'
)
)
]
)
submit = SubmitField()
def __init__(self, user: User, *args, **kwargs):
if 'data' not in kwargs:
kwargs['data'] = user.to_json_serializeable()
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-account-information-form'
super().__init__(*args, **kwargs)
self.user = user
def validate_email(self, field):
if (field.data != self.user.email
and User.query.filter_by(email=field.data).first()):
raise ValidationError('Email already registered')
def validate_username(self, field):
if (field.data != self.user.username
and User.query.filter_by(username=field.data).first()):
raise ValidationError('Username already in use')
class UpdateProfileInformationForm(FlaskForm):
full_name = StringField(
'Full name',
validators=[Length(max=128)]
)
about_me = TextAreaField(
'About me',
validators=[
Length(max=254)
]
)
website = StringField(
'Website',
validators=[
Length(max=254)
]
)
organization = StringField(
'Organization',
validators=[
Length(max=128)
]
)
location = StringField(
'Location',
validators=[
Length(max=128)
]
)
submit = SubmitField()
def __init__(self, user: User, *args, **kwargs):
if 'data' not in kwargs:
kwargs['data'] = user.to_json_serializeable()
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-profile-information-form'
super().__init__(*args, **kwargs)
class UpdateAvatarForm(FlaskForm):
avatar = FileField('File', validators=[FileRequired(), FileSize(2_000_000)])
submit = SubmitField()
def validate_avatar(self, field):
valid_mimetypes = ['image/jpeg', 'image/png']
if field.data.mimetype not in valid_mimetypes:
raise ValidationError('JPEG and PNG files only!')
def __init__(self, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-avatar-form'
super().__init__(*args, **kwargs)
class UpdatePasswordForm(FlaskForm):
password = PasswordField('Old password', validators=[DataRequired()])
new_password = PasswordField(
'New password',
validators=[
DataRequired(),
EqualTo('new_password_2', message='Passwords must match')
]
)
new_password_2 = PasswordField(
'New password confirmation',
validators=[
DataRequired(),
EqualTo('new_password', message='Passwords must match')
]
)
submit = SubmitField()
def __init__(self, user: User, *args, **kwargs):
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-password-form'
super().__init__(*args, **kwargs)
self.user = user
def validate_current_password(self, field):
if not self.user.verify_password(field.data):
raise ValidationError('Invalid password')
class UpdateNotificationsForm(FlaskForm):
job_status_mail_notification_level = SelectField(
'Job status mail notification level',
choices=[
(x.name, x.name.capitalize())
for x in UserSettingJobStatusMailNotificationLevel
],
validators=[DataRequired()]
)
submit = SubmitField()
def __init__(self, user: User, *args, **kwargs):
if 'data' not in kwargs:
kwargs['data'] = user.to_json_serializeable()
if 'prefix' not in kwargs:
kwargs['prefix'] = 'update-notifications-form'
super().__init__(*args, **kwargs)

View File

@@ -0,0 +1,158 @@
from flask import (
abort,
flash,
jsonify,
redirect,
render_template,
request,
url_for
)
from flask_login import current_user, login_required
from app import db
from app.models import Avatar
from . import bp
from .forms import (
UpdateAvatarForm,
UpdatePasswordForm,
UpdateNotificationsForm,
UpdateAccountInformationForm,
UpdateProfileInformationForm
)
@bp.route('', methods=['GET', 'POST'])
@login_required
def index():
update_account_information_form = UpdateAccountInformationForm(current_user)
update_profile_information_form = UpdateProfileInformationForm(current_user)
update_avatar_form = UpdateAvatarForm()
update_password_form = UpdatePasswordForm(current_user)
update_notifications_form = UpdateNotificationsForm(current_user)
# region handle update profile information form
if update_profile_information_form.submit.data and update_profile_information_form.validate():
current_user.about_me = update_profile_information_form.about_me.data
current_user.location = update_profile_information_form.location.data
current_user.organization = update_profile_information_form.organization.data
current_user.website = update_profile_information_form.website.data
current_user.full_name = update_profile_information_form.full_name.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.index'))
# endregion handle update profile information form
# region handle update avatar form
if update_avatar_form.submit.data and update_avatar_form.validate():
try:
Avatar.create(
update_avatar_form.avatar.data,
user=current_user
)
except (AttributeError, OSError):
abort(500)
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.index'))
# endregion handle update avatar form
# region handle update account information form
if update_account_information_form.submit.data and update_account_information_form.validate():
current_user.email = update_account_information_form.email.data
current_user.username = update_account_information_form.username.data
db.session.commit()
flash('Profile settings updated')
return redirect(url_for('.index'))
# endregion handle update account information form
# region handle update password form
if update_password_form.submit.data and update_password_form.validate():
current_user.password = update_password_form.new_password.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.index'))
# endregion handle update password form
# region handle update notifications form
if update_notifications_form.submit.data and update_notifications_form.validate():
current_user.setting_job_status_mail_notification_level = \
update_notifications_form.job_status_mail_notification_level.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.index'))
# endregion handle update notifications form
return render_template(
'settings/index.html.j2',
title='Settings',
update_account_information_form=update_account_information_form,
update_avatar_form=update_avatar_form,
update_notifications_form=update_notifications_form,
update_password_form=update_password_form,
update_profile_information_form=update_profile_information_form,
user=current_user
)
@bp.route('/profile-is-public', methods=['PUT'])
@login_required
def update_profile_is_public():
new_value = request.json
if not isinstance(new_value, bool):
abort(400)
current_user.is_public = new_value
db.session.commit()
return jsonify('Your changes have been saved'), 200
@bp.route('/profile-show-email', methods=['PUT'])
@login_required
def update_profile_show_email():
new_value = request.json
if not isinstance(new_value, bool):
abort(400)
if new_value:
current_user.add_profile_privacy_setting('SHOW_EMAIL')
else:
current_user.remove_profile_privacy_setting('SHOW_EMAIL')
db.session.commit()
return jsonify('Your changes have been saved'), 200
@bp.route('/profile-show-last-seen', methods=['PUT'])
@login_required
def update_profile_show_last_seen():
new_value = request.json
if not isinstance(new_value, bool):
abort(400)
if new_value:
current_user.add_profile_privacy_setting('SHOW_LAST_SEEN')
else:
current_user.remove_profile_privacy_setting('SHOW_LAST_SEEN')
db.session.commit()
return jsonify('Your changes have been saved'), 200
@bp.route('/profile-show-member-since', methods=['PUT'])
@login_required
def update_profile_show_member_since():
new_value = request.json
if not isinstance(new_value, bool):
abort(400)
if new_value:
current_user.add_profile_privacy_setting('SHOW_MEMBER_SINCE')
else:
current_user.remove_profile_privacy_setting('SHOW_MEMBER_SINCE')
db.session.commit()
return jsonify('Your changes have been saved'), 200

View File

@@ -2,4 +2,6 @@ from flask import Blueprint
bp = Blueprint('users', __name__)
from . import events, routes
from . import cli, events, routes

View File

@@ -0,0 +1,12 @@
from app.models import User
from app import db
from . import bp
@bp.cli.command('reset')
def reset():
''' Reset terms of use accept '''
for user in [x for x in User.query.all() if x.terms_of_use_accepted]:
print(f'Resetting user {user.username}')
user.terms_of_use_accepted = False
db.session.commit()

View File

@@ -0,0 +1,91 @@
from flask_login import current_user
from flask_socketio import join_room, leave_room
from app import hashids, socketio
from app.decorators import socketio_login_required
from app.models import User
@socketio.on('SUBSCRIBE User')
@socketio_login_required
def subscribe(user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {
'code': 400,
'name': 'Bad Request',
'description': 'Invalid User ID.'
}
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {
'code': 400,
'name': 'Bad Request',
'description': 'Invalid User ID.'
}
user = User.query.get(user_id)
if user is None:
return {
'code': 404,
'name': 'Not Found',
'description': 'User not found.'
}
if not (
user == current_user
or current_user.is_administrator
):
return {
'code': 403,
'name': 'Forbidden',
'description': 'Not allowed to subscribe to this user.'
}
join_room(f'/users/{user.hashid}')
return {'code': 204, 'name': 'No Content'}
@socketio.on('UNSUBSCRIBE User')
@socketio_login_required
def unsubscribe(user_hashid: str) -> dict:
if not isinstance(user_hashid, str):
return {
'code': 400,
'name': 'Bad Request',
'description': 'Invalid User ID.'
}
user_id = hashids.decode(user_hashid)
if not isinstance(user_id, int):
return {
'code': 400,
'name': 'Bad Request',
'description': 'Invalid User ID.'
}
user = User.query.get(user_id)
if user is None:
return {
'code': 404,
'name': 'Not Found',
'description': 'User not found.'
}
if not (
user == current_user
or current_user.is_administrator
):
return {
'code': 403,
'name': 'Forbidden',
'description': 'Not allowed to unsubscribe from this user.'
}
leave_room(f'/users/{user.hashid}')
return {'code': 204, 'name': 'No Content'}

View File

@@ -0,0 +1,134 @@
from flask import (
abort,
current_app,
Flask,
jsonify,
redirect,
render_template,
request,
send_from_directory,
url_for
)
from flask_login import current_user, login_required, logout_user
from threading import Thread
from app import db
from app.models import Avatar, User
from . import bp
@bp.route('')
@login_required
def index():
return redirect(url_for('main.social_area', _anchor='users'))
@bp.route('/<hashid:user_id>')
@login_required
def user(user_id: int):
user = User.query.get_or_404(user_id)
if not (
user.is_public
or user == current_user
or current_user.is_administrator
):
abort(403)
accept_json = request.accept_mimetypes.accept_json
accept_html = request.accept_mimetypes.accept_html
if accept_json and not accept_html:
return user.to_json_serializeable(
backrefs=True,
relationships=True
)
return render_template(
'users/user.html.j2',
title=user.username,
user=user
)
def _delete_user(app: Flask, user_id: int):
with app.app_context():
user = User.query.get(user_id)
user.delete()
db.session.commit()
@bp.route('/<hashid:user_id>', methods=['DELETE'])
@login_required
def delete_user(user_id: int):
user = User.query.get_or_404(user_id)
if not (
user == current_user
or current_user.is_administrator
):
abort(403)
if user == current_user:
logout_user()
thread = Thread(
target=_delete_user,
args=(current_app._get_current_object(), user.id)
)
thread.start()
return jsonify(f'User "{user.username}" marked for deletion'), 202
@bp.route('/<hashid:user_id>/avatar')
@login_required
def user_avatar(user_id: int):
user = User.query.get_or_404(user_id)
if not (
user.is_public
or user == current_user
or current_user.is_administrator
):
abort(403)
if user.avatar is None:
return redirect(url_for('static', filename='images/user_avatar.png'))
return send_from_directory(
user.avatar.path.parent,
user.avatar.path.name,
as_attachment=True,
download_name=user.avatar.filename,
mimetype=user.avatar.mimetype
)
def _delete_avatar(app: Flask, avatar_id: int):
with app.app_context():
avatar = Avatar.query.get(avatar_id)
avatar.delete()
db.session.commit()
@bp.route('/<hashid:user_id>/avatar', methods=['DELETE'])
@login_required
def delete_user_avatar(user_id: int):
user = User.query.get_or_404(user_id)
if user.avatar is None:
abort(409)
if not (
user == current_user
or current_user.is_administrator
):
abort(403)
thread = Thread(
target=_delete_avatar,
args=(current_app._get_current_object(), user.avatar.id)
)
thread.start()
return jsonify('Avatar marked for deletion'), 202

View File

@@ -1,5 +1,5 @@
from flask import Blueprint
bp = Blueprint('contributions', __name__)
bp = Blueprint('workshops', __name__)
from . import routes

View File

@@ -0,0 +1,15 @@
from flask import redirect, render_template, url_for
from . import bp
@bp.route('')
def workshops():
return redirect(url_for('main.dashboard'))
@bp.route('/fgho_sommerschule_2023')
def fgho_sommerschule_2023():
return render_template(
'workshops/fgho_sommerschule_2023.html.j2',
title='FGHO Sommerschule 2023',
)

View File

@@ -1,72 +0,0 @@
from flask import current_app
from flask_migrate import upgrade
import click
import os
from app.models import (
Role,
User,
TesseractOCRPipelineModel,
SpaCyNLPPipelineModel
)
def _make_default_dirs():
base_dir = current_app.config['NOPAQUE_DATA_DIR']
default_directories = [
os.path.join(base_dir, 'tmp'),
os.path.join(base_dir, 'users')
]
for directory in default_directories:
if os.path.exists(directory):
if not os.path.isdir(directory):
raise NotADirectoryError(f'{directory} is not a directory')
else:
os.mkdir(directory)
def register(app):
@app.cli.command()
def deploy():
''' Run deployment tasks. '''
# Make default directories
_make_default_dirs()
# migrate database to latest revision
upgrade()
# Insert/Update default database values
current_app.logger.info('Insert/Update default roles')
Role.insert_defaults()
current_app.logger.info('Insert/Update default users')
User.insert_defaults()
current_app.logger.info('Insert/Update default SpaCyNLPPipelineModels')
SpaCyNLPPipelineModel.insert_defaults()
current_app.logger.info('Insert/Update default TesseractOCRPipelineModels')
TesseractOCRPipelineModel.insert_defaults()
@app.cli.group()
def converter():
''' Converter commands. '''
pass
@converter.command()
@click.argument('json_db')
@click.argument('data_dir')
def sandpaper(json_db, data_dir):
''' Sandpaper converter '''
from app.converters.sandpaper import convert
convert(json_db, data_dir)
@app.cli.group()
def test():
''' Test commands. '''
pass
@test.command('run')
def run_test():
''' Run unit tests. '''
from unittest import TestLoader, TextTestRunner
from unittest.suite import TestSuite
tests: TestSuite = TestLoader().discover('tests')
TextTestRunner(verbosity=2).run(tests)

View File

@@ -1,121 +0,0 @@
from flask import current_app
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired
from wtforms import (
BooleanField,
StringField,
SubmitField,
SelectMultipleField,
IntegerField,
ValidationError
)
from wtforms.validators import InputRequired, Length
from app.services import SERVICES
class ContributionBaseForm(FlaskForm):
title = StringField(
'Title',
validators=[InputRequired(), Length(max=64)]
)
description = StringField(
'Description',
validators=[InputRequired(), Length(max=255)]
)
version = StringField(
'Version',
validators=[InputRequired(), Length(max=16)]
)
publisher = StringField(
'Publisher',
validators=[InputRequired(), Length(max=128)]
)
publisher_url = StringField(
'Publisher URL',
validators=[InputRequired(), Length(max=512)]
)
publishing_url = StringField(
'Publishing URL',
validators=[InputRequired(), Length(max=512)]
)
publishing_year = IntegerField(
'Publishing year',
validators=[InputRequired()]
)
compatible_service_versions = SelectMultipleField(
'Compatible service versions'
)
submit = SubmitField()
class CreateTesseractOCRPipelineModelForm(ContributionBaseForm):
tesseract_model_file = FileField(
'File',
validators=[FileRequired()]
)
def validate_tesseract_model_file(self, field):
if not field.data.filename.lower().endswith('.traineddata'):
raise ValidationError('traineddata files only!')
def __init__(self, *args, **kwargs):
service_manifest = SERVICES['tesseract-ocr-pipeline']
super().__init__(*args, **kwargs)
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''
class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm):
spacy_model_file = FileField(
'File',
validators=[FileRequired()]
)
pipeline_name = StringField(
'Pipeline name',
validators=[InputRequired(), Length(max=64)]
)
def validate_spacy_model_file(self, field):
if not field.data.filename.lower().endswith('.tar.gz'):
raise ValidationError('.tar.gz files only!')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
service_manifest = SERVICES['spacy-nlp-pipeline']
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''
class EditContributionBaseForm(ContributionBaseForm):
pass
class EditTesseractOCRPipelineModelForm(EditContributionBaseForm):
def __init__(self, *args, **kwargs):
service_manifest = SERVICES['tesseract-ocr-pipeline']
super().__init__(*args, **kwargs)
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''
class EditSpaCyNLPPipelineModelForm(EditContributionBaseForm):
pipeline_name = StringField(
'Pipeline name',
validators=[InputRequired(), Length(max=64)]
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
service_manifest = SERVICES['spacy-nlp-pipeline']
self.compatible_service_versions.choices = [('', 'Choose your option')]
self.compatible_service_versions.choices += [
(x, x) for x in service_manifest['versions'].keys()
]
self.compatible_service_versions.default = ''

View File

@@ -1,233 +0,0 @@
from flask import (
abort,
current_app,
flash,
Markup,
redirect,
render_template,
url_for
)
from flask_login import login_required, current_user
from threading import Thread
from app import db
from app.decorators import permission_required
from app.models import (
Permission,
SpaCyNLPPipelineModel,
TesseractOCRPipelineModel
)
from . import bp
from .forms import (
CreateSpaCyNLPPipelineModelForm,
CreateTesseractOCRPipelineModelForm,
EditSpaCyNLPPipelineModelForm,
EditTesseractOCRPipelineModelForm
)
@bp.before_request
@login_required
def before_request():
pass
@bp.route('/')
def contributions():
return render_template(
'contributions/contributions.html.j2',
title='Contributions'
)
@bp.route('/tesseract-ocr-pipeline-models')
def tesseract_ocr_pipeline_models():
return render_template(
'contributions/tesseract_ocr_pipeline_models.html.j2',
title='Tesseract OCR Pipeline Models'
)
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['GET', 'POST'])
def tesseract_ocr_pipeline_model(tesseract_ocr_pipeline_model_id):
tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
form = EditTesseractOCRPipelineModelForm(
data=tesseract_ocr_pipeline_model.to_json_serializeable(),
prefix='edit-tesseract-ocr-pipeline-model-form'
)
if form.validate_on_submit():
form.populate_obj(tesseract_ocr_pipeline_model)
if db.session.is_modified(tesseract_ocr_pipeline_model):
message = Markup(f'Tesseract OCR Pipeline model "<a href="{tesseract_ocr_pipeline_model.url}">{tesseract_ocr_pipeline_model.title}</a>" updated')
flash(message)
db.session.commit()
return redirect(url_for('.tesseract_ocr_pipeline_models'))
return render_template(
'contributions/tesseract_ocr_pipeline_model.html.j2',
form=form,
tesseract_ocr_pipeline_model=tesseract_ocr_pipeline_model,
title=f'{tesseract_ocr_pipeline_model.title} {tesseract_ocr_pipeline_model.version}'
)
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>', methods=['DELETE'])
def delete_tesseract_model(tesseract_ocr_pipeline_model_id):
def _delete_tesseract_ocr_pipeline_model(app, tesseract_ocr_pipeline_model_id):
with app.app_context():
tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get(tesseract_ocr_pipeline_model_id)
tesseract_ocr_pipeline_model.delete()
db.session.commit()
tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (tesseract_ocr_pipeline_model.user == current_user or current_user.is_administrator()):
abort(403)
thread = Thread(
target=_delete_tesseract_ocr_pipeline_model,
args=(current_app._get_current_object(), tesseract_ocr_pipeline_model_id)
)
thread.start()
return {}, 202
@bp.route('/tesseract-ocr-pipeline-models/create', methods=['GET', 'POST'])
def create_tesseract_ocr_pipeline_model():
form = CreateTesseractOCRPipelineModelForm(prefix='create-tesseract-ocr-pipeline-model-form')
if form.is_submitted():
if not form.validate():
response = {'errors': form.errors}
return response, 400
try:
tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.create(
form.tesseract_model_file.data,
compatible_service_versions=form.compatible_service_versions.data,
description=form.description.data,
publisher=form.publisher.data,
publisher_url=form.publisher_url.data,
publishing_url=form.publishing_url.data,
publishing_year=form.publishing_year.data,
is_public=False,
title=form.title.data,
version=form.version.data,
user=current_user
)
except OSError:
abort(500)
db.session.commit()
tesseract_ocr_pipeline_model_url = url_for(
'.tesseract_ocr_pipeline_model',
tesseract_ocr_pipeline_model_id=tesseract_ocr_pipeline_model.id
)
message = Markup(f'Tesseract OCR Pipeline model "<a href="{tesseract_ocr_pipeline_model_url}">{tesseract_ocr_pipeline_model.title}</a>" created')
flash(message)
return {}, 201, {'Location': tesseract_ocr_pipeline_model_url}
return render_template(
'contributions/create_tesseract_ocr_pipeline_model.html.j2',
form=form,
title='Create Tesseract OCR Pipeline Model'
)
@bp.route('/tesseract-ocr-pipeline-models/<hashid:tesseract_ocr_pipeline_model_id>/toggle-public-status', methods=['POST'])
@permission_required(Permission.CONTRIBUTE)
def toggle_tesseract_ocr_pipeline_model_public_status(tesseract_ocr_pipeline_model_id):
tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id)
if not (tesseract_ocr_pipeline_model.user == current_user or current_user.is_administrator()):
abort(403)
tesseract_ocr_pipeline_model.is_public = not tesseract_ocr_pipeline_model.is_public
db.session.commit()
return {}, 201
@bp.route('/spacy-nlp-pipeline-models')
def spacy_nlp_pipeline_models():
return render_template(
'contributions/spacy_nlp_pipeline_models.html.j2',
title='SpaCy NLP Pipeline Models'
)
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['GET', 'POST'])
def spacy_nlp_pipeline_model(spacy_nlp_pipeline_model_id):
spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
form = EditSpaCyNLPPipelineModelForm(
data=spacy_nlp_pipeline_model.to_json_serializeable(),
prefix='edit-spacy-nlp-pipeline-model-form'
)
if form.validate_on_submit():
form.populate_obj(spacy_nlp_pipeline_model)
if db.session.is_modified(spacy_nlp_pipeline_model):
message = Markup(f'SpaCy NLP Pipeline model "<a href="{spacy_nlp_pipeline_model.url}">{spacy_nlp_pipeline_model.title}</a>" updated')
flash(message)
db.session.commit()
return redirect(url_for('.spacy_nlp_pipeline_models'))
return render_template(
'contributions/spacy_nlp_pipeline_model.html.j2',
form=form,
spacy_nlp_pipeline_model=spacy_nlp_pipeline_model,
title=f'{spacy_nlp_pipeline_model.title} {spacy_nlp_pipeline_model.version}'
)
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>', methods=['DELETE'])
def delete_spacy_model(spacy_nlp_pipeline_model_id):
def _delete_spacy_model(app, spacy_nlp_pipeline_model_id):
with app.app_context():
spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get(spacy_nlp_pipeline_model_id)
spacy_nlp_pipeline_model.delete()
db.session.commit()
spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (spacy_nlp_pipeline_model.user == current_user or current_user.is_administrator()):
abort(403)
thread = Thread(
target=_delete_spacy_model,
args=(current_app._get_current_object(), spacy_nlp_pipeline_model_id)
)
thread.start()
return {}, 202
@bp.route('/spacy-nlp-pipeline-models/create', methods=['GET', 'POST'])
def create_spacy_nlp_pipeline_model():
form = CreateSpaCyNLPPipelineModelForm(prefix='create-spacy-nlp-pipeline-model-form')
if form.is_submitted():
if not form.validate():
response = {'errors': form.errors}
return response, 400
try:
spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.create(
form.spacy_model_file.data,
compatible_service_versions=form.compatible_service_versions.data,
description=form.description.data,
pipeline_name=form.pipeline_name.data,
publisher=form.publisher.data,
publisher_url=form.publisher_url.data,
publishing_url=form.publishing_url.data,
publishing_year=form.publishing_year.data,
is_public=False,
title=form.title.data,
version=form.version.data,
user=current_user
)
except OSError:
abort(500)
db.session.commit()
spacy_nlp_pipeline_model_url = url_for(
'.spacy_nlp_pipeline_model',
spacy_nlp_pipeline_model_id=spacy_nlp_pipeline_model.id
)
message = Markup(f'SpaCy NLP Pipeline model "<a href="{spacy_nlp_pipeline_model_url}">{spacy_nlp_pipeline_model.title}</a>" created')
flash(message)
return {}, 201, {'Location': spacy_nlp_pipeline_model_url}
return render_template(
'contributions/create_spacy_nlp_pipeline_model.html.j2',
form=form,
title='Create SpaCy NLP Pipeline Model'
)
@bp.route('/spacy-nlp-pipeline-models/<hashid:spacy_nlp_pipeline_model_id>/toggle-public-status', methods=['POST'])
@permission_required(Permission.CONTRIBUTE)
def toggle_spacy_nlp_pipeline_model_public_status(spacy_nlp_pipeline_model_id):
spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id)
if not (spacy_nlp_pipeline_model.user == current_user or current_user.is_administrator()):
abort(403)
spacy_nlp_pipeline_model.is_public = not spacy_nlp_pipeline_model.is_public
db.session.commit()
return {}, 201

22
app/converters/cli.py Normal file
View File

@@ -0,0 +1,22 @@
import click
from . import bp
from .sandpaper import SandpaperConverter
@bp.cli.group('converter')
def converter():
''' Converter commands. '''
pass
@converter.group('sandpaper')
def sandpaper_converter():
''' Sandpaper converter commands. '''
pass
@sandpaper_converter.command('run')
@click.argument('json_db_file')
@click.argument('data_dir')
def run_sandpaper_converter(json_db_file, data_dir):
''' Run the sandpaper converter. '''
sandpaper_converter = SandpaperConverter(json_db_file, data_dir)
sandpaper_converter.run()

View File

@@ -1,107 +1,100 @@
from datetime import datetime
from flask import current_app
from pathlib import Path
import json
import shutil
from app import db
from app.models import User, Corpus, CorpusFile
from datetime import datetime
import json
import os
import shutil
def convert(json_db_file, data_dir):
with open(json_db_file, 'r') as f:
json_db = json.loads(f.read())
class SandpaperConverter:
def __init__(self, json_db_file: Path, data_dir: Path):
self.json_db_file = json_db_file
self.data_dir = data_dir
for json_user in json_db:
if not json_user['confirmed']:
current_app.logger.info(f'Skip unconfirmed user {json_user["username"]}')
continue
user_dir = os.path.join(data_dir, str(json_user['id']))
convert_user(json_user, user_dir)
db.session.commit()
def run(self):
with self.json_db_file.open('r') as f:
json_db: list[dict] = json.load(f)
for json_user in json_db:
if not json_user['confirmed']:
current_app.logger.info(f'Skip unconfirmed user {json_user["username"]}')
continue
user_dir = self.data_dir / f'{json_user["id"]}'
self.convert_user(json_user, user_dir)
db.session.commit()
def convert_user(json_user, user_dir):
current_app.logger.info(f'Create User {json_user["username"]}...')
user = User(
confirmed=json_user['confirmed'],
email=json_user['email'],
last_seen=datetime.fromtimestamp(json_user['last_seen']),
member_since=datetime.fromtimestamp(json_user['member_since']),
password_hash=json_user['password_hash'], # TODO: Needs to be added manually
username=json_user['username']
)
db.session.add(user)
db.session.flush(objects=[user])
db.session.refresh(user)
try:
user.makedirs()
except OSError as e:
current_app.logger.error(e)
db.session.rollback()
raise Exception('Internal Server Error')
for json_corpus in json_user['corpora'].values():
if not json_corpus['files'].values():
current_app.logger.info(f'Skip empty corpus {json_corpus["title"]}')
continue
corpus_dir = os.path.join(user_dir, 'corpora', str(json_corpus['id']))
convert_corpus(json_corpus, user, corpus_dir)
current_app.logger.info('Done')
def convert_user(self, json_user: dict, user_dir: Path):
current_app.logger.info(f'Create User {json_user["username"]}...')
try:
user = User.create(
confirmed=json_user['confirmed'],
email=json_user['email'],
last_seen=datetime.fromtimestamp(json_user['last_seen']),
member_since=datetime.fromtimestamp(json_user['member_since']),
password_hash=json_user['password_hash'], # TODO: Needs to be added manually
username=json_user['username']
)
except OSError:
raise Exception('Internal Server Error')
for json_corpus in json_user['corpora'].values():
if not json_corpus['files'].values():
current_app.logger.info(f'Skip empty corpus {json_corpus["title"]}')
continue
corpus_dir = user_dir / 'corpora' / f'{json_corpus["id"]}'
self.convert_corpus(json_corpus, user, corpus_dir)
current_app.logger.info('Done')
def convert_corpus(json_corpus, user, corpus_dir):
current_app.logger.info(f'Create Corpus {json_corpus["title"]}...')
corpus = Corpus(
user=user,
creation_date=datetime.fromtimestamp(json_corpus['creation_date']),
description=json_corpus['description'],
title=json_corpus['title']
)
db.session.add(corpus)
db.session.flush(objects=[corpus])
db.session.refresh(corpus)
try:
corpus.makedirs()
except OSError as e:
current_app.logger.error(e)
db.session.rollback()
raise Exception('Internal Server Error')
for json_corpus_file in json_corpus['files'].values():
convert_corpus_file(json_corpus_file, corpus, corpus_dir)
current_app.logger.info('Done')
def convert_corpus(self, json_corpus: dict, user: User, corpus_dir: Path):
current_app.logger.info(f'Create Corpus {json_corpus["title"]}...')
try:
corpus = Corpus.create(
user=user,
creation_date=datetime.fromtimestamp(json_corpus['creation_date']),
description=json_corpus['description'],
title=json_corpus['title']
)
except OSError:
raise Exception('Internal Server Error')
for json_corpus_file in json_corpus['files'].values():
self.convert_corpus_file(json_corpus_file, corpus, corpus_dir)
current_app.logger.info('Done')
def convert_corpus_file(json_corpus_file, corpus, corpus_dir):
current_app.logger.info(f'Create CorpusFile {json_corpus_file["title"]}...')
corpus_file = CorpusFile(
corpus=corpus,
address=json_corpus_file['address'],
author=json_corpus_file['author'],
booktitle=json_corpus_file['booktitle'],
chapter=json_corpus_file['chapter'],
editor=json_corpus_file['editor'],
filename=json_corpus_file['filename'],
institution=json_corpus_file['institution'],
journal=json_corpus_file['journal'],
mimetype='application/vrt+xml',
pages=json_corpus_file['pages'],
publisher=json_corpus_file['publisher'],
publishing_year=json_corpus_file['publishing_year'],
school=json_corpus_file['school'],
title=json_corpus_file['title']
)
db.session.add(corpus_file)
db.session.flush(objects=[corpus_file])
db.session.refresh(corpus_file)
try:
shutil.copy2(
os.path.join(corpus_dir, json_corpus_file['filename']),
corpus_file.path
def convert_corpus_file(self, json_corpus_file: dict, corpus: Corpus, corpus_dir: Path):
current_app.logger.info(f'Create CorpusFile {json_corpus_file["title"]}...')
corpus_file = CorpusFile(
corpus=corpus,
address=json_corpus_file['address'],
author=json_corpus_file['author'],
booktitle=json_corpus_file['booktitle'],
chapter=json_corpus_file['chapter'],
editor=json_corpus_file['editor'],
filename=json_corpus_file['filename'],
institution=json_corpus_file['institution'],
journal=json_corpus_file['journal'],
mimetype='application/vrt+xml',
pages=json_corpus_file['pages'],
publisher=json_corpus_file['publisher'],
publishing_year=json_corpus_file['publishing_year'],
school=json_corpus_file['school'],
title=json_corpus_file['title']
)
except:
current_app.logger.warning(
'Can not convert corpus file: '
f'{os.path.join(corpus_dir, json_corpus_file["filename"])}'
' -> '
f'{corpus_file.path}'
)
current_app.logger.info('Done')
db.session.add(corpus_file)
db.session.flush(objects=[corpus_file])
db.session.refresh(corpus_file)
try:
shutil.copy2(
corpus_dir / json_corpus_file['filename'],
corpus_file.path
)
except:
current_app.logger.warning(
'Can not convert corpus file: '
f'{corpus_dir / json_corpus_file["filename"]}'
' -> '
f'{corpus_file.path}'
)
current_app.logger.info('Done')

View File

@@ -1,69 +1,25 @@
from flask import current_app
from pathlib import Path
def normalize_vrt_file(input_file, output_file):
def check_pos_attribute_order(vrt_lines):
# The following orders are possible:
# since 26.02.2019: 'word,lemma,simple_pos,pos,ner'
# since 26.03.2021: 'word,pos,lemma,simple_pos,ner'
# since 27.01.2022: 'word,pos,lemma,simple_pos'
# This Function tries to find out which order we have by looking at the
# number of attributes and the position of the simple_pos attribute
SIMPLE_POS_LABELS = [
'ADJ', 'ADP', 'ADV', 'AUX', 'CONJ',
'DET', 'INTJ', 'NOUN', 'NUM', 'PART',
'PRON', 'PROPN', 'PUNCT', 'SCONJ', 'SYM',
'VERB', 'X'
]
for line in vrt_lines:
if line.startswith('<'):
continue
pos_attrs = line.rstrip('\n').split('\t')
num_pos_attrs = len(pos_attrs)
if num_pos_attrs == 4:
if pos_attrs[3] in SIMPLE_POS_LABELS:
return ['word', 'pos', 'lemma', 'simple_pos']
continue
elif num_pos_attrs == 5:
if pos_attrs[2] in SIMPLE_POS_LABELS:
return ['word', 'lemma', 'simple_pos', 'pos', 'ner']
elif pos_attrs[3] in SIMPLE_POS_LABELS:
return ['word', 'pos', 'lemma', 'simple_pos', 'ner']
continue
return None
def check_has_ent_as_s_attr(vrt_lines):
for line in vrt_lines:
if line.startswith('<ent'):
return True
return False
def pos_attrs_to_string_1(pos_attrs):
return f'{pos_attrs[0]}\t{pos_attrs[3]}\t{pos_attrs[1]}\t{pos_attrs[2]}\n'
def pos_attrs_to_string_2(pos_attrs):
return f'{pos_attrs[0]}\t{pos_attrs[1]}\t{pos_attrs[2]}\t{pos_attrs[3]}\n'
def normalize_vrt_file(input_file: Path, output_file: Path):
current_app.logger.info(f'Converting {input_file}...')
with open(input_file) as f:
with input_file.open() as f:
input_vrt_lines = f.readlines()
pos_attr_order = check_pos_attribute_order(input_vrt_lines)
has_ent_as_s_attr = check_has_ent_as_s_attr(input_vrt_lines)
pos_attr_order = _check_pos_attribute_order(input_vrt_lines)
has_ent_as_s_attr = _check_has_ent_as_s_attr(input_vrt_lines)
current_app.logger.info(f'Detected pos_attr_order: [{",".join(pos_attr_order)}]')
current_app.logger.info(f'Detected has_ent_as_s_attr: {has_ent_as_s_attr}')
if pos_attr_order == ['word', 'lemma', 'simple_pos', 'pos', 'ner']:
pos_attrs_to_string_function = pos_attrs_to_string_1
pos_attrs_to_string_function = _pos_attrs_to_string_1
elif pos_attr_order == ['word', 'pos', 'lemma', 'simple_pos', 'ner']:
pos_attrs_to_string_function = pos_attrs_to_string_2
pos_attrs_to_string_function = _pos_attrs_to_string_2
elif pos_attr_order == ['word', 'pos', 'lemma', 'simple_pos']:
pos_attrs_to_string_function = pos_attrs_to_string_2
pos_attrs_to_string_function = _pos_attrs_to_string_2
else:
raise Exception('Can not handle format')
@@ -113,5 +69,49 @@ def normalize_vrt_file(input_file, output_file):
current_ent = pos_attrs[4]
output_vrt += pos_attrs_to_string_function(pos_attrs)
with open(output_file, 'w') as f:
with output_file.open(mode='w') as f:
f.write(output_vrt)
def _check_pos_attribute_order(vrt_lines: list[str]) -> list[str]:
# The following orders are possible:
# since 26.02.2019: 'word,lemma,simple_pos,pos,ner'
# since 26.03.2021: 'word,pos,lemma,simple_pos,ner'
# since 27.01.2022: 'word,pos,lemma,simple_pos'
# This Function tries to find out which order we have by looking at the
# number of attributes and the position of the simple_pos attribute
SIMPLE_POS_LABELS = [
'ADJ', 'ADP', 'ADV', 'AUX', 'CONJ', 'DET', 'INTJ', 'NOUN', 'NUM',
'PART', 'PRON', 'PROPN', 'PUNCT', 'SCONJ', 'SYM', 'VERB', 'X'
]
for line in vrt_lines:
if line.startswith('<'):
continue
pos_attrs = line.rstrip('\n').split('\t')
num_pos_attrs = len(pos_attrs)
if num_pos_attrs == 4:
if pos_attrs[3] in SIMPLE_POS_LABELS:
return ['word', 'pos', 'lemma', 'simple_pos']
continue
elif num_pos_attrs == 5:
if pos_attrs[2] in SIMPLE_POS_LABELS:
return ['word', 'lemma', 'simple_pos', 'pos', 'ner']
elif pos_attrs[3] in SIMPLE_POS_LABELS:
return ['word', 'pos', 'lemma', 'simple_pos', 'ner']
continue
# TODO: raise exception "can't determine attribute order"
def _check_has_ent_as_s_attr(vrt_lines: list[str]) -> bool:
for line in vrt_lines:
if line.startswith('<ent'):
return True
return False
def _pos_attrs_to_string_1(pos_attrs: list[str]) -> str:
return f'{pos_attrs[0]}\t{pos_attrs[3]}\t{pos_attrs[1]}\t{pos_attrs[2]}\n'
def _pos_attrs_to_string_2(pos_attrs: list[str]) -> str:
return f'{pos_attrs[0]}\t{pos_attrs[1]}\t{pos_attrs[2]}\t{pos_attrs[3]}\n'

View File

@@ -1,5 +0,0 @@
from flask import Blueprint
bp = Blueprint('corpora', __name__)
from . import cqi_over_socketio, routes # noqa

View File

@@ -1,115 +0,0 @@
from flask import session
from flask_login import current_user
from flask_socketio import ConnectionRefusedError
from threading import Lock
import cqi
from app import db, hashids, socketio
from app.decorators import socketio_login_required
from app.models import Corpus, CorpusStatus
'''
This package tunnels the Corpus Query interface (CQi) protocol through
Socket.IO (SIO) by wrapping each CQi function in a seperate SIO event.
This module only handles the SIO connect/disconnect, which handles the setup
and teardown of necessary ressources for later use. Each CQi function has a
corresponding SIO event. The event handlers are spread across the different
modules within this package.
Basic concept:
1. A client connects to the SIO namespace and provides the id of a corpus to be
analysed.
1.1 The analysis session counter of the corpus is incremented.
1.2 A CQiClient and a (Mutex) Lock belonging to it is created.
1.3 Wait until the CQP server is running.
1.4 Connect the CQiClient to the server.
1.5 Save the CQiClient and the Lock in the session for subsequential use.
2. A client emits an event and may provide a single json object with necessary
arguments for the targeted CQi function.
3. A SIO event handler (decorated with cqi_over_socketio) gets executed.
- The event handler function defines all arguments. Hence the client
is sent as a single json object, the decorator decomposes it to fit
the functions signature. This also includes type checking and proper
use of the lock (acquire/release) mechanism.
4. Wait for more events
5. The client disconnects from the SIO namespace
1.1 The analysis session counter of the corpus is decremented.
1.2 The CQiClient and (Mutex) Lock belonging to it are teared down.
'''
NAMESPACE = '/corpora/corpus/corpus_analysis'
# Import all CQi over Socket.IO event handlers
from .cqi_corpora_corpus_subcorpora import * # noqa
from .cqi_corpora_corpus_structural_attributes import * # noqa
from .cqi_corpora_corpus_positional_attributes import * # noqa
from .cqi_corpora_corpus_alignment_attributes import * # noqa
from .cqi_corpora_corpus import * # noqa
from .cqi_corpora import * # noqa
from .cqi import * # noqa
@socketio.on('connect', namespace=NAMESPACE)
@socketio_login_required
def connect(auth):
# the auth variable is used in a hacky way. It contains the corpus id for
# which a corpus analysis session should be started.
corpus_id = hashids.decode(auth['corpus_id'])
corpus = Corpus.query.get(corpus_id)
if corpus is None:
# return {'code': 404, 'msg': 'Not Found'}
raise ConnectionRefusedError('Not Found')
if not (corpus.user == current_user
or current_user.is_following_corpus(corpus)
or current_user.is_administrator()):
# return {'code': 403, 'msg': 'Forbidden'}
raise ConnectionRefusedError('Forbidden')
if corpus.status not in [
CorpusStatus.BUILT,
CorpusStatus.STARTING_ANALYSIS_SESSION,
CorpusStatus.RUNNING_ANALYSIS_SESSION,
CorpusStatus.CANCELING_ANALYSIS_SESSION
]:
# return {'code': 424, 'msg': 'Failed Dependency'}
raise ConnectionRefusedError('Failed Dependency')
if corpus.num_analysis_sessions is None:
corpus.num_analysis_sessions = 0
db.session.commit()
corpus.num_analysis_sessions = Corpus.num_analysis_sessions + 1
db.session.commit()
retry_counter = 20
while corpus.status != CorpusStatus.RUNNING_ANALYSIS_SESSION:
if retry_counter == 0:
corpus.num_analysis_sessions = Corpus.num_analysis_sessions - 1
db.session.commit()
return {'code': 408, 'msg': 'Request Timeout'}
socketio.sleep(3)
retry_counter -= 1
db.session.refresh(corpus)
cqi_client = cqi.CQiClient(f'cqpserver_{corpus_id}')
session['d'] = {
'corpus_id': corpus_id,
'cqi_client': cqi_client,
'cqi_client_lock': Lock(),
}
# return {'code': 200, 'msg': 'OK'}
@socketio.on('disconnect', namespace=NAMESPACE)
def disconnect():
if 'd' not in session:
return
session['d']['cqi_client_lock'].acquire()
try:
session['d']['cqi_client'].disconnect()
except (BrokenPipeError, cqi.errors.CQiException):
pass
session['d']['cqi_client_lock'].release()
corpus = Corpus.query.get(session['d']['corpus_id'])
corpus.num_analysis_sessions = Corpus.num_analysis_sessions - 1
db.session.commit()
session.pop('d')
# return {'code': 200, 'msg': 'OK'}

View File

@@ -1,43 +0,0 @@
from socket import gaierror
import cqi
from app import socketio
from app.decorators import socketio_login_required
from . import NAMESPACE as ns
from .utils import cqi_over_socketio
@socketio.on('cqi.connect', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_connect(cqi_client: cqi.CQiClient):
try:
cqi_status = cqi_client.connect()
except gaierror as e:
return {
'code': 500,
'msg': 'Internal Server Error',
'payload': {'code': e.args[0], 'desc': e.args[1]}
}
payload = {'code': cqi_status,
'msg': cqi.api.specification.lookup[cqi_status]}
return {'code': 200, 'msg': 'OK', 'payload': payload}
@socketio.on('cqi.disconnect', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_disconnect(cqi_client: cqi.CQiClient):
cqi_status = cqi_client.disconnect()
payload = {'code': cqi_status,
'msg': cqi.api.specification.lookup[cqi_status]}
return {'code': 200, 'msg': 'OK', 'payload': payload}
@socketio.on('cqi.ping', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_ping(cqi_client: cqi.CQiClient):
cqi_status = cqi_client.ping()
payload = {'code': cqi_status,
'msg': cqi.api.specification.lookup[cqi_status]}
return {'code': 200, 'msg': 'OK', 'payload': payload}

View File

@@ -1,22 +0,0 @@
import cqi
from app import socketio
from app.decorators import socketio_login_required
from . import NAMESPACE as ns
from .utils import cqi_over_socketio
@socketio.on('cqi.corpora.get', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_get(cqi_client: cqi.CQiClient, corpus_name: str):
cqi_corpus = cqi_client.corpora.get(corpus_name)
payload = {**cqi_corpus.attrs}
return {'code': 200, 'msg': 'OK', 'payload': payload}
@socketio.on('cqi.corpora.list', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_list(cqi_client: cqi.CQiClient):
payload = [{**x.attrs} for x in cqi_client.corpora.list()]
return {'code': 200, 'msg': 'OK', 'payload': payload}

View File

@@ -1,85 +0,0 @@
from flask import session
import cqi
import math
from app import db, socketio
from app.decorators import socketio_login_required
from app.models import Corpus
from . import NAMESPACE as ns
from .utils import cqi_over_socketio, lookups_by_cpos
@socketio.on('cqi.corpora.corpus.drop', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_drop(cqi_client: cqi.CQiClient, corpus_name: str):
cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_status = cqi_corpus.drop()
payload = {'code': cqi_status,
'msg': cqi.api.specification.lookup[cqi_status]}
return {'code': 200, 'msg': 'OK', 'payload': payload}
@socketio.on('cqi.corpora.corpus.query', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_query(cqi_client: cqi.CQiClient, corpus_name: str, subcorpus_name: str, query: str): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_status = cqi_corpus.query(subcorpus_name, query)
payload = {'code': cqi_status,
'msg': cqi.api.specification.lookup[cqi_status]}
return {'code': 200, 'msg': 'OK', 'payload': payload}
###############################################################################
# nopaque specific CQi extensions #
###############################################################################
@socketio.on('cqi.corpora.corpus.update_db', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_update_db(cqi_client: cqi.CQiClient, corpus_name: str):
corpus = Corpus.query.get(session['d']['corpus_id'])
corpus.num_tokens = cqi_client.corpora.get('CORPUS').attrs['size']
db.session.commit()
@socketio.on('cqi.corpora.corpus.paginate', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_paginate(cqi_client: cqi.CQiClient, corpus_name: str, page: int = 1, per_page: int = 20): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
# Sanity checks
if (
per_page < 1
or page < 1
or (
cqi_corpus.attrs['size'] > 0
and page > math.ceil(cqi_corpus.attrs['size'] / per_page)
)
):
return {'code': 416, 'msg': 'Range Not Satisfiable'}
first_cpos = (page - 1) * per_page
last_cpos = min(cqi_corpus.attrs['size'], first_cpos + per_page)
cpos_list = [*range(first_cpos, last_cpos)]
lookups = lookups_by_cpos(cqi_corpus, cpos_list)
payload = {}
# the items for the current page
payload['items'] = [cpos_list]
# the lookups for the items
payload['lookups'] = lookups
# the total number of items matching the query
payload['total'] = cqi_corpus.attrs['size']
# the number of items to be displayed on a page.
payload['per_page'] = per_page
# The total number of pages
payload['pages'] = math.ceil(payload['total'] / payload['per_page'])
# the current page number (1 indexed)
payload['page'] = page if payload['pages'] > 0 else None
# True if a previous page exists
payload['has_prev'] = payload['page'] > 1 if payload['page'] else False
# True if a next page exists.
payload['has_next'] = payload['page'] < payload['pages'] if payload['page'] else False # noqa
# Number of the previous page.
payload['prev_num'] = payload['page'] - 1 if payload['has_prev'] else None
# Number of the next page
payload['next_num'] = payload['page'] + 1 if payload['has_next'] else None
return {'code': 200, 'msg': 'OK', 'payload': payload}

View File

@@ -1,24 +0,0 @@
import cqi
from app import socketio
from app.decorators import socketio_login_required
from . import NAMESPACE as ns
from .utils import cqi_over_socketio
@socketio.on('cqi.corpora.corpus.alignment_attributes.get', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_alignment_attributes_get(cqi_client: cqi.CQiClient, corpus_name: str, alignment_attribute_name: str): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_alignment_attribute = cqi_corpus.alignment_attributes.get(alignment_attribute_name) # noqa
payload = {**cqi_alignment_attribute.attrs}
return {'code': 200, 'msg': 'OK', 'payload': payload}
@socketio.on('cqi.corpora.corpus.alignment_attributes.list', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_alignment_attributes_list(cqi_client: cqi.CQiClient, corpus_name: str): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
payload = [{**x.attrs} for x in cqi_corpus.alignment_attributes.list()]
return {'code': 200, 'msg': 'OK', 'payload': payload}

View File

@@ -1,24 +0,0 @@
import cqi
from app import socketio
from app.decorators import socketio_login_required
from . import NAMESPACE as ns
from .utils import cqi_over_socketio
@socketio.on('cqi.corpora.corpus.positional_attributes.get', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_positional_attributes_get(cqi_client: cqi.CQiClient, corpus_name: str, positional_attribute_name: str): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_positional_attribute = cqi_corpus.positional_attributes.get(positional_attribute_name) # noqa
payload = {**cqi_positional_attribute.attrs}
return {'code': 200, 'msg': 'OK', 'payload': payload}
@socketio.on('cqi.corpora.corpus.positional_attributes.list', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_positional_attributes_list(cqi_client: cqi.CQiClient, corpus_name: str): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
payload = [{**x.attrs} for x in cqi_corpus.positional_attributes.list()]
return {'code': 200, 'msg': 'OK', 'payload': payload}

View File

@@ -1,24 +0,0 @@
import cqi
from app import socketio
from app.decorators import socketio_login_required
from . import NAMESPACE as ns
from .utils import cqi_over_socketio
@socketio.on('cqi.corpora.corpus.structural_attributes.get', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_structural_attributes_get(cqi_client: cqi.CQiClient, corpus_name: str, structural_attribute_name: str): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_structural_attribute = cqi_corpus.structural_attributes.get(structural_attribute_name) # noqa
payload = {**cqi_structural_attribute.attrs}
return {'code': 200, 'msg': 'OK', 'payload': payload}
@socketio.on('cqi.corpora.corpus.structural_attributes.list', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_structural_attributes_list(cqi_client: cqi.CQiClient, corpus_name: str): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
payload = [{**x.attrs} for x in cqi_corpus.structural_attributes.list()]
return {'code': 200, 'msg': 'OK', 'payload': payload}

View File

@@ -1,125 +0,0 @@
import cqi
import math
from app import socketio
from app.decorators import socketio_login_required
from . import NAMESPACE as ns
from .utils import cqi_over_socketio, export_subcorpus, partial_export_subcorpus
@socketio.on('cqi.corpora.corpus.subcorpora.get', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_subcorpora_get(cqi_client: cqi.CQiClient, corpus_name: str, subcorpus_name: str): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
payload = {**cqi_subcorpus.attrs}
return {'code': 200, 'msg': 'OK', 'payload': payload}
@socketio.on('cqi.corpora.corpus.subcorpora.list', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_subcorpora_list(cqi_client: cqi.CQiClient, corpus_name: str): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
payload = [{**x.attrs} for x in cqi_corpus.subcorpora.list()]
return {'code': 200, 'msg': 'OK', 'payload': payload}
@socketio.on('cqi.corpora.corpus.subcorpora.subcorpus.drop', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_subcorpora_subcorpus_drop(cqi_client: cqi.CQiClient, corpus_name: str, subcorpus_name: str): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
cqi_status = cqi_subcorpus.drop()
payload = {'code': cqi_status,
'msg': cqi.api.specification.lookup[cqi_status]}
return {'code': 200, 'msg': 'OK', 'payload': payload}
@socketio.on('cqi.corpora.corpus.subcorpora.subcorpus.dump', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_subcorpora_subcorpus_dump(cqi_client: cqi.CQiClient):
return {'code': 501, 'msg': 'Not Implemented'}
@socketio.on('cqi.corpora.corpus.subcorpora.subcorpus.fdist_1', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_subcorpora_subcorpus_fdist_1(cqi_client: cqi.CQiClient):
return {'code': 501, 'msg': 'Not Implemented'}
@socketio.on('cqi.corpora.corpus.subcorpora.subcorpus.fdist_2', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_subcorpora_subcorpus_fdist_2(cqi_client: cqi.CQiClient):
return {'code': 501, 'msg': 'Not Implemented'}
###############################################################################
# nopaque specific CQi extensions #
###############################################################################
@socketio.on('cqi.corpora.corpus.subcorpora.subcorpus.paginate', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_subcorpora_subcorpus_paginate(cqi_client: cqi.CQiClient, corpus_name: str, subcorpus_name: str, context: int = 50, page: int = 1, per_page: int = 20): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
# Sanity checks
if (
per_page < 1
or page < 1
or (
cqi_subcorpus.attrs['size'] > 0
and page > math.ceil(cqi_subcorpus.attrs['size'] / per_page)
)
):
return {'code': 416, 'msg': 'Range Not Satisfiable'}
offset = (page - 1) * per_page
cutoff = per_page
cqi_results_export = export_subcorpus(
cqi_subcorpus, context=context, cutoff=cutoff, offset=offset)
payload = {}
# the items for the current page
payload['items'] = cqi_results_export.pop('matches')
# the lookups for the items
payload['lookups'] = cqi_results_export
# the total number of items matching the query
payload['total'] = cqi_subcorpus.attrs['size']
# the number of items to be displayed on a page.
payload['per_page'] = per_page
# The total number of pages
payload['pages'] = math.ceil(payload['total'] / payload['per_page'])
# the current page number (1 indexed)
payload['page'] = page if payload['pages'] > 0 else None
# True if a previous page exists
payload['has_prev'] = payload['page'] > 1 if payload['page'] else False
# True if a next page exists.
payload['has_next'] = payload['page'] < payload['pages'] if payload['page'] else False # noqa
# Number of the previous page.
payload['prev_num'] = payload['page'] - 1 if payload['has_prev'] else None
# Number of the next page
payload['next_num'] = payload['page'] + 1 if payload['has_next'] else None
return {'code': 200, 'msg': 'OK', 'payload': payload}
@socketio.on('cqi.corpora.corpus.subcorpora.subcorpus.partial_export', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_subcorpora_subcorpus_partial_export(cqi_client: cqi.CQiClient, corpus_name: str, subcorpus_name: str, match_id_list: list, context: int = 50): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
cqi_subcorpus_partial_export = partial_export_subcorpus(cqi_subcorpus, match_id_list, context=context)
return {'code': 200, 'msg': 'OK', 'payload': cqi_subcorpus_partial_export}
@socketio.on('cqi.corpora.corpus.subcorpora.subcorpus.export', namespace=ns)
@socketio_login_required
@cqi_over_socketio
def cqi_corpora_corpus_subcorpora_subcorpus_export(cqi_client: cqi.CQiClient, corpus_name: str, subcorpus_name: str, context: int = 50): # noqa
cqi_corpus = cqi_client.corpora.get(corpus_name)
cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name)
cqi_subcorpus_export = export_subcorpus(cqi_subcorpus, context=context)
return {'code': 200, 'msg': 'OK', 'payload': cqi_subcorpus_export}

View File

@@ -1,178 +0,0 @@
from flask import session
from functools import wraps
from inspect import signature
import cqi
def cqi_over_socketio(f):
@wraps(f)
def wrapped(*args):
if 'd' not in session:
return {'code': 424, 'msg': 'Failed Dependency'}
f_args = {}
# Check for missing args and if all provided args are of the right type
for param in signature(f).parameters.values():
if param.name == 'corpus_name':
f_args[param.name] = f'NOPAQUE_{session["d"]["corpus_id"]}'
continue
if param.name == 'cqi_client':
f_args[param.name] = session['d']['cqi_client']
continue
if param.default is param.empty:
# args
if param.name not in args[0]:
return {'code': 400, 'msg': 'Bad Request'}
arg = args[0][param.name]
if type(arg) is not param.annotation:
return {'code': 400, 'msg': 'Bad Request'}
f_args[param.name] = arg
else:
# kwargs
if param.name not in args[0]:
continue
arg = args[0][param.name]
if type(arg) is not param.annotation:
return {'code': 400, 'msg': 'Bad Request'}
f_args[param.name] = arg
session['d']['cqi_client_lock'].acquire()
try:
return_value = f(**f_args)
except BrokenPipeError:
return_value = {
'code': 500,
'msg': 'Internal Server Error'
}
except cqi.errors.CQiException as e:
return_value = {
'code': 500,
'msg': 'Internal Server Error',
'payload': {
'code': e.code,
'desc': e.description,
'msg': e.name
}
}
finally:
session['d']['cqi_client_lock'].release()
return return_value
return wrapped
def lookups_by_cpos(corpus, cpos_list):
lookups = {}
lookups['cpos_lookup'] = {cpos: {} for cpos in cpos_list}
for attr in corpus.positional_attributes.list():
cpos_attr_values = attr.values_by_cpos(cpos_list)
for i, cpos in enumerate(cpos_list):
lookups['cpos_lookup'][cpos][attr.attrs['name']] = \
cpos_attr_values[i]
for attr in corpus.structural_attributes.list():
# We only want to iterate over non subattributes, identifiable by
# attr.attrs['has_values'] == False
if attr.attrs['has_values']:
continue
cpos_attr_ids = attr.ids_by_cpos(cpos_list)
for i, cpos in enumerate(cpos_list):
if cpos_attr_ids[i] == -1:
continue
lookups['cpos_lookup'][cpos][attr.attrs['name']] = cpos_attr_ids[i]
occured_attr_ids = [x for x in set(cpos_attr_ids) if x != -1]
if not occured_attr_ids:
continue
subattrs = corpus.structural_attributes.list(filters={'part_of': attr})
if not subattrs:
continue
lookup_name = f'{attr.attrs["name"]}_lookup'
lookups[lookup_name] = {}
for attr_id in occured_attr_ids:
lookups[lookup_name][attr_id] = {}
for subattr in subattrs:
subattr_name = subattr.attrs['name'][(len(attr.attrs['name']) + 1):] # noqa
for i, subattr_value in enumerate(subattr.values_by_ids(occured_attr_ids)): # noqa
lookups[lookup_name][occured_attr_ids[i]][subattr_name] = subattr_value # noqa
return lookups
def partial_export_subcorpus(subcorpus, match_id_list, context=25):
if subcorpus.attrs['size'] == 0:
return {"matches": []}
match_boundaries = []
for match_id in match_id_list:
if match_id < 0 or match_id >= subcorpus.attrs['size']:
continue
match_boundaries.append(
(
match_id,
subcorpus.dump(subcorpus.attrs['fields']['match'], match_id, match_id)[0],
subcorpus.dump(subcorpus.attrs['fields']['matchend'], match_id, match_id)[0]
)
)
cpos_set = set()
matches = []
for match_boundary in match_boundaries:
match_num, match_start, match_end = match_boundary
c = (match_start, match_end)
if match_start == 0 or context == 0:
lc = None
cpos_list_lbound = match_start
else:
lc_lbound = max(0, (match_start - context))
lc_rbound = match_start - 1
lc = (lc_lbound, lc_rbound)
cpos_list_lbound = lc_lbound
if match_end == (subcorpus.collection.corpus.attrs['size'] - 1) or context == 0:
rc = None
cpos_list_rbound = match_end
else:
rc_lbound = match_end + 1
rc_rbound = min(
(match_end + context),
(subcorpus.collection.corpus.attrs['size'] - 1)
)
rc = (rc_lbound, rc_rbound)
cpos_list_rbound = rc_rbound
match = {'num': match_num, 'lc': lc, 'c': c, 'rc': rc}
matches.append(match)
cpos_set.update(range(cpos_list_lbound, cpos_list_rbound + 1))
lookups = lookups_by_cpos(subcorpus.collection.corpus, list(cpos_set))
return {'matches': matches, **lookups}
def export_subcorpus(subcorpus, context=25, cutoff=float('inf'), offset=0):
if subcorpus.attrs['size'] == 0:
return {"matches": []}
first_match = max(0, offset)
last_match = min((offset + cutoff - 1), (subcorpus.attrs['size'] - 1))
match_boundaries = zip(
list(range(first_match, last_match + 1)),
subcorpus.dump(subcorpus.attrs['fields']['match'], first_match, last_match),
subcorpus.dump(subcorpus.attrs['fields']['matchend'], first_match, last_match)
)
cpos_set = set()
matches = []
for match_num, match_start, match_end in match_boundaries:
c = (match_start, match_end)
if match_start == 0 or context == 0:
lc = None
cpos_list_lbound = match_start
else:
lc_lbound = max(0, (match_start - context))
lc_rbound = match_start - 1
lc = (lc_lbound, lc_rbound)
cpos_list_lbound = lc_lbound
if match_end == (subcorpus.collection.corpus.attrs['size'] - 1) or context == 0:
rc = None
cpos_list_rbound = match_end
else:
rc_lbound = match_end + 1
rc_rbound = min(
(match_end + context),
(subcorpus.collection.corpus.attrs['size'] - 1)
)
rc = (rc_lbound, rc_rbound)
cpos_list_rbound = rc_rbound
match = {'num': match_num, 'lc': lc, 'c': c, 'rc': rc}
matches.append(match)
cpos_set.update(range(cpos_list_lbound, cpos_list_rbound + 1))
lookups = lookups_by_cpos(subcorpus.collection.corpus, list(cpos_set))
return {'matches': matches, **lookups}

View File

@@ -1,332 +0,0 @@
from flask import (
abort,
current_app,
flash,
Markup,
redirect,
render_template,
request,
send_from_directory,
url_for
)
from flask_login import current_user, login_required
from threading import Thread
import os
from app import db, hashids
from app.models import Corpus, CorpusFile, CorpusStatus, CorpusFollowerAssociation, User
from . import bp
from .forms import ChangeCorpusSettingsForm, CreateCorpusFileForm, CreateCorpusForm, UpdateCorpusFileForm
@bp.route('')
@login_required
def corpora():
query = Corpus.query.filter(
(Corpus.user_id == current_user.id) | (Corpus.is_public == True)
)
corpora = [c.to_json_serializeable() for c in query.all()]
return render_template('corpora/corpora.html.j2', corpora=corpora, title='Corpora')
@bp.route('/create', methods=['GET', 'POST'])
@login_required
def create_corpus():
form = CreateCorpusForm()
if form.validate_on_submit():
try:
corpus = Corpus.create(
title=form.title.data,
description=form.description.data,
user=current_user
)
except OSError:
abort(500)
db.session.commit()
message = Markup(
f'Corpus "<a href="{corpus.url}">{corpus.title}</a>" created'
)
flash(message, 'corpus')
return redirect(corpus.url)
return render_template(
'corpora/create_corpus.html.j2',
form=form,
title='Create corpus'
)
@bp.route('/<hashid:corpus_id>', methods=['GET', 'POST'])
@login_required
def corpus(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user
or current_user.is_administrator()
or current_user.is_following_corpus(corpus)
or corpus.is_public):
abort(403)
corpus_settings_form = ChangeCorpusSettingsForm(
data=corpus.to_json_serializeable(),
prefix='corpus-settings-form'
)
if corpus_settings_form.validate_on_submit():
corpus.is_public = corpus_settings_form.is_public.data
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.corpus', corpus_id=corpus.id))
if corpus.user == current_user or current_user.is_administrator():
return render_template(
'corpora/corpus.html.j2',
corpus_settings_form=corpus_settings_form,
corpus=corpus,
title='Corpus'
)
else:
print('public')
return render_template(
'corpora/corpus_public.html.j2',
corpus=corpus,
title='Corpus'
)
# @bp.route('/<hashid:corpus_id>/update')
# @login_required
# def update_corpus(corpus_id):
# corpus = Corpus.query.get_or_404(corpus_id)
# if not (corpus.user == current_user or current_user.is_administrator()):
# abort(403)
# return render_template(
# 'corpora/update_corpus.html.j2',
# corpus=corpus,
# title='Corpus'
# )
@bp.route('/<hashid:corpus_id>', methods=['DELETE'])
@login_required
def delete_corpus(corpus_id):
def _delete_corpus(app, corpus_id):
with app.app_context():
corpus = Corpus.query.get(corpus_id)
corpus.delete()
db.session.commit()
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator()):
abort(403)
thread = Thread(
target=_delete_corpus,
args=(current_app._get_current_object(), corpus_id)
)
thread.start()
return {}, 202
@bp.route('/<hashid:corpus_id>/analyse')
@login_required
def analyse_corpus(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user
or current_user.is_administrator()
or current_user.is_following_corpus(corpus)
or corpus.is_public):
abort(403)
return render_template(
'corpora/analyse_corpus.html.j2',
corpus=corpus,
title=f'Analyse Corpus {corpus.title}'
)
@bp.route('/<hashid:corpus_id>/build', methods=['POST'])
@login_required
def build_corpus(corpus_id):
def _build_corpus(app, corpus_id):
with app.app_context():
corpus = Corpus.query.get(corpus_id)
corpus.build()
db.session.commit()
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator()):
abort(403)
# Check if the corpus has corpus files
if not corpus.files.all():
response = {'errors': {'message': 'Corpus file(s) required'}}
return response, 409
thread = Thread(
target=_build_corpus,
args=(current_app._get_current_object(), corpus_id)
)
thread.start()
return {}, 202
@bp.route('/<hashid:corpus_id>/files/create', methods=['GET', 'POST'])
@login_required
def create_corpus_file(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
if not (corpus.user == current_user or current_user.is_administrator()):
abort(403)
form = CreateCorpusFileForm()
if form.is_submitted():
if not form.validate():
response = {'errors': form.errors}
return response, 400
try:
corpus_file = CorpusFile.create(
form.vrt.data,
address=form.address.data,
author=form.author.data,
booktitle=form.booktitle.data,
chapter=form.chapter.data,
editor=form.editor.data,
institution=form.institution.data,
journal=form.journal.data,
pages=form.pages.data,
publisher=form.publisher.data,
publishing_year=form.publishing_year.data,
school=form.school.data,
title=form.title.data,
mimetype='application/vrt+xml',
corpus=corpus
)
except (AttributeError, OSError):
abort(500)
corpus.status = CorpusStatus.UNPREPARED
db.session.commit()
message = Markup(
'Corpus file'
f'"<a href="{corpus_file.url}">{corpus_file.filename}</a>" added'
)
flash(message, category='corpus')
return {}, 201, {'Location': corpus.url}
return render_template(
'corpora/create_corpus_file.html.j2',
corpus=corpus,
form=form,
title='Add corpus file'
)
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['GET', 'POST'])
@login_required
def corpus_file(corpus_id, corpus_file_id):
corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404()
if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
abort(403)
form = UpdateCorpusFileForm(data=corpus_file.to_json_serializeable())
if form.validate_on_submit():
form.populate_obj(corpus_file)
if db.session.is_modified(corpus_file):
corpus_file.corpus.status = CorpusStatus.UNPREPARED
db.session.commit()
message = Markup(f'Corpus file "<a href="{corpus_file.url}">{corpus_file.filename}</a>" updated')
flash(message, category='corpus')
return redirect(corpus_file.corpus.url)
return render_template(
'corpora/corpus_file.html.j2',
corpus=corpus_file.corpus,
corpus_file=corpus_file,
form=form,
title='Edit corpus file'
)
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', methods=['DELETE'])
@login_required
def delete_corpus_file(corpus_id, corpus_file_id):
def _delete_corpus_file(app, corpus_file_id):
with app.app_context():
corpus_file = CorpusFile.query.get(corpus_file_id)
corpus_file.delete()
db.session.commit()
corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404()
if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
abort(403)
thread = Thread(
target=_delete_corpus_file,
args=(current_app._get_current_object(), corpus_file_id)
)
thread.start()
return {}, 202
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/download')
@login_required
def download_corpus_file(corpus_id, corpus_file_id):
corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404()
if not (corpus_file.corpus.user == current_user or current_user.is_administrator()):
abort(403)
return send_from_directory(
os.path.dirname(corpus_file.path),
os.path.basename(corpus_file.path),
as_attachment=True,
attachment_filename=corpus_file.filename,
mimetype=corpus_file.mimetype
)
@bp.route('/import', methods=['GET', 'POST'])
@login_required
def import_corpus():
abort(503)
@bp.route('/<hashid:corpus_id>/export')
@login_required
def export_corpus(corpus_id):
abort(503)
@bp.route('/<hashid:corpus_id>/follow', methods=['GET', 'POST'])
@login_required
def follow_corpus(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
user_hashid = request.args.get('user_id')
if user_hashid is None:
user = current_user
else:
if not current_user.is_administrator():
abort(403)
else:
user_id = hashids.decode(user_hashid)
user = User.query.get_or_404(user_id)
if not user.is_following_corpus(corpus):
user.follow_corpus(corpus)
db.session.commit()
return {}, 202
@bp.route('/<hashid:corpus_id>/unfollow', methods=['GET', 'POST'])
@login_required
def unfollow_corpus(corpus_id):
corpus = Corpus.query.get_or_404(corpus_id)
user_hashid = request.args.get('user_id')
if user_hashid is None:
user = current_user
else:
if not current_user.is_administrator():
abort(403)
else:
user_id = hashids.decode(user_hashid)
user = User.query.get_or_404(user_id)
if user.is_following_corpus(corpus):
user.unfollow_corpus(corpus)
db.session.commit()
return {}, 202
@bp.route('/add_permission/<hashid:corpus_id>/<hashid:user_id>/<int:permission>')
def add_permission(corpus_id, user_id, permission):
a = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=user_id).first_or_404()
a.add_permission(permission)
db.session.commit()
return 'ok'
@bp.route('/remove_permission/<hashid:corpus_id>/<hashid:user_id>/<int:permission>')
def remove_permission(corpus_id, user_id, permission):
a = CorpusFollowerAssociation.query.filter_by(followed_corpus_id=corpus_id, following_user_id=user_id).first_or_404()
a.remove_permission(permission)
db.session.commit()
return 'ok'

View File

@@ -1,11 +0,0 @@
from app import db
from flask import Flask
from .corpus_utils import check_corpora
from .job_utils import check_jobs
def daemon(app: Flask):
with app.app_context():
check_corpora()
check_jobs()
db.session.commit()

View File

@@ -1,7 +1,8 @@
from flask import abort, current_app
from flask import abort, request
from flask_login import current_user
from functools import wraps
from threading import Thread
from typing import Optional
from werkzeug.exceptions import NotAcceptable
from app.models import Permission
@@ -22,22 +23,21 @@ def admin_required(f):
def socketio_login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
def wrapper(*args, **kwargs):
if current_user.is_authenticated:
return f(*args, **kwargs)
else:
return {'code': 401, 'msg': 'Unauthorized'}
return decorated_function
return {'status': 401, 'statusText': 'Unauthorized'}
return wrapper
def socketio_permission_required(permission):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
def wrapper(*args, **kwargs):
if not current_user.can(permission):
return {'code': 403, 'msg': 'Forbidden'}
return {'status': 403, 'statusText': 'Forbidden'}
return f(*args, **kwargs)
return decorated_function
return wrapper
return decorator
@@ -45,19 +45,35 @@ def socketio_admin_required(f):
return socketio_permission_required(Permission.ADMINISTRATE)(f)
def background(f):
'''
' This decorator executes a function in a Thread.
' Decorated functions need to be executed within a code block where an
' app context exists.
'
' NOTE: An app object is passed as a keyword argument to the decorated
' function.
'''
@wraps(f)
def wrapped(*args, **kwargs):
kwargs['app'] = current_app._get_current_object()
thread = Thread(target=f, args=args, kwargs=kwargs)
thread.start()
return thread
return wrapped
def content_negotiation(
produces: Optional[str | list[str]] = None,
consumes: Optional[str | list[str]] = None
):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
provided = request.mimetype
if consumes is None:
consumeables = None
elif isinstance(consumes, str):
consumeables = {consumes}
elif isinstance(consumes, list) and all(isinstance(x, str) for x in consumes):
consumeables = {*consumes}
else:
raise TypeError()
accepted = {*request.accept_mimetypes.values()}
if produces is None:
produceables = None
elif isinstance(produces, str):
produceables = {produces}
elif isinstance(produces, list) and all(isinstance(x, str) for x in produces):
produceables = {*produces}
else:
raise TypeError()
if produceables is not None and len(produceables & accepted) == 0:
raise NotAcceptable()
if consumeables is not None and provided not in consumeables:
raise NotAcceptable()
return f(*args, **kwargs)
return decorated_function
return decorator

View File

@@ -1,25 +1,32 @@
from flask import current_app, render_template
from flask import current_app, Flask, render_template
from flask_mail import Message
from threading import Thread
from app import mail
def create_message(recipient, subject, template, **kwargs):
subject_prefix: str = current_app.config['NOPAQUE_MAIL_SUBJECT_PREFIX']
msg: Message = Message(
body=render_template(f'{template}.txt.j2', **kwargs),
html=render_template(f'{template}.html.j2', **kwargs),
def create_message(
recipient: str,
subject: str,
template: str,
**context
) -> Message:
message = Message(
body=render_template(f'{template}.txt.j2', **context),
html=render_template(f'{template}.html.j2', **context),
recipients=[recipient],
subject=f'{subject_prefix} {subject}'
subject=f'[nopaque] {subject}'
)
return msg
return message
def send(msg, *args, **kwargs):
def _send(app, msg):
def send(message: Message) -> Thread:
def _send(app: Flask, message: Message):
with app.app_context():
mail.send(msg)
mail.send(message)
thread = Thread(target=_send, args=[current_app._get_current_object(), msg])
thread = Thread(
target=_send,
args=[current_app._get_current_object(), message]
)
thread.start()
return thread

View File

@@ -1,11 +0,0 @@
from flask import render_template, request
from werkzeug.exceptions import HTTPException
from . import bp
@bp.errorhandler(HTTPException)
def generic_error_handler(e):
if (request.accept_mimetypes.accept_json
and not request.accept_mimetypes.accept_html):
return {'errors': {'message': e.description}}, e.code
return render_template('errors/error.html.j2', error=e), e.code

View File

@@ -0,0 +1,20 @@
from flask import abort
from flask_admin import (
AdminIndexView as _AdminIndexView,
expose
)
from flask_admin.contrib.sqla import ModelView as _ModelView
from flask_login import current_user
class AdminIndexView(_AdminIndexView):
@expose('/')
def index(self):
if not current_user.is_administrator:
abort(403)
return super().index()
class ModelView(_ModelView):
def is_accessible(self):
return current_user.is_administrator

View File

@@ -0,0 +1,42 @@
import json
from app import db
class ContainerColumn(db.TypeDecorator):
impl = db.String
def __init__(self, container_type, *args, **kwargs):
super().__init__(*args, **kwargs)
self.container_type = container_type
def process_bind_param(self, value, dialect):
if isinstance(value, self.container_type):
return json.dumps(value)
elif isinstance(value, str) and isinstance(json.loads(value), self.container_type):
return value
else:
return TypeError()
def process_result_value(self, value, dialect):
return json.loads(value)
class IntEnumColumn(db.TypeDecorator):
impl = db.Integer
def __init__(self, enum_type, *args, **kwargs):
super().__init__(*args, **kwargs)
self.enum_type = enum_type
def process_bind_param(self, value, dialect):
if isinstance(value, self.enum_type) and isinstance(value.value, int):
return value.value
elif isinstance(value, int):
return self.enum_type(value).value
elif isinstance(value, str):
return self.enum_type[value].value
else:
return TypeError()
def process_result_value(self, value, dialect):
return self.enum_type(value)

View File

@@ -1,5 +1,2 @@
from flask import Blueprint
bp = Blueprint('jobs', __name__)
from . import routes
from .handle_corpora import handle_corpora
from .handle_jobs import handle_jobs

View File

@@ -1,12 +1,16 @@
from app import docker_client
from app.models import Corpus, CorpusStatus
from flask import current_app
import docker
import os
import shutil
from app import db, docker_client, scheduler
from app.models import Corpus, CorpusStatus
def check_corpora():
def handle_corpora():
with scheduler.app.app_context():
_handle_corpora()
def _handle_corpora():
corpora = Corpus.query.all()
for corpus in [x for x in corpora if x.status == CorpusStatus.SUBMITTED]:
_create_build_corpus_service(corpus)
@@ -17,40 +21,39 @@ def check_corpora():
for corpus in [x for x in corpora if x.status == CorpusStatus.RUNNING_ANALYSIS_SESSION and x.num_analysis_sessions == 0]:
corpus.status = CorpusStatus.CANCELING_ANALYSIS_SESSION
for corpus in [x for x in corpora if x.status == CorpusStatus.RUNNING_ANALYSIS_SESSION]:
_checkout_analysing_corpus_container(corpus)
_checkout_cqpserver_container(corpus)
for corpus in [x for x in corpora if x.status == CorpusStatus.STARTING_ANALYSIS_SESSION]:
_create_cqpserver_container(corpus)
for corpus in [x for x in corpora if x.status == CorpusStatus.CANCELING_ANALYSIS_SESSION]:
_remove_cqpserver_container(corpus)
db.session.commit()
def _create_build_corpus_service(corpus):
def _create_build_corpus_service(corpus: Corpus):
''' # Docker service settings # '''
''' ## Command ## '''
command = ['bash', '-c']
command.append(
f'mkdir /corpora/data/nopaque_{corpus.id}'
f'mkdir /corpora/data/nopaque-{corpus.hashid.lower()}'
' && '
'cwb-encode'
' -c utf8'
f' -d /corpora/data/nopaque_{corpus.id}'
f' -d /corpora/data/nopaque-{corpus.hashid.lower()}'
' -f /root/files/corpus.vrt'
f' -R /usr/local/share/cwb/registry/nopaque_{corpus.id}'
f' -R /usr/local/share/cwb/registry/nopaque-{corpus.hashid.lower()}'
' -P pos -P lemma -P simple_pos'
' -S ent:0+type -S s:0'
' -S text:0+address+author+booktitle+chapter+editor+institution+journal+pages+publisher+publishing_year+school+title'
' -xsB -9'
' && '
f'cwb-make -V NOPAQUE_{corpus.id}'
f'cwb-make -V NOPAQUE-{corpus.hashid.upper()}'
)
''' ## Constraints ## '''
constraints = ['node.role==worker']
''' ## Image ## '''
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1702'
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1887'
''' ## Labels ## '''
labels = {
'origin': current_app.config['SERVER_NAME'],
'type': 'corpus.build',
'corpus_id': str(corpus.id)
'nopaque.server_name': current_app.config['SERVER_NAME']
}
''' ## Mounts ## '''
mounts = []
@@ -95,7 +98,7 @@ def _create_build_corpus_service(corpus):
return
corpus.status = CorpusStatus.QUEUED
def _checkout_build_corpus_service(corpus):
def _checkout_build_corpus_service(corpus: Corpus):
service_name = f'build-corpus_{corpus.id}'
try:
service = docker_client.services.get(service_name)
@@ -123,8 +126,7 @@ def _checkout_build_corpus_service(corpus):
except docker.errors.DockerException as e:
current_app.logger.error(f'Remove service "{service_name}" failed: {e}')
def _create_cqpserver_container(corpus):
''' # Docker container settings # '''
def _create_cqpserver_container(corpus: Corpus):
''' ## Command ## '''
command = []
command.append(
@@ -139,21 +141,25 @@ def _create_cqpserver_container(corpus):
''' ## Entrypoint ## '''
entrypoint = ['bash', '-c']
''' ## Image ## '''
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1702'
image = f'{current_app.config["NOPAQUE_DOCKER_IMAGE_PREFIX"]}cwb:r1887'
''' ## Name ## '''
name = f'cqpserver_{corpus.id}'
name = f'nopaque-cqpserver-{corpus.id}'
''' ## Network ## '''
network = f'{current_app.config["DOCKER_NETWORK_NAME"]}'
network = f'{current_app.config["NOPAQUE_DOCKER_NETWORK_NAME"]}'
''' ## Volumes ## '''
volumes = []
''' ### Corpus data volume ### '''
data_volume_source = os.path.join(corpus.path, 'cwb', 'data')
data_volume_target = '/corpora/data'
# data_volume_source = os.path.join(corpus.path, 'cwb', 'data', f'nopaque_{corpus.id}')
# data_volume_target = f'/corpora/data/nopaque_{corpus.hashid.lower()}'
data_volume = f'{data_volume_source}:{data_volume_target}:rw'
volumes.append(data_volume)
''' ### Corpus registry volume ### '''
registry_volume_source = os.path.join(corpus.path, 'cwb', 'registry')
registry_volume_target = '/usr/local/share/cwb/registry'
# registry_volume_source = os.path.join(corpus.path, 'cwb', 'registry', f'nopaque_{corpus.id}')
# registry_volume_target = f'/usr/local/share/cwb/registry/nopaque_{corpus.hashid.lower()}'
registry_volume = f'{registry_volume_source}:{registry_volume_target}:rw'
volumes.append(registry_volume)
# Check if a cqpserver container already exists. If this is the case,
@@ -194,8 +200,8 @@ def _create_cqpserver_container(corpus):
return
corpus.status = CorpusStatus.RUNNING_ANALYSIS_SESSION
def _checkout_analysing_corpus_container(corpus):
container_name = f'cqpserver_{corpus.id}'
def _checkout_cqpserver_container(corpus: Corpus):
container_name = f'nopaque-cqpserver-{corpus.id}'
try:
docker_client.containers.get(container_name)
except docker.errors.NotFound as e:
@@ -205,8 +211,8 @@ def _checkout_analysing_corpus_container(corpus):
except docker.errors.DockerException as e:
current_app.logger.error(f'Get container "{container_name}" failed: {e}')
def _remove_cqpserver_container(corpus):
container_name = f'cqpserver_{corpus.id}'
def _remove_cqpserver_container(corpus: Corpus):
container_name = f'nopaque-cqpserver-{corpus.id}'
try:
container = docker_client.containers.get(container_name)
except docker.errors.NotFound:

View File

@@ -1,11 +1,3 @@
from app import db, docker_client, hashids
from app.models import (
Job,
JobResult,
JobStatus,
TesseractOCRPipelineModel,
SpaCyNLPPipelineModel
)
from datetime import datetime
from flask import current_app
from werkzeug.utils import secure_filename
@@ -13,9 +5,21 @@ import docker
import json
import os
import shutil
from app import db, docker_client, hashids, scheduler
from app.models import (
Job,
JobResult,
JobStatus,
TesseractOCRPipelineModel,
SpaCyNLPPipelineModel
)
def check_jobs():
def handle_jobs():
with scheduler.app.app_context():
_handle_jobs()
def _handle_jobs():
jobs = Job.query.all()
for job in [x for x in jobs if x.status == JobStatus.SUBMITTED]:
_create_job_service(job)
@@ -23,8 +27,9 @@ def check_jobs():
_checkout_job_service(job)
for job in [x for x in jobs if x.status == JobStatus.CANCELING]:
_remove_job_service(job)
db.session.commit()
def _create_job_service(job):
def _create_job_service(job: Job):
''' # Docker service settings # '''
''' ## Service specific settings ## '''
if job.service == 'file-setup-pipeline':
@@ -81,9 +86,7 @@ def _create_job_service(job):
constraints = ['node.role==worker']
''' ## Labels ## '''
labels = {
'origin': current_app.config['SERVER_NAME'],
'type': 'job',
'job_id': str(job.id)
'origin': current_app.config['SERVER_NAME']
}
''' ## Mounts ## '''
mounts = []
@@ -164,7 +167,7 @@ def _create_job_service(job):
return
job.status = JobStatus.QUEUED
def _checkout_job_service(job):
def _checkout_job_service(job: Job):
service_name = f'job_{job.id}'
try:
service = docker_client.services.get(service_name)
@@ -213,7 +216,7 @@ def _checkout_job_service(job):
except docker.errors.DockerException as e:
current_app.logger.error(f'Remove service "{service_name}" failed: {e}')
def _remove_job_service(job):
def _remove_job_service(job: Job):
service_name = f'job_{job.id}'
try:
service = docker_client.services.get(service_name)

View File

@@ -1,116 +0,0 @@
from flask import (
abort,
current_app,
render_template,
send_from_directory
)
from flask_login import current_user, login_required
from threading import Thread
import os
from app import db
from app.decorators import admin_required
from app.models import Job, JobInput, JobResult, JobStatus
from . import bp
@bp.route('/<hashid:job_id>')
@login_required
def job(job_id):
job = Job.query.get_or_404(job_id)
if not (job.user == current_user or current_user.is_administrator()):
abort(403)
return render_template(
'jobs/job.html.j2',
job=job,
title='Job'
)
@bp.route('/<hashid:job_id>', methods=['DELETE'])
@login_required
def delete_job(job_id):
def _delete_job(app, job_id):
with app.app_context():
job = Job.query.get(job_id)
job.delete()
db.session.commit()
job = Job.query.get_or_404(job_id)
if not (job.user == current_user or current_user.is_administrator()):
abort(403)
thread = Thread(
target=_delete_job,
args=(current_app._get_current_object(), job_id)
)
thread.start()
return {}, 202
@bp.route('/<hashid:job_id>/log')
@login_required
@admin_required
def job_log(job_id):
job = Job.query.get_or_404(job_id)
if job.status not in [JobStatus.COMPLETED, JobStatus.FAILED]:
response = {'errors': {'message': 'Job status is not completed or failed'}}
return response, 409
with open(os.path.join(job.path, 'pipeline_data', 'logs', 'pyflow_log.txt')) as log_file:
log = log_file.read()
return log, 200, {'Content-Type': 'text/plain; charset=utf-8'}
@bp.route('/<hashid:job_id>/restart', methods=['POST'])
@login_required
def restart_job(job_id):
def _restart_job(app, job_id):
with app.app_context():
job = Job.query.get(job_id)
job.restart()
db.session.commit()
job = Job.query.get_or_404(job_id)
if not (job.user == current_user or current_user.is_administrator()):
abort(403)
if job.status == JobStatus.FAILED:
response = {'errors': {'message': 'Job status is not "failed"'}}
return response, 409
thread = Thread(
target=_restart_job,
args=(current_app._get_current_object(), job_id)
)
thread.start()
return {}, 202
@bp.route('/<hashid:job_id>/inputs/<hashid:job_input_id>/download')
@login_required
def download_job_input(job_id, job_input_id):
job_input = JobInput.query.get_or_404(job_input_id)
if job_input.job.id != job_id:
abort(404)
if not (job_input.job.user == current_user or current_user.is_administrator()):
abort(403)
return send_from_directory(
os.path.dirname(job_input.path),
os.path.basename(job_input.path),
as_attachment=True,
attachment_filename=job_input.filename,
mimetype=job_input.mimetype
)
@bp.route('/<hashid:job_id>/results/<hashid:job_result_id>/download')
@login_required
def download_job_result(job_id, job_result_id):
job_result = JobResult.query.get_or_404(job_result_id)
if job_result.job.id != job_id:
abort(404)
if not (job_result.job.user == current_user or current_user.is_administrator()):
abort(403)
return send_from_directory(
os.path.dirname(job_result.path),
os.path.basename(job_result.path),
as_attachment=True,
attachment_filename=job_result.filename,
mimetype=job_result.mimetype
)

View File

@@ -1,64 +0,0 @@
from flask import flash, redirect, render_template, url_for
from flask_login import current_user, login_required, login_user
from app.auth.forms import LoginForm
from app.models import Corpus, User
from . import bp
@bp.route('', methods=['GET', 'POST'])
def index():
form = LoginForm(prefix='login-form')
if form.validate_on_submit():
user = User.query.filter((User.email == form.user.data.lower()) | (User.username == form.user.data)).first()
if user and user.verify_password(form.password.data):
login_user(user, form.remember_me.data)
flash('You have been logged in')
return redirect(url_for('.dashboard'))
flash('Invalid email/username or password', category='error')
redirect(url_for('.index'))
return render_template('main/index.html.j2', form=form, title='nopaque')
@bp.route('/faq')
def faq():
return render_template('main/faq.html.j2', title='Frequently Asked Questions')
@bp.route('/dashboard')
@login_required
def dashboard():
users = [
u.to_json_serializeable(filter_by_privacy_settings=True) for u
in User.query.filter(User.is_public == True, User.id != current_user.id).all()
]
corpora = [
c.to_json_serializeable() for c
in Corpus.query.filter(Corpus.is_public == True).all()
]
return render_template('main/dashboard.html.j2', title='Dashboard', users=users, corpora=corpora)
@bp.route('/dashboard2')
@login_required
def dashboard2():
return render_template('main/dashboard2.html.j2', title='Dashboard')
@bp.route('/user_manual')
def user_manual():
return render_template('main/user_manual.html.j2', title='User manual')
@bp.route('/news')
def news():
return render_template('main/news.html.j2', title='News')
@bp.route('/privacy_policy')
def privacy_policy():
return render_template('main/privacy_policy.html.j2', title='Privacy statement (GDPR)')
@bp.route('/terms_of_use')
def terms_of_use():
return render_template('main/terms_of_use.html.j2', title='Terms of Use')

File diff suppressed because it is too large Load Diff

45
app/models/__init__.py Normal file
View File

@@ -0,0 +1,45 @@
from .anonymous_user import AnonymousUser
from .avatar import Avatar
from .corpus_file import CorpusFile
from .corpus_follower_association import CorpusFollowerAssociation
from .corpus_follower_role import CorpusFollowerPermission, CorpusFollowerRole
from .corpus import CorpusStatus, Corpus
from .job_input import JobInput
from .job_result import JobResult
from .job import JobStatus, Job
from .role import Permission, Role
from .spacy_nlp_pipeline_model import SpaCyNLPPipelineModel
from .tesseract_ocr_pipeline_model import TesseractOCRPipelineModel
from .token import Token
from .user import (
ProfilePrivacySettings,
UserSettingJobStatusMailNotificationLevel,
User
)
_models = [
Avatar,
CorpusFile,
CorpusFollowerAssociation,
CorpusFollowerRole,
Corpus,
JobInput,
JobResult,
Job,
Role,
SpaCyNLPPipelineModel,
TesseractOCRPipelineModel,
Token,
User
]
_enums = [
CorpusFollowerPermission,
CorpusStatus,
JobStatus,
Permission,
ProfilePrivacySettings,
UserSettingJobStatusMailNotificationLevel
]

View File

@@ -0,0 +1,10 @@
from flask_login import AnonymousUserMixin
class AnonymousUser(AnonymousUserMixin):
def can(self, permissions):
return False
@property
def is_administrator(self):
return False

40
app/models/avatar.py Normal file
View File

@@ -0,0 +1,40 @@
from flask import current_app
from flask_hashids import HashidMixin
from pathlib import Path
from app import db
from .file_mixin import FileMixin
class Avatar(HashidMixin, FileMixin, db.Model):
__tablename__ = 'avatars'
# Primary key
id = db.Column(db.Integer, primary_key=True)
# Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
# Relationships
user = db.relationship('User', back_populates='avatar')
@property
def path(self) -> Path:
return self.user.path / 'avatar'
# return os.path.join(self.user.path, 'avatar')
def delete(self):
try:
self.path.unlink(missing_ok=True)
except OSError as e:
current_app.logger.error(e)
raise
db.session.delete(self)
def to_json_serializeable(self, backrefs=False, relationships=False):
json_serializeable = {
'id': self.hashid,
**self.file_mixin_to_json_serializeable()
}
if backrefs:
json_serializeable['user'] = \
self.user.to_json_serializeable(backrefs=True)
if relationships:
pass
return json_serializeable

Some files were not shown because too many files have changed in this diff Show More