diff --git a/.gitignore b/.gitignore index f764c0e..f237e98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +duckpond.db ### Python ### # Byte-compiled / optimized / DLL files diff --git a/backend/README.md b/backend/README.md index 2d93090..f407cd7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1 +1,58 @@ -# Duck Pond backend \ No newline at end of file +# Duck Pond backend + +## Endpoints + +### GET `/entries` + +Gets a list of all the duck ponds in the database. + +### POST `/entries//new` + +Creates a new duck pond entry. + +JSON body arguments: +* `name` - reqiured +* `location` - required, in the form + ```json + { + "lat": -1.2345, + "long": 33.56643 + } + ``` +* `imageURL` + +Votes will be initialised as zero. + +Return sample: + +```json +{ + "id": "uuid" +} +``` + +### PATCH `/entries/` + +Updates an entry with the ID `id`. + +JSON body arguments: +* `name` +* `location` +* `imageURL` +* `votes` + +### GET `/entries/` + +Gets the JSON of the pond. + +```json +{ + "id": "uuid", + "name": "The Pondiest Pond", + "location": { + "lat": -1.2345, + "long": 33.56643 + }, + "imageURL": "https://example.com" +} +``` \ No newline at end of file diff --git a/backend/duckpond/__init__.py b/backend/duckpond/__init__.py deleted file mode 100644 index b794fd4..0000000 --- a/backend/duckpond/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '0.1.0' diff --git a/backend/duckpond/__main__.py b/backend/duckpond/__main__.py new file mode 100644 index 0000000..516b8f7 --- /dev/null +++ b/backend/duckpond/__main__.py @@ -0,0 +1,20 @@ +import flask + +import db +import endpoints + + +__version__ = '0.1.0' + + +def main(): + app = flask.Flask(__name__) + + database = db.DB("duckpond.db") + _ = endpoints.Endpoints(app, database) + + app.run(port=8080, debug=True, host="127.0.0.1") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/duckpond/db.py b/backend/duckpond/db.py new file mode 100644 index 0000000..07b94a5 --- /dev/null +++ b/backend/duckpond/db.py @@ -0,0 +1,90 @@ +import sqlite3 +from dataclasses import dataclass +from typing import * +import urllib + +@dataclass +class Entry: + id: str + name: str + location_lat: float + location_long: float + votes: int + image_url: Optional[str] + + def validate(self, only_populated_fields=False) -> Tuple[bool, str]: + if (self.name == "" or self.name is None) and not only_populated_fields: + return False, "name cannot be empty" + + if self.votes < 0: + return False, "votes cannot be negative" + + if self.image_url is not None: + try: + urllib.parse.urlparse(self.image_url) + except Exception: + return False, "invalid URL" + + if self.location_lat is None or self.location_long is None: + return False, "missing locations" + + return True, "" + + def as_dict(self) -> Dict: + res = {} + + res["id"] = self.id + res["name"] = self.name + res["location"] = { + "lat": self.location_lat, + "long": self.location_long, + } + res["votes"] = self.votes + + if self.image_url is not None: + res["imageURL"] = self.image_url + + return res + + +class DB: + conn: sqlite3.Connection + + def __init__(self, filename: str): + self.conn = sqlite3.connect(filename) + + def _setup(self, filename: str): + cursor = self.conn.cursor() + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS entries + ([ID] INTEGER PRIMARY KEY, [title] TEXT, [latitude] FLOAT, [longitude] FLOAT, [votes] INTEGER, [image_url], STRING) + ''') + + cursor.commit() + + def getEntries(self): + cursor = self.conn.cursor() + cursor.execute('SELECT * FROM entries') + + myresult = cursor.fetchall() + + for x in myresult: + print(x) + + def addEntry(self, title, latitude, longitude, votes, image_url): + insertArray = [title, latitude, longitude, votes, image_url] + cursor = self.conn.cursor() + cursor.execute('INSERT INTO entries (title, latitude, longitude, votes, image_url) VALUES (?, ?, ?, ?, ?);', insertArray) + cursor.commit() + + def deleteEntry(self, ID): + cursor = self.conn.cursor() + cursor.execute('DELETE FROM entries WHERE ID = ?', ID) + cursor.commit() + + def updateEntry(self, ID, title, latitude, longitude, votes, image_url): + updateArray = [ID, title, latitude, longitude, votes, image_url] + cursor = self.conn.cursor() + cursor.execute('UPDATE entries SET title = ?, latitude = ?, longitude = ?, votes = ?, image_url = ?;', updateArray) + cursor.commit() \ No newline at end of file diff --git a/backend/duckpond/endpoints.py b/backend/duckpond/endpoints.py new file mode 100644 index 0000000..0592a7d --- /dev/null +++ b/backend/duckpond/endpoints.py @@ -0,0 +1,66 @@ +from typing import * +import uuid +import flask + +import db +import paths + + +class Endpoints: + db: db.DB + + def __init__(self, app: flask.Flask, database: db.DB): + self.db = database + + app.add_url_rule(paths.ENTRIES, view_func=self.list_entries, methods=["GET"]) + app.add_url_rule(paths.GET_ENTRY, view_func=self.get_entry, methods=["GET"]) + app.add_url_rule(paths.UPDATE_ENTRY, view_func=self.update_entry, methods=["PATCH"]) + app.add_url_rule(paths.CREATE_ENTRY, view_func=self.create_entry, methods=["POST"]) + + def list_entries(self): + # TODO: populate from databaase + + a = db.Entry("203fc6a0-9587-41a4-9862-e1b72039b98b", "Birmingham Duck Pond", -1.2345, 33.4567, 0, None) + b = db.Entry("b140e048-ea2c-4827-b670-ef41ba48c56d", "Northwich Duck Pond", -3.2345, 25.4567, 0, None) + + return flask.jsonify([a.as_dict(), b.as_dict()]) + + def get_entry(self, entry_id: str): + # TODO: Fetch from database + + return flask.jsonify({ + "id": entry_id, + "TODO": "TODO" + }) + + def update_entry(self): + return "", 204 + + def create_entry(self): + body = flask.request.get_json() + if body is None: + return "no JSON body", 400 + + coordinates = body.get("location", None) + if coordinates is None: + return "missing location", 400 + + new_entry = db.Entry( + uuid.uuid4(), + body.get("name"), + coordinates.get("lat"), + coordinates.get("long"), + 0, + body.get("imageURL")) + + validation_result, error_text = new_entry.validate() + + if not validation_result: + return flask.abort(400, error_text) + + # TODO: store in database + # TODO: Form responses + + return flask.jsonify({ + "id": uuid.uuidv4() + }) diff --git a/backend/duckpond/paths.py b/backend/duckpond/paths.py new file mode 100644 index 0000000..ff2e699 --- /dev/null +++ b/backend/duckpond/paths.py @@ -0,0 +1,4 @@ +ENTRIES = "/entries" +GET_ENTRY = "/entry/" +UPDATE_ENTRY = "/entry/" +CREATE_ENTRY = "/entry" \ No newline at end of file diff --git a/backend/poetry.lock b/backend/poetry.lock new file mode 100644 index 0000000..6a76eea --- /dev/null +++ b/backend/poetry.lock @@ -0,0 +1,241 @@ +[[package]] +name = "atomicwrites" +version = "1.4.1" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "22.1.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "flask" +version = "2.2.2" +description = "A simple framework for building complex web applications." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=8.0" +importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} +itsdangerous = ">=2.0" +Jinja2 = ">=3.0" +Werkzeug = ">=2.2.2" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "importlib-metadata" +version = "5.0.0" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"] +perf = ["ipython"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] + +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markupsafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "more-itertools" +version = "9.0.0" +description = "More routines for operating on iterables, beyond itertools" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "dev" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pytest" +version = "5.4.3" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=17.4.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +more-itertools = ">=4.0.0" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.5.0" +wcwidth = "*" + +[package.extras] +checkqa-mypy = ["mypy (==v0.761)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "werkzeug" +version = "2.2.2" +description = "The comprehensive WSGI web application library." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog"] + +[[package]] +name = "zipp" +version = "3.10.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "jaraco.functools", "more-itertools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.8" +content-hash = "6662025d53c721988eccf3e4101ee5d3686702f80c82f900e6f4d087282743a1" + +[metadata.files] +atomicwrites = [] +attrs = [] +click = [] +colorama = [] +flask = [] +importlib-metadata = [] +itsdangerous = [] +jinja2 = [] +markupsafe = [] +more-itertools = [] +packaging = [] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +py = [] +pyparsing = [] +pytest = [] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] +werkzeug = [] +zipp = [] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 66d020a..a094268 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,6 +6,7 @@ authors = ["AKP "] [tool.poetry.dependencies] python = "^3.8" +Flask = "^2.2.2" [tool.poetry.dev-dependencies] pytest = "^5.2" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7e10602 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1 @@ +flask