diff options
34 files changed, 1702 insertions, 605 deletions
diff --git a/python/.gitignore b/python/.gitignore index a0bc258b..48b921f1 100644 --- a/python/.gitignore +++ b/python/.gitignore @@ -1,9 +1,11 @@  .env +*.env  codegen-out/  build/  dist/  *.egg-info  *.json.gz +!example.env  !.coveragerc  !.pylintrc  !.gitignore diff --git a/python/Pipfile b/python/Pipfile index e793ad96..321b2d50 100644 --- a/python/Pipfile +++ b/python/Pipfile @@ -24,6 +24,9 @@ Flask = ">=1"  flask-uuid = "*"  flask-debugtoolbar = "*"  flask-login = "*" +flask-wtf = "*" +Flask-Misaka = "*" +WTForms = "*"  loginpass = "*"  requests = ">=2"  raven = { extras = ['flask'], version = "*" } diff --git a/python/Pipfile.lock b/python/Pipfile.lock index c4271c88..3aaa0c66 100644 --- a/python/Pipfile.lock +++ b/python/Pipfile.lock @@ -1,7 +1,7 @@  {      "_meta": {          "hash": { -            "sha256": "9b4f0371d2f2963acb915f4a3be7d46b1fb6923a099b79b2dd5d109c5b976a23" +            "sha256": "fa764b53404517e2f5e60b61fa8083f0ef50499e9b9b537994720abf85f2aac9"          },          "pipfile-spec": 6,          "requires": { @@ -172,6 +172,15 @@              "index": "pypi",              "version": "==0.4.1"          }, +        "flask-misaka": { +            "hashes": [ +                "sha256:bcfdacc0803ccea75d377737e82c83489b2153d922c9d9f9eabc5148d216ed70", +                "sha256:d0cfb0efd9e5afacda76defd4a605a68390f4fb1bef283c71534fd3ce0d3efb5", +                "sha256:f423c3beb5502742a57330a272f81d53223f6f99d45cc45b03926e3a3034f589" +            ], +            "index": "pypi", +            "version": "==1.0.0" +        },          "flask-uuid": {              "hashes": [                  "sha256:f9a8196eb896599ba9e74dcf713cfd1aca4669d418c19069e088620ae6294805" @@ -179,6 +188,14 @@              "index": "pypi",              "version": "==0.2"          }, +        "flask-wtf": { +            "hashes": [ +                "sha256:5d14d55cfd35f613d99ee7cba0fc3fbbe63ba02f544d349158c14ca15561cc36", +                "sha256:d9a9e366b32dcbb98ef17228e76be15702cd2600675668bca23f63a7947fd5ac" +            ], +            "index": "pypi", +            "version": "==0.14.2" +        },          "ftfy": {              "hashes": [                  "sha256:84a1614190173bb447ac9d581e50185c6aa35b538754b6bedaba0cc0f83d8e80", @@ -225,34 +242,34 @@          },          "lxml": {              "hashes": [ -                "sha256:0358b9e9642bc7d39aac5cffe9884a99a5ca68e5e2c1b89e570ed60da9139908", -                "sha256:091a359c4dafebbecd3959d9013f1b896b5371859165e4e50b01607a98d9e3e2", -                "sha256:1998e4e60603c64bcc35af61b4331ab3af087457900d3980e18d190e17c3a697", -                "sha256:2000b4088dee9a41f459fddaf6609bba48a435ce6374bb254c5ccdaa8928c5ba", -                "sha256:2afb0064780d8aaf165875be5898c1866766e56175714fa5f9d055433e92d41d", -                "sha256:2d8f1d9334a4e3ff176d096c14ded3100547d73440683567d85b8842a53180bb", -                "sha256:2e38db22f6a3199fd63675e1b4bd795d676d906869047398f29f38ca55cb453a", -                "sha256:3181f84649c1a1ca62b19ddf28436b1b2cb05ae6c7d2628f33872e713994c364", -                "sha256:37462170dfd88af8431d04de6b236e6e9c06cda71e2ca26d88ef2332fd2a5237", -                "sha256:3a9d8521c89bf6f2a929c3d12ad3ad7392c774c327ea809fd08a13be6b3bc05f", -                "sha256:3d0bbd2e1a28b4429f24fd63a122a450ce9edb7a8063d070790092d7343a1aa4", -                "sha256:483d60585ce3ee71929cea70949059f83850fa5e12deb9c094ed1c8c2ec73cbd", -                "sha256:4888be27d5cba55ce94209baef5bcd7bbd7314a3d17021a5fc10000b3a5f737d", -                "sha256:64b0d62e4209170a2a0c404c446ab83b941a0003e96604d2e4f4cb735f8a2254", -                "sha256:68010900898fdf139ac08549c4dba8206c584070a960ffc530aebf0c6f2794ef", -                "sha256:872ecb066de602a0099db98bd9e57f4cfc1d62f6093d94460c787737aa08f39e", -                "sha256:88a32b03f2e4cd0e63f154cac76724709f40b3fc2f30139eb5d6f900521b44ed", -                "sha256:b1dc7683da4e67ab2bebf266afa68098d681ae02ce570f0d1117312273d2b2ac", -                "sha256:b29e27ce9371810250cb1528a771d047a9c7b0f79630dc7dc5815ff828f4273b", -                "sha256:ce197559596370d985f1ce6b7051b52126849d8159040293bf8b98cb2b3e1f78", -                "sha256:d45cf6daaf22584eff2175f48f82c4aa24d8e72a44913c5aff801819bb73d11f", -                "sha256:e2ff9496322b2ce947ba4a7a5eb048158de9d6f3fe9efce29f1e8dd6878561e6", -                "sha256:f7b979518ec1f294a41a707c007d54d0f3b3e1fd15d5b26b7e99b62b10d9a72e", -                "sha256:f9c7268e9d16e34e50f8246c4f24cf7353764affd2bc971f0379514c246e3f6b", -                "sha256:f9c839806089d79de588ee1dde2dae05dc1156d3355dfeb2b51fde84d9c960ad", -                "sha256:ff962953e2389226adc4d355e34a98b0b800984399153c6678f2367b11b4d4b8" -            ], -            "version": "==4.3.2" +                "sha256:03984196d00670b2ab14ae0ea83d5cc0cfa4f5a42558afa9ab5fa745995328f5", +                "sha256:0815b0c9f897468de6a386dc15917a0becf48cc92425613aa8bbfc7f0f82951f", +                "sha256:175f3825f075cf02d15099eb52658457cf0ff103dcf11512b5d2583e1d40f58b", +                "sha256:30e14c62d88d1e01a26936ecd1c6e784d4afc9aa002bba4321c5897937112616", +                "sha256:3210da6f36cf4b835ff1be853962b22cc354d506f493b67a4303c88bbb40d57b", +                "sha256:40f60819fbd5bad6e191ba1329bfafa09ab7f3f174b3d034d413ef5266963294", +                "sha256:43b26a865a61549919f8a42e094dfdb62847113cf776d84bd6b60e4e3fc20ea3", +                "sha256:4a03dd682f8e35a10234904e0b9508d705ff98cf962c5851ed052e9340df3d90", +                "sha256:62f382cddf3d2e52cf266e161aa522d54fd624b8cc567bc18f573d9d50d40e8e", +                "sha256:7b98f0325be8450da70aa4a796c4f06852949fe031878b4aa1d6c417a412f314", +                "sha256:846a0739e595871041385d86d12af4b6999f921359b38affb99cdd6b54219a8f", +                "sha256:a3080470559938a09a5d0ec558c005282e99ac77bf8211fb7b9a5c66390acd8d", +                "sha256:ad841b78a476623955da270ab8d207c3c694aa5eba71f4792f65926dc46c6ee8", +                "sha256:afdd75d9735e44c639ffd6258ce04a2de3b208f148072c02478162d0944d9da3", +                "sha256:b4fbf9b552faff54742bcd0791ab1da5863363fb19047e68f6592be1ac2dab33", +                "sha256:b90c4e32d6ec089d3fa3518436bdf5ce4d902a0787dbd9bb09f37afe8b994317", +                "sha256:b91cfe4438c741aeff662d413fd2808ac901cc6229c838236840d11de4586d63", +                "sha256:bdb0593a42070b0a5f138b79b872289ee73c8e25b3f0bea6564e795b55b6bcdd", +                "sha256:c4e4bca2bb68ce22320297dfa1a7bf070a5b20bcbaec4ee023f83d2f6e76496f", +                "sha256:cec4ab14af9eae8501be3266ff50c3c2aecc017ba1e86c160209bb4f0423df6a", +                "sha256:e83b4b2bf029f5104bc1227dbb7bf5ace6fd8fabaebffcd4f8106fafc69fc45f", +                "sha256:e995b3734a46d41ae60b6097f7c51ba9958648c6d1e0935b7e0ee446ee4abe22", +                "sha256:f679d93dec7f7210575c85379a31322df4c46496f184ef650d3aba1484b38a2d", +                "sha256:fd213bb5166e46974f113c8228daaef1732abc47cb561ce9c4c8eaed4bd3b09b", +                "sha256:fdcb57b906dbc1f80666e6290e794ab8fb959a2e17aa5aee1758a85d1da4533f", +                "sha256:ff424b01d090ffe1947ec7432b07f536912e0300458f9a7f48ea217dd8362b86" +            ], +            "version": "==4.3.3"          },          "markupsafe": {              "hashes": [ @@ -287,6 +304,12 @@              ],              "version": "==1.1.1"          }, +        "misaka": { +            "hashes": [ +                "sha256:62f35254550095d899fc2ab8b33e156fc5e674176f074959cbca43cf7912ecd7" +            ], +            "version": "==2.1.1" +        },          "pycparser": {              "hashes": [                  "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" @@ -359,11 +382,11 @@          },          "python-snappy": {              "hashes": [ -                "sha256:59c79d83350f931ad5cf8f06ccb1c9bd1087a77c3ca7e00806884cda654a6faf", -                "sha256:8a7f803f06083d4106d55387d2daa32c12b5e376c3616b0e2da8b8a87a27d74a" +                "sha256:9c0ba725755b749ef9b03f6ed7582cefb957c0d9f6f064a7c4314148a9dbdb61", +                "sha256:d9c26532cfa510f45e8d135cde140e8a5603d3fb254cfec273ebc0ecf9f668e2"              ],              "index": "pypi", -            "version": "==0.5.3" +            "version": "==0.5.4"          },          "raven": {              "hashes": [ @@ -398,10 +421,10 @@          },          "soupsieve": {              "hashes": [ -                "sha256:afa56bf14907bb09403e5d15fbed6275caa4174d36b975226e3b67a3bb6e2c4b", -                "sha256:eaed742b48b1f3e2d45ba6f79401b2ed5dc33b2123dfe216adb90d4bfa0ade26" +                "sha256:3aef141566afd07201b525c17bfaadd07580a8066f82b57f7c9417f26adbd0a3", +                "sha256:e41a65e99bd125972d84221022beb1e4b5cfc68fa12c170c39834ce32d1b294c"              ], -            "version": "==1.8" +            "version": "==1.9"          },          "tabulate": {              "hashes": [ @@ -425,10 +448,18 @@          },          "werkzeug": {              "hashes": [ -                "sha256:590abe38f8be026d78457fe3b5200895b3543e58ac3fc1dd792c6333ea11af64", -                "sha256:ee11b0f0640c56fb491b43b38356c4b588b3202b415a1e03eacf1c5561c961cf" +                "sha256:0a73e8bb2ff2feecfc5d56e6f458f5b99290ef34f565ffb2665801ff7de6af7a", +                "sha256:7fad9770a8778f9576693f0cc29c7dcc36964df916b83734f4431c0e612a7fbc"              ], -            "version": "==0.15.0" +            "version": "==0.15.2" +        }, +        "wtforms": { +            "hashes": [ +                "sha256:0cdbac3e7f6878086c334aa25dc5a33869a3954e9d1e015130d65a69309b3b61", +                "sha256:e3ee092c827582c50877cdbd49e9ce6d2c5c1f6561f849b3b068c1b8029626f1" +            ], +            "index": "pypi", +            "version": "==2.2.1"          }      },      "develop": { @@ -536,11 +567,11 @@          },          "ipython": {              "hashes": [ -                "sha256:06de667a9e406924f97781bda22d5d76bfb39762b678762d86a466e63f65dc39", -                "sha256:5d3e020a6b5f29df037555e5c45ab1088d6a7cf3bd84f47e0ba501eeb0c3ec82" +                "sha256:b038baa489c38f6d853a3cfc4c635b0cda66f2864d136fe8f40c1a6e334e2a6b", +                "sha256:f5102c1cd67e399ec8ea66bcebe6e3968ea25a8977e53f012963e5affeb1fe38"              ],              "index": "pypi", -            "version": "==7.3.0" +            "version": "==7.4.0"          },          "ipython-genutils": {              "hashes": [ @@ -551,10 +582,10 @@          },          "isort": {              "hashes": [ -                "sha256:18c796c2cd35eb1a1d3f012a214a542790a1aed95e29768bdcb9f2197eccbd0b", -                "sha256:96151fca2c6e736503981896495d344781b60d18bfda78dc11b290c6125ebdb6" +                "sha256:08f8e3f0f0b7249e9fad7e5c41e2113aba44969798a26452ee790c06f155d4ec", +                "sha256:4e9e9c4bd1acd66cf6c36973f29b031ec752cbfd991c69695e4e259f9a756927"              ], -            "version": "==4.3.15" +            "version": "==4.3.16"          },          "jedi": {              "hashes": [ @@ -606,11 +637,11 @@          },          "more-itertools": {              "hashes": [ -                "sha256:0125e8f60e9e031347105eb1682cef932f5e97d7b9a1a28d9bf00c22a5daef40", -                "sha256:590044e3942351a1bdb1de960b739ff4ce277960f2425ad4509446dbace8d9d1" +                "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", +                "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a"              ],              "markers": "python_version > '2.7'", -            "version": "==6.0.0" +            "version": "==7.0.0"          },          "parso": {              "hashes": [ @@ -730,11 +761,11 @@          },          "pytest": {              "hashes": [ -                "sha256:592eaa2c33fae68c7d75aacf042efc9f77b27c08a6224a4f59beab8d9a420523", -                "sha256:ad3ad5c450284819ecde191a654c09b0ec72257a2c711b9633d677c71c9850c4" +                "sha256:13c5e9fb5ec5179995e9357111ab089af350d788cbc944c628f3cde72285809b", +                "sha256:f21d2f1fb8200830dcbb5d8ec466a9c9120e20d8b53c7585d180125cce1d297a"              ],              "index": "pypi", -            "version": "==4.3.1" +            "version": "==4.4.0"          },          "pytest-cov": {              "hashes": [ diff --git a/python/fatcat_web/__init__.py b/python/fatcat_web/__init__.py index e80393c2..3a332e84 100644 --- a/python/fatcat_web/__init__.py +++ b/python/fatcat_web/__init__.py @@ -1,8 +1,11 @@  from flask import Flask +from flask.logging import create_logger  from flask_uuid import FlaskUUID  from flask_debugtoolbar import DebugToolbarExtension  from flask_login import LoginManager +from flask_wtf.csrf import CSRFProtect +from flask_misaka import Misaka  from authlib.flask.client import OAuth  from loginpass import create_flask_blueprint, Gitlab  from raven.contrib.flask import Sentry @@ -10,11 +13,22 @@ import fatcat_client  from fatcat_web.web_config import Config +  toolbar = DebugToolbarExtension()  app = Flask(__name__)  app.config.from_object(Config)  toolbar = DebugToolbarExtension(app)  FlaskUUID(app) +app.csrf = CSRFProtect(app) +app.log = create_logger(app) + +# This is the Markdown processor; setting default here +Misaka(app, +    autolink=True, +    no_intra_emphasis=True, +    strikethrough=True, +    escape=True, +)  login_manager = LoginManager()  login_manager.init_app(app) @@ -46,7 +60,7 @@ else:      print("No privileged token found")      priv_api = None -from fatcat_web import routes, auth, cors +from fatcat_web import routes, editing_routes, auth, cors, forms  gitlab_bp = create_flask_blueprint(Gitlab, oauth, auth.handle_oauth)  app.register_blueprint(gitlab_bp, url_prefix='/auth/gitlab') diff --git a/python/fatcat_web/auth.py b/python/fatcat_web/auth.py index 1953151b..7f51b970 100644 --- a/python/fatcat_web/auth.py +++ b/python/fatcat_web/auth.py @@ -5,7 +5,7 @@ import pymacaroons  from flask import Flask, render_template, send_from_directory, request, \      url_for, abort, g, redirect, jsonify, session, flash  from flask_login import logout_user, login_user, UserMixin -from fatcat_web import login_manager, api, priv_api, Config +from fatcat_web import login_manager, app, api, priv_api, Config  import fatcat_client  def handle_logout(): @@ -20,6 +20,7 @@ def handle_token_login(token):          m = pymacaroons.Macaroon.deserialize(token)      except pymacaroons.exceptions.MacaroonDeserializationException:          # TODO: what kind of Exceptions? +        app.log.warn("auth fail: MacaroonDeserializationException")          return abort(400)      # extract editor_id      editor_id = None @@ -28,6 +29,7 @@ def handle_token_login(token):          if caveat.startswith(b"editor_id = "):              editor_id = caveat[12:].decode('utf-8')      if not editor_id: +        app.log.warn("auth fail: editor_id missing in macaroon")          abort(400)      # fetch editor info      editor = api.get_editor(editor_id) @@ -93,12 +95,11 @@ def handle_ia_xauth(email, password):          try:              flash("Internet Archive email/password didn't match: {}".format(resp.json()['values']['reason']))          except: -            print("IA XAuth fail: {}".format(resp.content)) +            app.log.warn("IA XAuth fail: {}".format(resp.content))          return render_template('auth_ia_login.html', email=email), resp.status_code      elif resp.status_code != 200:          flash("Internet Archive login failed (internal error?)") -        # TODO: log.warn -        print("IA XAuth fail: {}".format(resp.content)) +        app.log.warn("IA XAuth fail: {}".format(resp.content))          return render_template('auth_ia_login.html', email=email), resp.status_code      # Successful login; now fetch info... @@ -112,8 +113,7 @@ def handle_ia_xauth(email, password):          })      if resp.status_code != 200:          flash("Internet Archive login failed (internal error?)") -        # TODO: log.warn -        print("IA XAuth fail: {}".format(resp.content)) +        app.log.warn("IA XAuth fail: {}".format(resp.content))          return render_template('auth_ia_login.html', email=email), resp.status_code      ia_info = resp.json()['values'] @@ -139,5 +139,6 @@ def load_user(editor_id):      user.id = editor_id      user.editor_id = editor_id      user.username = editor['username'] +    user.is_admin = editor['is_admin']      user.token = token      return user diff --git a/python/fatcat_web/editing_routes.py b/python/fatcat_web/editing_routes.py new file mode 100644 index 00000000..a9b3d326 --- /dev/null +++ b/python/fatcat_web/editing_routes.py @@ -0,0 +1,269 @@ + +import os +import json +from flask import Flask, render_template, send_from_directory, request, \ +    url_for, abort, g, redirect, jsonify, session, flash, Response +from flask_login import login_required + +from fatcat_client import Editgroup +from fatcat_client.rest import ApiException +from fatcat_tools.transforms import * +from fatcat_web import app, api, auth_api, priv_api +from fatcat_web.auth import handle_token_login, handle_logout, load_user, handle_ia_xauth +from fatcat_web.cors import crossdomain +from fatcat_web.search import * +from fatcat_web.forms import * + + +### Helper Methods ########################################################## + +def form_editgroup_get_or_create(api, edit_form): +    """ +    This function expects a submitted, validated  +    """ +    if edit_form.editgroup_id.data: +        try: +            eg = api.get_editgroup(edit_form.editgroup_id.data) +        except ApiException as ae: +            if ae.status == 404: +                edit_form.editgroup_id.errors.append("Editgroup does not exist") +                return None +            app.log.warn(ae) +            abort(ae.status) +        # TODO: check here that editgroup hasn't been merged already +    else: +        # if no editgroup, create one from description +        try: +            eg = api.create_editgroup( +                Editgroup(description=edit_form.editgroup_description.data or None)) +        except ApiException as ae: +            app.log.warn(ae) +            abort(ae.status) +        # set this session editgroup_id +        session['active_editgroup_id'] = eg.editgroup_id +        flash('Started new editgroup <a href="/editgroup/{}">{}</a>' \ +            .format(eg.editgroup_id, eg.editgroup_id)) +    return eg + +### Views ################################################################### + +@app.route('/container/create', methods=['GET', 'POST']) +@login_required +def container_create(): +    form = ContainerEntityForm() +    if form.is_submitted(): +        if form.validate_on_submit(): +            # API on behalf of user +            user_api = auth_api(session['api_token']) +            eg = form_editgroup_get_or_create(user_api, form) +            if eg: +                # no merge or anything hard to do; just create the entity +                entity = form.to_entity() +                try: +                    edit = user_api.create_container(entity, editgroup_id=eg.editgroup_id) +                except ApiException as ae: +                    app.log.warn(ae) +                    abort(ae.status) +                # redirect to new entity +                return redirect('/container/{}'.format(edit.ident)) +        elif form.errors: +            app.log.info("form errors (did not validate): {}".format(form.errors)) +    else: +        editgroup_id = session.get('active_editgroup_id', None) +        form.editgroup_id.data = editgroup_id +    return render_template('container_create.html', form=form) + +@login_required +@app.route('/container/<ident>/edit', methods=['GET', 'POST']) +def container_edit(ident): +    # TODO: prev_rev interlock +    try: +        entity = api.get_container(ident) +    except ApiException as ae: +        abort(ae.status) +    form = ContainerEntityForm() +    if form.is_submitted(): +        if form.validate_on_submit(): +            # API on behalf of user +            user_api = auth_api(session['api_token']) +            eg = form_editgroup_get_or_create(user_api, form) +            if eg: +                # all the tricky logic is in the update method +                form.update_entity(entity) +                try: +                    edit = user_api.update_container(entity.ident, entity, +                        editgroup_id=eg.editgroup_id) +                except ApiException as ae: +                    app.log.warn(ae) +                    abort(ae.status) +                # redirect to entity revision +                # TODO: container_rev_view +                return redirect('/container/{}'.format(edit.ident)) +        elif form.errors: +            app.log.info("form errors (did not validate): {}".format(form.errors)) +    else: +        form = ContainerEntityForm.from_entity(entity) +    if not form.is_submitted(): +        editgroup_id = session.get('active_editgroup_id', None) +        form.editgroup_id.data = editgroup_id +    return render_template('container_edit.html', form=form, entity=entity) + +@app.route('/creator/<ident>/edit', methods=['GET']) +def creator_edit(ident): +    try: +        entity = api.get_creator(ident) +    except ApiException as ae: +        abort(ae.status) +    return render_template('entity_edit.html') + +@app.route('/file/create', methods=['GET', 'POST']) +@login_required +def file_create(): +    form = FileEntityForm() +    if form.is_submitted(): +        if form.validate_on_submit(): +            # API on behalf of user +            user_api = auth_api(session['api_token']) +            eg = form_editgroup_get_or_create(user_api, form) +            if eg: +                # no merge or anything hard to do; just create the entity +                entity = form.to_entity() +                try: +                    edit = user_api.create_file(entity, editgroup_id=eg.editgroup_id) +                except ApiException as ae: +                    app.log.warn(ae) +                    abort(ae.status) +                # redirect to new entity +                return redirect('/file/{}'.format(edit.ident)) +        elif form.errors: +            app.log.info("form errors (did not validate): {}".format(form.errors)) +    else: +        editgroup_id = session.get('active_editgroup_id', None) +        form.editgroup_id.data = editgroup_id +        form.urls.append_entry() +        form.release_ids.append_entry() +    return render_template('file_create.html', +        form=form) + +@login_required +@app.route('/file/<ident>/edit', methods=['GET', 'POST']) +def file_edit(ident): +    # TODO: prev_rev interlock +    try: +        entity = api.get_file(ident) +    except ApiException as ae: +        abort(ae.status) +    form = FileEntityForm() +    if form.is_submitted(): +        if form.validate_on_submit(): +            # API on behalf of user +            user_api = auth_api(session['api_token']) +            eg = form_editgroup_get_or_create(user_api, form) +            if eg: +                # all the tricky logic is in the update method +                form.update_entity(entity) +                try: +                    edit = user_api.update_file(entity.ident, entity, +                        editgroup_id=eg.editgroup_id) +                except ApiException as ae: +                    app.log.warn(ae) +                    abort(ae.status) +                # redirect to entity revision +                # TODO: file_rev_view +                return redirect('/file/{}'.format(edit.ident)) +        elif form.errors: +            app.log.info("form errors (did not validate): {}".format(form.errors)) +    else: # not submitted +        form = FileEntityForm.from_entity(entity) +        editgroup_id = session.get('active_editgroup_id', None) +        form.editgroup_id.data = editgroup_id +    return render_template('file_edit.html', form=form, entity=entity) + +@app.route('/fileset/<ident>/edit', methods=['GET']) +def fileset_edit(ident): +    try: +        entity = api.get_fileset(ident) +    except ApiException as ae: +        abort(ae.status) +    return render_template('entity_edit.html') + +@app.route('/webcapture/<ident>/edit', methods=['GET']) +def webcapture_edit(ident): +    try: +        entity = api.get_webcapture(ident) +    except ApiException as ae: +        abort(ae.status) +    return render_template('entity_edit.html') + +@app.route('/release/create', methods=['GET', 'POST']) +@login_required +def release_create(): +    form = ReleaseEntityForm() +    if form.is_submitted(): +        if form.validate_on_submit(): +            # API on behalf of user +            user_api = auth_api(session['api_token']) +            eg = form_editgroup_get_or_create(user_api, form) +            if eg: +                # no merge or anything hard to do; just create the entity +                entity = form.to_entity() +                try: +                    edit = user_api.create_release(entity, editgroup_id=eg.editgroup_id) +                except ApiException as ae: +                    app.log.warn(ae) +                    abort(ae.status) +                # redirect to new release +                return redirect('/release/{}'.format(edit.ident)) +        elif form.errors: +            app.log.info("form errors (did not validate): {}".format(form.errors)) +    else: # not submitted +        form.contribs.append_entry() +        editgroup_id = session.get('active_editgroup_id', None) +        form.editgroup_id.data = editgroup_id +    return render_template('release_create.html', form=form) + +@login_required +@app.route('/release/<ident>/edit', methods=['GET', 'POST']) +def release_edit(ident): +    # TODO: prev_rev interlock +    try: +        entity = api.get_release(ident) +    except ApiException as ae: +        abort(ae.status) +    form = ReleaseEntityForm() +    if form.is_submitted(): +        if form.validate_on_submit(): +            # API on behalf of user +            user_api = auth_api(session['api_token']) +            eg = form_editgroup_get_or_create(user_api, form) +            if eg: +                # all the tricky logic is in the update method +                form.update_entity(entity) +                try: +                    edit = user_api.update_release(entity.ident, entity, +                        editgroup_id=eg.editgroup_id) +                except ApiException as ae: +                    app.log.warn(ae) +                    abort(ae.status) +                # redirect to entity revision +                # TODO: release_rev_view +                return redirect('/release/{}'.format(edit.ident)) +        elif form.errors: +            app.log.info("form errors (did not validate): {}".format(form.errors)) +    else: # not submitted +        form = ReleaseEntityForm.from_entity(entity) +        editgroup_id = session.get('active_editgroup_id', None) +        form.editgroup_id.data = editgroup_id +    return render_template('release_edit.html', form=form, entity=entity) + +@app.route('/work/create', methods=['GET']) +def work_create_view(): +    return abort(404) + +@app.route('/work/<ident>/edit', methods=['GET']) +def work_edit_view(ident): +    try: +        entity = api.get_work(ident) +    except ApiException as ae: +        abort(ae.status) +    return render_template('entity_edit.html') diff --git a/python/fatcat_web/forms.py b/python/fatcat_web/forms.py new file mode 100644 index 00000000..776812ae --- /dev/null +++ b/python/fatcat_web/forms.py @@ -0,0 +1,315 @@ + +""" +Note: in thoery could use, eg, https://github.com/christabor/swagger_wtforms, +but can't find one that is actually maintained. +""" + +from flask_wtf import FlaskForm +from wtforms import SelectField, DateField, StringField, IntegerField, \ +    HiddenField, FormField, FieldList, validators + +from fatcat_client import ContainerEntity, CreatorEntity, FileEntity, \ +    ReleaseEntity, ReleaseContrib, FileEntityUrls + +release_type_options = [ +    ('', 'Unknown'), +    ('article-journal', 'Journal Article'), +    ('paper-conference', 'Conference Proceeding'), +    ('article', 'Article (non-journal)'), +    ('book', 'Book'), +    ('chapter', 'Book Chapter'), +    ('dataset', 'Dataset'), +    ('stub', 'Invalid/Stub'), +] +release_status_options = [ +    ('', 'Unknown'), +    ('draft', 'Draft'), +    ('submitted', 'Submitted'), +    ('accepted', 'Accepted'), +    ('published', 'Published'), +    ('updated', 'Updated'), +] +role_type_options = [ +    ('author', 'Author'), +    ('editor', 'Editor'), +    ('translator', 'Translator'), +] + +class EntityEditForm(FlaskForm): +    editgroup_id = StringField('Editgroup ID', +        [validators.Optional(True), +         validators.Length(min=26, max=26)]) +    editgroup_description = StringField('Editgroup Description', +        [validators.Optional(True)]) +    edit_description = StringField('Description of Changes', +        [validators.Optional(True)]) + +class ReleaseContribForm(FlaskForm): +    class Meta: +        # this is a sub-form, so disable CSRF +        csrf = False + +    #surname +    #given_name +    #creator_id (?) +    #orcid (for match?) +    prev_index = HiddenField('prev_revision index', default=None) +    raw_name = StringField('Display Name', +        [validators.DataRequired()]) +    role = SelectField( +        [validators.DataRequired()], +        choices=role_type_options, +        default='author') + +RELEASE_SIMPLE_ATTRS = ['title', 'original_title', 'work_id', 'container_id', +    'release_type', 'release_status', 'release_date', 'doi', 'wikidata_qid', +    'isbn13', 'pmid', 'pmcid', 'volume', 'issue', 'pages', 'publisher', +    'language', 'license_slug'] + +class ReleaseEntityForm(EntityEditForm): +    """ +    TODO: +    - field types: fatcat id +    - date +    """ +    title = StringField('Title', +        [validators.DataRequired()]) +    original_title = StringField('Original Title') +    work_id = StringField('Work FCID', +        [validators.Optional(True), +         validators.Length(min=26, max=26)]) +    container_id = StringField('Container FCID', +        [validators.Optional(True), +         validators.Length(min=26, max=26)]) +    release_type = SelectField('Release Type', +        [validators.DataRequired()], +        choices=release_type_options, +        default='') +    release_status = SelectField(choices=release_status_options) +    release_date = DateField('Release Date', +        [validators.Optional(True)]) +    #release_year +    doi = StringField('DOI', +        [validators.Regexp('^10\..*\/.*', message="DOI must be valid"), +         validators.Optional(True)]) +    wikidata_qid = StringField('Wikidata QID') +    isbn13 = StringField('ISBN-13') +    pmid = StringField('PubMed Id') +    pmcid = StringField('PubMed Central Id') +    #core_id +    #arxiv_id +    #jstor_id +    volume = StringField('Volume') +    issue = StringField('Issue') +    pages = StringField('Pages') +    publisher = StringField('Publisher (optional)') +    language = StringField('Language (code)') +    license_slug = StringField('License (slug)') +    contribs = FieldList(FormField(ReleaseContribForm)) +    #refs +    #abstracts + +    @staticmethod +    def from_entity(re): +        """ +        Initializes form with values from an existing release entity. +        """ +        ref = ReleaseEntityForm() +        for simple_attr in RELEASE_SIMPLE_ATTRS: +            a = getattr(ref, simple_attr) +            a.data = getattr(re, simple_attr) +        for i, c in enumerate(re.contribs): +            rcf = ReleaseContribForm() +            rcf.prev_index = i +            rcf.role = c.role +            rcf.raw_name = c.raw_name +            ref.contribs.append_entry(rcf) +        return ref + +    def to_entity(self): +        assert(self.title.data) +        entity = ReleaseEntity(title=self.title.data) +        self.update_entity(entity) +        return entity + +    def update_entity(self, re): +        """ +        Mutates a release entity in place, updating fields with values from +        this form. + +        Form must be validated *before* calling this function. +        """ +        for simple_attr in RELEASE_SIMPLE_ATTRS: +            a = getattr(self, simple_attr).data +            # special case blank strings +            if a == '': +                a = None +            setattr(re, simple_attr, a) +        # bunch of complexity here to preserve old contrib metadata (eg, +        # affiliation and extra) not included in current forms +        # TODO: this may be broken; either way needs tests +        if re.contribs: +            old_contribs = re.contribs.copy() +            re.contribs = [] +        else: +            old_contribs = [] +            re.contribs = [] +        for c in self.contribs: +            if c.prev_index.data not in ('', None): +                rc = old_contribs[int(c.prev_index.data)] +                rc.role = c.role.data or None +                rc.raw_name = c.raw_name.data or None +            else: +                rc = ReleaseContrib( +                    role=c.role.data or None, +                    raw_name=c.raw_name.data or None, +                ) +            re.contribs.append(rc) +        if self.edit_description.data: +            re.edit_extra = dict(description=self.edit_description.data) + +container_type_options = ( +    ('journal', 'Journal'), +    ('proceedings', 'Conference Proceedings'), +    ('blog', 'Blog'), +) + +CONTAINER_SIMPLE_ATTRS = ['name', 'container_type', 'publisher', 'issnl', +    'wikidata_qid'] + +class ContainerEntityForm(EntityEditForm): +    name = StringField('Name/Title', +        [validators.DataRequired()]) +    container_type = SelectField('Container Type', +        [validators.Optional(True)], +        choices=container_type_options, +        default='') +    publisher = StringField("Publisher") +    issnl = StringField("ISSN-L") +    wikidata_qid = StringField('Wikidata QID') + +    @staticmethod +    def from_entity(re): +        """ +        Initializes form with values from an existing container entity. +        """ +        ref = ContainerEntityForm() +        for simple_attr in CONTAINER_SIMPLE_ATTRS: +            a = getattr(ref, simple_attr) +            a.data = getattr(re, simple_attr) +        return ref + +    def to_entity(self): +        assert(self.name.data) +        entity = ContainerEntity(name=self.name.data) +        self.update_entity(entity) +        return entity + +    def update_entity(self, ce): +        """ +        Mutates a container entity in place, updating fields with values from +        this form. + +        Form must be validated *before* calling this function. +        """ +        for simple_attr in CONTAINER_SIMPLE_ATTRS: +            a = getattr(self, simple_attr).data +            # special case blank strings +            if a == '': +                a = None +            setattr(ce, simple_attr, a) +        if self.edit_description.data: +            ce.edit_extra = dict(description=self.edit_description.data) + +url_rel_options = [ +    ('web', 'Public Web'), +    ('webarchive', 'Web Archive'), +    ('repository', 'Repository'), +    ('social', 'Academic Social Network'), +    ('publisher', 'Publisher'), +    ('dweb', 'Decentralized Web'), +] + +FILE_SIMPLE_ATTRS = ['size', 'md5', 'sha1', 'sha256', 'mimetype'] + +class FileUrlForm(FlaskForm): +    class Meta: +        # this is a sub-form, so disable CSRF +        csrf = False + +    url = StringField('Display Name', +        [validators.DataRequired(), +         validators.URL(require_tld=False)]) +    rel = SelectField( +        [validators.DataRequired()], +        choices=url_rel_options, +        default='web') + +class FileEntityForm(EntityEditForm): +    size = IntegerField('Size (bytes)', +        [validators.DataRequired()]) +        # TODO: positive definite +    md5 = StringField("MD5", +        [validators.Optional(True), +         validators.Length(min=32, max=32)]) +    sha1 = StringField("SHA-1", +        [validators.DataRequired(), +         validators.Length(min=40, max=40)]) +    sha256 = StringField("SHA-256", +        [validators.Optional(True), +         validators.Length(min=64, max=64)]) +    urls = FieldList(FormField(FileUrlForm)) +    mimetype = StringField("Mimetype") +    release_ids = FieldList( +        StringField("Release FCID", +            [validators.DataRequired(), +            validators.Length(min=26, max=26)])) + +    @staticmethod +    def from_entity(fe): +        """ +        Initializes form with values from an existing file entity. +        """ +        ref = FileEntityForm() +        for simple_attr in FILE_SIMPLE_ATTRS: +            a = getattr(ref, simple_attr) +            a.data = getattr(fe, simple_attr) +        for i, c in enumerate(fe.urls): +            ruf = FileUrlForm() +            ruf.rel = c.rel +            ruf.url = c.url +            ref.urls.append_entry(ruf) +        for r in fe.release_ids: +            ref.release_ids.append_entry(r) +        return ref + +    def to_entity(self): +        assert(self.sha1.data) +        entity = FileEntity() +        self.update_entity(entity) +        return entity + +    def update_entity(self, fe): +        """ +        Mutates in place, updating fields with values from this form. + +        Form must be validated *before* calling this function. +        """ +        for simple_attr in FILE_SIMPLE_ATTRS: +            a = getattr(self, simple_attr).data +            # special case blank strings +            if a == '': +                a = None +            setattr(fe, simple_attr, a) +        fe.urls = [] +        for u in self.urls: +            fe.urls.append(FileEntityUrls( +                rel=u.rel.data or None, +                url=u.url.data or None, +            )) +        fe.release_ids = [] +        for ri in self.release_ids: +            fe.release_ids.append(ri.data) +        if self.edit_description.data: +            fe.edit_extra = dict(description=self.edit_description.data) + diff --git a/python/fatcat_web/routes.py b/python/fatcat_web/routes.py index e2c5fc3a..8e07aff0 100644 --- a/python/fatcat_web/routes.py +++ b/python/fatcat_web/routes.py @@ -4,7 +4,9 @@ import json  from flask import Flask, render_template, send_from_directory, request, \      url_for, abort, g, redirect, jsonify, session, flash, Response  from flask_login import login_required +from flask_wtf.csrf import CSRFError +from fatcat_client import Editgroup, EditgroupAnnotation  from fatcat_client.rest import ApiException  from fatcat_tools.transforms import *  from fatcat_web import app, api, auth_api, priv_api @@ -21,6 +23,7 @@ def container_history(ident):          entity = api.get_container(ident)          history = api.get_container_history(ident)      except ApiException as ae: +        app.log.info(ae)          abort(ae.status)      #print(history)      return render_template('entity_history.html', @@ -29,43 +32,6 @@ def container_history(ident):          entity=entity,          history=history) -@app.route('/container/<ident>/edit', methods=['GET']) -def container_edit_view(ident): -    try: -        entity = api.get_container(ident) -    except ApiException as ae: -        abort(ae.status) -    return render_template('entity_edit.html') - -#@app.route('/container/<ident>/edit', methods=['POST']) -#def container_edit(ident): -#    raise NotImplemented() -#    params = dict() -#    for k in request.form: -#        if k.startswith('container_'): -#            params[k[10:]] = request.form[k] -#    edit = api.update_container(params=params) -#    return redirect("/container/{}".format(edit.ident)) -#    # else: -#    #return render_template('container_edit.html') - -@app.route('/container/create', methods=['GET']) -@login_required -def container_create_view(): -    return render_template('container_create.html') - -@app.route('/container/create', methods=['POST']) -@login_required -def container_create(): -    raise NotImplementedError -    params = dict() -    for k in request.form: -        if k.startswith('container_'): -            params[k[10:]] = request.form[k] -    container = None -    #edit = api.create_container(container, params=params) -    #return redirect("/container/{}".format(edit.ident)) -  @app.route('/container/lookup', methods=['GET'])  def container_lookup():      extid = None @@ -93,7 +59,7 @@ def container_view(ident):              stats = get_elastic_container_stats(entity.issnl)          except Exception as e:              stats = None -            print(e) +            app.log.error(e)      else:          stats = None @@ -119,14 +85,6 @@ def creator_history(ident):          entity=entity,          history=history) -@app.route('/creator/<ident>/edit', methods=['GET']) -def creator_edit_view(ident): -    try: -        entity = api.get_creator(ident) -    except ApiException as ae: -        abort(ae.status) -    return render_template('entity_edit.html') -  @app.route('/creator/lookup', methods=['GET'])  def creator_lookup():      for key in ('orcid', 'wikidata_qid'): @@ -167,14 +125,6 @@ def file_history(ident):          entity=entity,          history=history) -@app.route('/file/<ident>/edit', methods=['GET']) -def file_edit_view(ident): -    try: -        entity = api.get_file(ident) -    except ApiException as ae: -        abort(ae.status) -    return render_template('entity_edit.html') -  @app.route('/file/lookup', methods=['GET'])  def file_lookup():      for key in ('md5', 'sha1', 'sha256'): @@ -221,14 +171,6 @@ def fileset_history(ident):          entity=entity,          history=history) -@app.route('/fileset/<ident>/edit', methods=['GET']) -def fileset_edit_view(ident): -    try: -        entity = api.get_fileset(ident) -    except ApiException as ae: -        abort(ae.status) -    return render_template('entity_edit.html') -  @app.route('/fileset/lookup', methods=['GET'])  def fileset_lookup():      raise NotImplementedError @@ -266,14 +208,6 @@ def webcapture_history(ident):          entity=entity,          history=history) -@app.route('/webcapture/<ident>/edit', methods=['GET']) -def webcapture_edit_view(ident): -    try: -        entity = api.get_webcapture(ident) -    except ApiException as ae: -        abort(ae.status) -    return render_template('entity_edit.html') -  @app.route('/webcapture/lookup', methods=['GET'])  def webcapture_lookup():      raise NotImplementedError @@ -311,23 +245,6 @@ def release_lookup():          abort(ae.status)      return redirect('/release/{}'.format(resp.ident)) -@app.route('/release/create', methods=['GET']) -@login_required -def release_create_view(): -    return render_template('release_create.html') - -@app.route('/release/create', methods=['POST']) -@login_required -def release_create(): -    raise NotImplementedError -    params = dict() -    for k in request.form: -        if k.startswith('release_'): -            params[k[10:]] = request.form[k] -    release = None -    #edit = api.create_release(release, params=params) -    #return redirect("/release/{}".format(edit.ident)) -  @app.route('/release/<ident>/history', methods=['GET'])  def release_history(ident):      try: @@ -341,14 +258,6 @@ def release_history(ident):          entity=entity,          history=history) -@app.route('/release/<ident>/edit', methods=['GET']) -def release_edit_view(ident): -    try: -        entity = api.get_release(ident) -    except ApiException as ae: -        abort(ae.status) -    return render_template('entity_edit.html') -  @app.route('/release/<ident>', methods=['GET'])  def release_view(ident):      try: @@ -373,10 +282,6 @@ def release_view(ident):      return render_template('release_view.html', release=entity,          authors=authors, container=container) -@app.route('/work/create', methods=['GET']) -def work_create_view(): -    return abort(404) -  @app.route('/work/<ident>/history', methods=['GET'])  def work_history(ident):      try: @@ -390,14 +295,6 @@ def work_history(ident):          entity=entity,          history=history) -@app.route('/work/<ident>/edit', methods=['GET']) -def work_edit_view(ident): -    try: -        entity = api.get_work(ident) -    except ApiException as ae: -        abort(ae.status) -    return render_template('entity_edit.html') -  @app.route('/work/<ident>', methods=['GET'])  def work_view(ident):      try: @@ -414,11 +311,104 @@ def work_view(ident):  @app.route('/editgroup/<ident>', methods=['GET'])  def editgroup_view(ident):      try: -        entity = api.get_editgroup(str(ident)) -        entity.editor = api.get_editor(entity.editor_id) +        eg = api.get_editgroup(str(ident)) +        eg.editor = api.get_editor(eg.editor_id) +        eg.annotations = api.get_editgroup_annotations(eg.editgroup_id, expand="editors") +    except ApiException as ae: +        abort(ae.status) +    # TODO: idomatic check for login? +    auth_to = dict( +        submit=False, +        accept=False, +        annotate=False, +    ) +    if session.get('editor'): +        user = load_user(session['editor']['editor_id']) +        auth_to['annotate'] = True +        if user.is_admin or user.editor_id == eg.editor_id: +            auth_to['submit'] = True +        if user.is_admin: +            auth_to['accept'] = True +    return render_template('editgroup_view.html', editgroup=eg, +        auth_to=auth_to) + +@app.route('/editgroup/<ident>/annotation', methods=['POST']) +@login_required +def editgroup_create_annotation(ident): +    app.csrf.protect() +    comment_markdown = request.form.get('comment_markdown') +    if not comment_markdown: +        app.log.info("empty comment field") +        abort(400) +    # on behalf of user... +    user_api = auth_api(session['api_token']) +    try: +        eg = user_api.get_editgroup(str(ident)) +        if eg.changelog_index: +            flash("Editgroup already accepted") +            abort(400) +        ega = EditgroupAnnotation( +            comment_markdown=comment_markdown, +            extra=None, +        ) +        user_api.create_editgroup_annotation(eg.editgroup_id, ega) +    except ApiException as ae: +        app.log.info(ae) +        abort(ae.status) +    return redirect('/editgroup/{}'.format(ident)) + +@app.route('/editgroup/<ident>/accept', methods=['POST']) +@login_required +def editgroup_accept(ident): +    app.csrf.protect() +    # on behalf of user... +    user_api = auth_api(session['api_token']) +    try: +        eg = user_api.get_editgroup(str(ident)) +        if eg.changelog_index: +            flash("Editgroup already accepted") +            abort(400) +        user_api.accept_editgroup(str(ident)) +    except ApiException as ae: +        app.log.info(ae) +        abort(ae.status) +    return redirect('/editgroup/{}'.format(ident)) + +@app.route('/editgroup/<ident>/unsubmit', methods=['POST']) +@login_required +def editgroup_unsubmit(ident): +    app.csrf.protect() +    # on behalf of user... +    user_api = auth_api(session['api_token']) +    try: +        eg = user_api.get_editgroup(str(ident)) +        if eg.changelog_index: +            flash("Editgroup already accepted") +            abort(400) +        user_api.update_editgroup(eg.editgroup_id, eg, submit=False) +    except ApiException as ae: +        app.log.info(ae) +        abort(ae.status) +    return redirect('/editgroup/{}'.format(ident)) + +@app.route('/editgroup/<ident>/submit', methods=['POST']) +@login_required +def editgroup_submit(ident): +    app.csrf.protect() +    # on behalf of user... +    print("submitting...") +    user_api = auth_api(session['api_token']) +    try: +        eg = user_api.get_editgroup(str(ident)) +        if eg.changelog_index: +            flash("Editgroup already accepted") +            abort(400) +        user_api.update_editgroup(eg.editgroup_id, eg, submit=True)      except ApiException as ae: +        print(ae) +        app.log.info(ae)          abort(ae.status) -    return render_template('editgroup_view.html', editgroup=entity) +    return redirect('/editgroup/{}'.format(ident))  @app.route('/editor/<ident>', methods=['GET'])  def editor_view(ident): @@ -433,15 +423,29 @@ def editor_editgroups(ident):      try:          editor = api.get_editor(ident)          editgroups = api.get_editor_editgroups(ident, limit=50) +        # cheaper than API-side expand? +        for eg in editgroups: +            eg.editor = editor      except ApiException as ae:          abort(ae.status)      return render_template('editor_editgroups.html', editor=editor,          editgroups=editgroups) +@app.route('/editor/<ident>/annotations', methods=['GET']) +def editor_annotations(ident): +    try: +        editor = api.get_editor(ident) +        annotations = api.get_editor_annotations(ident, limit=50) +    except ApiException as ae: +        abort(ae.status) +    return render_template('editor_annotations.html', editor=editor, +        annotations=annotations) +  @app.route('/changelog', methods=['GET'])  def changelog_view():      try: -        entries = api.get_changelog(limit=request.args.get('limit')) +        #limit = int(request.args.get('limit', 10)) +        entries = api.get_changelog() # TODO: expand="editors"      except ApiException as ae:          abort(ae.status)      return render_template('changelog.html', entries=entries) @@ -451,10 +455,21 @@ def changelog_entry_view(index):      try:          entry = api.get_changelog_entry(int(index))          entry.editgroup.editor = api.get_editor(entry.editgroup.editor_id) +        entry.editgroup.annotations = \ +            api.get_editgroup_annotations(entry.editgroup_id, expand="editors")      except ApiException as ae:          abort(ae.status)      return render_template('changelog_view.html', entry=entry, editgroup=entry.editgroup) +@app.route('/reviewable', methods=['GET']) +def reviewable_view(): +    try: +        #limit = int(request.args.get('limit', 10)) +        entries = api.get_editgroups_reviewable(expand="editors") +    except ApiException as ae: +        abort(ae.status) +    return render_template('editgroup_reviewable.html', entries=entries) +  ### Search ##################################################################  @app.route('/release/search', methods=['GET', 'POST']) @@ -501,7 +516,7 @@ def stats_page():          stats = get_elastic_entity_stats()          stats.update(get_changelog_stats())      except Exception as ae: -        print(ae) +        app.log.error(ae)          abort(503)      return render_template('stats.html', stats=stats) @@ -514,7 +529,7 @@ def stats_json():          stats = get_elastic_entity_stats()          stats.update(get_changelog_stats())      except Exception as ae: -        print(ae) +        app.log.error(ae)          abort(503)      return jsonify(stats) @@ -524,7 +539,7 @@ def container_issnl_stats(issnl):      try:          stats = get_elastic_container_stats(issnl)      except Exception as ae: -        print(ae) +        app.log.error(ae)          abort(503)      return jsonify(stats) @@ -558,6 +573,11 @@ def release_citeproc(ident):      else:          return Response(cite, mimetype="text/plain") +@app.route('/health.json', methods=['GET', 'OPTIONS']) +@crossdomain(origin='*',headers=['access-control-allow-origin','Content-Type']) +def health_json(): +    return jsonify({'ok': True}) +  ### Auth #################################################################### @@ -588,6 +608,7 @@ def token_login():  @app.route('/auth/change_username', methods=['POST'])  @login_required  def change_username(): +    app.csrf.protect()      # show the user a list of login options      if not 'username' in request.form:          abort(400) @@ -627,6 +648,10 @@ def page_not_found(e):  def page_not_authorized(e):      return render_template('403.html'), 403 +@app.errorhandler(405) +def page_method_not_allowed(e): +    return render_template('405.html'), 405 +  @app.errorhandler(400)  def page_bad_request(e):      return render_template('400.html'), 400 @@ -645,6 +670,10 @@ def page_server_error(e):  def page_server_down(e):      return render_template('503.html'), 503 +@app.errorhandler(CSRFError) +def page_csrf_error(e): +    return render_template('csrf_error.html', reason=e.description), 400 +  @app.route('/', methods=['GET'])  def page_home():      return render_template('home.html') @@ -672,8 +701,3 @@ def fatcat_photo():      return send_from_directory(os.path.join(app.root_path, 'static'),                                 'fatcat.jpg',                                 mimetype='image/jpeg') - -@app.route('/health', methods=['GET', 'OPTIONS']) -@crossdomain(origin='*',headers=['access-control-allow-origin','Content-Type']) -def health(): -    return jsonify({'ok': True}) diff --git a/python/fatcat_web/templates/405.html b/python/fatcat_web/templates/405.html new file mode 100644 index 00000000..97d21d73 --- /dev/null +++ b/python/fatcat_web/templates/405.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block body %} + +<center> +<div style="font-size: 8em;">405</div> +<div style="font-size: 3em;">Method Not Allowed</div> + +<p>Either we have a bug, or you tried something weird (like making up a URL). + +</center> + +{% endblock %} diff --git a/python/fatcat_web/templates/auth_account.html b/python/fatcat_web/templates/auth_account.html index 57155722..4b1562d7 100644 --- a/python/fatcat_web/templates/auth_account.html +++ b/python/fatcat_web/templates/auth_account.html @@ -1,27 +1,33 @@  {% extends "base.html" %}  {% block body %} -<h1>Your Account</h1> +<h1 class="ui header"> +  <i class="settings icon"></i> +  Account Settings +</h1>  <p><b>Username:</b> <code>{{ current_user.username }}</code>  <p><b>Editor Id:</b> <code><a href="/editor/{{ current_user.editor_id }}">{{ current_user.editor_id }}</a></code> -<div> -<p>Change username: +<br> +<div class="ui segment"> +<h3 class="ui header">Change Username</h3>  <form class="" role="change_username" action="/auth/change_username" method="post"> +  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>    <div class="ui form"> -      <div class="ui action input medium fluid"> +    <div class="ui action input medium">        <input type="text" name="username" value="{{ current_user.username }}" aria-label="account username"> -      <button class="ui button">Update</button> +      <button class="ui red button">Update</button>      </div>    </div>  </form>  </div> -<p>In the future, you might be able to... +<br> +<p>In the future, you will be able to...  <ul> -  <li>Create a bot user -  <li>Generate an API token +  <li>Create and manage bot accounts +  <li>Generate API tokens  </ul>  {% endblock %} diff --git a/python/fatcat_web/templates/base.html b/python/fatcat_web/templates/base.html index d3353ca4..dda905e0 100644 --- a/python/fatcat_web/templates/base.html +++ b/python/fatcat_web/templates/base.html @@ -59,10 +59,10 @@        <div class="ui simple dropdown item">        {{ current_user.username }} <i class="dropdown icon"></i>          <div class="menu"> -          <a class="item" href="#"><i class="edit icon"></i>Edits in Progress</a> -          <a class="item" href="/editor/{{ current_user.editor_id }}/changelog"><i class="history icon"></i>History</a> +          <a class="item" href="/editor/{{ current_user.editor_id }}/editgroups"><i class="history icon"></i>Edit History</a> +          <a class="item" href="/editor/{{ current_user.editor_id }}/annotations"><i class="edit icon"></i>Comment History</a>            <div class="divider"></div> -          <a class="item" href="/auth/account"><i class="user icon"></i>Account</a> +          <a class="item" href="/auth/account"><i class="settings icon"></i>Account</a>            <a class="item" href="/auth/logout"><i class="sign out icon"></i>Logout</a>          </div>        </div> @@ -84,7 +84,7 @@      <div class="header">Now Hear This...</div>      <ul class="list">      {% for message in messages %} -      <li>{{ message }} +      <li>{{ message|safe }}      {% endfor %}      </ul>      </div> diff --git a/python/fatcat_web/templates/changelog.html b/python/fatcat_web/templates/changelog.html index f33fe7c8..7d5505bc 100644 --- a/python/fatcat_web/templates/changelog.html +++ b/python/fatcat_web/templates/changelog.html @@ -4,20 +4,31 @@  <h1 class="ui header">Recent Changes  <div class="sub header"><code>changelog</code></div></h1> -Limited to the most recent ~50 entries. +Limited to the most recent entries.  <table class="ui table">    <thead><tr><th>Changelog<br>Index -             <th>Timestamp (UTC)               <th>Editgroup -             <th>Editor               <th>Description    <tbody>    {% for entry in entries %} -  <tr><td><a href="/changelog/{{ entry.index }}">{{ entry.index }}</a> -      <td>{{ entry.timestamp }} -      <td><a href="/editgroup/{{ entry.editgroup_id }}">{{ entry.editgroup_id }}</a> -      <td><a href="/editor/{{ entry.editgroup.editor_id }}">{{ entry.editgroup.editor_id }}</a> +  <tr><td><a href="/changelog/{{ entry.index }}">#{{ entry.index }}</a> +          <br>{{ entry.timestamp.strftime("%Y-%m-%d %H:%M:%S") }} +      <td> +        {# +        {% if entry.editgroup.editor.is_bot %} +          <i class="icon bug"></i> +        {% else %} +          <i class="icon user"></i> +        {% endif %} +        #} +        Editor: <code><a href="/editor/{{ entry.editgroup.editor_id }}"> +          {{ entry.editgroup.editor_id[:8] }}...</a> +        </a></code> +        <br> +        <small><code><a href="/editgroup/{{ entry.editgroup.editgroup_id }}"> +          {{ entry.editgroup.editgroup_id }} +        </a></code></small>        <td>{% if entry.editgroup.description != None %}{{ entry.editgroup.description }}{% endif %}    {% endfor %}  </table> diff --git a/python/fatcat_web/templates/changelog_view.html b/python/fatcat_web/templates/changelog_view.html index 1a758559..8c4684d5 100644 --- a/python/fatcat_web/templates/changelog_view.html +++ b/python/fatcat_web/templates/changelog_view.html @@ -7,7 +7,7 @@  </div>  </h1> -<br><b>Timestamp:</b> {{ entry.timestamp }} +<br><b>Timestamp:</b> {{ entry.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}  <br><b><a href="/editgroup/{{editgroup.editgroup_id}}">Editgroup</a></b>  <br>  {% endblock %} diff --git a/python/fatcat_web/templates/container_create.html b/python/fatcat_web/templates/container_create.html index 15288142..5dde37bf 100644 --- a/python/fatcat_web/templates/container_create.html +++ b/python/fatcat_web/templates/container_create.html @@ -1,7 +1,8 @@ -{% extends "base.html" %} -{% block body %} +{% extends "container_edit.html" %} + +{% block edit_form_prefix %}  <div class="ui segment"> -<h1 class="ui header">Adding a New Container</h1> +<h1 class="ui header">Create New Container Entity</h1>  <p>A "container" is a anything that groups publications together. For example,  a journal (eg, "New England Journal of Medicine"), conference proceedings, a @@ -9,160 +10,18 @@ book series, or a blog.  <p>Not all publications are in a container. -<form class="ui form" id="add_container_form" method="post" action="/container/create"> - -  <h3 class="ui dividing header">The Basics</h3> - -  <div class="ui huge field required"> -    <label>Name or Title</label> -    <input name="container_name" type="text" placeholder="Title of Container (in English)"> -  </div> - -  <div class="ui field required"> -    <label>Type of Container</label> -    <select class="ui dropdown" id="container_type"> -      <option value="">Primary Type</option> -      <option value="journal">Journal</option> -      <option value="book-series">Book Series</option> -      <option value="conference">Conference Proceedings</option> -      <option value="blog">Blog</option> -      <option value="other">Other</option> -    </select> -  </div> - -  <!-- Publisher --> -  <div class="ui huge field required"> -    <label>Name of Publisher</label> -    <input name="container_publisher" type="text" placeholder="Name of Publisher"> -  </div> - -  <!-- Identifier --> -  <div class="ui huge field required"> -    <label>ISSN Number</label> -    <input name="container_issn" type="text" placeholder="eg, 1234-567X"> -  </div> - -  <!-- Primary/Original Language --> -  <div class="field"> -    <label>Primary Language</label> -    <select class="ui search select dropdown" id="language-select"> -      <option value="">Select if Appropriate</option> -      <option value="en">English</option> -      <option value="es">Spanish</option> -      <option value="">Russian</option> -      <option value="">Thai</option> -      <option value="">Indonesian</option> -      <option value="">Chinese</option> -    </select> -  </div> - -  <!-- Subject / Categorization / Tags --> -  <div class="field"> -    <label>Subject</label> -    <select multiple="" class="ui dropdown" id="subjects"> -      <option value="">Select Subject/Tags</option> -      <option value="AF">Natural Sciences</option> -      <option value="AX">Humanities</option> -      <option value="AL">Arts</option> -      <option value="AL">Engineering</option> -      <option value="AL">Other</option> -    </select> -  </div> - -  <!-- Date --> -  <!-- Container / Part-Of --> -  <!-- Region --> -  <!-- Anything Else? --> -  <h3 class="ui dividing header">Anything Else?</h3> - -<div class="ui submit button">Create container</div> - -<p><i>Entity will be created as part of the current edit group, which needs to be -submited and approved before the entity will formally be included in the -catalog.</i> +<form class="ui form" id="create_container_form" method="POST" action="/container/create"> +{% endblock %} +{% block edit_form_suffix %} +  <br><br> +  <input class="ui primary submit button" type="submit" value="Create Container!"> +  <p> +  <i>New entity will be part of the current editgroup, which needs to be +  submited and approved before the entity will formally be included in the +  catalog.</i>  </form> -  </div>  {% endblock %} -{% block postscript %} -<script> -<!-- Form validation code --> -$(document).ready(function() { - -  $('#add_container_form') -    .form({ -        fields: { -        name: { -            identifier: 'name', -            rules: [ -            { -                type   : 'empty', -                prompt : 'Please enter your name' -            } -            ] -        }, -        skills: { -            identifier: 'skills', -            rules: [ -            { -                type   : 'minCount[2]', -                prompt : 'Please select at least two skills' -            } -            ] -        }, -        gender: { -            identifier: 'gender', -            rules: [ -            { -                type   : 'empty', -                prompt : 'Please select a gender' -            } -            ] -        }, -        username: { -            identifier: 'username', -            rules: [ -            { -                type   : 'empty', -                prompt : 'Please enter a username' -            } -            ] -        }, -        password: { -            identifier: 'password', -            rules: [ -            { -                type   : 'empty', -                prompt : 'Please enter a password' -            }, -            { -                type   : 'minLength[6]', -                prompt : 'Your password must be at least {ruleValue} characters' -            } -            ] -        }, -        terms: { -            identifier: 'terms', -            rules: [ -            { -                type   : 'checked', -                prompt : 'You must agree to the terms and conditions' -            } -            ] -        } -        } -    }) -    ; - -  $('#container_type').dropdown(); -  $('#subjects').dropdown(); -  $('#language-select').dropdown(); - -  console.log("Page loaded"); - -}); -</script> -{% endblock %} diff --git a/python/fatcat_web/templates/container_edit.html b/python/fatcat_web/templates/container_edit.html new file mode 100644 index 00000000..2a3f6f5f --- /dev/null +++ b/python/fatcat_web/templates/container_edit.html @@ -0,0 +1,50 @@ +{% import "edit_macros.html" as edit_macros %} +{% extends "base.html" %} + +{% block body %} +{% block edit_form_prefix %} +<div class="ui segment"> +<h1 class="ui header">Edit Container Entity</h1> + +<form class="ui form" id="edit_container_form" method="POST" action="/container/{{ entity.ident }}/edit"> +{% endblock %} +  {{ form.hidden_tag() }} + +  <br> +  {{ edit_macros.editgroup_dropdown(form) }} + +  <h3 class="ui dividing header">The Basics</h3> +  <br> +  {{ edit_macros.form_field_inline(form.container_type, "required") }} +  {{ edit_macros.form_field_inline(form.name, "required") }} +  {{ edit_macros.form_field_inline(form.publisher) }} +  {{ edit_macros.form_field_inline(form.issnl) }} +  {{ edit_macros.form_field_inline(form.wikidata_qid) }} + +  <br> +  <h3 class="ui dividing header">Submit</h3> +  {{ edit_macros.form_field_basic(form.edit_description) }} +  This description will be attached to the individual edit, not to the +  editgroup as a whole. +{% block edit_form_suffix %} +  <br><br> +  <input class="ui primary submit button" type="submit" value="Update Container!"> +  <p> +  <i>Edit will be part of the current editgroup, which needs to be submited and +  approved before the change is included in the catalog.</i> +</form> +</div> +{% endblock %} +{% endblock %} + +{% block postscript %} +<script> +<!-- Form code --> +$(document).ready(function() { + +  $('.ui.accordion').accordion(); + +}); +</script> +{% endblock %} + diff --git a/python/fatcat_web/templates/container_view.html b/python/fatcat_web/templates/container_view.html index 1e9a524b..3d0627ca 100644 --- a/python/fatcat_web/templates/container_view.html +++ b/python/fatcat_web/templates/container_view.html @@ -68,9 +68,10 @@    {% if container.extra != None and container.extra.ISSNe != None and (container.extra.ISSNe|length > 0) %}      <br><i class="icon plug"></i>Electronic:  <code>{{ container.extra.ISSNe }}</code>    {% endif %} +  <br>  {% endif %}  {% if container.wikidata_qid != None %} -  <br><b>Wikidata:</b>  <a href="https://wikidata.org/wiki/{{ container.wikidata_qid }}"><code>{{ container.wikidata_qid }}</code></a> +  <b>Wikidata</b>  <a href="https://wikidata.org/wiki/{{ container.wikidata_qid }}"><code>{{ container.wikidata_qid }}</code></a>  {% endif %}    </div><div class="ui segment attached">  {% endif %} diff --git a/python/fatcat_web/templates/csrf_error.html b/python/fatcat_web/templates/csrf_error.html new file mode 100644 index 00000000..357f9047 --- /dev/null +++ b/python/fatcat_web/templates/csrf_error.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block body %} + +<center> +<div style="font-size: 8em;">400</div> +<div style="font-size: 3em;">Cross-Site Scripting Error</div> +{{ reason }} +</center> + +{% endblock %} diff --git a/python/fatcat_web/templates/edit_macros.html b/python/fatcat_web/templates/edit_macros.html new file mode 100644 index 00000000..9bd14596 --- /dev/null +++ b/python/fatcat_web/templates/edit_macros.html @@ -0,0 +1,56 @@ + +{% macro form_field_errors(field) -%} +  {% if field.errors %} +    <div class="ui pointing red label"> +    {% for err in field.errors %} +        {{ err }} +    {% endfor %} +    </div> +  {% endif %} +{%- endmacro %} + +{% macro form_field_basic(field, div_classes="") -%} +<div class="field {{ div_classes }} {% if field.errors %}error{% endif %}"> +  {{ field.label }} +  {{ field() }} +  {{ form_field_errors(field) }} +</div> +{%- endmacro %} + +{% macro form_field_inline(field, div_classes="") -%} +<div class="ui grid"> +  <div class="three wide column middle aligned right aligned" {# style="padding-right: 0.5rem;" #}> +    <div class="field inline {{ div_classes }} {% if field.errors %}error{% endif %}"> +      {{ field.label }} +    </div> +  </div> +  <div class="twelve wide column" {# style="padding-left: 0.5rem;" #}> +    <div class="field {{ div_classes }} {% if field.errors %}error{% endif %}"> +      {{ field() }} +      {{ form_field_errors(field) }} +    </div> +  </div> +  <div class="one wide column"> +  </div> +</div> +{%- endmacro %} + +{% macro editgroup_dropdown(form) -%} +  <div class="ui accordion"> +    <div class="{% if not editgroup_id %}active{% endif %} title"> +      <h3><i class="dropdown icon"></i>Editgroup Meta</h3> +    </div> +    <div class="{% if not editgroup_id %}active{% endif %} content"> +      {% if editgroup_id %} +        <p>You have an editgroup in progress, and this edit will be included by +        default. You can override this below. +      {% else %} +        <p>No existing editgroup is in progress (or at least, not is selected). +        An existing ID can be pasted in, or if you leave that blank but give a +        description, a new editgroup will be created for this edit. +      {% endif %} +      {{ form_field_inline(form.editgroup_id) }} +      {{ form_field_inline(form.editgroup_description) }} +    </div> +  </div> +{%- endmacro %} diff --git a/python/fatcat_web/templates/editgroup_reviewable.html b/python/fatcat_web/templates/editgroup_reviewable.html new file mode 100644 index 00000000..b1ece6af --- /dev/null +++ b/python/fatcat_web/templates/editgroup_reviewable.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block body %} + +<h1 class="ui header">Reviewable Editgroups +</h1> + +Limited to the most recent entries. + +<table class="ui table"> +  <thead><tr><th>Editgroup +             <th>Description +  <tbody> +  {% for editgroup in entries %} +  <tr><td> +        {% if editgroup.editor.is_bot %} +          <i class="icon bug"></i> +        {% else %} +          <i class="icon user"></i> +        {% endif %} +        <a href="/editor/{{ editgroup.editor_id }}">{{ editgroup.editor.username }}</a> +        <br> +        Submitted: {{ editgroup.submitted.strftime("%Y-%m-%d %H:%M:%S") }} +        <br> +        <small><code><a href="/editgroup/{{ editgroup.editgroup_id }}"> +          {{ editgroup.editgroup_id }} +        </a></code></small> +      <td>{% if editgroup.description != None %}{{ editgroup.description }}{% endif %} +  {% endfor %} +</table> + +{% endblock %} diff --git a/python/fatcat_web/templates/editgroup_view.html b/python/fatcat_web/templates/editgroup_view.html index 2341f06a..f7f3ad45 100644 --- a/python/fatcat_web/templates/editgroup_view.html +++ b/python/fatcat_web/templates/editgroup_view.html @@ -1,4 +1,6 @@  {% extends "base.html" %} +{% import "entity_macros.html" as entity_macros %} +  {% block body %}  {% macro edit_list(edits, entity_type, entity_name) -%} @@ -35,13 +37,60 @@  {# extended by changelog_entry #}  {% block editgroupheader %} -<h1 class="ui header">Edit Group +{% if not editgroup.changelog_index %} +  <div class="ui right floated center aligned segment"> +    {% if auth_to.accept %} +      <form id="submit_editgroup_form" method="POST" action="/editgroup/{{ editgroup.editgroup_id }}/accept"> +        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> +        <button class="ui orange button">Accept Edits</button> +      </form><br> +    {% endif %} +    {% if auth_to.submit %} +      {% if editgroup.submitted %} +        <form id="submit_editgroup_form" method="POST" action="/editgroup/{{ editgroup.editgroup_id }}/unsubmit"> +          <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> +          <button class="ui button">Un-Submit</button> +        </form><br> +        <form id="submit_editgroup_form" method="POST" action="/editgroup/{{ editgroup.editgroup_id }}/submit"> +          <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> +          <button class="ui button">Re-Submit</button> +        </form> +      {% else %} +        <form id="submit_editgroup_form" method="POST" action="/editgroup/{{ editgroup.editgroup_id }}/submit"> +          <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> +          <button class="ui primary button">Submit</button> +        </form> +      {% endif %} +    {% endif %} +  </div> +{% endif %} + +<h1 class="ui header">Editgroup  <div class="sub header"><code>editgroup {{ editgroup.editgroup_id }}</code></div></h1>  {% endblock %} -<b>Editor:</b> <a href="/editor/{{editgroup.editor_id}}">{{ editgroup.editor.username }}</a> -<br><b>Description:</b> {{ editgroup.description }} -<br><br> +<p><b>What is an editgroup?</b> +An editgroup is a set of entity edits, bundled together into a coherent, +reviewable bundle. +<br> + +<br><b>Status:</b> +{% if editgroup.changelog_index %} +  Merged (<a href="/changelog/{{ editgroup.changelog_index }}">Changelog #{{ editgroup.changelog_index }}</a>) +{% elif editgroup.submitted %} +  Submitted ({{ editgroup.submitted.strftime("%Y-%m-%d %H:%M:%S") }}) +{% else %} +  Not Submitted +{% endif %} + +<br><b>Editor:</b> <a href="/editor/{{editgroup.editor_id}}">{{ editgroup.editor.username }}</a> +<br><b>Description:</b> +{% if editgroup.description %} +  {{ editgroup.description }} +{% else %} +  <i>none</i> +{% endif %} +<br><br clear="all">  <div class="ui styled fluid accordion">    {{ edit_list(editgroup.edits.works, "work", "Work") }} @@ -53,11 +102,51 @@    {{ edit_list(editgroup.edits.webcaptures, "webcapture", "Web Capture") }}  </div> -  <br> -<p><b>What is an editgroup?</b> -An editgroup is a set of entity edits, bundled together into a coherent, -reviewable bundle. +<h2 class="ui header">Comments and Annotations</h2> +{% for annotation in editgroup.annotations|reverse %} +  <div class="ui segments"> +    <div class="ui top attached secondary segment"> +      {% if annotation.editor.is_bot %} +        <i class="icon bug"></i> +      {% else %} +        <i class="icon user"></i> +      {% endif %} +      <b><a href="/editor/{{ annotation.editor_id }}">{{ annotation.editor.username}}</a></b> +      {% if annotation.editor.is_admin %} +        <span class="ui tiny olive label">Admin</span> +      {% endif %} +      at {{ annotation.created.strftime("%Y-%m-%d %H:%M:%S") }} +    </div> +    {% if annotation.extra %} +    <div class="ui attached segment"> +      {{ entity_macros.extra_metadata(annotation.extra) }} +    </div> +    {% endif %} +    <div class="ui bottom attached segment"> +      {{ annotation.comment_markdown|markdown(escape=True) }} +    </div> +  </div> +{% else %} +  <i>None!</i> +{% endfor %} + +{% if not editgroup.changelog_index and auth_to.annotate %} +  <div class="ui segment"> +    <h3 class="ui header">Add Comment</h3> +    <form class="ui form" id="submit_editgroup_annotation_form" method="POST" action="/editgroup/{{ editgroup.editgroup_id }}/annotation"> +      <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> +      <div class="field"> +        <textarea rows="2" name="comment_markdown" required type="text" value=""></textarea> +      </div> +      <i>Markdown is allowed</i> +      <button class="ui right floated primary button"> +        <i class="icon edit"></i> Submit +      </button> +      <br> +    </form><br> +  </div> +{% endif %}  {% endblock %} diff --git a/python/fatcat_web/templates/editor_annotations.html b/python/fatcat_web/templates/editor_annotations.html new file mode 100644 index 00000000..c46039f5 --- /dev/null +++ b/python/fatcat_web/templates/editor_annotations.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block body %} + +<h1 class="ui header">Comments and Annotations +<div class="sub header"> +    <code>editor +      <a href="/editor/{{editor.editor_id}}">{{ editor.username }}</a> +    </code> +  </a> +</div> +</h1> + +<br> +{% for annotation in annotations %} +  <div class="ui segments"> +    <div class="ui top attached secondary segment"> +      On <b><small><code><a href="/editgroup/{{ annotation.editgroup_id }}"> +        {{ annotation.editgroup_id }} +      </a></code></small></b> +      at {{ annotation.created.strftime("%Y-%m-%d %H:%M:%S") }} +    </div> +    {% if annotation.extra %} +    <div class="ui attached segment"> +      {{ entity_macros.extra_metadata(annotation.extra) }} +    </div> +    {% endif %} +    <div class="ui bottom attached segment"> +      {{ annotation.comment_markdown|markdown(escape=True) }} +    </div> +  </div> +{% else %} +  <i>None!</i> +{% endfor %} + +{% endblock %} diff --git a/python/fatcat_web/templates/editor_editgroups.html b/python/fatcat_web/templates/editor_editgroups.html index cb9b2f56..3c3dd20d 100644 --- a/python/fatcat_web/templates/editor_editgroups.html +++ b/python/fatcat_web/templates/editor_editgroups.html @@ -1,25 +1,35 @@  {% extends "base.html" %}  {% block body %} -<h1 class="ui header">Editor Changelog: {{ editor.username }} +<h1 class="ui header">Edit History  <div class="sub header"> -  <a href="/editor/{{editor.editor_id}}"> -    <code>editor {{ editor.editor_id }}</code> +    <code>editor +      <a href="/editor/{{editor.editor_id}}">{{ editor.username }}</a> +    </code>    </a>  </div>  </h1> -<p>Changes accepted (aka, merged editgroups):  <table class="ui table"> -  <thead><tr><th>Created (UTC) +  <thead><tr>{# <th>Created (UTC) #} +             <th>Status               <th>Editgroup -             <th>Editor               <th>Description    <tbody>    {% for editgroup in editgroups %} -  <tr><td>{{ editgroup.created }} -      <td><code><a href="/editgroup/{{ editgroup.editgroup_id }}">{{ editgroup.editgroup_id }}</a></code> -      <td><code><a href="/editor/{{ editgroup.editor_id }}">{{ editgroup.editor_id }}</a></code> +  <tr>{# <td>{{ editgroup.created.strftime("%Y-%m-%d %H:%M:%S") }} #} +      <td>{% if editgroup.changelog_index %} +            Merged +            <br><a href="/changelog/{{ editgroup.changelog_index }}">#{{ editgroup.changelog_index }}</a> +          {% elif editgroup.submitted %} +            Submitted +            <br>{{ editgroup.submitted.strftime("%Y-%m-%d %H:%M:%S") }} +          {% else %} +            Work in Progress +          {% endif %} +      <td><small><code><a href="/editgroup/{{ editgroup.editgroup_id }}"> +            {{ editgroup.editgroup_id }} +          </a></code></small>        <td>{% if editgroup.description != None %}{{ editgroup.description }}{% endif %}    {% endfor %}  </table> diff --git a/python/fatcat_web/templates/editor_view.html b/python/fatcat_web/templates/editor_view.html index eef4f040..c62f8d93 100644 --- a/python/fatcat_web/templates/editor_view.html +++ b/python/fatcat_web/templates/editor_view.html @@ -7,6 +7,7 @@  </div>  </h1> -<p><b><a href="/editor/{{ editor.editor_id }}/changelog">View editor's changelog</a></b> +<p><b><a href="/editor/{{ editor.editor_id }}/editgroups">Edit History</a></b> +<p><b><a href="/editor/{{ editor.editor_id }}/annotations">Comments and Annotation History</a></b>  {% endblock %} diff --git a/python/fatcat_web/templates/file_create.html b/python/fatcat_web/templates/file_create.html new file mode 100644 index 00000000..a7c99b96 --- /dev/null +++ b/python/fatcat_web/templates/file_create.html @@ -0,0 +1,20 @@ +{% extends "file_edit.html" %} + +{% block edit_form_prefix %} +<div class="ui segment"> +<h1 class="ui header">Create New File Entity</h1> + +<form class="ui form" id="create_file_form" method="POST" action="/file/create"> +{% endblock %} + +{% block edit_form_suffix %} +  <br><br> +  <input class="ui primary submit button" type="submit" value="Create File!"> +  <p> +  <i>New entity will be part of the current editgroup, which needs to be +  submited and approved before the entity will formally be included in the +  catalog.</i> +</form> +</div> +{% endblock %} + diff --git a/python/fatcat_web/templates/file_edit.html b/python/fatcat_web/templates/file_edit.html new file mode 100644 index 00000000..279acca9 --- /dev/null +++ b/python/fatcat_web/templates/file_edit.html @@ -0,0 +1,233 @@ +{% import "edit_macros.html" as edit_macros %} +{% extends "base.html" %} + +{% block body %} +{% block edit_form_prefix %} +<div class="ui segment"> +<h1 class="ui header">Edit File Entity</h1> + +<form class="ui form" id="edit_file_form" method="POST" action="/file/{{ entity.ident }}/edit"> +{% endblock %} +  {{ form.hidden_tag() }} + +  <br> +  {{ edit_macros.editgroup_dropdown(form) }} + +  <br> +  <h3 class="ui dividing header">File Metadata</h3> + +  {{ edit_macros.form_field_inline(form.size, "required") }} +  {{ edit_macros.form_field_inline(form.mimetype) }} +  {{ edit_macros.form_field_inline(form.md5) }} +  {{ edit_macros.form_field_inline(form.sha1, "required") }} +  {{ edit_macros.form_field_inline(form.sha256) }} + +  <br> +  <h3 class="ui dividing header">Locations (URLs)</h3> +  <i>Public web (no login/paywall) locations of this exact file (should match +  by hashes).</i> +  <br><br> +  <div class="list-group" id="url_list" name="url_list"> +  {% for cform in form.urls %} +    <div class="list-group-item ui grid" style="padding-right: 1em;"> +      {{ cform.hidden_tag() }} +      <div class="one wide column middle aligned center aligned" style="padding-bottom: 0px; padding-right: 0px; padding-left: 0px;"> +        <i class="arrows alternate vertical icon"></i> +      </div> +      <div class="three wide column" style="padding-bottom: 0px; padding-left: 0px;"> +        <div class="field {% if cform.rel.errors %}error{% endif %}"> +          {{ cform.rel() }} +        </div> +      </div> +      <div class="eleven wide column" style="padding-bottom: 0px;"> +        <div class="field {% if cform.url.errors %}error{% endif %}"> +          {{ cform.url() }} +        </div> +      </div> +      <div class="one wide column right aligned" style="padding-bottom: 0px; padding-left: 0rem;"> +        <button type="button" class="ui icon red button delete-url-button"><i class="trash icon"></i></button> +      </div> +    </div> +  {% endfor %} +  </div> +  <br> +  <button type="button" id="add-url-button" class="ui right floated icon green button" style="margin-right: 0.3rem;"> +    <i class="plus icon"></i> +  </button> + +  <br> +  <h3 class="ui dividing header">Releases</h3> +  <i>Usually one, but sometimes multiple Release entities (by FCID) that this +  file is a fulltext copy of.</i> +  <br><br> +  <div class="list-group" id="release_id_list" name="release_id_list"> +  {% for rfield in form.release_ids %} +    <div class="list-group-item ui grid" style="padding-right: 1em;"> +      <div class="one wide column middle aligned center aligned" style="padding-bottom: 0px; padding-right: 0px; padding-left: 0px;"> +      </div> +      <div class="fourteen wide column" style="padding-bottom: 0px;"> +        <div class="field {% if rfield.errors %}error{% endif %}"> +          {{ rfield() }} +        </div> +      </div> +      <div class="one wide column right aligned" style="padding-bottom: 0px; padding-left: 0rem;"> +        <button type="button" class="ui icon red button delete-release_id-button"><i class="trash icon"></i></button> +      </div> +    </div> +  {% endfor %} +  </div> +  <br> +  <button type="button" id="add-release_id-button" class="ui right floated icon green button" style="margin-right: 0.3rem;"> +    <i class="plus icon"></i> +  </button> + +  <br> +  <h3 class="ui dividing header">Submit</h3> +  {{ edit_macros.form_field_basic(form.edit_description) }} +  This description will be attached to the individual edit, not to the +  editgroup as a whole. +{% block edit_form_suffix %} +  <br><br> +  <input class="ui primary submit button" type="submit" value="Update File!"> +  <p> +  <i>Edit will be part of the current editgroup, which needs to be submited and +  approved before the change is included in the catalog.</i> +</form> +</div> +{% endblock %} +{% endblock %} + +{% block postscript %} +<script> +<!-- Form code --> +$(document).ready(function() { + +  $('.ui.accordion').accordion(); + +  var fixup_url_numbering = function(group_item) { +    items = Array.from(group_item.querySelectorAll(".list-group-item")) +    for (var i = 0; i < items.length; i++) { +      var item_el = items[i]; +      input_el = item_el.querySelectorAll("input")[0]; +      select_el = item_el.querySelectorAll("select")[0]; +      //console.log(input_el.id); +      //console.log(select_el.id); +      input_el.id = "urls-" + i + "-url"; +      input_el.name = input_el.id; +      select_el.id = "urls-" + i + "-rel"; +      select_el.name = select_el.id; +      //console.log(input_el.id); +      //console.log(select_el.id); +    }; +    console.log("re-named url rows up to i=" + i); +  }; + +  var url_list = document.getElementById('url_list'); +  fixup_url_numbering(url_list); + +  var url_delete_handler = function(ev) { +    row = ev.target.parentNode.parentNode; +    // I don't understand why this hack is needed; maybe because of the sortable stuff? +    if(!row.classList.contains("list-group-item")) { +        row = row.parentNode; +    } +    // console.log(row); +    console.assert(row.classList.contains("list-group-item")); +    row.parentNode.removeChild(row); +    fixup_url_numbering(url_list); +  }; + +  var attach_url_delete_handler = function(topthing) { +    Array.from(topthing.querySelectorAll(".delete-url-button")).forEach((el) => { +      el.addEventListener("click", url_delete_handler); +    }); +  }; +  attach_url_delete_handler(document); + +  // XXX: really need some way to not duplicate this code from above... +  var url_template = ` +  <div class="list-group-item ui grid" style="padding-right: 1em;"> +      <div class="one wide column middle aligned center aligned" style="padding-bottom: 0px; padding-right: 0px; padding-left: 0px;"> +      </div> +      <div class="three wide column" style="padding-bottom: 0px; padding-left: 0px;"> +        <select id="urls-X-rel" name="urls-X-rel"><option selected value="web">Public Web</option><option value="webarchive">Web Archive</option><option value="repository">Repository</option><option value="social">Academic Social Network</option><option value="publisher">Publisher</option><option value="dweb">Decentralized Web</option></select> +      </div> +      <div class="eleven wide column" style="padding-bottom: 0px;"> +        <input id="urls-X-url" name="urls-X-url" type="text" value=""> +      </div> +      <div class="one wide column right aligned" style="padding-bottom: 0px; padding-left: 0rem;"> +        <button type="button" class="ui icon red button delete-url-button"><i class="trash icon"></i></button> +      </div> +    </div> +  `; + +  var add_url_button = document.getElementById("add-url-button"); +  add_url_button.addEventListener("click", function(){ +    url_list.insertAdjacentHTML('beforeend', url_template); +    attach_url_delete_handler(url_list.lastElementChild); +    fixup_url_numbering(url_list); +  }); + +  var fixup_release_id_numbering = function(group_item) { +    items = Array.from(group_item.querySelectorAll(".list-group-item")) +    for (var i = 0; i < items.length; i++) { +      var item_el = items[i]; +      input_el = item_el.querySelectorAll("input")[0]; +      //console.log(input_el.id); +      input_el.id = "release_ids-" + i; +      input_el.name = input_el.id; +      //console.log(input_el.id); +    }; +    console.log("re-named release_id rows up to i=" + i); +  }; + +  var release_id_list = document.getElementById('release_id_list'); +  fixup_release_id_numbering(release_id_list); + +  var release_id_delete_handler = function(ev) { +    row = ev.target.parentNode.parentNode; +    // I don't understand why this hack is needed; maybe because of the sortable stuff? +    if(!row.classList.contains("list-group-item")) { +        row = row.parentNode; +    } +    // console.log(row); +    console.assert(row.classList.contains("list-group-item")); +    row.parentNode.removeChild(row); +    fixup_release_id_numbering(release_id_list); +  }; + +  var attach_release_id_delete_handler = function(topthing) { +    Array.from(topthing.querySelectorAll(".delete-release_id-button")).forEach((el) => { +      el.addEventListener("click", release_id_delete_handler); +    }); +  }; +  attach_release_id_delete_handler(document); + +  // XXX: really need some way to not duplicate this code from above... +  var release_id_template = ` +  <div class="list-group-item ui grid" style="padding-right: 1em;"> +      <div class="one wide column middle aligned center aligned" style="padding-bottom: 0px; padding-right: 0px; padding-left: 0px;"> +      </div> +      <div class="fourteen wide column" style="padding-bottom: 0px;"> +        <div class="field "> +          <input id="release_ids-X" name="release_ids-X" type="text" value="" required> +        </div> +      </div> +      <div class="one wide column right aligned" style="padding-bottom: 0px; padding-left: 0rem;"> +        <button type="button" class="ui icon red button delete-release_id-button"><i class="trash icon"></i></button> +      </div> +    </div> +  `; + +  var add_release_id_button = document.getElementById("add-release_id-button"); +  add_release_id_button.addEventListener("click", function(){ +    release_id_list.insertAdjacentHTML('beforeend', release_id_template); +    attach_release_id_delete_handler(release_id_list.lastElementChild); +    fixup_release_id_numbering(release_id_list); +  }); + +  console.log("Page loaded"); + +}); +</script> +{% endblock %} diff --git a/python/fatcat_web/templates/home.html b/python/fatcat_web/templates/home.html index d55cc96b..f4d9d8f6 100644 --- a/python/fatcat_web/templates/home.html +++ b/python/fatcat_web/templates/home.html @@ -55,6 +55,7 @@ indexing (aka, linking together of pre-prints and final copies).          <br>published version of a Work  {% if config.FATCAT_DOMAIN == 'fatcat.wiki' %}      <td><a href="/release/search">Search</a> +        <a href="/release/create">Create</a>      <td><a href="/release/tb24ghkawzgaho5bkim3cbmbnu">Paper</a>  {% else %}      <td><a href="/release/search">Search</a> @@ -111,7 +112,7 @@ indexing (aka, linking together of pre-prints and final copies).      <td>      <td><a href="/file/wklqsb5apzfhbbxxc7rgu2yw6m">PDF</a>  {% else %} -    <td> +    <td><a href="/file/create">Create</a>      <td><a href="/file/wklqsb5apzfhbbxxc7rgu2yw6m">PDF</a> (prod)          <br><a href="/file/aaaaaaaaaaaaamztaaaaaaaaai">Dummy</a>          <br><a href="/file/aaaaaaaaaaaaamztaaaaaaaaam">Realistic</a> diff --git a/python/fatcat_web/templates/release_changelog.html b/python/fatcat_web/templates/release_changelog.html deleted file mode 100644 index 706a5642..00000000 --- a/python/fatcat_web/templates/release_changelog.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} -{% block body %} - -<h1>Release Changelog: {{ release.id }}</h1> - -<p>release: <a href="/release/{{ release.id }}">{{ release.id }}</a> - -<p>Changelog: -<ul> -{% for entry in changelog_entries %} -  <li><a href="/editgroup/{{ entry.editgroup }}">Edit Group #{{ entry.editgroup }}</a> (on {{ entry.timestamp }}) -{% else %} -NONE -{% endfor %} -</ul> - -{% endblock %} diff --git a/python/fatcat_web/templates/release_create.html b/python/fatcat_web/templates/release_create.html index ac8a8169..5ec2efe5 100644 --- a/python/fatcat_web/templates/release_create.html +++ b/python/fatcat_web/templates/release_create.html @@ -1,215 +1,20 @@ -{% extends "base.html" %} -{% block body %} -<div class="ui segment"> -<h1 class="ui header">Adding a New Thing</h1> - -<form class="ui form" id="add_work_form"> - -  <h3 class="ui dividing header">The Basics</h3> - -  <div class="ui huge field required"> -    <label>Title</label> -    <input name="work_title" type="text" placeholder="Title of Work (in English)"> -  </div> - -  <div class="ui field required"> -    <label>Type of Work</label> -    <select class="ui dropdown" id="work_type"> -      <option value="">Primary Type</option> -      <option value="journal-article">Journal Article</option> -      <option value="book">Book</option> -      <option value="book-chapter">Book Chapter</option> -      <option value="dataset">Dataset</option> -      <option value="dissertation">Thesis or Dissertation</option> -      <option value="monograph">Monograph</option> -      <option value="proceedings-article">Conference Proceeding</option> -      <option value="report">Report</option> -      <option value="other">Other</option> -    </select> -  </div> - -  <!-- Primary Creators/Authors --> -  <div class="ui field search" id="work_creators"> -    <label>Primary Creator(s)</label> -    <div class="ui icon input"> -      <input class="prompt" type="text" placeholder="Search..."> -      <i class="search icon"></i> -    </div> -    <div class="results"></div> -  </div> - -  <!-- Description (not an abstract) --> -  <div class="ui field"> -    <label>Description</label> -    <div class="field"> -      <label>Not an abstract...</label> -      <textarea rows="2"></textarea> -    </div> -  </div> - -  <!-- Primary/Original Language --> -  <div class="field"> -    <label>Primary Language</label> -    <select class="ui search select dropdown" id="language-select"> -      <option value="">Select if Appropriate</option> -      <option value="en">English</option> -      <option value="es">Spanish</option> -    </select> -  </div> - -  <!-- Subject / Categorization / Tags --> -  <div class="field"> -    <label>Subject</label> -    <select multiple="" class="ui dropdown" id="subjects"> -      <option value="">Select Subject/Tags</option> -      <option value="AF">Afghanistan</option> -      <option value="AX">Ă…land Islands</option> -      <option value="AL">Albania</option> -      <option value="DZ">Algeria</option> -      <option value="AS">American Samoa</option> -      <option value="AD">Andorra</option> -      <option value="AO">Angola</option> -    </select> -  </div> - - -  <h3 class="ui dividing header">Primary Release / Edition</h3> - -  <!-- Contributors (and how) --> -  <div class="ui field search" id="release_creators"> -    <label>Primary Creator(s)</label> -    <div class="ui icon input"> -      <input class="prompt" type="text" placeholder="Search..."> -      <i class="search icon"></i> -    </div> -    <div class="results"></div> -  </div> +{% extends "release_edit.html" %} -  <!-- Date --> -  <!-- Container / Part-Of --> -  <!-- Publisher --> -  <!-- Identifier --> -  <!-- Language --> -  <!-- Type / Media --> -  <!-- Issue / Volume / Pages / Chapter --> - -  <!-- Anything Else? --> -  <h3 class="ui dividing header">Anything Else?</h3> +{% block edit_form_prefix %} +<div class="ui segment"> +<h1 class="ui header">Create New Release Entity</h1> -  <!-- File / Copy / URL --> -  <!-- Citations --> +<form class="ui form" id="create_release_form" method="POST" action="/release/create"> +{% endblock %} -<div class="ui submit button">Create Work</div> +{% block edit_form_suffix %} +  <br><br> +  <input class="ui primary submit button" type="submit" value="Create Release!"> +  <p> +  <i>New entity will be part of the current editgroup, which needs to be +  submited and approved before the entity will formally be included in the +  catalog.</i>  </form> -  </div>  {% endblock %} -{% block postscript %} -<script> -<!-- Form validation code --> -$(document).ready(function() { - -  $('#add_work_form') -    .form({ -        fields: { -        name: { -            identifier: 'name', -            rules: [ -            { -                type   : 'empty', -                prompt : 'Please enter your name' -            } -            ] -        }, -        skills: { -            identifier: 'skills', -            rules: [ -            { -                type   : 'minCount[2]', -                prompt : 'Please select at least two skills' -            } -            ] -        }, -        gender: { -            identifier: 'gender', -            rules: [ -            { -                type   : 'empty', -                prompt : 'Please select a gender' -            } -            ] -        }, -        username: { -            identifier: 'username', -            rules: [ -            { -                type   : 'empty', -                prompt : 'Please enter a username' -            } -            ] -        }, -        password: { -            identifier: 'password', -            rules: [ -            { -                type   : 'empty', -                prompt : 'Please enter a password' -            }, -            { -                type   : 'minLength[6]', -                prompt : 'Your password must be at least {ruleValue} characters' -            } -            ] -        }, -        terms: { -            identifier: 'terms', -            rules: [ -            { -                type   : 'checked', -                prompt : 'You must agree to the terms and conditions' -            } -            ] -        } -        } -    }) -    ; - -  var example_authors = [ -    { title: 'Andorra' }, -    { title: 'United Arab Emirates' }, -    { title: 'Afghanistan' }, -    { title: 'Antigua' }, -    { title: 'Anguilla' }, -    { title: 'Albania' }, -    { title: 'Armenia' }, -    { title: 'Netherlands Antilles' }, -    { title: 'Angola' }, -    { title: 'Argentina' }, -    { title: 'American Samoa' }, -    { title: 'Austria' }, -    { title: 'Australia' }, -    { title: 'Aruba' }, -  ]; - -  $('#work_creators') -    .search({ -        source: example_authors -    }) -  ; - -  $('#release_creators') -    .search({ -        source: example_authors -    }) -  ; - -  $('#work_type').dropdown(); -  $('#subjects').dropdown(); -  $('#language-select').dropdown(); - -  console.log("Page loaded"); - -}); -</script> -{% endblock %} diff --git a/python/fatcat_web/templates/release_edit.html b/python/fatcat_web/templates/release_edit.html new file mode 100644 index 00000000..16c189ab --- /dev/null +++ b/python/fatcat_web/templates/release_edit.html @@ -0,0 +1,218 @@ +{% import "edit_macros.html" as edit_macros %} +{% extends "base.html" %} + +{% block body %} +{% block edit_form_prefix %} +<div class="ui segment"> +<h1 class="ui header">Edit Release Entity</h1> + +<form class="ui form" id="edit_release_form" method="POST" action="/release/{{ entity.ident }}/edit"> +{% endblock %} +  {{ form.hidden_tag() }} + +  <br> +  {{ edit_macros.editgroup_dropdown(form) }} + +  <br> +  <h3 class="ui dividing header">The Basics</h3> +  <div class="ui grid"> +    <div class="three wide column" style="padding-bottom: 0px;"></div> +    <div class="twelve wide column" style="padding-bottom: 0px;"> +      <div class="ui equal width fields"> +        {{ edit_macros.form_field_basic(form.release_type, "required") }} +        {{ edit_macros.form_field_basic(form.release_status) }} +      </div> +    </div> +    <div class="one wide column" style="padding-bottom: 0px;"></div> +  </div> + +  {{ edit_macros.form_field_inline(form.title, "required") }} +  {{ edit_macros.form_field_inline(form.original_title) }} +  {{ edit_macros.form_field_inline(form.work_id) }} +  {{ edit_macros.form_field_inline(form.release_date) }} +  <div class="ui grid"> +    <div class="three wide column" style="padding-bottom: 0px;"></div> +    <div class="twelve wide column" style="padding-bottom: 0px;"> +      <div class="ui equal width fields"> +        {{ edit_macros.form_field_basic(form.language) }} +        {{ edit_macros.form_field_basic(form.license_slug) }} +      </div> +    </div> +    <div class="one wide column" style="padding-bottom: 0px;"></div> +  </div> + +  <br> +  <h3 class="ui dividing header">Contributors</h3> +  <div class="list-group" id="contrib_list" name="contrib_list"> +  {% for cform in form.contribs %} +    <div class="list-group-item ui grid" style="padding-right: 1em;"> +      {{ cform.hidden_tag() }} +      <div class="one wide column middle aligned center aligned sortable-handle" style="padding-bottom: 0px; padding-right: 0px; padding-left: 0px;"> +        <i class="arrows alternate vertical icon"></i> +      </div> +      <div class="three wide column" style="padding-bottom: 0px; padding-left: 0px;"> +        <div class="field {% if cform.role.errors %}error{% endif %}"> +          {{ cform.role() }} +        </div> +      </div> +      <div class="eleven wide column" style="padding-bottom: 0px;"> +        <div class="field {% if cform.raw_name.errors %}error{% endif %}"> +          {{ cform.raw_name() }} +        </div> +      </div> +      <div class="one wide column right aligned" style="padding-bottom: 0px; padding-left: 0rem;"> +        <button type="button" class="ui icon red button delete-contrib-button"><i class="trash icon"></i></button> +      </div> +    </div> +  {% endfor %} +  </div> +  <br> +  <button type="button" id="add-contrib-button" class="ui right floated icon green button" style="margin-right: 0.3rem;"> +    <i class="plus icon"></i> +  </button> +  <br> + +  <br> +  <h3 class="ui dividing header">Identifers</h3> +  <br> +  {{ edit_macros.form_field_inline(form.doi) }} +  {{ edit_macros.form_field_inline(form.wikidata_qid) }} +  {{ edit_macros.form_field_inline(form.isbn13) }} +  <div class="ui grid"> +    <div class="three wide column" style="padding-bottom: 0px;"></div> +    <div class="twelve wide column" style="padding-bottom: 0px;"> +      <div class="ui equal width fields"> +        {{ edit_macros.form_field_basic(form.pmid) }} +        {{ edit_macros.form_field_basic(form.pmcid) }} +      </div> +    </div> +    <div class="one wide column" style="padding-bottom: 0px;"></div> +  </div> + +  <br> +  <h3 class="ui dividing header">Container</h3> +  <br> +  {{ edit_macros.form_field_inline(form.container_id) }} +  {{ edit_macros.form_field_inline(form.publisher) }} +  <br> +  <div class="ui grid"> +    <div class="three wide column" style="padding-bottom: 0px;"></div> +    <div class="twelve wide column" style="padding-bottom: 0px;"> +      <div class="ui equal width fields"> +        {{ edit_macros.form_field_basic(form.pages) }} +        {{ edit_macros.form_field_basic(form.volume) }} +        {{ edit_macros.form_field_basic(form.issue) }} +      </div> +    </div> +    <div class="one wide column" style="padding-bottom: 0px;"></div> +  </div> + +  <br><br> +  <h3 class="ui dividing header">Submit</h3> +  {{ edit_macros.form_field_basic(form.edit_description) }} +  This description will be attached to the individual edit, not to the +  editgroup as a whole. +{% block edit_form_suffix %} +  <br><br> +  <input class="ui primary submit button" type="submit" value="Update Release!"> +  <p> +  <i>Edit will be part of the current editgroup, which needs to be submited and +  approved before the change is included in the catalog.</i> +</form> +</div> +{% endblock %} +{% endblock %} + +{% block postscript %} +<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script> +<script> +<!-- Form code --> +$(document).ready(function() { + +  // these javascript dropdowns hide the original <input>, which breaks browser +  // form focusing (eg, for required fields) :( +  //$('#release_type').dropdown(); +  //$('#release_status').dropdown(); +  $('.ui.accordion').accordion(); + +  var fixup_contrib_numbering = function(group_item) { +    items = Array.from(group_item.querySelectorAll(".list-group-item")) +    for (var i = 0; i < items.length; i++) { +      var item_el = items[i]; +      prev_el = item_el.querySelectorAll("input")[0]; +      input_el = item_el.querySelectorAll("input")[1]; +      select_el = item_el.querySelectorAll("select")[0]; +      //console.log(input_el.id); +      //console.log(select_el.id); +      input_el.id = "contribs-" + i + "-raw_name"; +      input_el.name = input_el.id; +      prev_el.id = "contribs-" + i + "-prev_index"; +      prev_el.name = prev_el.id; +      select_el.id = "contribs-" + i + "-role"; +      select_el.name = select_el.id; +      //console.log(input_el.id); +      //console.log(select_el.id); +    }; +    console.log("re-named contrib rows up to i=" + i); +  }; + +  var contrib_list = document.getElementById('contrib_list'); +  var contrib_sortable = Sortable.create(contrib_list, { +    handle: '.sortable-handle', +    animation: 150, +    onSort: function(evt) { +      fixup_contrib_numbering(contrib_list); +    }, +  }); +  fixup_contrib_numbering(contrib_list); + +  var contrib_delete_handler = function(ev) { +    row = ev.target.parentNode.parentNode; +    // I don't understand why this hack is needed; maybe because of the sortable stuff? +    if(!row.classList.contains("list-group-item")) { +        row = row.parentNode; +    } +    // console.log(row); +    console.assert(row.classList.contains("list-group-item")); +    row.parentNode.removeChild(row); +    fixup_contrib_numbering(contrib_list); +  }; + +  var attach_contrib_delete_handler = function(topthing) { +    Array.from(topthing.querySelectorAll(".delete-contrib-button")).forEach((el) => { +      el.addEventListener("click", contrib_delete_handler); +    }); +  }; +  attach_contrib_delete_handler(document); + +  // XXX: really need some way to not duplicate this code from above... +  var contrib_template = ` +  <div class="list-group-item ui grid" style="padding-right: 1em;"> +      <input id="contribs-X-prev_index" name="contribs-X-prev_index" type="hidden" value=""> +      <div class="one wide column middle aligned center aligned sortable-handle" style="padding-bottom: 0px; padding-right: 0px; padding-left: 0px;"> +        <i class="arrows alternate vertical icon"></i> +      </div> +      <div class="three wide column" style="padding-bottom: 0px; padding-left: 0px;"> +        <select id="contribs-X-role" name="contribs-X-role"><option value="author">Author</option><option value="editor">Editor</option><option value="translator">Translator</option></select> +      </div> +      <div class="eleven wide column" style="padding-bottom: 0px;"> +        <input id="contribs-X-raw_name" name="contribs-X-raw_name" type="text" value=""> +      </div> +      <div class="one wide column right aligned" style="padding-bottom: 0px; padding-left: 0rem;"> +        <button type="button" class="ui icon red button delete-contrib-button"><i class="trash icon"></i></button> +      </div> +    </div> +  `; + +  var add_contrib_button = document.getElementById("add-contrib-button"); +  add_contrib_button.addEventListener("click", function(){ +    contrib_list.insertAdjacentHTML('beforeend', contrib_template); +    attach_contrib_delete_handler(contrib_list.lastElementChild); +    fixup_contrib_numbering(contrib_list); +  }); + +  console.log("Page loaded"); + +}); +</script> +{% endblock %} diff --git a/python/fatcat_web/templates/release_view.html b/python/fatcat_web/templates/release_view.html index d2078d13..fa157193 100644 --- a/python/fatcat_web/templates/release_view.html +++ b/python/fatcat_web/templates/release_view.html @@ -262,7 +262,7 @@    <tbody>    {% for webcapture in entity.webcaptures %}    <tr><td><b><a href="{{ webcapture.original_url }}">{{ webcapture.original_url }}</a></b> -      <br>{{ webcapture.timestamp }} | {{ webcapture.cdx|count }} resources +      <br>{{ webcapture.timestamp.strftime("%Y-%m-%d %H:%M:%S") }} | {{ webcapture.cdx|count }} resources        <br><small><code><a href="/webcapture/{{ webcapture.ident }}">webcapture:{{ webcapture.ident }}</a></code></small>        <td class="single line">          {% for url in webcapture.archive_urls[:5] %} diff --git a/python/fatcat_web/templates/stats.html b/python/fatcat_web/templates/stats.html index f11ca820..d117e1a4 100644 --- a/python/fatcat_web/templates/stats.html +++ b/python/fatcat_web/templates/stats.html @@ -7,7 +7,7 @@ You can also fetch these numbers <a href="./stats.json">as JSON</a>.  <h3>Changelog</h3> -<p>Latest changelog index is {{ stats.changelog.latest.index }} ({{ stats.changelog.latest.timestamp[:10] }}). +<p>Latest changelog index is {{ stats.changelog.latest.index }} ({{ stats.changelog.latest.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}).  <h3>Entities</h3> diff --git a/python/fatcat_web/templates/webcapture_view.html b/python/fatcat_web/templates/webcapture_view.html index a6c628d9..921d5d48 100644 --- a/python/fatcat_web/templates/webcapture_view.html +++ b/python/fatcat_web/templates/webcapture_view.html @@ -43,7 +43,7 @@            <code><a href="{{ row.url }}">{{ row.url}}</a></code>          </div>          <div style="margin-left: 1em;"> -          {{ row.timestamp }} +          {{ row.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}            {% if row.mimetype %}| {{ row.mimetype }}{% endif %}            <br>            <code><small style="color: #666;"> @@ -72,7 +72,7 @@ No known public archive for this webcapture.  {% if webcapture.timestamp != None %}  <div class="ui segment attached"> -  <p><b>Capture Time</b>  {{ webcapture.timestamp }} +  <p><b>Capture Time</b>  {{ webcapture.timestamp.strftime("%Y-%m-%d %H:%M:%S") }}  </div>  {% endif %} diff --git a/python/fatcat_web/web_config.py b/python/fatcat_web/web_config.py index 8fe50049..b12cb114 100644 --- a/python/fatcat_web/web_config.py +++ b/python/fatcat_web/web_config.py @@ -39,8 +39,18 @@ class Config(object):      IA_XAUTH_CLIENT_ID = os.environ.get("IA_XAUTH_CLIENT_ID", default=None)      IA_XAUTH_CLIENT_SECRET = os.environ.get("IA_XAUTH_CLIENT_SECRET", default=None) -    # protect cookies (which include API tokens) -    if FATCAT_DOMAIN != "dev.fatcat.wiki": +    # CSRF on by default, but only for WTF forms (not, eg, search, lookups, GET +    # forms) +    WTF_CSRF_CHECK_DEFAULT = False +    WTF_CSRF_TIME_LIMIT = None + +    if FATCAT_DOMAIN == "dev.fatcat.wiki": +        # "Even more verbose" debug options +        #SQLALCHEMY_ECHO = True +        #DEBUG = True +        pass +    else: +        # protect cookies (which include API tokens)          SESSION_COOKIE_HTTPONLY = True          SESSION_COOKIE_SECURE = True          SESSION_COOKIE_SAMESITE = 'Lax' @@ -61,6 +71,3 @@ class Config(object):          },      } -    # "Even more verbose" debug options -    #SQLALCHEMY_ECHO = True -    #DEBUG = True diff --git a/python/tests/routes.py b/python/tests/routes.py index 5ed6e322..ece91d65 100644 --- a/python/tests/routes.py +++ b/python/tests/routes.py @@ -7,7 +7,7 @@ from fixtures import *  def test_static_routes(app): -    for route in ('/health', '/robots.txt', '/', '/about'): +    for route in ('/health.json', '/robots.txt', '/', '/about'):          rv = app.get(route)          assert rv.status_code == 200  | 
