From f2e8e074b68d1c7a616b5071d7527bcd2c2ef510 Mon Sep 17 00:00:00 2001 From: Anton Lydike Date: Sat, 14 Nov 2020 00:16:58 +0100 Subject: [PATCH] rest api basics working, still some old crap here --- .gitignore | 2 + .idea/.gitignore | 2 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 7 + .idea/modules.xml | 8 ++ .idea/totpal.iml | 21 +++ .idea/vcs.xml | 6 + Dockerfile | 11 ++ README.md | 1 + app.py | 26 ++++ default.nix | 11 ++ docker-compose.yml | 31 +++++ requirements.txt | 4 + server/__init__.py | 15 +++ server/api.py | 120 ++++++++++++++++++ server/models/__init__.py | 1 + server/models/models.py | 71 +++++++++++ shell.nix | 21 +++ static/site.js | 20 +++ static/socket.js | 32 +++++ static/style.css | 83 ++++++++++++ templates/base.html | 50 ++++++++ templates/error.html | 6 + templates/room.html | 78 ++++++++++++ templates/start.html | 42 ++++++ 25 files changed, 675 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/totpal.iml create mode 100644 .idea/vcs.xml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 default.nix create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 server/__init__.py create mode 100644 server/api.py create mode 100644 server/models/__init__.py create mode 100644 server/models/models.py create mode 100644 shell.nix create mode 100644 static/site.js create mode 100644 static/socket.js create mode 100644 static/style.css create mode 100644 templates/base.html create mode 100644 templates/error.html create mode 100644 templates/room.html create mode 100644 templates/start.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01d7f95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +venv \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..e7e9d11 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,2 @@ +# Default ignored files +/workspace.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..ae53f32 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a8c7001 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/totpal.iml b/.idea/totpal.iml new file mode 100644 index 0000000..c617868 --- /dev/null +++ b/.idea/totpal.iml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8ab633f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.8 + +WORKDIR /usr/src/app + +COPY requirements.txt ./ + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD [ "python", "app.py" ] diff --git a/README.md b/README.md new file mode 100644 index 0000000..651eb71 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Family history mapping tool \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..8adc17f --- /dev/null +++ b/app.py @@ -0,0 +1,26 @@ +from os import getenv +from flask import Flask +from flask_socketio import SocketIO + +from server import setup + +import eventlet +eventlet.monkey_patch() + +app = Flask(__name__) + +app.secret_key = getenv('APP_SECRET') + +if not app.secret_key: + print("Please set a secret key via environment variable APP_SECRET") + import sys + sys.exit(1) + +socketio = SocketIO(app, async_mode='eventlet') + +setup(app, socketio) + + +if __name__ == '__main__': + print("starting") + socketio.run(app, debug=(getenv('DEV', 'false') == 'true'), port=int(getenv('PORT', 5000)), host='0.0.0.0') diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..2d9aa40 --- /dev/null +++ b/default.nix @@ -0,0 +1,11 @@ +with import {}; + +stdenv.mkDerivation rec { + name = "totpal-run-env"; + + buildInputs = [ + (python3.withPackages (ps: with ps; [ + flask flask-socketio eventlet pylint + ])) + ]; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..739abbb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +version: '3' +services: + neo4j: + image: neo4j:4.1 + volumes: + - 'db-data:/data' + environment: + NEO4J_AUTH: neo4j/my_password + ports: + - '127.0.0.1:7474:7474' + - '127.0.0.1:7687:7687' + + python: + build: . + environment: + PORT: 5000 + NEO4J_HOST: 'neo4j' + NEO4J_USER: 'neo4j' + NEO4J_PW: 'my_password' + UPLOAD_DIR: '/run/uploads' + DEV: 'true' + APP_SECRET: 'dev' + volumes: + - 'uploads:/run/uploads' + - './:/usr/src/app' + ports: + - '127.0.0.1:5000:5000' + +volumes: + db-data: + uploads: \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eefca81 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask +flask-socketio +eventlet +neomodel \ No newline at end of file diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..9a42bd1 --- /dev/null +++ b/server/__init__.py @@ -0,0 +1,15 @@ +from os import getenv +from neomodel import config + +pw = getenv('NEO4J_PW') +user = getenv('NEO4J_USER') +host = getenv('NEO4J_HOST') +port = getenv('NEO4J_PORT', 7687) + +config.DATABASE_URL = f'bolt://{user}:{pw}@{host}:{port}' + +from .api import attach_to_flask + + +def setup(app, socketio): + attach_to_flask(app) diff --git a/server/api.py b/server/api.py new file mode 100644 index 0000000..17f7fa7 --- /dev/null +++ b/server/api.py @@ -0,0 +1,120 @@ +import sys +from .models import * +from flask import Flask, request, jsonify +from typing import Dict, Type, List, Final +from datetime import datetime + + +class ApiError(BaseException): + def __init__(self, msg, status=400): + super().__init__() + self.msg = msg + self.status = status + + +def connect_callback(attr, target): + """returns a callback to connect attr with target""" + return lambda: attr.connect(target) + + +def construct_object_from_request(cls, uid=None): + if uid: + obj: Final = cls.nodes.get_or_none(uid=uid) + + if not obj: + raise ApiError(f"No {cls.__name__} found with uid {request.form.get('uid')}!", status=404) + else: + obj: Final = cls() + + relationship_attach_callbacks = list() + + for name, value in cls.__dict__.items(): + if name[0] == '_' or not name[0].islower(): + continue + + cls_name = value.__class__.__name__ + convert_with = None + + if cls_name == 'StringProperty': + convert_with = str + elif cls_name == 'DateProperty': + convert_with = datetime.fromisoformat + elif cls_name == 'IntegerProperty': + convert_with = int + elif cls_name == 'FloatProperty': + convert_with = float + elif cls_name == 'RelationshipDefinition': + if name not in request.form: + continue + + target_cls = value._raw_class + # convert class string to class by loading it from module object + if isinstance(target_cls, str): + target_cls = getattr(cls.__module__, target_cls) + + for val in request.form.getlist(name): + target = target_cls.nodes.first_or_none(uid=val) + if not target: + raise ApiError(f'Cannot find referenced object uid={val} of type {target_cls.__name__}!') + + relationship_attach_callbacks.append(connect_callback(getattr(obj, name), target)) + if not convert_with: + # skip this property + continue + + if name in request.form: + setattr(obj, name, convert_with(request.form.get(name))) + + return obj, lambda: [c() for c in relationship_attach_callbacks] + + +def func_api_obj(cls: Type): + """basic method for handling /api/type calls""" + if request.method == 'GET': + return jsonify([p.json(include_relations=False) for p in cls.nodes.all()]) + if request.method == 'POST': + p, attach_relationships = construct_object_from_request(cls) + p.save() + attach_relationships() + return jsonify(p.json()) + + +def func_api_obj_id(cls: Type, uid: str): + if request.method == 'GET': + person = Person.nodes.get_or_none(uid=uid) + if not person: + return "Not found", 404 + return jsonify(person.json()) + if request.method == 'PUT': + p, attach_relationships = construct_object_from_request(cls, uid=uid) + p.save() + attach_relationships() + return jsonify(p.json()) + + +def attach_to_flask(app: Flask): + @app.route('/api/person', methods=['POST', 'GET']) + def func_api_person(): + return func_api_obj(Person) + + @app.route('/api/person/', methods=['PUT', 'GET']) + def func_api_person_id(uid): + return func_api_obj_id(Person, uid) + + @app.route('/api/event', methods=['POST', 'GET']) + def func_api_event(): + return func_api_obj(Event) + + @app.route('/api/event/', methods=['PUT', 'GET']) + def func_api_event_id(uid): + return func_api_obj_id(Event, uid) + + @app.route('/api/place', methods=['POST', 'GET']) + def func_api_place(): + return func_api_obj(Place) + + @app.route('/api/place/', methods=['PUT', 'GET']) + def func_api_place_id(uid): + return func_api_obj_id(Place, uid) + + diff --git a/server/models/__init__.py b/server/models/__init__.py new file mode 100644 index 0000000..d0c018b --- /dev/null +++ b/server/models/__init__.py @@ -0,0 +1 @@ +from .models import Place, Person, Event, Image, VisitRel diff --git a/server/models/models.py b/server/models/models.py new file mode 100644 index 0000000..a618072 --- /dev/null +++ b/server/models/models.py @@ -0,0 +1,71 @@ +from neomodel import StructuredNode, StringProperty, RelationshipTo, RelationshipFrom, \ + UniqueIdProperty, DateProperty, StructuredRel, RelationshipManager +from neomodel.cardinality import ZeroOrOne, ZeroOrMore, One, OneOrMore + + +class Jsonifiable(object): + def json(self, include_relations=True): + json = dict() + for name, value in vars(self).items(): + if name == 'id': + continue + print(name, value) + if isinstance(value, RelationshipManager): + if not include_relations: + continue + if isinstance(value, (One, ZeroOrOne)): + val = value.get_or_none() + print(val) + json[name] = val.json(include_relations=False) if val else None + elif isinstance(value, (OneOrMore, ZeroOrMore)): + json[name] = [val.json(include_relations=False) for val in value.all()] + else: + json[name] = value + return json + + +class VisitRel(StructuredRel, Jsonifiable): + title = StringProperty(required=True) + description = StringProperty() + date = DateProperty() + + +class Person(StructuredNode, Jsonifiable): + uid = UniqueIdProperty() + name = StringProperty(required=True) + father = RelationshipTo('Person', 'FATHER', cardinality=ZeroOrOne) + mother = RelationshipTo('Person', 'MOTHER', cardinality=ZeroOrOne) + birthdate = DateProperty() + deceased = DateProperty() + description = StringProperty() + picture = StringProperty() + places = RelationshipTo('Place', 'VISITOR', ZeroOrMore, model=VisitRel) + + +class Event(StructuredNode, Jsonifiable): + uid = UniqueIdProperty() + name = StringProperty(required=True) + participants = RelationshipTo(Person, 'PARTICIPANT', cardinality=ZeroOrMore) + places = RelationshipTo('Place', 'HAPPENED_AT', cardinality=ZeroOrMore) + date = DateProperty() + end_date = DateProperty() + related_events = RelationshipTo('Event', 'RELATED_EVENT') + + +class Place(StructuredNode, Jsonifiable): + uid = UniqueIdProperty() + name = StringProperty(required=True) + description = StringProperty() + visitors = RelationshipFrom(Person, 'VISITOR', ZeroOrMore, model=VisitRel) + + +class Image(StructuredNode, Jsonifiable): + uid = StringProperty(unique_index=True, required=True) + description = StringProperty() + pictured = RelationshipTo(Person, 'PICTURED') + place = RelationshipTo(Place, 'TAKEN_AT') + date = DateProperty() + + + + diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..63f6c50 --- /dev/null +++ b/shell.nix @@ -0,0 +1,21 @@ +with import {}; +let + extensions = (with pkgs.vscode-extensions; [ + ms-python.python + ]); + vscode-with-extensions = pkgs.vscode-with-extensions.override { + vscodeExtensions = extensions; + }; +in + stdenv.mkDerivation rec { + name = "totpal-dev-env"; + + buildInputs = [ + (python3.withPackages (ps: with ps; [ + flask flask-socketio eventlet pylint + ])) + git + openjdk11 + jetbrains.pycharm-professional + ]; + } diff --git a/static/site.js b/static/site.js new file mode 100644 index 0000000..8bb0b3e --- /dev/null +++ b/static/site.js @@ -0,0 +1,20 @@ +const $ = sel => document.querySelector(sel) +const $$ = sel => Array.from(document.querySelectorAll(sel)) +const create_elm = str => { + const div = document.createElement('div') + div.innerHTML = str + return Array.from(div.childNodes) +} + +const flash_msg = (message) => { + const elm = create_elm('
' + message + ' ×
')[0] + $('main .container').prepend(elm) + elm.querySelector('.close-btn').addEventListener('click', e => elm.remove()) +} + +onload(() => { + // remove flash messages when close button is clicked + $$('.flash-msg .close-btn').forEach(elm => elm.addEventListener('click', e => { + elm.parentElement.remove() + })) +}) \ No newline at end of file diff --git a/static/socket.js b/static/socket.js new file mode 100644 index 0000000..4d95d24 --- /dev/null +++ b/static/socket.js @@ -0,0 +1,32 @@ +onload(() => { + const socket = io.connect(document.location.host); + + socket.on('flash_msg', flash_msg); + + socket.on('player_state', (data) => { + if (!data.secret) { + // our secret seems to be to old + localStorage.removeItem('secret'); + return register(); + } + //flash_msg("Logged in!"); + localStorage.setItem('secret', data.secret); + localStorage.setItem('name', data.username); + document.cookie = `secret=${data.secret}`; + window.username = data.username; + $('.username').innerText = 'playing as ' + window.username; + }); + + function register() { + const name = localStorage.getItem('name') || prompt("Whats your username?"); + socket.emit('signup', name) + } + + if (!localStorage.getItem('secret')) { + register() + } else { + socket.emit('resume', localStorage.getItem('secret')) + } + + window.socket = socket; +}); \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..f0cc707 --- /dev/null +++ b/static/style.css @@ -0,0 +1,83 @@ +html, body { + width: 100%; + margin: 0; + padding: 0; + font-size: 16px; + display: flex; + flex-direction: column; + min-height: 100vh; + font-family: 'roboto', 'open-sans', sans-serif; +} + +.flex { + display: flex; +} + +.flex-grow { + flex-grow: 1; +} + +.container { + width: 70%; + max-width: 1080px; + margin: auto; +} + +footer { + padding: 8px 0; +} + +/* specific styles */ + +html { + background: rgb(35, 36, 37); + color: #fff; +} + +nav { + background: rgb(22, 22, 22); + margin-bottom: 32px; + font-size: 24px; +} + +nav h1 { + padding: 0px; + margin: 0px; + font-size: inherit; +} + +nav h1 span { + font-weight: 400; +} + +nav .container div { + padding: 16px 0; +} + +nav .container .username { + padding: 16px; + font-weight: 300; +} + +a { + color: rgb(85, 150, 255); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.flash-msg { + border: 1px solid; + padding: 8px 16px; + border-radius: 3px; + margin: 16px; + margin-top: -16px; + margin-bottom: 32px; + background: rgb(48, 50, 51) +} + +.flash-msg .close-btn { + cursor: pointer; +} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..954a365 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,50 @@ + + + + + + + + + TOTPAL - {{ title }} + + {% block head %} + {% endblock %} + + + +
+
+ {% for message in get_flashed_messages() %} +
{{ message | safe }} ×
+ {% endfor %} + + {% block content %} + No content + {% endblock %} +
+
+
+
+ {% block footer %} + © Copyright by AntonLydike - play at your own risk + {% endblock %} +
+
+ + + \ No newline at end of file diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..116541b --- /dev/null +++ b/templates/error.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %} + +{% block content %} +

Error: {{ name }}

+

{{ message }}

+{% endblock %} \ No newline at end of file diff --git a/templates/room.html b/templates/room.html new file mode 100644 index 0000000..c9011f4 --- /dev/null +++ b/templates/room.html @@ -0,0 +1,78 @@ +{% extends 'base.html' %} + +{% block head %} + +{% endblock %} + +{% block content %} +

Game phase: None

+

Players:

+
    +
    + +
    + +
    +

    The player is

    +
    + + + +
    +
    + +
    +

    The articles title is: ...

    + +
    +
    +

    The article was submitted by !

    +
    + +{% endblock %} \ No newline at end of file diff --git a/templates/start.html b/templates/start.html new file mode 100644 index 0000000..815c3dc --- /dev/null +++ b/templates/start.html @@ -0,0 +1,42 @@ +{% extends 'base.html' %} + +{% block head %} + +{% endblock %} + +{% block content %} +

    Games waiting:

    + +
    + + + +
    + +
      + + + +{% endblock %} \ No newline at end of file