diff --git a/README.md b/README.md new file mode 100644 index 0000000..a209c98 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# GIS App + +This is a small project that can generate random coordinate data, store it in a Postgres database, and fetch the data using a Sanic API. \ No newline at end of file diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..906e47a --- /dev/null +++ b/app/app.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# By: K Agajanian + +# Standard libraries +import json + +# External libraries +from sanic import Sanic, response, HTTPResponse +# from sanic.response import json + +from sqlalchemy import select, func, text +from sqlalchemy.orm import sessionmaker + +# Internal libraries +from db_setup import engine_go_vroom +from db_setup import Trajectory + + +app = Sanic("gis-app") + + +@app.route("/") +def get(request): + engine = engine_go_vroom() + Session = sessionmaker(bind=engine) + session = Session() + + # TODO: Currently hard-coded to get a single trajectory + q = select(Trajectory, func.ST_AsText(Trajectory.geom).label("geom")).where( + Trajectory.id == 2 + ) # select + q = text( + "SELECT ST_AsText(geom), feed_item_id FROM gps.trajectory WHERE id = 2" + ) # text + + with session as s: + trajs = s.execute(q).scalars().one_or_none() + + # return response.json(trajs.to_dict()) # for select stmt + return response.json(trajs) # for text stmt + + +@app.post("/") +def post(request): + engine = engine_go_vroom() + Session = sessionmaker(bind=engine) + session = Session() + + gps_data = parse_data(request.body) + + try: + with session as s: + s.add_all(gps_data) + s.commit() + + return HTTPResponse(status=202) + + except Exception as e: + print(e) + return HTTPResponse(status=400) + + +def parse_data(data): + data = json.loads(data) + feed_item_id = data["feed_item_id"] + del data["feed_item_id"] + + data = data["gps_data"] + + # Create rows from sample data + gps_data = [ + Trajectory( + geom=f"SRID=4326;LINESTRINGZM({data})", + feed_item_id=feed_item_id, + ) + ] + return gps_data + + +if __name__ == "__main__": + app.run() diff --git a/db_setup.py b/app/db_setup.py similarity index 54% rename from db_setup.py rename to app/db_setup.py index 98cf39c..3b70997 100644 --- a/db_setup.py +++ b/app/db_setup.py @@ -2,86 +2,82 @@ # -*- coding: utf-8 -*- # # By: K Agajanian +# Created: 2/21/24 # Standard libraries from datetime import datetime, timezone -import uuid - -# External libraries - -# Internal libraries - -# Created: 2/21/24 -# as of 3/6/24 this fucking works - -# GeoAlchemy ORM Tutorial -# https://geoalchemy-2.readthedocs.io/en/latest/orm_tutorial.html - -import docker -import logging +import enum import os import time import traceback + +# External libraries from geoalchemy2 import Geometry from sqlalchemy import ( create_engine, Column, Integer, + schema, text, - MetaData, - Table, - ForeignKey, - DateTime, TIMESTAMP, UUID, ) -from sqlalchemy.orm import declarative_base, sessionmaker - - -ENV_VARS = { - "MYDB__HOST": "localhost", - "MYDB__DATABASE": "nyc", - "POSTGRES_USER": "nyc", - "MYDB__PORT": 5432, - "MYDB__DRIVERNAME": "postgresql", - "POSTGRES_PASSWORD": "gis", - "platform": "linux/amd64", -} - -LOCALHOST = ENV_VARS["MYDB__HOST"] -DB = ENV_VARS["MYDB__DATABASE"] -DRIVERNAME = ENV_VARS["MYDB__DRIVERNAME"] -USER = ENV_VARS["POSTGRES_USER"] -PW = ENV_VARS["POSTGRES_PASSWORD"] -PORT = ENV_VARS["MYDB__PORT"] +from sqlalchemy.orm import declarative_base +from sqlalchemy.orm.state import InstanceState +# Internal libraries Base = declarative_base() class Trajectory(Base): __tablename__ = "trajectory" + __table_args__ = {"schema": "gps"} id = Column(Integer, primary_key=True) create_time = Column(TIMESTAMP, default=datetime.now(timezone.utc)) updated_time = Column(TIMESTAMP, default=datetime.now(timezone.utc)) geom = Column(Geometry("LINESTRINGZM")) feed_item_id = Column(UUID) + def to_dict(self): + d = {} + + for field, value in self.__dict__.items(): + if any( + ( + isinstance(value, InstanceState), + isinstance(value, list), + ) + ): + continue + if isinstance(value, (int, float, bool, str, type(None))): + d[field] = value + elif isinstance(value, enum.Enum): + d[field] = value.name + else: + d[field] = str(value) + + return d + def setup_pg(): """Create GIS engine, connect, and create tables.""" - url = f"{DRIVERNAME}://{USER}:{PW}@{LOCALHOST}:{PORT}/{DB}" - engine = create_engine(url, echo=True) + # Start sqlalchemy engine & wait for connection + engine = engine_go_vroom() pg_check(engine=engine) - # # Create Postgis extension & check that it works + # # Create Postgis extension and new schema with engine.connect() as conn: conn.execute(text("CREATE EXTENSION IF NOT EXISTS postgis")) conn.execute(text("SELECT postgis_full_version();")) + conn.execute(schema.CreateSchema("gps")) + conn.commit() + # Create Trajectory table in gps schema + with engine.connect() as conn: Trajectory.__table__.create(engine) conn.commit() @@ -107,30 +103,25 @@ def pg_check(engine, max_retries=10): raise exc_ -# Still needs fixing -def insert_sample_data(engine): - Session = sessionmaker(bind=engine) - session = Session() - - # Generate sample data - sample_data = [ - Trajectory( - geom="SRID=4326;LINESTRINGZM(0 0 0 0, 1 1 1 1)", - feed_item_id=uuid.uuid4(), - ) - for _ in range(20) - ] +def engine_go_vroom(): + USER = os.getenv("POSTGRES_USER", "nyc") + PW = os.getenv("POSTGRES_PASSWORD", "gis") + DB = os.getenv("POSTGRES_DB", "nyc") + HOST = os.getenv( + "POSTGRES_HOST", "localhost" + ) # this localhost only applies if running script locally + DRIVERNAME = os.getenv("POSTGRES_DRIVERNAME", "postgresql") + PORT = os.getenv("PORT", "5432") + url = f"{DRIVERNAME}://{USER}:{PW}@{HOST}:{PORT}/{DB}" + engine = create_engine(url, echo=True) - session.add_all(sample_data) - session.commit() + return engine if __name__ == "__main__": try: setup_pg() print("DB set up!") - insert_sample_data() - print("Sample data added!") except Exception as e: print(traceback.format_exc()) print(e) diff --git a/app/generate_data.py b/app/generate_data.py new file mode 100644 index 0000000..4bdc516 --- /dev/null +++ b/app/generate_data.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# By: K Agajanian +# Created: 4/18/24 + +# Standard libraries +import random +import uuid + +# from datetime import datetime + +# External libraries +from sqlalchemy.orm import sessionmaker + +# Internal libraries +from db_setup import Trajectory, engine_go_vroom + + +# Pre-determined areas for random data generation +central_park = {"lon": (-73.973, -73.958), "lat": (40.765, 40.800)} +northeast = {"lon": (-70, -75), "lat": (40, 43)} + + +def main(): + """Enter sample data into Trajectory table.""" + engine = engine_go_vroom() + Session = sessionmaker(bind=engine) + session = Session() + + for _ in range(5): + try: + # Create rows from sample data + sample_data = [ + Trajectory( + geom=f"SRID=4326;LINESTRINGZM({create_sample_linestring(central_park)})", + feed_item_id=uuid.uuid4(), + ) + ] + + session.add_all(sample_data) + session.commit() + + except Exception as e: + print(e) + continue + + +def create_sample_linestring(area): + linestring = "" + + num_of_entries = int(random.uniform(1, 5)) # must have at least 2 + time = 0 + + for n in range(num_of_entries): + lon = round(random.uniform(area["lon"][0], area["lon"][1]), 3) # x coordinate + lat = round(random.uniform(area["lat"][0], area["lat"][1]), 3) # y coordinate + alt = round(random.uniform(-1, 1), 0) # altitude + time = time + int(random.uniform(0, 5)) # time + linestring += f"{lon} {lat} {alt} {time}," + + # Linestring hates being alone and I don't wanna deal with POINTs + if num_of_entries <= 1: + linestring += linestring + + linestring = linestring[:-1] + print(f"HERE'S MY LINESTRING BETCH: {linestring}") + return linestring + + +if __name__ == "__main__": + main() diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..79c7f6a --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,4 @@ +geoalchemy2~=0.14.4 +psycopg2-binary~=2.9.9 +sanic~=23.12.1 +sqlalchemy~=2.0.24 diff --git a/docker/Dockerfile b/docker/Dockerfile index 8e9ff5b..834bc31 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,12 +1,14 @@ -FROM python +FROM python:3.11-slim-bullseye -COPY ./ . +# Set the working directory in the container +WORKDIR /app -ENV POSTGRES_USER nyc -ENV POSTGRES_PASSWORD gis -ENV POSTGRES_DB nyc +# Copy the current directory contents into the container at /app +COPY ../app . -RUN pip install -r requirements.txt -CMD [python db_setup.py] +# Install any needed packages specified in requirements.txt +RUN pip install --no-cache-dir -r requirements.txt +# Run db_setup.py when the container launches +CMD ["python", "db_setup.py"] diff --git a/docker/compose.yaml b/docker/compose.yaml index 7f492fa..04d1bf3 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -1,21 +1,27 @@ + version: "3" +x-common-variables: &common-variables + POSTGRES_USER: nyc + POSTGRES_PASSWORD: gis #${POSTGRES_PASSWORD} + POSTGRES_DB: nyc + POSTGRES_HOST: db + POSTGRES_DRIVERNAME: postgresql + PORT: 5432 + + services: db: - container_name: geos + container_name: db image: postgis/postgis:14-3.4 restart: always - environment: - POSTGRES_USER: nyc # ${POSTGRES_USER} - POSTGRES_PASSWORD: gis #${POSTGRES_PASSWORD} - POSTGRES_DB: nyc # ${POSTGRES_DB} + environment: *common-variables ports: - "5432:5432" - pgadmin: # Not necessary since using SRID 4326, but keeping it anyway - container_name: pgadmin4_container - image: dpage/pgadmin4 - restart: always - environment: - PGADMIN_DEFAULT_EMAIL: kei@admin.com - PGADMIN_DEFAULT_PASSWORD: root - ports: - - "5050:80" \ No newline at end of file + + db-setup: # create Trajectory table + container_name: db-setup + environment: *common-variables + build: + context: .. + dockerfile: docker/Dockerfile + diff --git a/docker/requirements.txt b/docker/requirements.txt deleted file mode 100644 index b10607c..0000000 --- a/docker/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -sqlalchemy \ No newline at end of file diff --git a/generate_data.py b/generate_data.py deleted file mode 100644 index 4e7faef..0000000 --- a/generate_data.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# By: K Agajanian -# Created: 4/18/24 - -# Standard libraries -from datetime import datetime - -# External libraries -from geoalchemy2 import Geometry -from sqlalchemy import Column, create_engine, text, insert - -# Internal libraries -from db_setup import Trajectory, DRIVERNAME, USER, PW, LOCALHOST, PORT, DB - - -def create_new_data(): - # connect to engine - url = f"{DRIVERNAME}://{USER}:{PW}@{LOCALHOST}:{PORT}/{DB}" - engine = create_engine(url, echo=True) - - with engine.connect() as conn: - stmt = insert(Trajectory).values( - id=1234, - ) - - -def main(): - pass - - -if __name__ == "__main__": - main()