diff options
Diffstat (limited to 'fatcat')
-rwxr-xr-x | fatcat/backend.py | 301 | ||||
-rw-r--r-- | fatcat/static/robots.txt | 1 | ||||
-rw-r--r-- | fatcat/templates/base.html | 70 | ||||
-rw-r--r-- | fatcat/templates/home.html | 32 | ||||
-rw-r--r-- | fatcat/templates/work_add.html | 215 | ||||
-rw-r--r-- | fatcat/templates/work_view.html | 37 | ||||
-rw-r--r-- | fatcat/test_backend.py | 53 | ||||
-rwxr-xr-x | fatcat/webface.py | 87 |
8 files changed, 796 insertions, 0 deletions
diff --git a/fatcat/backend.py b/fatcat/backend.py new file mode 100755 index 00000000..a39ae790 --- /dev/null +++ b/fatcat/backend.py @@ -0,0 +1,301 @@ + +import argparse +from flask import Flask, render_template, send_from_directory, request, \ + url_for, abort, g, redirect, jsonify +from sqlalchemy import create_engine, MetaData, Table + +app = Flask(__name__) +app.config.from_object(__name__) + +# Load default config and override config from an environment variable +app.config.update(dict( + DATABASE_URI='sqlite://:memory:', + SECRET_KEY='development-key', + USERNAME='admin', + PASSWORD='admin' +)) +app.config.from_envvar('FATCAT_BACKEND_CONFIG', silent=True) + +metadata = MetaData() + + +## SQL Schema ############################################################### + +import enum +from sqlalchemy import Table, Column, Integer, String, MetaData, ForeignKey, \ + Enum + +# TODO: http://docs.sqlalchemy.org/en/latest/orm/extensions/declarative/mixins.html + +class IdState(enum.Enum): + normal = 1 + redirect = 2 + removed = 3 + +work_id = Table('work_id', metadata, + Column('id', Integer, primary_key=True, autoincrement=False), + Column('revision', ForeignKey('work_revision.id')), + ) + +work_revision = Table('work_revision', metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('previous', ForeignKey('work_revision.id'), nullable=True), + Column('state', Enum(IdState)), + Column('redirect_id', ForeignKey('work_id.id'), nullable=True), + Column('edit_id', ForeignKey('edit.id')), + Column('extra_json', ForeignKey('extra_json.sha1'), nullable=True), + + Column('title', String), + Column('work_type', String), + Column('date', String), + ) + +release_id = Table('release_id', metadata, + Column('id', Integer, primary_key=True, autoincrement=False), + Column('revision', ForeignKey('release_revision.id')), + ) + +release_revision = Table('release_revision', metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('previous', ForeignKey('release_revision.id'), nullable=True), + Column('state', Enum(IdState)), + Column('redirect_id', ForeignKey('release_id.id'), nullable=True), + Column('edit_id', ForeignKey('edit.id')), + Column('extra_json', ForeignKey('extra_json.sha1'), nullable=True), + + #Column('work', ForeignKey('work_id.id')), + Column('container', ForeignKey('container_id.id')), + Column('title', String), + Column('license', String), # TODO: oa status foreign key + Column('release_type', String), # TODO: foreign key + Column('date', String), # TODO: datetime + Column('doi', String), # TODO: identifier table + ) + +creator_id = Table('creator_id', metadata, + Column('id', Integer, primary_key=True, autoincrement=False), + Column('revision', ForeignKey('creator_revision.id')), + ) + +creator_revision = Table('creator_revision', metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('previous', ForeignKey('creator_revision.id'), nullable=True), + Column('state', Enum(IdState)), + Column('redirect_id', ForeignKey('creator_id.id'), nullable=True), + Column('edit_id', ForeignKey('edit.id')), + Column('extra_json', ForeignKey('extra_json.sha1'), nullable=True), + + Column('name', String), + Column('sortname', String), + Column('orcid', String), # TODO: identifier table + ) + +work_contrib = Table('work_contrib', metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('work_rev', ForeignKey('work_revision.id'), nullable=False), + Column('creator_id', ForeignKey('creator_id.id'), nullable=False), + Column('stub', String, nullable=False), + ) + +release_contrib = Table('release_contrib', metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('release_rev', ForeignKey('release_revision.id'), nullable=False), + Column('creator_id', ForeignKey('creator_id.id'), nullable=False), + ) + +container_id = Table('container_id', metadata, + Column('id', Integer, primary_key=True, autoincrement=False), + Column('revision', ForeignKey('container_revision.id')), + ) + +container_revision = Table('container_revision', metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('previous', ForeignKey('container_revision.id'), nullable=True), + Column('state', Enum(IdState)), + Column('redirect_id', ForeignKey('container_id.id'), nullable=True), + Column('edit_id', ForeignKey('edit.id')), + Column('extra_json', ForeignKey('extra_json.sha1'), nullable=True), + + Column('name', String), + Column('container', ForeignKey('container_id.id')), + Column('publisher', String), # TODO: foreign key + Column('sortname', String), + Column('issn', String), # TODO: identifier table + ) + +file_id = Table('file_id', metadata, + Column('id', Integer, primary_key=True, autoincrement=False), + Column('revision', ForeignKey('container_revision.id')), + ) + +file_revision = Table('file_revision', metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('previous', ForeignKey('file_revision.id'), nullable=True), + Column('state', Enum(IdState)), + Column('redirect_id', ForeignKey('file_id.id'), nullable=True), + Column('edit_id', ForeignKey('edit.id')), + Column('extra_json', ForeignKey('extra_json.sha1'), nullable=True), + + Column('size', Integer), + Column('sha1', Integer), # TODO: hash table... only or in addition? + Column('url', Integer), # TODO: URL table + ) + +release_file= Table('release_file', metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('release_rev', ForeignKey('release_revision.id'), nullable=False), + Column('file_id', ForeignKey('file_id.id'), nullable=False), + ) + +edit = Table('edit', metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('edit_group', ForeignKey('edit_group.id')), + Column('editor', ForeignKey('editor.id')), + Column('description', String), + ) + +edit_group = Table('edit_group', metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('editor', ForeignKey('editor.id')), + Column('description', String), + ) + +editor = Table('editor', metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('username', String), + ) + +changelog = Table('changelog', metadata, + Column('id', Integer, primary_key=True, autoincrement=True), + Column('edit_id', ForeignKey('edit.id')), + Column('timestamp', Integer), + ) + +extra_json = Table('extra_json', metadata, + Column('sha1', String, primary_key=True, autoincrement=True), + Column('json', String), + ) + +## Helpers ################################################################## + +def is_fcid(s): + return len(s) == 26 and s.isalnum() + +# XXX: why isn't this running? +def test_is_fcid(): + + for s in ("rzga5b9cd7efgh04iljk", "RZGA5B9CD7Efgh04iljk"): + assert is_fcid() is True + + for s in ("rzga59cd7efgh04iljk", "rzga.b9cd7efgh04iljk", "", + "rzga5b9cd7efgh04iljkz"): + assert is_fcid() is False + +def release_list(id_list): + # XXX: MOCK + l = [] + for i in id_list: + l.append({ + "id": i, + "rev": "8fkj28fjhqkjdhkjkj9s", + "previous": "0021jdfjhqkjdhkjkj9s", + "state": "normal", + "redirect_id": None, + "edit_id": "932582iuhckjvssk", + "extra_json": None, + + "container_id": "0021jdfjhqkjdhkjkj9s", + "title": "Mocks are great", + "license": "CC-0", + "release_type": "publication", + "date": "2017-11-22", + "doi": "10.1000/953kj.sdfkj", + }) + return l + +def release_hydrate(release_id): + e = release_list([release_id])[0] + e['container'] = container_hydrate(d['container_id']) + e.pop('container_id') + e['creators'] = [creator_hydrate(c['id']) for c in e['creator_ids']] + return e + +def work_list(id_list): + """This is the fast/light version: populates entity-specific lists (eg, + identifiers), and any primaries, but doesn't transclude all other + entities""" + if len(id_list) == 0: + return [] + + l = [] + for i in id_list: + l.append({ + "id": "rzga5b9cd7efgh04iljk", + "rev": "8fkj28fjhqkjdhkjkj9s", + "previous": "0021jdfjhqkjdhkjkj9s", + "state": "normal", + "redirect_id": None, + "edit_id": "932582iuhckjvssk", + "extra_json": None, + + "title": "Mocks are great", + "contributors": [], + "work_type": "journal-article", + "date": None, + + "primary_release": release_list(["8fkj28fjhqkjdhkjkj9s"])[0], + }) + return l + +def work_hydrate(work_id): + """This is the heavy/slowversion: everything from get_works(), but also + recursively transcludes single-linked entities""" + # XXX: + return work_list([work_id])[0] + +## API Methods ############################################################## + +@app.route('/health', methods=['GET']) +def health(): + return jsonify({'ok': True}) + + +@app.route('/v0/work/<work_id>', methods=['GET']) +def work_get(work_id): + if not is_fcid(work_id): + print("not fcid: {}".format(work_id)) + return abort(404) + work = work_hydrate(work_id) + return jsonify(work) + +## Entry Point ############################################################## + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--debug', + action='store_true', + help="enable debugging interface") + parser.add_argument('--host', + default="127.0.0.1", + help="listen on this host/IP") + parser.add_argument('--port', + type=int, + default=8040, + help="listen on this port") + parser.add_argument('--database-uri', + default=app.config['DATABASE_URI'], + help="sqlalchemy database string") + args = parser.parse_args() + + app.config['DATABASE_URI'] = args.database_uri + app.conn = create_engine(app.config['DATABASE_URI'], convert_unicode=True) + metadata.create_all(bind=engine) + + # XXX: + db_test_data() + + app.run(debug=args.debug, host=args.host, port=args.port) + + +if __name__ == '__main__': + main() diff --git a/fatcat/static/robots.txt b/fatcat/static/robots.txt new file mode 100644 index 00000000..a168f11b --- /dev/null +++ b/fatcat/static/robots.txt @@ -0,0 +1 @@ +# Hello friends! diff --git a/fatcat/templates/base.html b/fatcat/templates/base.html new file mode 100644 index 00000000..4e9dcd4b --- /dev/null +++ b/fatcat/templates/base.html @@ -0,0 +1,70 @@ +<!DOCTYPE html> +<html lang="en" style="position: relative; min-height: 100%; height: auto;"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width"> + + <title>{% block title %}fatcat!{% endblock %}</title> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/semantic-ui@2.2.13/dist/semantic.min.css"> + <script + src="https://code.jquery.com/jquery-3.1.1.min.js" + integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" + crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/semantic-ui@2.2.13/dist/semantic.min.js"></script> + +</head> +<body style="margin-bottom: 100px; height: auto;"> + +<header class="ui fixed inverted menu"> + <div class="ui container"> + <a href="/" class="header item"> + <!-- <img class="logo" src="assets/images/logo.png"> --> + fatcat! + </a> + <a href="/about" class="item">About</a> + <a href="#" class="item">Guide</a> + <div class="right menu"> + <div class="item"> + <div class="ui transparent inverted icon input"> + <i class="search icon"></i> + <input type="text" placeholder="Search..."> + </div> + </div> + <div class="ui simple dropdown item"> + acidburn <i class="dropdown icon"></i> + <div class="menu"> + <a class="item" href="#">Open Submissions</a> + <a class="item" href="#">Edit History</a> + <div class="divider"></div> + <a class="item" href="/account">Account</a> + <a class="item" href="/logout">Logout</a> + </div> + </div> + + </div> + </div> +</header> + +<main class="ui main text container" style="margin-top: 4em; margin-bottom: 2em;"> +{% block body %}Nothing to see here.{% endblock %} +</main> + + +<footer class="ui inverted vertical footer segment" style="margin-top: 2em; padding-top: 2em; padding-bottom:2em; position: absolute; bottom: 0px; width: 100%;"> + <div class="ui center aligned container"> + <div class="ui horizontal inverted small divided link list"> + <span class="item">fatcat!</span> + <a class="item" href="/about">About</a> + <a class="item" href="/sources">Sources</a> + <a class="item" href="#">Status</a> + <a class="item" href="#">Datasets</a> + <a class="item" href="https://git.bnewbold.net/fatcat/">Source Code</a> + </div> + </div> +</footer> + +{% block postscript %}{% endblock %} + +</body> +</html> diff --git a/fatcat/templates/home.html b/fatcat/templates/home.html new file mode 100644 index 00000000..d9cc34a2 --- /dev/null +++ b/fatcat/templates/home.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block body %} + +<h1>Salutations!</h1> + +Just mockups for now... + +<ul> + <li><b>Work:</b> + <a href="/work/create">Create</a>, + <a href="/work/random">Random</a> + <li><b><strike>Release:</strike></b> + <a href="/release/create">Create</a>, + <a href="/release/random">Random</a> + <li><b><strike>File:</strike></b> + <a href="/file/create">Create</a>, + <a href="/file/random">Random</a> + <li><b><strike>Contributor:</strike></b> + <a href="/contrib/create">Create</a>, + <a href="/contrib/random">Random</a> + <li><b><strike>Container:</strike></b> + <a href="/container/create">Create</a>, + <a href="/container/random">Random</a> + <li><b><strike>Publisher:</strike></b> + <a href="/publisher/create">Create</a>, + <a href="/publisher/random">Random</a> + <li>Edit groups... + <li>Changelog... + <li>Login/Signup... +</ul> + +{% endblock %} diff --git a/fatcat/templates/work_add.html b/fatcat/templates/work_add.html new file mode 100644 index 00000000..ac8a8169 --- /dev/null +++ b/fatcat/templates/work_add.html @@ -0,0 +1,215 @@ +{% 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> + + <!-- Date --> + <!-- Container / Part-Of --> + <!-- Publisher --> + <!-- Identifier --> + <!-- Language --> + <!-- Type / Media --> + <!-- Issue / Volume / Pages / Chapter --> + + <!-- Anything Else? --> + <h3 class="ui dividing header">Anything Else?</h3> + + <!-- File / Copy / URL --> + <!-- Citations --> + +<div class="ui submit button">Create Work</div> +</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/fatcat/templates/work_view.html b/fatcat/templates/work_view.html new file mode 100644 index 00000000..8c5e955d --- /dev/null +++ b/fatcat/templates/work_view.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block body %} + +<h1>{{ work.title }}</h1> + +<p>Work type: {{ work.type }} +<p><a href="/work/{{ work.id }}/history">History</a> +<p>Contributors: +{% for c in work.contributors %} {{ c.name }}; {% endfor %} + +{% if primary %} +<h2>Primary Release/Edition</h2> +<p>Title: {{ primary.title }} +<p>Date: {{ primary.date }} + +{% if primary.container %} +<p>Container: <a href="/container/{{ primary.container.id }}">{{ primary.container.title }}</a> +{% endif %} + +{% if primary.doi %} +<p>DOI: <a href="https://dx.doi.org/{{ primary.doi }}">{{ primary.doi }}</a> +{% endif %} + +{% else %} +<p>No primary release +{% endif %} + +{% if releases %} +<ul> +{% for r in releases %} + <ul><a href="/release/{{ r.id }}">{{ r.title }}</a> ({{ y.date }} - {{ y.release_type }}) +{% endfor %} +</ul> +{% else %} +{% endif %} + +{% endblock %} diff --git a/fatcat/test_backend.py b/fatcat/test_backend.py new file mode 100644 index 00000000..429b5ae7 --- /dev/null +++ b/fatcat/test_backend.py @@ -0,0 +1,53 @@ + +import os +import json +import backend +import unittest +import tempfile +from nose.tools import * + +# TODO: replace all these "assert" with unit test version (which displays left +# and right on failure) + +# TODO: http://alextechrants.blogspot.com/2013/08/unit-testing-sqlalchemy-apps.html + +## Helpers ################################################################## + +def check_entity_fields(e): + for key in ('id', 'rev', 'previous', 'state', 'redirect_id', 'edit_id', + 'extra_json'): + assert_in(key, e) + for key in ('id', 'rev'): + assert_is_not_none(e[key]) + +## API Tests ################################################################ + +class BackendTestCase(unittest.TestCase): + + def setUp(self): + backend.app.config['DATABASE_URI'] = 'sqlite://:memory:' + backend.app.testing = True + self.app = backend.app.test_client() + + def test_health(self): + rv = self.app.get('/health') + obj = json.loads(rv.data.decode('utf-8')) + assert obj['ok'] + + def test_works(self): + + # Invalid Id + rv = self.app.get('/v0/work/_') + assert rv.status_code == 404 + + # Missing Id (TODO) + #rv = self.app.get('/v0/work/rzga5b9cd7efgh04iljk') + #assert rv.status is 404 + + # Valid Id + rv = self.app.get('/v0/work/r3zga5b9cd7ef8gh084714iljk') + assert rv.status_code == 200 + obj = json.loads(rv.data.decode('utf-8')) + check_entity_fields(obj) + assert obj['title'] + assert_equal(obj['work_type'], "journal-article") diff --git a/fatcat/webface.py b/fatcat/webface.py new file mode 100755 index 00000000..33833e25 --- /dev/null +++ b/fatcat/webface.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 + +import os +import argparse +import requests +from flask import Flask, render_template, send_from_directory, request, \ + url_for, abort, g, redirect, jsonify + +app = Flask(__name__) +app.config.from_object(__name__) + + +### Views ################################################################### + +@app.route('/health', methods=['GET']) +def health(): + return jsonify({'ok': True}) + +@app.route('/work/create', methods=['GET']) +def work_create(): + return render_template('work_add.html') + +@app.route('/work/random', methods=['GET']) +def work_random(): + work = { + "title": "Structure and Interpretation", + "work_type": "book", + "date": None, + "contributors": [ + {"name": "Alyssa P. Hacker"}, + ], + "primary": { + "title": "Structure and Interpretation", + "release_type": "online", + "date": "2000-01-01", + "doi": "10.491/599.sdo14", + }, + "releases": [ + ] + } + return render_template('work_view.html', work=work, primary=work['primary']) + +@app.route('/work/<work_id>/random', methods=['GET']) +def work_view(work_id): + return render_template('work_view.html') + + +### Static Routes ########################################################### + +@app.route('/', methods=['GET']) +def homepage(): + return render_template('home.html') + +@app.route('/about', methods=['GET']) +def aboutpage(): + return render_template('about.html') + +@app.route('/robots.txt', methods=['GET']) +def robots(): + return send_from_directory(os.path.join(app.root_path, 'static'), + 'robots.txt', + mimetype='text/plain') + + +### Entry Point ############################################################# + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--debug', + action='store_true', + help="enable debugging interface") + parser.add_argument('--host', + default="127.0.0.1", + help="listen on this host/IP") + parser.add_argument('--port', + type=int, + default=5050, + help="listen on this port") + parser.add_argument('--backend-api', + default="localhost:6060", + help="backend API to connect to") + args = parser.parse_args() + + app.run(debug=args.debug, host=args.host, port=args.port) + +if __name__ == '__main__': + main() |