diff --git a/flask-connexion-rest-part-4/.gitignore b/flask-connexion-rest-part-4/.gitignore new file mode 100644 index 0000000000..18fbabb962 --- /dev/null +++ b/flask-connexion-rest-part-4/.gitignore @@ -0,0 +1,118 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# ignore the database +people.db \ No newline at end of file diff --git a/flask-connexion-rest-part-4/__init__.py b/flask-connexion-rest-part-4/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/flask-connexion-rest-part-4/build_database.py b/flask-connexion-rest-part-4/build_database.py new file mode 100644 index 0000000000..feeb1d0534 --- /dev/null +++ b/flask-connexion-rest-part-4/build_database.py @@ -0,0 +1,63 @@ +import os +from datetime import datetime +from config import db +from models import Person, Note + +# Data to initialize database with +PEOPLE = [ + { + "fname": "Doug", + "lname": "Farrell", + "notes": [ + ("Cool, a mini-blogging application!", "2019-01-06 22:17:54"), + ("This could be useful", "2019-01-08 22:17:54"), + ("Well, sort of useful", "2019-03-06 22:17:54"), + ], + }, + { + "fname": "Kent", + "lname": "Brockman", + "notes": [ + ( + "I'm going to make really profound observations", + "2019-01-07 22:17:54", + ), + ( + "Maybe they'll be more obvious than I thought", + "2019-02-06 22:17:54", + ), + ], + }, + { + "fname": "Bunny", + "lname": "Easter", + "notes": [ + ("Has anyone seen my Easter eggs?", "2019-01-07 22:47:54"), + ("I'm really late delivering these!", "2019-04-06 22:17:54"), + ], + }, +] + +# Delete database file if it exists currently +if os.path.exists("people.db"): + os.remove("people.db") + +# Create the database +db.create_all() + +# iterate over the PEOPLE structure and populate the database +for person in PEOPLE: + p = Person(lname=person.get("lname"), fname=person.get("fname")) + + # Add the notes for the person + for note in person.get("notes"): + content, timestamp = note + p.notes.append( + Note( + content=content, + timestamp=datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S"), + ) + ) + db.session.add(p) + +db.session.commit() diff --git a/flask-connexion-rest-part-4/config.py b/flask-connexion-rest-part-4/config.py new file mode 100644 index 0000000000..002cd2a481 --- /dev/null +++ b/flask-connexion-rest-part-4/config.py @@ -0,0 +1,26 @@ +import os +import connexion +from flask_sqlalchemy import SQLAlchemy +from flask_marshmallow import Marshmallow + +basedir = os.path.abspath(os.path.dirname(__file__)) + +# Create the connexion application instance +connex_app = connexion.App(__name__, specification_dir=basedir) + +# Get the underlying Flask app instance +app = connex_app.app + +# Build the Sqlite ULR for SqlAlchemy +sqlite_url = "sqlite:////" + os.path.join(basedir, "people.db") + +# Configure the SqlAlchemy part of the app instance +app.config["SQLALCHEMY_ECHO"] = False +app.config["SQLALCHEMY_DATABASE_URI"] = sqlite_url +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +# Create the SqlAlchemy db instance +db = SQLAlchemy(app) + +# Initialize Marshmallow +ma = Marshmallow(app) diff --git a/flask-connexion-rest-part-4/models.py b/flask-connexion-rest-part-4/models.py new file mode 100644 index 0000000000..7e970977eb --- /dev/null +++ b/flask-connexion-rest-part-4/models.py @@ -0,0 +1,80 @@ +from datetime import datetime +from config import db, ma +from marshmallow import fields + + +class Person(db.Model): + __tablename__ = "person" + person_id = db.Column(db.Integer, primary_key=True) + lname = db.Column(db.String(32)) + fname = db.Column(db.String(32)) + timestamp = db.Column( + db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + notes = db.relationship( + "Note", + backref="person", + cascade="all, delete, delete-orphan", + single_parent=True, + order_by="desc(Note.timestamp)", + ) + + +class Note(db.Model): + __tablename__ = "note" + note_id = db.Column(db.Integer, primary_key=True) + person_id = db.Column(db.Integer, db.ForeignKey("person.person_id")) + content = db.Column(db.String, nullable=False) + timestamp = db.Column( + db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + + +class PersonSchema(ma.ModelSchema): + def __init__(self, **kwargs): + super().__init__(strict=True, **kwargs) + + class Meta: + model = Person + sqla_session = db.session + + notes = fields.Nested("PersonNoteSchema", default=[], many=True) + + +class PersonNoteSchema(ma.ModelSchema): + """ + This class exists to get around a recursion issue + """ + + def __init__(self, **kwargs): + super().__init__(strict=True, **kwargs) + + note_id = fields.Int() + person_id = fields.Int() + content = fields.Str() + timestamp = fields.Str() + + +class NoteSchema(ma.ModelSchema): + def __init__(self, **kwargs): + super().__init__(strict=True, **kwargs) + + class Meta: + model = Note + sqla_session = db.session + + person = fields.Nested("NotePersonSchema", default=None) + + +class NotePersonSchema(ma.ModelSchema): + """ + This class exists to get around a recursion issue + """ + + def __init__(self, **kwargs): + super().__init__(strict=True, **kwargs) + + person_id = fields.Int() + lname = fields.Str() + fname = fields.Str() + timestamp = fields.Str() diff --git a/flask-connexion-rest-part-4/notes.py b/flask-connexion-rest-part-4/notes.py new file mode 100644 index 0000000000..9e3805e146 --- /dev/null +++ b/flask-connexion-rest-part-4/notes.py @@ -0,0 +1,151 @@ +""" +This is the people module and supports all the REST actions for the +people data +""" + +from flask import make_response, abort +from config import db +from models import Person, Note, NoteSchema + + +def read_all(): + """ + This function responds to a request for /api/people/notes + with the complete list of notes, sorted by note timestamp + + :return: json list of all notes for all people + """ + # Query the database for all the notes + notes = Note.query.order_by(db.desc(Note.timestamp)).all() + + # Serialize the list of notes from our data + note_schema = NoteSchema(many=True, exclude=["person.notes"]) + data = note_schema.dump(notes).data + return data + + +def read_one(person_id, note_id): + """ + This function responds to a request for + /api/people/{person_id}/notes/{note_id} + with one matching note for the associated person + + :param person_id: Id of person the note is related to + :param note_id: Id of the note + :return: json string of note contents + """ + # Query the database for the note + note = ( + Note.query.join(Person, Person.person_id == Note.person_id) + .filter(Person.person_id == person_id) + .filter(Note.note_id == note_id) + .one_or_none() + ) + + # Was a note found? + if note is not None: + note_schema = NoteSchema() + data = note_schema.dump(note).data + return data + + # Otherwise, nope, didn't find that note + else: + abort(404, f"Note not found for Id: {note_id}") + + +def create(person_id, note): + """ + This function creates a new note related to the passed in person id. + + :param person_id: Id of the person the note is related to + :param note: The JSON containing the note data + :return: 201 on success + """ + # get the parent person + person = Person.query.filter(Person.person_id == person_id).one_or_none() + + # Was a person found? + if person is None: + abort(404, f"Person not found for Id: {person_id}") + + # Create a note schema instance + schema = NoteSchema() + new_note = schema.load(note, session=db.session).data + + # Add the note to the person and database + person.notes.append(new_note) + db.session.commit() + + # Serialize and return the newly created note in the response + data = schema.dump(new_note).data + + return data, 201 + + +def update(person_id, note_id, note): + """ + This function updates an existing note related to the passed in + person id. + + :param person_id: Id of the person the note is related to + :param note_id: Id of the note to update + :param content: The JSON containing the note data + :return: 200 on success + """ + update_note = ( + Note.query.filter(Person.person_id == person_id) + .filter(Note.note_id == note_id) + .one_or_none() + ) + + # Did we find an existing note? + if update_note is not None: + + # turn the passed in note into a db object + schema = NoteSchema() + update = schema.load(note, session=db.session).data + + # Set the id's to the note we want to update + update.person_id = update_note.person_id + update.note_id = update_note.note_id + + # merge the new object into the old and commit it to the db + db.session.merge(update) + db.session.commit() + + # return updated note in the response + data = schema.dump(update_note).data + + return data, 200 + + # Otherwise, nope, didn't find that note + else: + abort(404, f"Note not found for Id: {note_id}") + + +def delete(person_id, note_id): + """ + This function deletes a note from the note structure + + :param person_id: Id of the person the note is related to + :param note_id: Id of the note to delete + :return: 200 on successful delete, 404 if not found + """ + # Get the note requested + note = ( + Note.query.filter(Person.person_id == person_id) + .filter(Note.note_id == note_id) + .one_or_none() + ) + + # did we find a note? + if note is not None: + db.session.delete(note) + db.session.commit() + return make_response( + "Note {note_id} deleted".format(note_id=note_id), 200 + ) + + # Otherwise, nope, didn't find that note + else: + abort(404, f"Note not found for Id: {note_id}") diff --git a/flask-connexion-rest-part-4/people.py b/flask-connexion-rest-part-4/people.py new file mode 100644 index 0000000000..04fd0ccafb --- /dev/null +++ b/flask-connexion-rest-part-4/people.py @@ -0,0 +1,148 @@ +""" +This is the people module and supports all the REST actions for the +people data +""" + +from flask import make_response, abort +from config import db +from models import Person, PersonSchema, Note + + +def read_all(): + """ + This function responds to a request for /api/people + with the complete lists of people + + :return: json string of list of people + """ + # Create the list of people from our data + people = Person.query.order_by(Person.lname).all() + + # Serialize the data for the response + person_schema = PersonSchema(many=True) + data = person_schema.dump(people).data + return data + + +def read_one(person_id): + """ + This function responds to a request for /api/people/{person_id} + with one matching person from people + + :param person_id: Id of person to find + :return: person matching id + """ + # Build the initial query + person = ( + Person.query.filter(Person.person_id == person_id) + .outerjoin(Note) + .one_or_none() + ) + + # Did we find a person? + if person is not None: + + # Serialize the data for the response + person_schema = PersonSchema() + data = person_schema.dump(person).data + return data + + # Otherwise, nope, didn't find that person + else: + abort(404, f"Person not found for Id: {person_id}") + + +def create(person): + """ + This function creates a new person in the people structure + based on the passed in person data + + :param person: person to create in people structure + :return: 201 on success, 406 on person exists + """ + fname = person.get("fname") + lname = person.get("lname") + + existing_person = ( + Person.query.filter(Person.fname == fname) + .filter(Person.lname == lname) + .one_or_none() + ) + + # Can we insert this person? + if existing_person is None: + + # Create a person instance using the schema and the passed in person + schema = PersonSchema() + new_person = schema.load(person, session=db.session).data + + # Add the person to the database + db.session.add(new_person) + db.session.commit() + + # Serialize and return the newly created person in the response + data = schema.dump(new_person).data + + return data, 201 + + # Otherwise, nope, person exists already + else: + abort(409, f"Person {fname} {lname} exists already") + + +def update(person_id, person): + """ + This function updates an existing person in the people structure + + :param person_id: Id of the person to update in the people structure + :param person: person to update + :return: updated person structure + """ + # Get the person requested from the db into session + update_person = Person.query.filter( + Person.person_id == person_id + ).one_or_none() + + # Did we find an existing person? + if update_person is not None: + + # turn the passed in person into a db object + schema = PersonSchema() + update = schema.load(person, session=db.session).data + + # Set the id to the person we want to update + update.person_id = update_person.person_id + + # merge the new object into the old and commit it to the db + db.session.merge(update) + db.session.commit() + + # return updated person in the response + data = schema.dump(update_person).data + + return data, 200 + + # Otherwise, nope, didn't find that person + else: + abort(404, f"Person not found for Id: {person_id}") + + +def delete(person_id): + """ + This function deletes a person from the people structure + + :param person_id: Id of the person to delete + :return: 200 on successful delete, 404 if not found + """ + # Get the person requested + person = Person.query.filter(Person.person_id == person_id).one_or_none() + + # Did we find a person? + if person is not None: + db.session.delete(person) + db.session.commit() + return make_response(f"Person {person_id} deleted", 200) + + # Otherwise, nope, didn't find that person + else: + abort(404, f"Person not found for Id: {person_id}") diff --git a/flask-connexion-rest-part-4/requirements.txt b/flask-connexion-rest-part-4/requirements.txt new file mode 100644 index 0000000000..c9efff71aa --- /dev/null +++ b/flask-connexion-rest-part-4/requirements.txt @@ -0,0 +1,25 @@ +certifi==2018.11.29 +chardet==3.0.4 +Click==7.0 +clickclick==1.2.2 +connexion==2.2.0 +Flask==1.0.2 +flask-marshmallow==0.9.0 +Flask-SQLAlchemy==2.3.2 +idna==2.8 +inflection==0.3.1 +itsdangerous==1.1.0 +Jinja2==2.10 +jsonschema==2.6.0 +MarkupSafe==1.1.0 +marshmallow==2.18.0 +marshmallow-sqlalchemy==0.15.0 +openapi-spec-validator==0.2.4 +pathlib==1.0.1 +PyYAML==4.2b4 +requests==2.21.0 +six==1.12.0 +SQLAlchemy==1.2.17 +swagger-ui-bundle==0.0.3 +urllib3==1.24.1 +Werkzeug==0.14.1 diff --git a/flask-connexion-rest-part-4/server.py b/flask-connexion-rest-part-4/server.py new file mode 100644 index 0000000000..1fcbac807a --- /dev/null +++ b/flask-connexion-rest-part-4/server.py @@ -0,0 +1,60 @@ +""" +Main module of the server file +""" + +# 3rd party moudles +from flask import render_template + +# Local modules +import config + + +# Get the application instance +connex_app = config.connex_app + +# Read the swagger.yml file to configure the endpoints +connex_app.add_api("swagger.yml") + + +# Create a URL route in our application for "/" +@connex_app.route("/") +def home(): + """ + This function just responds to the browser URL + localhost:5000/ + + :return: the rendered template "home.html" + """ + return render_template("home.html") + + +# Create a URL route in our application for "/people" +@connex_app.route("/people") +@connex_app.route("/people/") +def people(person_id=""): + """ + This function just responds to the browser URL + localhost:5000/people + + :return: the rendered template "people.html" + """ + return render_template("people.html", person_id=person_id) + + +# Create a URL route to the notes page +@connex_app.route("/people/") +@connex_app.route("/people//notes") +@connex_app.route("/people//notes/") +def notes(person_id, note_id=""): + """ + This function responds to the browser URL + localhost:5000/notes/ + + :param person_id: Id of the person to show notes for + :return: the rendered template "notes.html" + """ + return render_template("notes.html", person_id=person_id, note_id=note_id) + + +if __name__ == "__main__": + connex_app.run(debug=True) diff --git a/flask-connexion-rest-part-4/static/css/home.css b/flask-connexion-rest-part-4/static/css/home.css new file mode 100644 index 0000000000..c991616111 --- /dev/null +++ b/flask-connexion-rest-part-4/static/css/home.css @@ -0,0 +1,18 @@ +/* + * CSS stylesheet for home page + */ + +div.blog { + margin: 10px 10px 10px 10px; + border: 2px solid darkgrey; +} + +td.name { + text-align: center; + width: 175px; +} + +td.timestamp { + text-align: center; + width: 280px; +} diff --git a/flask-connexion-rest-part-4/static/css/notes.css b/flask-connexion-rest-part-4/static/css/notes.css new file mode 100644 index 0000000000..be0652ed3d --- /dev/null +++ b/flask-connexion-rest-part-4/static/css/notes.css @@ -0,0 +1,65 @@ +/* + * This is the CSS stylesheet for the notes creation/update/delete application + */ + +.container { + padding: 10px; +} + +.banner { + text-align: center; +} + +.display, .editor { + width: 50%; + margin-left: auto; + margin-right: auto; + padding: 5px; + border: 2px solid darkgrey; + border-radius: 4px; + margin-bottom: 5px; +} + +display div, .editor div { + margin-bottom: 5px; +} + +label { + display: inline-block; + margin-bottom: 5px; +} + +button { + padding: 5px; + margin-right: 5px; + border-radius: 3px; + background-color: #eee; +} + +input[type=text] { + width: 550px; +} + +div.notes { + margin: 10px 10px 10px 10px; + border: 2px solid darkgrey; +} + +.blog table thead { + padding: 10px 0 10px 0; +} + +td.name { + text-align: center; + width: 175px; +} + +td.timestamp { + text-align: center; + width: 230px; +} + +td.content { + text-align: left; + padding-left: 5px; +} diff --git a/flask-connexion-rest-part-4/static/css/parent.css b/flask-connexion-rest-part-4/static/css/parent.css new file mode 100644 index 0000000000..fb08763195 --- /dev/null +++ b/flask-connexion-rest-part-4/static/css/parent.css @@ -0,0 +1,103 @@ +/* + * This is the parent CSS stylesheet that all pages get basic styling from + */ + +@import url(http://fonts.googleapis.com/css?family=Roboto:400,300,500,700); + +body, .ui-btn { + font-family: Roboto; +} + +.navigation { + display: flex; + flex-direction: row; + padding: 15px 10px 15px 10px; + border-bottom: 2px solid darkgrey; + background-color: whitesmoke; +} + +.navigation a { + margin-right: 10px; + padding: 5px; + border: 1px solid darkgrey; + border-radius: 4px; + text-decoration: none; + cursor: pointer; + text: black; + background-color: lightskyblue; +} + +.navigation .page_name { + flex-grow: 2; + text-align: center; + font-size: 1.5em; + font-weight: bold; +} + +.navigation a:hover { + background-color: deepskyblue; +} + +.container { + padding: 10px; +} + +table { + width: 100%; + border-collapse: collapse; +} + +table caption { + font-weight: bold; + padding: 10px 0 10px 0; + border-bottom: 2px solid darkgrey; + background-color: antiquewhite; +} + +th:not(:last-child), td:not(:last-child) { + border-right: 2px solid darkgrey; +} + + +tbody tr:nth-child(odd) { + background-color: gainsboro; +} + +tbody tr { + cursor: pointer; + height: 33px; +} + +thead tr { + height: 33px; + border-bottom: 2px solid darkgrey; +} + +tbody tr:not(:last-child){ + border-bottom: 2px solid darkgrey; +} + +tbody tr:hover { + background-color: powderblue; +} + +.error { + width: 50%; + margin-left: auto; + margin-right: auto; + padding: 5px; + text-align: center; + border: 1px solid lightgrey; + border-radius: 3px; + background-color: #fbb; + opacity: 0; +} + +.visible { + opacity: 1; +} + +.hidden { + opacity: 0; + transition: visibility 0s 2s, opacity 2s linear; +} \ No newline at end of file diff --git a/flask-connexion-rest-part-4/static/css/people.css b/flask-connexion-rest-part-4/static/css/people.css new file mode 100644 index 0000000000..0a2da61c5f --- /dev/null +++ b/flask-connexion-rest-part-4/static/css/people.css @@ -0,0 +1,43 @@ +/* + * This is the CSS stylesheet for the demo people application + */ + +.editor { + width: 50%; + margin-left: auto; + margin-right: auto; + padding: 5px; + border: 2px solid darkgrey; + border-radius: 4px; + margin-bottom: 5px; +} + +label { + display: inline-block; + margin-bottom: 5px; +} + +button { + padding: 5px; + margin-right: 5px; + border-radius: 3px; + background-color: #eee; +} + +div.people { + margin: 10px 30px 10px 30px; + border: 2px solid darkgrey; +} + +td { + text-align: center; +} + +tbody td.timestamp { + width: 280px; +} + +tbody td.name { + cursor: pointer; + height: 33px; +} diff --git a/flask-connexion-rest-part-4/static/js/home.js b/flask-connexion-rest-part-4/static/js/home.js new file mode 100644 index 0000000000..b30c83e368 --- /dev/null +++ b/flask-connexion-rest-part-4/static/js/home.js @@ -0,0 +1,118 @@ +/** + * JavaScript file for the Home page + */ + +/* jshint esversion: 8 */ + +/** + * This is the model class which provides access to the server REST API + * @type {{}} + */ +class Model { + async read() { + let options = { + method: "GET", + cache: "no-cache", + headers: { + "Content-Type": "application/json" + } + }; + // call the REST endpoint and wait for data + let response = await fetch("/api/notes", options); + let data = await response.json(); + return data; + } +} + + +/** + * This is the view class which provides access to the DOM + */ +class View { + constructor() { + this.table = document.querySelector(".blog table"); + this.error = document.querySelector(".error"); + } + + buildTable(notes) { + let tbody = this.table.createTBody(); + let html = ""; + + // Iterate over the notes and build the table + notes.forEach((note) => { + html += ` + + ${note.timestamp} + ${note.person.fname} ${note.person.lname} + ${note.content} + `; + }); + // Replace the tbody with our new content + tbody.innerHTML = html; + } + + errorMessage(message) { + this.error.innerHTML = message; + this.error.classList.remove("hidden"); + this.error.classList.add("visible"); + setTimeout(() => { + this.error.classList.remove("visible"); + this.error.classList.add("hidden"); + }, 2000); + } +} + + +/** + * This is the controller class for the user interaction + */ +class Controller { + constructor(model, view) { + this.model = model; + this.view = view; + + this.initialize(); + } + async initialize() { + try { + let notes = await this.model.read(); + this.view.buildTable(notes); + } catch(err) { + this.view.errorMessage(err); + } + + // handle application events + document.querySelector("table tbody").addEventListener("dblclick", (evt) => { + let target = evt.target, + parent = target.parentElement; + + // is this the name td? + if (target.classList.contains("name")) { + let person_id = parent.getAttribute("data-person_id"); + + window.location = `/people/${person_id}`; + + // is this the content td + } else if (target.classList.contains("content")) { + let person_id = parent.getAttribute("data-person_id"), + note_id = parent.getAttribute("data-note_id"); + + window.location = `people/${person_id}/notes/${note_id}`; + } + }); + } +} + +// Create the MVC components +const model = new Model(); +const view = new View(); +const controller = new Controller(model, view); + +// export the MVC components as the default +export default { + model, + view, + controller +}; + + diff --git a/flask-connexion-rest-part-4/static/js/notes.js b/flask-connexion-rest-part-4/static/js/notes.js new file mode 100644 index 0000000000..9f29aee2ef --- /dev/null +++ b/flask-connexion-rest-part-4/static/js/notes.js @@ -0,0 +1,307 @@ +/** + * JavaScript file for the Notes page + */ + +/* jshint esversion: 8 */ + +/** + * This is the model class which provides access to the server REST API + * @type {{}} + */ +class Model { + async read(personId) { + let options = { + method: "GET", + cache: "no-cache", + headers: { + "Content-Type": "application/json" + } + }; + // Call the REST endpoint and wait for data + let response = await fetch(`/api/people/${personId}`, options); + let data = await response.json(); + return data; + } + + async readOne(personId, noteId) { + let options = { + method: "GET", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + "accepts": "application/json" + } + }; + // Call the REST endpoint and wait for data + let response = await fetch(`/api/people/${personId}/notes/${noteId}`, options); + let data = await response.json(); + return data; + } + + async create(personId, note) { + let options = { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + "accepts": "application/json" + }, + body: JSON.stringify(note) + }; + // Call the REST endpoint and wait for data + let response = await fetch(`/api/people/${personId}/notes`, options); + let data = await response.json(); + return data; + } + + async update(personId, note) { + let options = { + method: "PUT", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + "accepts": "application/json" + }, + body: JSON.stringify(note) + }; + // Call the REST endpoint and wait for data + let response = await fetch(`/api/people/${personId}/notes/${note.noteId}`, options); + let data = await response.json(); + return data; + } + + async delete(personId, noteId) { + let options = { + method: "DELETE", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + "accepts": "application/json" + } + }; + // Call the REST endpoint and wait for data + let response = await fetch(`/api/people/${personId}/notes/${noteId}`, options); + return response; + } +} + + +/** + * This is the view class which provides access to the DOM + */ +class View { + constructor() { + this.NEW_NOTE = 0; + this.EXISTING_NOTE = 1; + this.table = document.querySelector(".notes table"); + this.error = document.querySelector(".error"); + this.personId = document.getElementById("person_id"); + this.fname = document.getElementById("fname"); + this.lname = document.getElementById("lname"); + this.timestamp = document.getElementById("timestamp"); + this.noteId = document.getElementById("note_id"); + this.note = document.getElementById("note"); + this.createButton = document.getElementById("create"); + this.updateButton = document.getElementById("update"); + this.deleteButton = document.getElementById("delete"); + this.resetButton = document.getElementById("reset"); + } + + reset() { + this.noteId.textContent = ""; + this.note.value = ""; + this.note.focus(); + } + + updateEditor(note) { + this.noteId.textContent = note.noteId; + this.note.value = note.content; + this.note.focus(); + } + + setButtonState(state) { + if (state === this.NEW_NOTE) { + this.createButton.disabled = false; + this.updateButton.disabled = true; + this.deleteButton.disabled = true; + } else if (state === this.EXISTING_NOTE) { + this.createButton.disabled = true; + this.updateButton.disabled = false; + this.deleteButton.disabled = false; + } + } + + buildTable(person) { + let tbody, + html = ""; + + // Update the person data + this.personId.textContent = person.person_id; + this.fname.textContent = person.fname; + this.lname.textContent = person.lname; + this.timestamp.textContent = person.timestamp; + + // Iterate over the notes and build the table + person.notes.forEach((note) => { + html += ` + + ${note.timestamp} + ${note.content} + `; + }); + // Is there currently a tbody in the table? + if (this.table.tBodies.length !== 0) { + this.table.removeChild(this.table.getElementsByTagName("tbody")[0]); + } + // Update tbody with our new content + tbody = this.table.createTBody(); + tbody.innerHTML = html; + } + + errorMessage(error_msg) { + let error = document.querySelector(".error"); + + error.innerHTML = error_msg; + error.classList.add("visible"); + error.classList.remove("hidden"); + setTimeout(() => { + error.classList.add("hidden"); + error.classList.remove("visible"); + }, 2000); + } +} + + +/** + * This is the controller class for the user interaction + */ +class Controller { + constructor(model, view) { + this.model = model; + this.view = view; + + this.initialize(); + } + + async initialize() { + await this.initializeTable(); + this.initializeTableEvents(); + this.initializeCreateEvent(); + this.initializeUpdateEvent(); + this.initializeDeleteEvent(); + this.initializeResetEvent(); + } + + async initializeTable() { + try { + let urlPersonId = +document.getElementById("url_person_id").value, + urlNoteId = +document.getElementById("url_note_id").value, + person = await this.model.read(urlPersonId); + + this.view.buildTable(person); + + // Did we navigate here with a note selected? + if (urlNoteId) { + let note = await this.model.readOne(urlPersonId, urlNoteId); + this.view.updateEditor(note); + this.view.setButtonState(this.view.EXISTING_NOTE); + + // Otherwise, nope, so leave the editor blank + } else { + this.view.reset(); + this.view.setButtonState(this.view.NEW_NOTE); + } + this.initializeTableEvents(); + } catch (err) { + this.view.errorMessage(err); + } + } + + initializeTableEvents() { + document.querySelector("table tbody").addEventListener("click", (evt) => { + let target = evt.target.parentElement, + noteId = target.getAttribute("data-note_id"), + content = target.getAttribute("data-content"); + + this.view.updateEditor({ + noteId: noteId, + content: content + }); + this.view.setButtonState(this.view.EXISTING_NOTE); + }); + } + + initializeCreateEvent() { + document.getElementById("create").addEventListener("click", async (evt) => { + let urlPersonId = +document.getElementById("person_id").textContent, + note = document.getElementById("note").value; + + evt.preventDefault(); + try { + await this.model.create(urlPersonId, { + content: note + }); + await this.initializeTable(); + } catch(err) { + this.view.errorMessage(err); + } + }); + } + + initializeUpdateEvent() { + document.getElementById("update").addEventListener("click", async (evt) => { + let personId = +document.getElementById("person_id").textContent, + noteId = +document.getElementById("note_id").textContent, + note = document.getElementById("note").value; + + evt.preventDefault(); + try { + await this.model.update(personId, { + personId: personId, + noteId: noteId, + content: note + }); + await this.initializeTable(); + } catch(err) { + this.view.errorMessage(err); + } + }); + } + + initializeDeleteEvent() { + document.getElementById("delete").addEventListener("click", async (evt) => { + let personId = +document.getElementById("person_id").textContent, + noteId = +document.getElementById("note_id").textContent; + + evt.preventDefault(); + try { + await this.model.delete(personId, noteId); + await this.initializeTable(); + } catch(err) { + this.view.errorMessage(err); + } + }); + } + + initializeResetEvent() { + document.getElementById("reset").addEventListener("click", async (evt) => { + evt.preventDefault(); + this.view.reset(); + this.view.setButtonState(this.view.NEW_NOTE); + }); + } +} + +// Create the MVC components +const model = new Model(); +const view = new View(); +const controller = new Controller(model, view); + +// export the MVC components as the default +export default { + model, + view, + controller +}; + + diff --git a/flask-connexion-rest-part-4/static/js/people.js b/flask-connexion-rest-part-4/static/js/people.js new file mode 100644 index 0000000000..e9f949817b --- /dev/null +++ b/flask-connexion-rest-part-4/static/js/people.js @@ -0,0 +1,311 @@ +/** + * JavaScript file for the People page + */ + +/* jshint esversion: 8 */ + +/** + * This is the model class which provides access to the server REST API + * @type {{}} + */ +class Model { + async read() { + let options = { + method: "GET", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + "accepts": "application/json" + } + }; + // Call the REST endpoint and wait for data + let response = await fetch("/api/people", options); + let data = await response.json(); + return data; + } + + async readOne(personId) { + let options = { + method: "GET", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + "accepts": "application/json" + } + }; + // Call the REST endpoint and wait for data + let response = await fetch(`/api/people/${personId}`, options); + let data = await response.json(); + return data; + } + + async create(person) { + let options = { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + "accepts": "application/json" + }, + body: JSON.stringify(person) + }; + // Call the REST endpoint and wait for data + let response = await fetch(`/api/people`, options); + let data = await response.json(); + return data; + } + + async update(person) { + let options = { + method: "PUT", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + "accepts": "application/json" + }, + body: JSON.stringify(person) + }; + // Call the REST endpoint and wait for data + let response = await fetch(`/api/people/${person.personId}`, options); + let data = await response.json(); + return data; + } + + async delete(personId) { + let options = { + method: "DELETE", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + "accepts": "application/json" + } + }; + // Call the REST endpoint and wait for data + let response = await fetch(`/api/people/${personId}`, options); + return response; + } +} + + +/** + * This is the view class which provides access to the DOM + */ +class View { + constructor() { + this.NEW_NOTE = 0; + this.EXISTING_NOTE = 1; + this.table = document.querySelector(".people table"); + this.error = document.querySelector(".error"); + this.personId = document.getElementById("person_id"); + this.fname = document.getElementById("fname"); + this.lname = document.getElementById("lname"); + this.createButton = document.getElementById("create"); + this.updateButton = document.getElementById("update"); + this.deleteButton = document.getElementById("delete"); + this.resetButton = document.getElementById("reset"); + } + + reset() { + this.personId.textContent = ""; + this.lname.value = ""; + this.fname.value = ""; + this.fname.focus(); + } + + updateEditor(person) { + this.personId.textContent = person.person_id; + this.lname.value = person.lname; + this.fname.value = person.fname; + this.fname.focus(); + } + + setButtonState(state) { + if (state === this.NEW_NOTE) { + this.createButton.disabled = false; + this.updateButton.disabled = true; + this.deleteButton.disabled = true; + } else if (state === this.EXISTING_NOTE) { + this.createButton.disabled = true; + this.updateButton.disabled = false; + this.deleteButton.disabled = false; + } + } + + buildTable(people) { + let tbody, + html = ""; + + // Iterate over the people and build the table + people.forEach((person) => { + html += ` + + ${person.timestamp} + ${person.fname} ${person.lname} + `; + }); + // Is there currently a tbody in the table? + if (this.table.tBodies.length !== 0) { + this.table.removeChild(this.table.getElementsByTagName("tbody")[0]); + } + // Update tbody with our new content + tbody = this.table.createTBody(); + tbody.innerHTML = html; + } + + errorMessage(message) { + this.error.innerHTML = message; + this.error.classList.add("visible"); + this.error.classList.remove("hidden"); + setTimeout(() => { + this.error.classList.add("hidden"); + this.error.classList.remove("visible"); + }, 2000); + } +} + + +/** + * This is the controller class for the user interaction + */ +class Controller { + constructor(model, view) { + this.model = model; + this.view = view; + + this.initialize(); + } + + async initialize() { + await this.initializeTable(); + this.initializeTableEvents(); + this.initializeCreateEvent(); + this.initializeUpdateEvent(); + this.initializeDeleteEvent(); + this.initializeResetEvent(); + } + + async initializeTable() { + try { + let urlPersonId = +document.getElementById("url_person_id").value, + people = await this.model.read(); + + this.view.buildTable(people); + + // Did we navigate here with a person selected? + if (urlPersonId) { + let person = await this.model.readOne(urlPersonId); + this.view.updateEditor(person); + this.view.setButtonState(this.view.EXISTING_NOTE); + + // Otherwise, nope, so leave the editor blank + } else { + this.view.reset(); + this.view.setButtonState(this.view.NEW_NOTE); + } + this.initializeTableEvents(); + } catch (err) { + this.view.errorMessage(err); + } + } + + initializeTableEvents() { + document.querySelector("table tbody").addEventListener("dblclick", (evt) => { + let target = evt.target, + parent = target.parentElement; + + evt.preventDefault(); + + // Is this the name td? + if (target) { + let personId = parent.getAttribute("data-person_id"); + + window.location = `/people/${personId}/notes`; + } + }); + document.querySelector("table tbody").addEventListener("click", (evt) => { + let target = evt.target.parentElement, + person_id = target.getAttribute("data-person_id"), + fname = target.getAttribute("data-fname"), + lname = target.getAttribute("data-lname"); + + this.view.updateEditor({ + person_id: person_id, + fname: fname, + lname: lname + }); + this.view.setButtonState(this.view.EXISTING_NOTE); + }); + } + + initializeCreateEvent() { + document.getElementById("create").addEventListener("click", async (evt) => { + let fname = document.getElementById("fname").value, + lname = document.getElementById("lname").value; + + evt.preventDefault(); + try { + await this.model.create({ + fname: fname, + lname: lname + }); + await this.initializeTable(); + } catch(err) { + this.view.errorMessage(err); + } + }); + } + + initializeUpdateEvent() { + document.getElementById("update").addEventListener("click", async (evt) => { + let personId = +document.getElementById("person_id").textContent, + fname = document.getElementById("fname").value, + lname = document.getElementById("lname").value; + + evt.preventDefault(); + try { + await this.model.update({ + personId: personId, + fname: fname, + lname: lname + }); + await this.initializeTable(); + } catch(err) { + this.view.errorMessage(err); + } + }); + } + + initializeDeleteEvent() { + document.getElementById("delete").addEventListener("click", async (evt) => { + let personId = +document.getElementById("person_id").textContent; + + evt.preventDefault(); + try { + await this.model.delete(personId); + await this.initializeTable(); + } catch(err) { + this.view.errorMessage(err); + } + }); + } + + initializeResetEvent() { + document.getElementById("reset").addEventListener("click", async (evt) => { + evt.preventDefault(); + this.view.reset(); + this.view.setButtonState(this.view.NEW_NOTE); + }); + } +} + +// Create the MVC components +const model = new Model(); +const view = new View(); +const controller = new Controller(model, view); + +// export the MVC components as the default +export default { + model, + view, + controller +}; diff --git a/flask-connexion-rest-part-4/swagger.yml b/flask-connexion-rest-part-4/swagger.yml new file mode 100644 index 0000000000..596bd61da7 --- /dev/null +++ b/flask-connexion-rest-part-4/swagger.yml @@ -0,0 +1,383 @@ +swagger: "2.0" +info: + description: This is the swagger file that goes with our server code + version: "1.0.0" + title: Swagger Rest Article +consumes: + - application/json +produces: + - application/json + +basePath: /api + +# Paths supported by the server application +paths: + /people: + get: + operationId: people.read_all + tags: + - People + summary: Read the entire set of people, sorted by last name + description: Read the entire set of people, sorted by last name + responses: + 200: + description: Successfully read people set operation + schema: + type: array + items: + properties: + person_id: + type: integer + description: Id of the person + fname: + type: string + description: First name of the person + lname: + type: string + description: Last name of the person + timestamp: + type: string + description: Create/Update timestamp of the person + notes: + type: array + items: + properties: + person_id: + type: integer + description: Id of person this note is associated with + note_id: + type: integer + description: Id of this note + content: + type: string + description: content of this note + timestamp: + type: string + description: Create/Update timestamp of this note + + post: + operationId: people.create + tags: + - People + summary: Create a person + description: Create a new person + parameters: + - name: person + in: body + description: Person to create + required: True + schema: + type: object + properties: + fname: + type: string + description: First name of person to create + lname: + type: string + description: Last name of person to create + responses: + 201: + description: Successfully created person + schema: + properties: + person_id: + type: integer + description: Id of the person + fname: + type: string + description: First name of the person + lname: + type: string + description: Last name of the person + timestamp: + type: string + description: Creation/Update timestamp of the person record + + /people/{person_id}: + get: + operationId: people.read_one + tags: + - People + summary: Read one person + description: Read one person + parameters: + - name: person_id + in: path + description: Id of the person to get + type: integer + required: True + responses: + 200: + description: Successfully read person from people data operation + schema: + type: object + properties: + person_id: + type: string + description: Id of the person + fname: + type: string + description: First name of the person + lname: + type: string + description: Last name of the person + timestamp: + type: string + description: Creation/Update timestamp of the person record + notes: + type: array + items: + properties: + person_id: + type: integer + description: Id of person this note is associated with + note_id: + type: integer + description: Id of this note + content: + type: string + description: content of this note + timestamp: + type: string + description: Create/Update timestamp of this note + + put: + operationId: people.update + tags: + - People + summary: Update a person + description: Update a person + parameters: + - name: person_id + in: path + description: Id the person to update + type: integer + required: True + - name: person + in: body + schema: + type: object + properties: + fname: + type: string + description: First name of the person + lname: + type: string + description: Last name of the person + responses: + 200: + description: Successfully updated person + schema: + properties: + person_id: + type: integer + description: Id of the person in the database + fname: + type: string + description: First name of the person + lname: + type: string + description: Last name of the person + timestamp: + type: string + description: Creation/Update timestamp of the person record + + delete: + operationId: people.delete + tags: + - People + summary: Delete a person from the people list + description: Delete a person + parameters: + - name: person_id + in: path + type: integer + description: Id of the person to delete + required: true + responses: + 200: + description: Successfully deleted a person + + /notes: + get: + operationId: notes.read_all + tags: + - Notes + summary: Read the entire set of notes for all people, sorted by timestamp + description: Read the entire set of notes for all people, sorted by timestamp + responses: + 200: + description: Successfully read notes for all people operation + schema: + type: array + items: + properties: + note_id: + type: integer + description: Id of the note + content: + type: string + description: Content of the note + timestamp: + type: string + description: Create/Update timestamp of the note + person: + type: object + properties: + person_id: + type: integer + description: Id of associated person + fname: + type: string + description: Frist name of associated person + lname: + type: string + description: Last name of associated person + timestamp: + type: string + description: Create/Update timestamp of associated person + + + /people/{person_id}/notes: + post: + operationId: notes.create + tags: + - Notes + summary: Create a note associated with a person + description: Create a note associated with a person + parameters: + - name: person_id + in: path + description: Id of person associated with note + type: integer + required: True + - name: note + in: body + description: Text content of the note to create + required: True + schema: + type: object + properties: + content: + type: string + description: Text of the note to create + responses: + 201: + description: Successfully created a note + schema: + properties: + person_id: + type: integer + description: Id of the person associated with the note + note_id: + type: integer + description: Id of the created note + content: + type: string + description: Text content of the note + timestamp: + type: string + description: Creation/Update timestamp of the person record + + /people/{person_id}/notes/{note_id}: + get: + operationId: notes.read_one + tags: + - Notes + summary: Read a particular note associated with a person + description: Read a particular note associated with a person + parameters: + - name: person_id + in: path + description: Id of person associated with note + type: integer + required: True + - name: note_id + in: path + description: Id of note + type: integer + required: True + responses: + 200: + description: Successfully read note for a person + schema: + type: object + properties: + note_id: + type: integer + description: Id of the note + person_id: + type: integer + description: Id of the person note associated with + content: + type: string + description: Text content of the note + timestamp: + type: string + description: Creation/Update timestamp of the note record + + put: + operationId: notes.update + tags: + - Notes + summary: Update a note associated with a person + description: Update a note associated with a person + parameters: + - name: person_id + in: path + description: Id the person to update + type: integer + required: True + - name: note_id + in: path + description: Id of the note associated with a person + type: integer + required: True + - name: note + in: body + schema: + type: object + properties: + content: + type: string + description: Text content of the note to updated + responses: + 200: + description: Successfully updated note + schema: + properties: + note_id: + type: string + description: Id of the note associated with a person + person_id: + type: integer + description: Id of the person in the database + content: + type: string + description: Text content of the updated note + timestamp: + type: string + description: Creation/Update timestamp of the note record + + delete: + operationId: notes.delete + tags: + - Notes + summary: Delete a note associated with a person + description: Delete a note associated with a person + parameters: + - name: person_id + in: path + description: Id of person associated with note + type: integer + required: True + - name: note_id + in: path + description: Id of note + type: integer + required: True + responses: + 200: + description: Successfully deleted a note + + diff --git a/flask-connexion-rest-part-4/templates/home.html b/flask-connexion-rest-part-4/templates/home.html new file mode 100644 index 0000000000..f6fd6f79d7 --- /dev/null +++ b/flask-connexion-rest-part-4/templates/home.html @@ -0,0 +1,38 @@ +{% extends "parent.html" %} +{% block title %}Home Blog{% endblock %} +{% block head %} + {{ super() }} + +{% endblock %} + +{% block page_name %}Mini-Blog Page Demo{% endblock %} + +{% block body %} +
+
+ + + + + + + + + +
People Blog Entries
Creation/Update TimestampPersonNote
+
+
+ + +{% endblock %} + +{% block javascript %} +{{ super() }} + +{% endblock %} diff --git a/flask-connexion-rest-part-4/templates/notes.html b/flask-connexion-rest-part-4/templates/notes.html new file mode 100644 index 0000000000..1779b39080 --- /dev/null +++ b/flask-connexion-rest-part-4/templates/notes.html @@ -0,0 +1,75 @@ +{% extends "parent.html" %} + +{% block title %}Notes{% endblock %} + +{% block head %} +{{ super() }} + +{% endblock %} + +{% block page_name %}Person Note Create/Update/Delete Page{% endblock %} + +{% block body %} +
+ + +
+
+ Person ID: + +
+
+ First Name: + +
+
+ Last Name: + +
+
+ Timestamp: + +
+
+
+
+ Note ID: + +
+ +
+ + + + +
+
+
+ + + + + + + + + + +
Notes
Update TimeContent
+
+
+ +{% endblock %} + +{% block javascript %} +{{ super() }} + +{% endblock %} \ No newline at end of file diff --git a/flask-connexion-rest-part-4/templates/parent.html b/flask-connexion-rest-part-4/templates/parent.html new file mode 100644 index 0000000000..7f6c0ab43f --- /dev/null +++ b/flask-connexion-rest-part-4/templates/parent.html @@ -0,0 +1,30 @@ + + + + + {% block head %} + {% block title %}{% endblock %} Page + + + {% endblock %} + + + + +{% block body %} +{% endblock %} + + +{% block javascript %} +{% endblock %} + + \ No newline at end of file diff --git a/flask-connexion-rest-part-4/templates/people.html b/flask-connexion-rest-part-4/templates/people.html new file mode 100644 index 0000000000..e310a5496d --- /dev/null +++ b/flask-connexion-rest-part-4/templates/people.html @@ -0,0 +1,57 @@ +{% extends "parent.html" %} +{% block title %}People{% endblock %} +{% block head %} + {{ super() }} + +{% endblock %} + +{% block page_name %}Person Create/Update/Delete Page{% endblock %} + +{% block body %} +
+ +
+
+ Person ID: + +
+ +
+ +
+ + + + +
+
+ + + + + + + + +
People
Creation/Update TimestampPerson
+
+
+
+
+
+{% endblock %} + +{% block javascript %} +{{ super() }} + +{% endblock %}