From 8c0891d7a8de072808523516fde7e4deb5990bcb Mon Sep 17 00:00:00 2001 From: mseng10 Date: Tue, 24 Sep 2024 20:02:47 -0500 Subject: [PATCH 01/44] BUG: Fix background.py --- server/.gitignore | 2 +- server/{background => }/background.py | 32 ++++++++++++--------------- server/background/__init__.py | 0 3 files changed, 15 insertions(+), 19 deletions(-) rename server/{background => }/background.py (71%) delete mode 100644 server/background/__init__.py diff --git a/server/.gitignore b/server/.gitignore index d7f7bcf..a14ed53 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -3,5 +3,5 @@ venv/ .idea/ *.log - +*.pyc *.json \ No newline at end of file diff --git a/server/background/background.py b/server/background.py similarity index 71% rename from server/background/background.py rename to server/background.py index d22b5a0..82b1b0e 100644 --- a/server/background/background.py +++ b/server/background.py @@ -2,35 +2,32 @@ Process dedicated to doing background processing on this application. This creates alerts, manages connections to active systems, etc. """ +from datetime import datetime +import logging + from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.engine import URL -from models.plant import Plant, Genus, Type -from models.system import System, Light +from models.plant import Plant from models.alert import PlantAlert -from models.todo import Todo, Task -from models.background.background import Ba - -from shared.db import init_db -from shared.db import Session +from shared.db import init_db, Session from shared.logger import setup_logger -import logging - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +# Create a logger for this specific module +logger = setup_logger(__name__, logging.DEBUG) +# Initialize DB connection init_db() def create_plant_alert(): """ Create different plant alerts. Right now just supports creating watering alerts. """ - db = Session() + session = Session() - existing_plant_alrts = db.query(PlantAlert).filter(PlantAlert.deprecated == False).all() + existing_plant_alrts = session.query(PlantAlert).filter(PlantAlert.deprecated == False).all() existing_plant_alrts_map = {} for existing_plant_alert in existing_plant_alrts: existing_plant_alrts_map[existing_plant_alert.plant_id] = existing_plant_alert @@ -46,17 +43,16 @@ def create_plant_alert(): plant_alert_type = "water" ) # Create the alert in the db - db.add(new_plant_alert) + session.add(new_plant_alert) existing_plant_alrts[new_plant_alert.plant_id] = new_plant_alert - db.commit() - db.close() + session.commit() + session.close() def main(): create_plant_alert() - if __name__ == "__main__": + logger.info("Starting background processing.") while True: - logger.info("Starting background processing.") main() \ No newline at end of file diff --git a/server/background/__init__.py b/server/background/__init__.py deleted file mode 100644 index e69de29..0000000 From 7f08749b3eca08cccc4101c2908d1585e4aa8b4e Mon Sep 17 00:00:00 2001 From: mseng10 Date: Wed, 25 Sep 2024 23:00:17 -0500 Subject: [PATCH 02/44] BUG: Plant edit from grid --- client/src/pages/plant/Plants.js | 4 ++-- server/adaptor_app.py | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/client/src/pages/plant/Plants.js b/client/src/pages/plant/Plants.js index 2277e63..92a2783 100644 --- a/client/src/pages/plant/Plants.js +++ b/client/src/pages/plant/Plants.js @@ -97,8 +97,8 @@ const Plants = ({ initialPlants }) => { checkboxSelection disableRowSelectionOnClick onRowSelectionModelChange={(newSelectionModel) => { - console.log(newSelectionModel); - const newSelectedPlants = newSelectionModel.map(index => plants[index - 2]); // why + const newSelectedPlants = newSelectionModel.map(index => plants[index-1]); + console.log(newSelectedPlants) setSelectedPlants(newSelectedPlants); }} diff --git a/server/adaptor_app.py b/server/adaptor_app.py index 03512e3..ae31ab2 100644 --- a/server/adaptor_app.py +++ b/server/adaptor_app.py @@ -1,16 +1,14 @@ """ Adaptor server. """ - import os +import logging from flask import Flask, request, jsonify, Response from flask_cors import CORS from shared.adaptor import generate_frames, read_sensor - from shared.logger import setup_logger -import logging logger = setup_logger(__name__, logging.DEBUG) From 9aee9566e9c5165c203570f0ad61d25324e7f6ed Mon Sep 17 00:00:00 2001 From: mseng10 Date: Thu, 26 Sep 2024 20:30:49 -0500 Subject: [PATCH 03/44] BUG: Can't edit system from grid --- client/src/pages/system/Systems.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/pages/system/Systems.js b/client/src/pages/system/Systems.js index 07d7e66..39bb9c5 100644 --- a/client/src/pages/system/Systems.js +++ b/client/src/pages/system/Systems.js @@ -74,7 +74,7 @@ const SystemCard = ({ system, deprecateSystem }) => { - navigate(`/system/${system.id}`)}> + navigate(`/systems/${system.id}`)}> setIsSystemsAlertsOpen(true)}> From b9a5f37d31ea7309a79e7227e5898ec7e408e1c5 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sat, 28 Sep 2024 11:56:16 -0500 Subject: [PATCH 04/44] BUG: Allow creation of container agnostic systems --- server/models/system.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/models/system.py b/server/models/system.py index 2212c07..cb09364 100644 --- a/server/models/system.py +++ b/server/models/system.py @@ -58,7 +58,7 @@ class System(Base, DeprecatableMixin, FlexibleModel): last_temperature = Column(Integer(), nullable=True) # F # Internal - container_id = Column(String(64), unique=True, nullable=False) + container_id = Column(String(64), unique=True, nullable=True) url = Column(String(200), nullable=False) # Plants belonging to this system @@ -93,4 +93,4 @@ def __repr__(self) -> str: 'distance': FieldConfig(), # 'plants': FieldConfig(nested=Plant.schema, include_nested=True, delete_with_parent=True) # 'lights': FieldConfig(nested=Light.schema, include_nested=True, delete_with_parent=True) - }) \ No newline at end of file + }) From 90eb903fc5c2be5e1e622f2682cb0ed594e11879 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 29 Sep 2024 16:55:14 -0500 Subject: [PATCH 05/44] BUG: Fix plant name display --- client/src/AppNavigation.js | 2 +- client/src/constants.js | 5 +++-- client/src/modals/plant/DeprecatePlantsForm.js | 4 +++- client/src/modals/plant/WaterPlantsForm.js | 7 ++++--- client/src/pages/plant/PlantUpdate.js | 6 ++---- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/client/src/AppNavigation.js b/client/src/AppNavigation.js index b2af2ea..be0188e 100644 --- a/client/src/AppNavigation.js +++ b/client/src/AppNavigation.js @@ -41,7 +41,7 @@ function AppNavigation({ window }) { { id: 'plants', url: '/plants', icon: 'plant', label: 'Plant' }, { id: 'systems', url: '/systems', icon: 'system', label: 'System' }, ]}, - { id: 'alert', url: '/alerts', icon: 'alert', color: 'primary', badgeCount: meta.alert_count, badgeCountColor: "error" }, + { id: 'alert', url: '/alerts', icon: 'alert', color: 'error', badgeCount: meta.alert_count, badgeCountColor: "error" }, { id: 'todo', url: '/todos', icon: 'todo', color: 'primary', badgeCount: meta.todo_count}, { id: 'stats', url: '/stats', icon: 'stats', color: 'primary'}, ]; diff --git a/client/src/constants.js b/client/src/constants.js index 97debe7..8f1ea62 100644 --- a/client/src/constants.js +++ b/client/src/constants.js @@ -1,9 +1,10 @@ // Plant Phases export const PHASE_LABELS = { + adult: "Adult", cutting: "Cutting", - seed: "Seed", juvy: "Juvy", - adult: "Adult", + leaf: "Leaf", + seed: "Seed", }; export const CARD_STYLE = { diff --git a/client/src/modals/plant/DeprecatePlantsForm.js b/client/src/modals/plant/DeprecatePlantsForm.js index d32d1e0..e442f05 100644 --- a/client/src/modals/plant/DeprecatePlantsForm.js +++ b/client/src/modals/plant/DeprecatePlantsForm.js @@ -9,6 +9,7 @@ import Checkbox from '@mui/material/Checkbox'; import Divider from '@mui/material/Divider'; import Modal from '@mui/material/Modal'; import { MODAL_STYLE } from '../../constants'; +import { useSpecies } from '../../hooks/usePlants'; // Enum for cause of death const CauseOfDeath = Object.freeze({ @@ -26,6 +27,7 @@ const DeprecatePlantsForm = ({isOpen, initialPlants, onRequestClose}) => { const { plants, isLoading, error, setPlants, deprecatePlants } = usePlants(initialPlants); const [checkedPlants, setCheckedPlants] = useState([]); const [allChecked, setAllChecked] = useState(true); + const {species} = useSpecies(); const [causeOfDeath, setCauseOfDeath] = useState(''); const [formError, setFormError] = useState(null); @@ -152,7 +154,7 @@ const DeprecatePlantsForm = ({isOpen, initialPlants, onRequestClose}) => { /> } > - + _s.id === plant.species_id)?.name || 'N/A'} style={{ color: "black" }}/> diff --git a/client/src/modals/plant/WaterPlantsForm.js b/client/src/modals/plant/WaterPlantsForm.js index f7ac261..765d354 100644 --- a/client/src/modals/plant/WaterPlantsForm.js +++ b/client/src/modals/plant/WaterPlantsForm.js @@ -10,9 +10,11 @@ import Divider from '@mui/material/Divider'; import Modal from '@mui/material/Modal'; import { MODAL_STYLE } from '../../constants'; import { ServerError } from '../../elements/Page'; +import { useSpecies } from '../../hooks/usePlants'; const WaterPlantsForm = ({ isOpen, initialPlants, onRequestClose }) => { const { plants, isLoading, error, setPlants, waterPlants } = usePlants(initialPlants); + const {species} = useSpecies(); const [checkedPlants, setCheckedPlants] = useState([]); const [allChecked, setAllChecked] = useState(true); const [formError, setFormError] = useState(null); @@ -23,8 +25,7 @@ const WaterPlantsForm = ({ isOpen, initialPlants, onRequestClose }) => { setCheckedPlants([...plants]); setAllChecked(true); } - console.log(initialPlants); - }, [plants, initialPlants]); + }, [plants, species, initialPlants]); const handleToggle = (plant) => () => { const currentIndex = checkedPlants.findIndex(_p => _p.id === plant.id); @@ -127,7 +128,7 @@ const WaterPlantsForm = ({ isOpen, initialPlants, onRequestClose }) => { /> } > - + _s.id === plant.species_id)?.name || 'N/A'} style={{ color: "black" }}/> diff --git a/client/src/pages/plant/PlantUpdate.js b/client/src/pages/plant/PlantUpdate.js index 87641d7..f9b8436 100644 --- a/client/src/pages/plant/PlantUpdate.js +++ b/client/src/pages/plant/PlantUpdate.js @@ -51,7 +51,6 @@ const PlantUpdate = ({ plantProp }) => { }, [plantProp, plants, id, species, mixes, systems]); const handleSubmit = async (event) => { - console.log(system); event.preventDefault(); const updatedPlant = { id, @@ -63,10 +62,9 @@ const PlantUpdate = ({ plantProp }) => { watering, phase }; - console.log(updatedPlant.system_id); try { await updatePlant(updatedPlant); - navigate("/"); + navigate("/plants"); } catch (error) { console.error('Error updating plant:', error); // You might want to show an error message to the user here @@ -74,7 +72,7 @@ const PlantUpdate = ({ plantProp }) => { }; const handleCancel = () => { - navigate("/"); + navigate("/plants"); }; if (isLoading) return ; From 422f2a84b2ef2d069f561c907dbef97c0b8da06b Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 29 Sep 2024 17:14:55 -0500 Subject: [PATCH 06/44] BUG: Fix custom routes in system --- server/models/plant.py | 10 +++++++++- server/routes/stat_routes.py | 1 - server/routes/system_routes.py | 26 +++++++++----------------- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/server/models/plant.py b/server/models/plant.py index a954711..db533df 100644 --- a/server/models/plant.py +++ b/server/models/plant.py @@ -5,9 +5,10 @@ # Standard library imports from datetime import datetime from typing import List +import enum # Third-party imports -from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Enum from sqlalchemy.orm import Mapped, relationship, mapped_column from sqlalchemy.ext.declarative import declared_attr @@ -27,6 +28,13 @@ def deprecated_on(cls): def deprecated_cause(cls): return Column(String(400), nullable=True) +class PHASES(enum.Enum): + ADULT = "Adult" + CUTTING = "Cutting" + JUVY = "Juvy" + LEAD = "Leaf" + SEED = "Seed" + class Plant(Base, DeprecatableMixin, FlexibleModel): """Plant model.""" diff --git a/server/routes/stat_routes.py b/server/routes/stat_routes.py index 0697033..294116f 100644 --- a/server/routes/stat_routes.py +++ b/server/routes/stat_routes.py @@ -38,7 +38,6 @@ def stats(): "total_cost": total_cost, "total_active_cost": total_active_cost } - print(stats) db.close() logger.info("Successfully generated statistical data.") diff --git a/server/routes/system_routes.py b/server/routes/system_routes.py index 5c0296f..537a8e9 100644 --- a/server/routes/system_routes.py +++ b/server/routes/system_routes.py @@ -2,6 +2,8 @@ from shared.db import Session from shared.logger import logger +from models.plant import Plant +from models.alert import PlantAlert from models.system import System, Light from routes import GenericCRUD, APIBuilder @@ -9,43 +11,33 @@ system_crud = GenericCRUD(System, System.schema) APIBuilder.register_resource(system_bp, 'systems', system_crud) -@APIBuilder.register_custom_route(system_bp, '/plants/', ['GET']) +@APIBuilder.register_custom_route(system_bp, '/systems//plants/', ['GET']) def get_systems_plants(system_id): """ Get system's plants. """ - # Log the request logger.info("Received request to get a system's plants") db = Session() plants = db.query(Plant).filter(Plant.system_id == system_id).all() db.close() - # Transform plant alerts to JSON format - plants_json = [plant.to_json() for plant in plants] + return jsonify([Plant.schema.serialize(plant) for plant in plants]) - # Return JSON response - return jsonify(plants_json) - -@APIBuilder.register_custom_route(system_bp, "/alerts/", ["GET"]) +@APIBuilder.register_custom_route(system_bp, "/systems//alerts/", ["GET"]) def get_systems_alerts(system_id): """ Get system's alerts. """ - # Log the request logger.info("Received request to get a system's alerts") db = Session() - plant_alerts = db.query(PlantAlert).filter(Plant.system_id == system_id).all() + plant_alerts = db.query(PlantAlert).filter(PlantAlert.system_id == system_id).all() db.close() - # Transform plant alerts to JSON format - plant_alerts_json = [plant_alert.to_json() for plant_alert in plant_alerts] - - # Return JSON response - return jsonify(plant_alerts_json) + return jsonify([PlantAlert.schema.serialize(plant_alert) for plant_alert in plant_alerts]) -@APIBuilder.register_custom_route(system_bp, "/video_feed/", ["GET"]) +@APIBuilder.register_custom_route(system_bp, "/systems//video_feed/", ["GET"]) def get_video_feed(system_id): session = Session() system = session.query(System).get(system_id) @@ -67,7 +59,7 @@ def generate(): return Response(generate(), mimetype='multipart/x-mixed-replace; boundary=frame') -@APIBuilder.register_custom_route(system_bp, "/sensor_data/", ["GET"]) +@APIBuilder.register_custom_route(system_bp, "/systems//sensor_data/", ["GET"]) def get_sensor_data(system_id): session = Session() system = session.query(System).get(system_id) From 3e6aa5dc5d208b8b16c69ab8d57fc6a91bf8d406 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 29 Sep 2024 17:25:46 -0500 Subject: [PATCH 07/44] MISC: Mix grid view --- client/src/App.js | 2 ++ client/src/AppNavigation.js | 1 + client/src/pages/mix/Mixes.js | 68 +++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 client/src/pages/mix/Mixes.js diff --git a/client/src/App.js b/client/src/App.js index 64ff4bb..17db357 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -27,6 +27,7 @@ import TodoCreate from './pages/todo/TodoCreate'; import TodoUpdate from './pages/todo/TodoUpdate'; import MixCreate from './pages/mix/MixCreate'; import Stats from './pages/stat/Stats'; +import Mixes from './pages/mix/Mixes'; const drawerWidth = 240; @@ -156,6 +157,7 @@ function App() { {/* } /> */} + } /> } /> diff --git a/client/src/AppNavigation.js b/client/src/AppNavigation.js index be0188e..1ce6b0b 100644 --- a/client/src/AppNavigation.js +++ b/client/src/AppNavigation.js @@ -39,6 +39,7 @@ function AppNavigation({ window }) { ]}, { id: 'view', icon: 'view', color: 'primary', subMenu: [ { id: 'plants', url: '/plants', icon: 'plant', label: 'Plant' }, + { id: 'mixes', url: '/mixes', icon: 'mix', label: 'Mix' }, { id: 'systems', url: '/systems', icon: 'system', label: 'System' }, ]}, { id: 'alert', url: '/alerts', icon: 'alert', color: 'error', badgeCount: meta.alert_count, badgeCountColor: "error" }, diff --git a/client/src/pages/mix/Mixes.js b/client/src/pages/mix/Mixes.js new file mode 100644 index 0000000..f73c0a5 --- /dev/null +++ b/client/src/pages/mix/Mixes.js @@ -0,0 +1,68 @@ +import React from 'react'; +import Grid from '@mui/material/Grid'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import { CardActionArea, CardHeader } from '@mui/material'; +import Avatar from '@mui/material/Avatar'; +import IconButton from '@mui/material/IconButton'; +import CardActions from '@mui/material/CardActions'; +import { CARD_STYLE, AVATAR_STYLE } from '../../constants'; +import { EditSharp } from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; +import { NoData, ServerError, Loading } from '../../elements/Page'; +import { useMixes } from '../../hooks/useMix'; +import DeleteOutlineSharpIcon from '@mui/icons-material/DeleteOutlineSharp'; +import PieChartOutlineSharpIcon from '@mui/icons-material/PieChartOutlineSharp'; + +const MixCard = ({ mix, deprecateMix }) => { + const navigate = useNavigate(); + + return ( + <> + + + + + + } + title={mix.name} + subheader={mix.created_on} + /> + + {mix.description} + + + navigate(`/mixes/${mix.id}`)}> + + + deprecateMix(mix.id)}> + + + + + + + ); +}; + +const Mixes = () => { + const { mixes, isLoading, error, deprecateMix } = useMixes(); + + if (isLoading) return ; + if (error) return ; + if (mixes.length == 0) return ; + + return ( + + {mixes.map((mix) => ( + + + + ))} + + ); +}; + +export default Mixes; \ No newline at end of file From ff3899163394783e5aba721431f7a0aa27fd2f37 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Mon, 7 Oct 2024 16:48:56 -0500 Subject: [PATCH 08/44] BUG: Fix docker startup --- client/src/pages/mix/MixCreate.js | 1 - docker-compose.yml | 15 ++++++++++++--- server/routes/system_routes.py | 24 ------------------------ 3 files changed, 12 insertions(+), 28 deletions(-) diff --git a/client/src/pages/mix/MixCreate.js b/client/src/pages/mix/MixCreate.js index ae39386..6774b9b 100644 --- a/client/src/pages/mix/MixCreate.js +++ b/client/src/pages/mix/MixCreate.js @@ -110,7 +110,6 @@ const MixCreate = () => {
-
Data

from 16th April, 2014
diff --git a/docker-compose.yml b/docker-compose.yml index 2fd87b7..fa4e088 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,19 @@ version: '3.8' - services: backend: build: ./server ports: - - "5000:5000" + - "5001:5000" environment: - DATABASE_URL=postgresql://postgres:admin@db:5432/plnts - USE_LOCAL_HARDWARE=true - ENVIRONMENT=docker + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=admin + - POSTGRES_DB=plnts depends_on: - - db + db: + condition: service_healthy client: build: ./client @@ -18,6 +21,7 @@ services: - "3000:3000" depends_on: - backend + db: image: postgres:13 volumes: @@ -26,6 +30,11 @@ services: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=admin - POSTGRES_DB=plnts + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 volumes: postgres_data: \ No newline at end of file diff --git a/server/routes/system_routes.py b/server/routes/system_routes.py index 537a8e9..a4c8fb4 100644 --- a/server/routes/system_routes.py +++ b/server/routes/system_routes.py @@ -90,27 +90,3 @@ def get_sensor_data(system_id): light_bp = Blueprint('lights', __name__) light_crud = GenericCRUD(Light, Light.schema) APIBuilder.register_resource(light_bp, 'lights', light_crud, ["GET", "GET_MANY", "POST"]) - -# TODO: Fix when I have more strength -# # Potentially create lights that were created alongside the system -# potentially_new_light = new_system_json["light"] -# if potentially_new_light is not None: -# count = potentially_new_light["count"] if potentially_new_light["count"] else 1 -# logger.info(f"Attempting to create {count} embedded lights from system request") - -# potentially_new_light["system_id"] = new_system.id -# new_lights = [create_light_from_json(potentially_new_light) for i in range(count)] -# db.add_all(new_lights) -# db.commit() - -# db.close() - -# def create_light_from_json(light): -# """ -# Utiltity method to create multiple lights -# """ -# return Light( -# name=light["name"], -# cost=light["cost"], -# system_id=light["system_id"] -# ) From bed6497afb53de480461c431fb58d179e9932d46 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Mon, 7 Oct 2024 18:19:28 -0500 Subject: [PATCH 09/44] BUG: Fix docker --- client/.env | 1 + client/Dockerfile | 12 +++++++++--- client/src/api.js | 2 +- docker-compose.yml | 34 +++++++++++++++++++++++++--------- server/Dockerfile | 13 +++++++++++-- server/app.py | 5 +++-- server/entrypoint.sh | 18 +++++++++++------- server/requirements.txt | 1 + server/supervisord.conf | 18 ------------------ 9 files changed, 62 insertions(+), 42 deletions(-) create mode 100644 client/.env mode change 100644 => 100755 server/Dockerfile mode change 100644 => 100755 server/app.py mode change 100644 => 100755 server/entrypoint.sh mode change 100644 => 100755 server/requirements.txt delete mode 100644 server/supervisord.conf diff --git a/client/.env b/client/.env new file mode 100644 index 0000000..f6669df --- /dev/null +++ b/client/.env @@ -0,0 +1 @@ +REACT_APP_API_URL=http://localhost:8002 \ No newline at end of file diff --git a/client/Dockerfile b/client/Dockerfile index b7c8a3d..8a0e726 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -1,7 +1,13 @@ FROM node:14 + WORKDIR /app -COPY package.json package-lock.json ./ + +COPY package*.json ./ + RUN npm install + COPY . . -RUN npm run build -CMD ["npm", "start"] + +EXPOSE 3000 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/client/src/api.js b/client/src/api.js index 7293657..41668b2 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -1,4 +1,4 @@ -const API_BASE_URL = 'http://127.0.0.1:5000'; +const API_BASE_URL = 'http://127.0.0.1:8002'; export const APIS = { plant: { diff --git a/docker-compose.yml b/docker-compose.yml index fa4e088..ddd43f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: backend: build: ./server ports: - - "5001:5000" + - "8002:5000" environment: - DATABASE_URL=postgresql://postgres:admin@db:5432/plnts - USE_LOCAL_HARDWARE=true @@ -11,16 +11,13 @@ services: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=admin - POSTGRES_DB=plnts + volumes: + - ./server:/app depends_on: db: condition: service_healthy - - client: - build: ./client - ports: - - "3000:3000" - depends_on: - - backend + networks: + - app-network db: image: postgres:13 @@ -35,6 +32,25 @@ services: interval: 5s timeout: 5s retries: 5 + ports: + - "8001:5432" + networks: + - app-network + + client: + build: ./client + ports: + - "3000:3000" + environment: + - REACT_APP_API_URL=http://localhost:8002 + depends_on: + - backend + networks: + - app-network volumes: - postgres_data: \ No newline at end of file + postgres_data: + +networks: + app-network: + driver: bridge \ No newline at end of file diff --git a/server/Dockerfile b/server/Dockerfile old mode 100644 new mode 100755 index 30d40f1..378bfb4 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,10 +1,19 @@ FROM python:3.9 + WORKDIR /app + COPY requirements.txt . RUN pip install -r requirements.txt -RUN apt-get update && apt-get install -y supervisor postgresql-client + +RUN apt-get update && apt-get install -y postgresql-client + COPY . . -COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf + +RUN chmod -R 755 /app + COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh + +EXPOSE 5000 + ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/server/app.py b/server/app.py old mode 100644 new mode 100755 index 6567d0f..b1a6a8a --- a/server/app.py +++ b/server/app.py @@ -60,7 +60,7 @@ app.register_blueprint(species_bp) app.register_blueprint(soils_bp) -CORS(app) +CORS(app, resources={r"/*": {"origins": "http://localhost:3000"}}) @app.route("/meta/", methods=["GET"]) def get_meta(): @@ -98,4 +98,5 @@ def get_notebook(): if __name__ == "__main__": # Run the Flask app - app.run(debug=True) + app.run(host='0.0.0.0', port=8002) + diff --git a/server/entrypoint.sh b/server/entrypoint.sh old mode 100644 new mode 100755 index 229b16e..166ff6e --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -1,21 +1,25 @@ #!/bin/sh +# Debug information +echo "Debugging information:" +ls -la /app + # Wait for the database to be ready until PGPASSWORD=$POSTGRES_PASSWORD psql -h "db" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c '\q'; do >&2 echo "Postgres is unavailable - sleeping" sleep 1 done - >&2 echo "Postgres is up - executing command" # Check if the install flag exists if [ ! -f "/app/.installed" ]; then - echo "Running install script..." - python install.py - touch /app/.installed + echo "Running install script..." + python install.py + touch /app/.installed else - echo "Install script already run, skipping..." + echo "Install script already run, skipping..." fi -# Start the main application -exec supervisord -c /etc/supervisor/conf.d/supervisord.conf \ No newline at end of file +# Start the Flask application +echo "Starting Flask app..." +exec gunicorn -b 0.0.0.0:5000 app:app \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt old mode 100644 new mode 100755 index 5f6bd38..9ffd337 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -37,6 +37,7 @@ Flask==3.0.1 Flask-Cors==4.0.1 fonttools==4.53.1 fqdn==1.5.1 +gunicorn==23.0.0 h11==0.14.0 httpcore==1.0.5 httpx==0.27.2 diff --git a/server/supervisord.conf b/server/supervisord.conf deleted file mode 100644 index 0c1b699..0000000 --- a/server/supervisord.conf +++ /dev/null @@ -1,18 +0,0 @@ -[supervisord] -nodaemon=true - -[program:gunicorn] -command=gunicorn -b 0.0.0.0:5000 app:app -directory=/app -autostart=true -autorestart=true -stderr_logfile=/var/log/gunicorn.err.log -stdout_logfile=/var/log/gunicorn.out.log - -[program:background_process] -command=python background/background.py -directory=/app -autostart=true -autorestart=true -stderr_logfile=/var/log/background_process.err.log -stdout_logfile=/var/log/background_process.out.log \ No newline at end of file From f3f9c83b0703a31714d9411dc51b4e2cca703f96 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Mon, 7 Oct 2024 19:32:38 -0500 Subject: [PATCH 10/44] MISC: Cleanup --- server/adaptor_app.py | 5 ++--- server/app.py | 13 +------------ server/background.py | 4 ---- server/install.py | 13 ++----------- 4 files changed, 5 insertions(+), 30 deletions(-) diff --git a/server/adaptor_app.py b/server/adaptor_app.py index ae31ab2..8b34680 100644 --- a/server/adaptor_app.py +++ b/server/adaptor_app.py @@ -1,10 +1,9 @@ """ Adaptor server. """ -import os import logging -from flask import Flask, request, jsonify, Response +from flask import Flask, jsonify, Response from flask_cors import CORS from shared.adaptor import generate_frames, read_sensor @@ -36,4 +35,4 @@ def sensor_data(): if __name__ == "__main__": # Run the Flask app - app.run(debug=True) + app.run(host='0.0.0.0', port=8003) diff --git a/server/app.py b/server/app.py index b1a6a8a..d27da22 100755 --- a/server/app.py +++ b/server/app.py @@ -3,26 +3,15 @@ """ import logging -from datetime import datetime -from flask import Flask, request, jsonify +from flask import Flask, jsonify from flask_cors import CORS -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from sqlalchemy.engine import URL -from models import Base -from models.plant import Plant, PlantGenus, PlantGenusType, PlantSpecies -from models.system import System, Light from models.alert import PlantAlert from models.todo import Todo -from models.mix import Mix, Soil, SoilPart from shared.db import init_db, Session - from shared.logger import setup_logger -import logging - from shared.discover import discover_systems # Create a logger for this specific module diff --git a/server/background.py b/server/background.py index 82b1b0e..39c6736 100644 --- a/server/background.py +++ b/server/background.py @@ -5,10 +5,6 @@ from datetime import datetime import logging -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from sqlalchemy.engine import URL - from models.plant import Plant from models.alert import PlantAlert diff --git a/server/install.py b/server/install.py index c3d10b6..bae255b 100644 --- a/server/install.py +++ b/server/install.py @@ -2,26 +2,17 @@ Process dedicated to installing static data (e.g. genuses, species, etc). Could eventually see this moving to cloud. """ -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from sqlalchemy.engine import URL - from models.plant import PlantGenusType, PlantGenus, PlantSpecies from models.mix import Soil -from concurrent.futures import ThreadPoolExecutor, as_completed - from shared.db import init_db from shared.db import Session from shared.logger import setup_logger import logging -import numpy as np -import csv - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +# Create a logger for this specific module +logger = setup_logger(__name__, logging.DEBUG) init_db() From 8fb4fd0aedd2b19c0faac8f732595868921541ec Mon Sep 17 00:00:00 2001 From: mseng10 Date: Mon, 7 Oct 2024 19:52:08 -0500 Subject: [PATCH 11/44] MISC: Cleanup --- .gitignore | 4 +++- server/models/alert.py | 7 +++---- server/models/mix.py | 2 +- server/models/plant.py | 2 +- server/models/system.py | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 9e46b49..3a07fca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea/ -todo.txt \ No newline at end of file +todo.txt + +.installed \ No newline at end of file diff --git a/server/models/alert.py b/server/models/alert.py index 549639f..da962c5 100644 --- a/server/models/alert.py +++ b/server/models/alert.py @@ -3,13 +3,12 @@ """ from datetime import datetime -from typing import List -from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column from models.plant import DeprecatableMixin -from models import FlexibleModel, ModelConfig, FieldConfig, Base +from models import ModelConfig, FieldConfig, Base class Alert(Base, DeprecatableMixin): """Alert Base Class""" diff --git a/server/models/mix.py b/server/models/mix.py index 70fd63f..a1c4084 100644 --- a/server/models/mix.py +++ b/server/models/mix.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, DateTime, Table, ForeignKey, Boolean +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean from sqlalchemy.orm import relationship, Mapped from datetime import datetime diff --git a/server/models/plant.py b/server/models/plant.py index db533df..7ae3391 100644 --- a/server/models/plant.py +++ b/server/models/plant.py @@ -8,7 +8,7 @@ import enum # Third-party imports -from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Enum +from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey from sqlalchemy.orm import Mapped, relationship, mapped_column from sqlalchemy.ext.declarative import declared_attr diff --git a/server/models/system.py b/server/models/system.py index cb09364..25543dc 100644 --- a/server/models/system.py +++ b/server/models/system.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import List -from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey from sqlalchemy.orm import relationship, Mapped, mapped_column from models.plant import DeprecatableMixin From dc58673872c92328ec5e7bd1e4511c1ebab1e8fd Mon Sep 17 00:00:00 2001 From: mseng10 Date: Mon, 7 Oct 2024 20:03:46 -0500 Subject: [PATCH 12/44] MISC: misc changes --- server/models/__init__.py | 7 ++----- server/routes/__init__.py | 6 ++---- server/routes/alert_routes.py | 4 ++-- server/routes/installable_model_routes.py | 2 +- server/routes/mix_routes.py | 2 +- server/routes/stat_routes.py | 5 +---- server/routes/system_routes.py | 2 +- server/routes/todo_routes.py | 2 +- server/shared/adaptor.py | 1 + server/shared/db.py | 11 +---------- 10 files changed, 13 insertions(+), 29 deletions(-) diff --git a/server/models/__init__.py b/server/models/__init__.py index 1d80e09..708ae91 100644 --- a/server/models/__init__.py +++ b/server/models/__init__.py @@ -1,10 +1,7 @@ -from flask import Blueprint, request - -from sqlalchemy import create_engine, Column -from sqlalchemy.orm import sessionmaker, declarative_base, relationship, joinedload, contains_eager +from sqlalchemy.orm import declarative_base from sqlalchemy.ext.declarative import declarative_base -from typing import Type, List, Callable, Any, Dict, Optional +from typing import List, Any, Dict, Optional from dataclasses import dataclass import numpy as np diff --git a/server/routes/__init__.py b/server/routes/__init__.py index 84befa7..5e753a6 100644 --- a/server/routes/__init__.py +++ b/server/routes/__init__.py @@ -1,14 +1,12 @@ from flask import Blueprint, request, jsonify -from sqlalchemy.orm import sessionmaker, declarative_base, joinedload, contains_eager -from typing import Type, List, Callable, Any, Dict, Optional +from sqlalchemy.orm import joinedload, contains_eager +from typing import List, Callable, Any from shared.db import Session from shared.logger import logger from models import ModelConfig -from shared.logger import logger - class GenericCRUD: def __init__(self, model, config: ModelConfig): self.model = model diff --git a/server/routes/alert_routes.py b/server/routes/alert_routes.py index 3d0405f..0ceb547 100644 --- a/server/routes/alert_routes.py +++ b/server/routes/alert_routes.py @@ -1,6 +1,6 @@ -from flask import Blueprint, jsonify, request +from flask import Blueprint -from models.alert import PlantAlert, Alert +from models.alert import Alert from routes import GenericCRUD, APIBuilder bp = Blueprint('alerts', __name__) diff --git a/server/routes/installable_model_routes.py b/server/routes/installable_model_routes.py index 334c78d..9df819a 100644 --- a/server/routes/installable_model_routes.py +++ b/server/routes/installable_model_routes.py @@ -1,4 +1,4 @@ -from flask import Blueprint, jsonify, request +from flask import Blueprint from routes import GenericCRUD, APIBuilder from models.plant import PlantGenusType, PlantGenus, PlantSpecies diff --git a/server/routes/mix_routes.py b/server/routes/mix_routes.py index 683df7d..bcda870 100644 --- a/server/routes/mix_routes.py +++ b/server/routes/mix_routes.py @@ -1,4 +1,4 @@ -from flask import Blueprint, jsonify, request +from flask import Blueprint from models.mix import Mix from routes import GenericCRUD, APIBuilder diff --git a/server/routes/stat_routes.py b/server/routes/stat_routes.py index 294116f..be11244 100644 --- a/server/routes/stat_routes.py +++ b/server/routes/stat_routes.py @@ -1,10 +1,7 @@ -from sqlalchemy import func - -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify from shared.db import Session, safe_sum from shared.logger import logger -from models.mix import Mix from models.plant import Plant from models.system import System from models.system import Light diff --git a/server/routes/system_routes.py b/server/routes/system_routes.py index a4c8fb4..ce1fc10 100644 --- a/server/routes/system_routes.py +++ b/server/routes/system_routes.py @@ -1,4 +1,4 @@ -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify from shared.db import Session from shared.logger import logger diff --git a/server/routes/todo_routes.py b/server/routes/todo_routes.py index d8f9f65..d1d1f20 100644 --- a/server/routes/todo_routes.py +++ b/server/routes/todo_routes.py @@ -1,4 +1,4 @@ -from flask import Blueprint, jsonify, request, make_response +from flask import Blueprint from routes import GenericCRUD, APIBuilder from models.todo import Todo, Task diff --git a/server/shared/adaptor.py b/server/shared/adaptor.py index 2477316..7e1a7ec 100644 --- a/server/shared/adaptor.py +++ b/server/shared/adaptor.py @@ -4,6 +4,7 @@ import cv2 import time import adafruit_dht +import logger def generate_frames(id=0): """ Access the camera of this application. """ diff --git a/server/shared/db.py b/server/shared/db.py index 04f6176..f278e7a 100644 --- a/server/shared/db.py +++ b/server/shared/db.py @@ -1,21 +1,12 @@ """ This is the main source for anything db related. """ +import os from sqlalchemy import create_engine, func from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.engine import URL from models import Base -from models.plant import Plant, PlantGenus, PlantGenusType, PlantSpecies -from models.system import System, Light -from models.alert import PlantAlert, Alert -from models.todo import Todo, Task -from models.mix import Soil, Mix - -import json -import os # Create the SQLAlchemy engine DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:admin@localhost:5432/postgres") From d7270750902a3670b856e5352943b520b711c8f9 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Fri, 11 Oct 2024 22:35:24 -0500 Subject: [PATCH 13/44] ENH: Kubernetes base --- k8s/backend-deployment.yaml | 29 +++++++++++++++++++++++ k8s/backend-external-service.yaml | 14 +++++++++++ k8s/backend-service.yaml | 11 +++++++++ k8s/configmap.yaml: | 7 ++++++ k8s/database-deployment.yaml | 39 +++++++++++++++++++++++++++++++ k8s/database-service.yaml | 11 +++++++++ k8s/frontend-deployment.yaml | 22 +++++++++++++++++ k8s/frontend-service.yaml: | 22 +++++++++++++++++ k8s/ingress.yaml | 25 ++++++++++++++++++++ k8s/kustomization.yaml | 15 ++++++++++++ k8s/network-policy.yaml | 18 ++++++++++++++ k8s/secrets.yaml | 9 +++++++ 12 files changed, 222 insertions(+) create mode 100644 k8s/backend-deployment.yaml create mode 100644 k8s/backend-external-service.yaml create mode 100644 k8s/backend-service.yaml create mode 100644 k8s/configmap.yaml: create mode 100644 k8s/database-deployment.yaml create mode 100644 k8s/database-service.yaml create mode 100644 k8s/frontend-deployment.yaml create mode 100644 k8s/frontend-service.yaml: create mode 100644 k8s/ingress.yaml create mode 100644 k8s/kustomization.yaml create mode 100644 k8s/network-policy.yaml create mode 100644 k8s/secrets.yaml diff --git a/k8s/backend-deployment.yaml b/k8s/backend-deployment.yaml new file mode 100644 index 0000000..f7af4bf --- /dev/null +++ b/k8s/backend-deployment.yaml @@ -0,0 +1,29 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend +spec: + replicas: 3 + selector: + matchLabels: + app: backend + template: + metadata: + labels: + app: backend + spec: + containers: + - name: backend + image: your-registry/backend:latest + ports: + - containerPort: 5000 + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: db-secrets + key: database-url + - name: START_FLASK_APP + value: "true" + - name: START_BACKGROUND_PROCESS + value: "true" \ No newline at end of file diff --git a/k8s/backend-external-service.yaml b/k8s/backend-external-service.yaml new file mode 100644 index 0000000..9acd97c --- /dev/null +++ b/k8s/backend-external-service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: backend-external-service +spec: + selector: + app: backend + ports: + - protocol: TCP + port: 80 + targetPort: 5000 + type: LoadBalancer + externalIPs: + - 203.0.113.10 # Replace with your actual external IP \ No newline at end of file diff --git a/k8s/backend-service.yaml b/k8s/backend-service.yaml new file mode 100644 index 0000000..03aba19 --- /dev/null +++ b/k8s/backend-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: backend-service +spec: + selector: + app: backend + ports: + - protocol: TCP + port: 80 + targetPort: 5000 \ No newline at end of file diff --git a/k8s/configmap.yaml: b/k8s/configmap.yaml: new file mode 100644 index 0000000..0bca8df --- /dev/null +++ b/k8s/configmap.yaml: @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config +data: + APP_SETTINGS: "Production" + API_VERSION: "v1" \ No newline at end of file diff --git a/k8s/database-deployment.yaml b/k8s/database-deployment.yaml new file mode 100644 index 0000000..4dceabf --- /dev/null +++ b/k8s/database-deployment.yaml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:13 + ports: + - containerPort: 5432 + env: + - name: POSTGRES_DB + value: plnts + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: db-secrets + key: postgres-user + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: db-secrets + key: postgres-password + volumeMounts: + - name: postgres-storage + mountPath: /var/lib/postgresql/data + volumes: + - name: postgres-storage + persistentVolumeClaim: + claimName: postgres-pvc \ No newline at end of file diff --git a/k8s/database-service.yaml b/k8s/database-service.yaml new file mode 100644 index 0000000..f2fb934 --- /dev/null +++ b/k8s/database-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: postgres +spec: + selector: + app: postgres + ports: + - protocol: TCP + port: 5432 + targetPort: 5432 \ No newline at end of file diff --git a/k8s/frontend-deployment.yaml b/k8s/frontend-deployment.yaml new file mode 100644 index 0000000..8c72c33 --- /dev/null +++ b/k8s/frontend-deployment.yaml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend +spec: + replicas: 2 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + image: your-registry/frontend:latest + ports: + - containerPort: 3000 + env: + - name: REACT_APP_API_URL + value: "http://backend-service" \ No newline at end of file diff --git a/k8s/frontend-service.yaml: b/k8s/frontend-service.yaml: new file mode 100644 index 0000000..8c72c33 --- /dev/null +++ b/k8s/frontend-service.yaml: @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend +spec: + replicas: 2 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + image: your-registry/frontend:latest + ports: + - containerPort: 3000 + env: + - name: REACT_APP_API_URL + value: "http://backend-service" \ No newline at end of file diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..e5b3d94 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: main-ingress + annotations: + kubernetes.io/ingress.class: nginx +spec: + rules: + - host: yourdomain.com + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: backend-service + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: frontend-service + port: + number: 80 \ No newline at end of file diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..ba09feb --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - backend-deployment.yaml + - backend-service.yaml + - backend-external-service.yaml + - frontend-deployment.yaml + - frontend-service.yaml + - database-deployment.yaml + - database-service.yaml + - ingress.yaml + - network-policy.yaml + - configmap.yaml + - secrets.yaml \ No newline at end of file diff --git a/k8s/network-policy.yaml b/k8s/network-policy.yaml new file mode 100644 index 0000000..3b57a6b --- /dev/null +++ b/k8s/network-policy.yaml @@ -0,0 +1,18 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-backend-access +spec: + podSelector: + matchLabels: + app: backend + ingress: + - from: + - podSelector: + matchLabels: + app: frontend + - ipBlock: + cidr: 203.0.113.0/24 # IP range of your other servers + ports: + - protocol: TCP + port: 5000 \ No newline at end of file diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml new file mode 100644 index 0000000..a0802f4 --- /dev/null +++ b/k8s/secrets.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: db-secrets +type: Opaque +stringData: + database-url: "postgresql://username:password@postgres:5432/plnts" + postgres-user: "postgres" + postgres-password: "admin" \ No newline at end of file From 4c85e978d5e8c32ebbcffa11e326098c4161e365 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sat, 12 Oct 2024 10:27:05 -0500 Subject: [PATCH 14/44] BUG: Fix Docker compose... --- client/nginx.conf | 44 -------------------------------------------- docker-compose.yml | 3 +++ nginx/nginx.conf | 13 +++++++++++++ server/.gitignore | 4 +++- server/app.py | 4 +++- server/entrypoint.sh | 7 ++++--- 6 files changed, 26 insertions(+), 49 deletions(-) delete mode 100644 client/nginx.conf create mode 100644 nginx/nginx.conf diff --git a/client/nginx.conf b/client/nginx.conf deleted file mode 100644 index ca9adbf..0000000 --- a/client/nginx.conf +++ /dev/null @@ -1,44 +0,0 @@ -user www-data; -worker_processes auto; -pid /run/nginx.pid; -include /etc/nginx/modules-enabled/*.conf; - -events { - worker_connections 768; -} - -http { - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - types_hash_max_size 2048; - include /etc/nginx/mime.types; - default_type application/octet-stream; - - ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE - ssl_prefer_server_ciphers on; - - access_log /var/log/nginx/access.log; - error_log /var/log/nginx/error.log; - - gzip on; - - include /etc/nginx/conf.d/*.conf; - - server { - listen 80 default_server; - listen [::]:80 default_server; - root /var/www/html; - index index.html index.htm index.nginx-debian.html; - - server_name _; - - location / { - try_files $uri $uri/ =404; - } - - } - -} - diff --git a/docker-compose.yml b/docker-compose.yml index ddd43f2..62c2687 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,9 @@ services: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=admin - POSTGRES_DB=plnts + - START_PLANT_API=true + - START_BACKGROUND_PROCESS=false + - START_SYSTEM_ADAPTOR=false volumes: - ./server:/app depends_on: diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..cef669e --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,13 @@ +http { + upstream backend_servers { + server 203.0.113.10:80; # Kubernetes backend service + server 203.0.113.20:5000; # Non-Kubernetes backend server + } + + server { + listen 80; + location /api/ { + proxy_pass http://backend_servers; + } + } +} \ No newline at end of file diff --git a/server/.gitignore b/server/.gitignore index a14ed53..56bb2fe 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -4,4 +4,6 @@ venv/ *.log *.pyc -*.json \ No newline at end of file +*.json + +myenv~/ \ No newline at end of file diff --git a/server/app.py b/server/app.py index d27da22..0328b85 100755 --- a/server/app.py +++ b/server/app.py @@ -11,6 +11,7 @@ from models.todo import Todo from shared.db import init_db, Session + from shared.logger import setup_logger from shared.discover import discover_systems @@ -49,7 +50,7 @@ app.register_blueprint(species_bp) app.register_blueprint(soils_bp) -CORS(app, resources={r"/*": {"origins": "http://localhost:3000"}}) +CORS(app) @app.route("/meta/", methods=["GET"]) def get_meta(): @@ -89,3 +90,4 @@ def get_notebook(): # Run the Flask app app.run(host='0.0.0.0', port=8002) + #app.run(debug=True) diff --git a/server/entrypoint.sh b/server/entrypoint.sh index 166ff6e..0d045ce 100755 --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -2,7 +2,8 @@ # Debug information echo "Debugging information:" -ls -la /app +cd ../ +cd app/ # Wait for the database to be ready until PGPASSWORD=$POSTGRES_PASSWORD psql -h "db" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c '\q'; do @@ -12,10 +13,10 @@ done >&2 echo "Postgres is up - executing command" # Check if the install flag exists -if [ ! -f "/app/.installed" ]; then +if [ ! -f ".installed" ]; then echo "Running install script..." python install.py - touch /app/.installed + touch .installed else echo "Install script already run, skipping..." fi From 4df48e8470008644b1ee9c2db15c38fe7ab1285c Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 13 Oct 2024 14:27:17 -0500 Subject: [PATCH 15/44] MISC: Move adaptor script to own file --- server/adaptor/__init__.py | 0 server/{ => adaptor}/adaptor_app.py | 3 +++ server/{ => adaptor}/test_adaptor.py | 0 3 files changed, 3 insertions(+) create mode 100644 server/adaptor/__init__.py rename server/{ => adaptor}/adaptor_app.py (90%) rename server/{ => adaptor}/test_adaptor.py (100%) diff --git a/server/adaptor/__init__.py b/server/adaptor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/adaptor_app.py b/server/adaptor/adaptor_app.py similarity index 90% rename from server/adaptor_app.py rename to server/adaptor/adaptor_app.py index 8b34680..9c294d0 100644 --- a/server/adaptor_app.py +++ b/server/adaptor/adaptor_app.py @@ -2,6 +2,9 @@ Adaptor server. """ import logging +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from flask import Flask, jsonify, Response from flask_cors import CORS diff --git a/server/test_adaptor.py b/server/adaptor/test_adaptor.py similarity index 100% rename from server/test_adaptor.py rename to server/adaptor/test_adaptor.py From ece27d75943be6f8fd2a4da92a90d8b7cb97d163 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 13 Oct 2024 14:27:53 -0500 Subject: [PATCH 16/44] MISC: Move background.py --- server/background/__init__.py | 0 server/{ => background}/background.py | 9 ++++++--- 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 server/background/__init__.py rename server/{ => background}/background.py (83%) diff --git a/server/background/__init__.py b/server/background/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/background.py b/server/background/background.py similarity index 83% rename from server/background.py rename to server/background/background.py index 39c6736..92db1e9 100644 --- a/server/background.py +++ b/server/background/background.py @@ -2,8 +2,11 @@ Process dedicated to doing background processing on this application. This creates alerts, manages connections to active systems, etc. """ -from datetime import datetime +from datetime import datetime, timedelta import logging +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from models.plant import Plant from models.alert import PlantAlert @@ -31,7 +34,7 @@ def create_plant_alert(): existing_plants = session.query(Plant).filter(Plant.deprecated == False).all() now = datetime.now() for plant in existing_plants: - end_date = plant.watered_on + datetime.timedelta(days=float(plant.watering)) + end_date = plant.watered_on + timedelta(days=float(plant.watering)) if end_date < datetime.now() and existing_plant_alrts_map.get(plant.id) is None: new_plant_alert = PlantAlert( plant_id = plant.id, @@ -40,7 +43,7 @@ def create_plant_alert(): ) # Create the alert in the db session.add(new_plant_alert) - existing_plant_alrts[new_plant_alert.plant_id] = new_plant_alert + existing_plant_alrts_map[new_plant_alert.plant_id] = new_plant_alert session.commit() session.close() From 25f9da50284bca9052e8f61993bd20310f59a75c Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 13 Oct 2024 14:29:00 -0500 Subject: [PATCH 17/44] MISC: Move app.py --- server/{ => app}/Dockerfile | 0 server/app/__init__.py | 0 server/{ => app}/app.py | 3 +++ 3 files changed, 3 insertions(+) rename server/{ => app}/Dockerfile (100%) create mode 100644 server/app/__init__.py rename server/{ => app}/app.py (96%) diff --git a/server/Dockerfile b/server/app/Dockerfile similarity index 100% rename from server/Dockerfile rename to server/app/Dockerfile diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app.py b/server/app/app.py similarity index 96% rename from server/app.py rename to server/app/app.py index 0328b85..640d869 100755 --- a/server/app.py +++ b/server/app/app.py @@ -3,6 +3,9 @@ """ import logging +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from flask import Flask, jsonify from flask_cors import CORS From a3be2e861a666b76ba46262997f03f9c3fcc6e68 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 13 Oct 2024 21:01:45 -0500 Subject: [PATCH 18/44] ENH: Make background.py a background flask task --- server/app/app.py | 35 ++++++++++++++++++++ server/background/__init__.py | 0 server/background/background.py | 57 --------------------------------- server/requirements.txt | 3 ++ 4 files changed, 38 insertions(+), 57 deletions(-) delete mode 100644 server/background/__init__.py delete mode 100644 server/background/background.py diff --git a/server/app/app.py b/server/app/app.py index 640d869..9467eb1 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -5,10 +5,12 @@ import logging import sys import os +import datetime sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from flask import Flask, jsonify from flask_cors import CORS +from flask_apscheduler import APScheduler from models.alert import PlantAlert from models.todo import Todo @@ -88,6 +90,39 @@ def get_notebook(): # Serve the HTML return body +scheduler = APScheduler() +scheduler.init_app(app) +scheduler.start() + +@scheduler.task('cron', id='nightly', minute='*') +def create_plant_alert(): + """ + Create different plant alerts. Right now just supports creating watering alerts. + """ + session = Session() + + existing_plant_alrts = session.query(PlantAlert).filter(PlantAlert.deprecated == False).all() + existing_plant_alrts_map = {} + for existing_plant_alert in existing_plant_alrts: + existing_plant_alrts_map[existing_plant_alert.plant_id] = existing_plant_alert + + existing_plants = session.query(Plant).filter(Plant.deprecated == False).all() + now = datetime.now() + for plant in existing_plants: + end_date = plant.watered_on + timedelta(days=float(plant.watering)) + if end_date < datetime.now() and existing_plant_alrts_map.get(plant.id) is None: + new_plant_alert = PlantAlert( + plant_id = plant.id, + system_id = plant.system_id, + plant_alert_type = "water" + ) + # Create the alert in the db + session.add(new_plant_alert) + existing_plant_alrts_map[new_plant_alert.plant_id] = new_plant_alert + + session.commit() + session.close() + if __name__ == "__main__": # Run the Flask app diff --git a/server/background/__init__.py b/server/background/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/server/background/background.py b/server/background/background.py deleted file mode 100644 index 92db1e9..0000000 --- a/server/background/background.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -Process dedicated to doing background processing on this application. -This creates alerts, manages connections to active systems, etc. -""" -from datetime import datetime, timedelta -import logging -import sys -import os -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from models.plant import Plant -from models.alert import PlantAlert - -from shared.db import init_db, Session -from shared.logger import setup_logger - -# Create a logger for this specific module -logger = setup_logger(__name__, logging.DEBUG) - -# Initialize DB connection -init_db() - -def create_plant_alert(): - """ - Create different plant alerts. Right now just supports creating watering alerts. - """ - session = Session() - - existing_plant_alrts = session.query(PlantAlert).filter(PlantAlert.deprecated == False).all() - existing_plant_alrts_map = {} - for existing_plant_alert in existing_plant_alrts: - existing_plant_alrts_map[existing_plant_alert.plant_id] = existing_plant_alert - - existing_plants = session.query(Plant).filter(Plant.deprecated == False).all() - now = datetime.now() - for plant in existing_plants: - end_date = plant.watered_on + timedelta(days=float(plant.watering)) - if end_date < datetime.now() and existing_plant_alrts_map.get(plant.id) is None: - new_plant_alert = PlantAlert( - plant_id = plant.id, - system_id = plant.system_id, - plant_alert_type = "water" - ) - # Create the alert in the db - session.add(new_plant_alert) - existing_plant_alrts_map[new_plant_alert.plant_id] = new_plant_alert - - session.commit() - session.close() - -def main(): - create_plant_alert() - -if __name__ == "__main__": - logger.info("Starting background processing.") - while True: - main() \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt index 9ffd337..a61bc59 100755 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -8,6 +8,7 @@ Adafruit-PlatformDetect==3.74.0 Adafruit-PureIO==1.1.11 anyio==4.4.0 appnope==0.1.4 +APScheduler==3.10.4 argon2-cffi==23.1.0 argon2-cffi-bindings==21.2.0 arrow==1.3.0 @@ -34,6 +35,7 @@ exceptiongroup==1.2.2 executing==2.1.0 fastjsonschema==2.20.0 Flask==3.0.1 +Flask-APScheduler==1.13.1 Flask-Cors==4.0.1 fonttools==4.53.1 fqdn==1.5.1 @@ -126,6 +128,7 @@ traitlets==5.14.3 types-python-dateutil==2.9.0.20240906 typing_extensions==4.12.0 tzdata==2024.1 +tzlocal==5.2 uri-template==1.3.0 urllib3==2.2.1 wcwidth==0.2.13 From c475b8a4ba9877ad1bcabb996c3f45012cc87ba8 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 13 Oct 2024 21:03:16 -0500 Subject: [PATCH 19/44] MISC: Move everything to app --- server/app/entrypoint.sh | 26 +++++++++++++++++++ server/{ => app}/install.py | 0 server/{ => app}/routes/__init__.py | 0 server/{ => app}/routes/alert_routes.py | 0 .../routes/installable_model_routes.py | 0 server/{ => app}/routes/mix_routes.py | 0 server/{ => app}/routes/plant_routes.py | 0 server/{ => app}/routes/stat_routes.py | 0 server/{ => app}/routes/system_routes.py | 0 server/{ => app}/routes/todo_routes.py | 0 10 files changed, 26 insertions(+) create mode 100755 server/app/entrypoint.sh rename server/{ => app}/install.py (100%) rename server/{ => app}/routes/__init__.py (100%) rename server/{ => app}/routes/alert_routes.py (100%) rename server/{ => app}/routes/installable_model_routes.py (100%) rename server/{ => app}/routes/mix_routes.py (100%) rename server/{ => app}/routes/plant_routes.py (100%) rename server/{ => app}/routes/stat_routes.py (100%) rename server/{ => app}/routes/system_routes.py (100%) rename server/{ => app}/routes/todo_routes.py (100%) diff --git a/server/app/entrypoint.sh b/server/app/entrypoint.sh new file mode 100755 index 0000000..0d045ce --- /dev/null +++ b/server/app/entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +# Debug information +echo "Debugging information:" +cd ../ +cd app/ + +# Wait for the database to be ready +until PGPASSWORD=$POSTGRES_PASSWORD psql -h "db" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c '\q'; do + >&2 echo "Postgres is unavailable - sleeping" + sleep 1 +done +>&2 echo "Postgres is up - executing command" + +# Check if the install flag exists +if [ ! -f ".installed" ]; then + echo "Running install script..." + python install.py + touch .installed +else + echo "Install script already run, skipping..." +fi + +# Start the Flask application +echo "Starting Flask app..." +exec gunicorn -b 0.0.0.0:5000 app:app \ No newline at end of file diff --git a/server/install.py b/server/app/install.py similarity index 100% rename from server/install.py rename to server/app/install.py diff --git a/server/routes/__init__.py b/server/app/routes/__init__.py similarity index 100% rename from server/routes/__init__.py rename to server/app/routes/__init__.py diff --git a/server/routes/alert_routes.py b/server/app/routes/alert_routes.py similarity index 100% rename from server/routes/alert_routes.py rename to server/app/routes/alert_routes.py diff --git a/server/routes/installable_model_routes.py b/server/app/routes/installable_model_routes.py similarity index 100% rename from server/routes/installable_model_routes.py rename to server/app/routes/installable_model_routes.py diff --git a/server/routes/mix_routes.py b/server/app/routes/mix_routes.py similarity index 100% rename from server/routes/mix_routes.py rename to server/app/routes/mix_routes.py diff --git a/server/routes/plant_routes.py b/server/app/routes/plant_routes.py similarity index 100% rename from server/routes/plant_routes.py rename to server/app/routes/plant_routes.py diff --git a/server/routes/stat_routes.py b/server/app/routes/stat_routes.py similarity index 100% rename from server/routes/stat_routes.py rename to server/app/routes/stat_routes.py diff --git a/server/routes/system_routes.py b/server/app/routes/system_routes.py similarity index 100% rename from server/routes/system_routes.py rename to server/app/routes/system_routes.py diff --git a/server/routes/todo_routes.py b/server/app/routes/todo_routes.py similarity index 100% rename from server/routes/todo_routes.py rename to server/app/routes/todo_routes.py From c38af3fa4a544a98d659d8954a3ab3bae61b2fe0 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 13 Oct 2024 21:17:50 -0500 Subject: [PATCH 20/44] ENH: adaptor app --- docker-compose.yml | 49 +++++++++++++++++-- server/adaptor/Dockerfile | 13 +++++ server/adaptor/{adaptor_app.py => adaptor.py} | 0 server/app/entrypoint.sh | 2 +- server/entrypoint.sh | 26 ---------- 5 files changed, 58 insertions(+), 32 deletions(-) create mode 100755 server/adaptor/Dockerfile rename server/adaptor/{adaptor_app.py => adaptor.py} (100%) delete mode 100755 server/entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index 62c2687..2c74f39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,10 @@ version: '3.8' + services: - backend: - build: ./server + app: + build: + context: ./server + dockerfile: app/Dockerfile ports: - "8002:5000" environment: @@ -15,13 +18,28 @@ services: - START_BACKGROUND_PROCESS=false - START_SYSTEM_ADAPTOR=false volumes: - - ./server:/app + - ./server/shared:/app/shared + - ./server/app:/app/app depends_on: db: condition: service_healthy networks: - app-network +adaptor: + build: + context: ./server + dockerfile: adaptor/Dockerfile + ports: + - "8003:5000" + environment: + - ENVIRONMENT=docker + volumes: + - ./server/shared:/app/shared + - ./server/adaptor:/app/adaptor + networks: + - app-network + db: image: postgres:13 volumes: @@ -41,12 +59,33 @@ services: - app-network client: - build: ./client + build: + context: ./client + dockerfile: Dockerfile + args: + - NODE_ENV=${NODE_ENV:-development} ports: - "3000:3000" environment: - - REACT_APP_API_URL=http://localhost:8002 + - REACT_APP_API_URL=/api + - NODE_ENV=${NODE_ENV:-development} + volumes: + - ./client:/app + - /app/node_modules + networks: + - app-network + + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./client/build:/usr/share/nginx/html + environment: + - NODE_ENV=${NODE_ENV:-development} depends_on: + - client - backend networks: - app-network diff --git a/server/adaptor/Dockerfile b/server/adaptor/Dockerfile new file mode 100755 index 0000000..a1673f1 --- /dev/null +++ b/server/adaptor/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.9 + +WORKDIR /app + +COPY adaptor/requirements.txt . +RUN pip install -r requirements.txt + +COPY shared ./shared +COPY adaptor ./adaptor + +ENV PYTHONPATH="/app:${PYTHONPATH}" + +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "adaptor.adaptor:app"] \ No newline at end of file diff --git a/server/adaptor/adaptor_app.py b/server/adaptor/adaptor.py similarity index 100% rename from server/adaptor/adaptor_app.py rename to server/adaptor/adaptor.py diff --git a/server/app/entrypoint.sh b/server/app/entrypoint.sh index 0d045ce..de87da1 100755 --- a/server/app/entrypoint.sh +++ b/server/app/entrypoint.sh @@ -23,4 +23,4 @@ fi # Start the Flask application echo "Starting Flask app..." -exec gunicorn -b 0.0.0.0:5000 app:app \ No newline at end of file +exec gunicorn -b 0.0.0.0:5000 app.app:app \ No newline at end of file diff --git a/server/entrypoint.sh b/server/entrypoint.sh deleted file mode 100755 index 0d045ce..0000000 --- a/server/entrypoint.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh - -# Debug information -echo "Debugging information:" -cd ../ -cd app/ - -# Wait for the database to be ready -until PGPASSWORD=$POSTGRES_PASSWORD psql -h "db" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c '\q'; do - >&2 echo "Postgres is unavailable - sleeping" - sleep 1 -done ->&2 echo "Postgres is up - executing command" - -# Check if the install flag exists -if [ ! -f ".installed" ]; then - echo "Running install script..." - python install.py - touch .installed -else - echo "Install script already run, skipping..." -fi - -# Start the Flask application -echo "Starting Flask app..." -exec gunicorn -b 0.0.0.0:5000 app:app \ No newline at end of file From a5d4be471dbbe2e4530c2024b8f494cb63ac4408 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Mon, 14 Oct 2024 22:04:54 -0500 Subject: [PATCH 21/44] BUG: Fix app docker image --- docker-compose.yml | 7 ++----- server/adaptor/Dockerfile | 2 +- server/app/Dockerfile | 6 +++--- server/app/app.py | 14 +++++++------- server/app/install.py | 4 ++++ server/app/routes/alert_routes.py | 2 +- server/app/routes/installable_model_routes.py | 2 +- server/app/routes/mix_routes.py | 2 +- server/app/routes/plant_routes.py | 2 +- server/app/routes/system_routes.py | 2 +- server/app/routes/todo_routes.py | 2 +- server/shared/adaptor.py | 2 +- 12 files changed, 24 insertions(+), 23 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 2c74f39..c123308 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,9 +14,6 @@ services: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=admin - POSTGRES_DB=plnts - - START_PLANT_API=true - - START_BACKGROUND_PROCESS=false - - START_SYSTEM_ADAPTOR=false volumes: - ./server/shared:/app/shared - ./server/app:/app/app @@ -26,7 +23,7 @@ services: networks: - app-network -adaptor: + adaptor: build: context: ./server dockerfile: adaptor/Dockerfile @@ -86,7 +83,7 @@ adaptor: - NODE_ENV=${NODE_ENV:-development} depends_on: - client - - backend + - app networks: - app-network diff --git a/server/adaptor/Dockerfile b/server/adaptor/Dockerfile index a1673f1..274579e 100755 --- a/server/adaptor/Dockerfile +++ b/server/adaptor/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.9 WORKDIR /app -COPY adaptor/requirements.txt . +COPY ../requirements.txt . RUN pip install -r requirements.txt COPY shared ./shared diff --git a/server/app/Dockerfile b/server/app/Dockerfile index 378bfb4..9ecfa25 100755 --- a/server/app/Dockerfile +++ b/server/app/Dockerfile @@ -2,16 +2,16 @@ FROM python:3.9 WORKDIR /app -COPY requirements.txt . +COPY ../requirements.txt . RUN pip install -r requirements.txt RUN apt-get update && apt-get install -y postgresql-client - COPY . . +COPY ../shared . RUN chmod -R 755 /app -COPY entrypoint.sh /entrypoint.sh +COPY app/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh EXPOSE 5000 diff --git a/server/app/app.py b/server/app/app.py index 9467eb1..0834e0c 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -32,13 +32,13 @@ # Create Flask app app = Flask(__name__) -from routes.system_routes import system_bp, light_bp -from routes.plant_routes import bp as plant_bp -from routes.todo_routes import bp as todo_bp -from routes.mix_routes import bp as mix_bp -from routes.stat_routes import bp as stat_bp -from routes.installable_model_routes import genus_types_bp, species_bp, soils_bp, genus_bp -from routes.alert_routes import bp as alert_bp +from app.routes.system_routes import system_bp, light_bp +from app.routes.plant_routes import bp as plant_bp +from app.routes.todo_routes import bp as todo_bp +from app.routes.mix_routes import bp as mix_bp +from app.routes.stat_routes import bp as stat_bp +from app.routes.installable_model_routes import genus_types_bp, species_bp, soils_bp, genus_bp +from app.routes.alert_routes import bp as alert_bp # Models app.register_blueprint(system_bp) diff --git a/server/app/install.py b/server/app/install.py index bae255b..148bdbe 100644 --- a/server/app/install.py +++ b/server/app/install.py @@ -2,6 +2,10 @@ Process dedicated to installing static data (e.g. genuses, species, etc). Could eventually see this moving to cloud. """ +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from models.plant import PlantGenusType, PlantGenus, PlantSpecies from models.mix import Soil diff --git a/server/app/routes/alert_routes.py b/server/app/routes/alert_routes.py index 0ceb547..cf21913 100644 --- a/server/app/routes/alert_routes.py +++ b/server/app/routes/alert_routes.py @@ -1,7 +1,7 @@ from flask import Blueprint from models.alert import Alert -from routes import GenericCRUD, APIBuilder +from app.routes import GenericCRUD, APIBuilder bp = Blueprint('alerts', __name__) alert_crud = GenericCRUD(Alert, Alert.schema) diff --git a/server/app/routes/installable_model_routes.py b/server/app/routes/installable_model_routes.py index 9df819a..dd1de42 100644 --- a/server/app/routes/installable_model_routes.py +++ b/server/app/routes/installable_model_routes.py @@ -1,6 +1,6 @@ from flask import Blueprint -from routes import GenericCRUD, APIBuilder +from app.routes import GenericCRUD, APIBuilder from models.plant import PlantGenusType, PlantGenus, PlantSpecies from models.mix import Soil diff --git a/server/app/routes/mix_routes.py b/server/app/routes/mix_routes.py index bcda870..106c763 100644 --- a/server/app/routes/mix_routes.py +++ b/server/app/routes/mix_routes.py @@ -1,7 +1,7 @@ from flask import Blueprint from models.mix import Mix -from routes import GenericCRUD, APIBuilder +from app.routes import GenericCRUD, APIBuilder bp = Blueprint('mixes', __name__) mix_crud = GenericCRUD(Mix, Mix.schema) diff --git a/server/app/routes/plant_routes.py b/server/app/routes/plant_routes.py index 871c185..fb83227 100644 --- a/server/app/routes/plant_routes.py +++ b/server/app/routes/plant_routes.py @@ -1,7 +1,7 @@ from flask import Blueprint, jsonify, request from models.plant import Plant -from routes import GenericCRUD, APIBuilder +from app.routes import GenericCRUD, APIBuilder bp = Blueprint('plants', __name__) plant_crud = GenericCRUD(Plant, Plant.schema) diff --git a/server/app/routes/system_routes.py b/server/app/routes/system_routes.py index ce1fc10..25f5999 100644 --- a/server/app/routes/system_routes.py +++ b/server/app/routes/system_routes.py @@ -5,7 +5,7 @@ from models.plant import Plant from models.alert import PlantAlert from models.system import System, Light -from routes import GenericCRUD, APIBuilder +from app.routes import GenericCRUD, APIBuilder system_bp = Blueprint('systems', __name__) system_crud = GenericCRUD(System, System.schema) diff --git a/server/app/routes/todo_routes.py b/server/app/routes/todo_routes.py index d1d1f20..046e223 100644 --- a/server/app/routes/todo_routes.py +++ b/server/app/routes/todo_routes.py @@ -1,6 +1,6 @@ from flask import Blueprint -from routes import GenericCRUD, APIBuilder +from app.routes import GenericCRUD, APIBuilder from models.todo import Todo, Task bp = Blueprint('todos', __name__) diff --git a/server/shared/adaptor.py b/server/shared/adaptor.py index 7e1a7ec..5b9ff07 100644 --- a/server/shared/adaptor.py +++ b/server/shared/adaptor.py @@ -4,7 +4,7 @@ import cv2 import time import adafruit_dht -import logger +from shared.logger import logger def generate_frames(id=0): """ Access the camera of this application. """ From 275c6aeaccce8d6fc1361d058cb01241d34bdd13 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Mon, 23 Dec 2024 19:31:36 -0600 Subject: [PATCH 22/44] BUG: Fix imports --- server/app/app.py | 16 ++++++++-------- server/app/install.py | 4 ++-- server/app/routes/alert_routes.py | 2 +- server/app/routes/installable_model_routes.py | 2 +- server/app/routes/mix_routes.py | 2 +- server/app/routes/plant_routes.py | 2 +- server/app/routes/system_routes.py | 2 +- server/app/routes/todo_routes.py | 2 +- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/server/app/app.py b/server/app/app.py index 0834e0c..a662a2b 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -32,13 +32,13 @@ # Create Flask app app = Flask(__name__) -from app.routes.system_routes import system_bp, light_bp -from app.routes.plant_routes import bp as plant_bp -from app.routes.todo_routes import bp as todo_bp -from app.routes.mix_routes import bp as mix_bp -from app.routes.stat_routes import bp as stat_bp -from app.routes.installable_model_routes import genus_types_bp, species_bp, soils_bp, genus_bp -from app.routes.alert_routes import bp as alert_bp +from routes.system_routes import system_bp, light_bp +from routes.plant_routes import bp as plant_bp +from routes.todo_routes import bp as todo_bp +from routes.mix_routes import bp as mix_bp +from routes.stat_routes import bp as stat_bp +from routes.installable_model_routes import genus_types_bp, species_bp, soils_bp, genus_bp +from routes.alert_routes import bp as alert_bp # Models app.register_blueprint(system_bp) @@ -95,7 +95,7 @@ def get_notebook(): scheduler.start() @scheduler.task('cron', id='nightly', minute='*') -def create_plant_alert(): +def manage_plant_alerts(): """ Create different plant alerts. Right now just supports creating watering alerts. """ diff --git a/server/app/install.py b/server/app/install.py index 148bdbe..462c986 100644 --- a/server/app/install.py +++ b/server/app/install.py @@ -64,10 +64,10 @@ def create_all_models(): logger.info("All models have been created.") -def main(): +def install(): create_all_models() if __name__ == "__main__": logger.info("Initializing installation processing.") - main() + install() logger.info("Successfully completed installation process.") \ No newline at end of file diff --git a/server/app/routes/alert_routes.py b/server/app/routes/alert_routes.py index cf21913..0ceb547 100644 --- a/server/app/routes/alert_routes.py +++ b/server/app/routes/alert_routes.py @@ -1,7 +1,7 @@ from flask import Blueprint from models.alert import Alert -from app.routes import GenericCRUD, APIBuilder +from routes import GenericCRUD, APIBuilder bp = Blueprint('alerts', __name__) alert_crud = GenericCRUD(Alert, Alert.schema) diff --git a/server/app/routes/installable_model_routes.py b/server/app/routes/installable_model_routes.py index dd1de42..9df819a 100644 --- a/server/app/routes/installable_model_routes.py +++ b/server/app/routes/installable_model_routes.py @@ -1,6 +1,6 @@ from flask import Blueprint -from app.routes import GenericCRUD, APIBuilder +from routes import GenericCRUD, APIBuilder from models.plant import PlantGenusType, PlantGenus, PlantSpecies from models.mix import Soil diff --git a/server/app/routes/mix_routes.py b/server/app/routes/mix_routes.py index 106c763..bcda870 100644 --- a/server/app/routes/mix_routes.py +++ b/server/app/routes/mix_routes.py @@ -1,7 +1,7 @@ from flask import Blueprint from models.mix import Mix -from app.routes import GenericCRUD, APIBuilder +from routes import GenericCRUD, APIBuilder bp = Blueprint('mixes', __name__) mix_crud = GenericCRUD(Mix, Mix.schema) diff --git a/server/app/routes/plant_routes.py b/server/app/routes/plant_routes.py index fb83227..871c185 100644 --- a/server/app/routes/plant_routes.py +++ b/server/app/routes/plant_routes.py @@ -1,7 +1,7 @@ from flask import Blueprint, jsonify, request from models.plant import Plant -from app.routes import GenericCRUD, APIBuilder +from routes import GenericCRUD, APIBuilder bp = Blueprint('plants', __name__) plant_crud = GenericCRUD(Plant, Plant.schema) diff --git a/server/app/routes/system_routes.py b/server/app/routes/system_routes.py index 25f5999..ce1fc10 100644 --- a/server/app/routes/system_routes.py +++ b/server/app/routes/system_routes.py @@ -5,7 +5,7 @@ from models.plant import Plant from models.alert import PlantAlert from models.system import System, Light -from app.routes import GenericCRUD, APIBuilder +from routes import GenericCRUD, APIBuilder system_bp = Blueprint('systems', __name__) system_crud = GenericCRUD(System, System.schema) diff --git a/server/app/routes/todo_routes.py b/server/app/routes/todo_routes.py index 046e223..d1d1f20 100644 --- a/server/app/routes/todo_routes.py +++ b/server/app/routes/todo_routes.py @@ -1,6 +1,6 @@ from flask import Blueprint -from app.routes import GenericCRUD, APIBuilder +from routes import GenericCRUD, APIBuilder from models.todo import Todo, Task bp = Blueprint('todos', __name__) From f08ea41a6e33fe4ad50dbbd67c55a06a3f2fcf19 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Wed, 25 Dec 2024 15:11:13 -0600 Subject: [PATCH 23/44] MISC: Comments --- client/src/api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/api.js b/client/src/api.js index 41668b2..4720d75 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -113,7 +113,7 @@ export const simplePost = (url, model) => { }); }; -/** Wrapper for fetch with error handling and jsonifying. */ +/** Wrapper for update handling and jsonifying. */ export const simplePatch = (url, patchModel) => { return fetch(url, { method: 'PATCH', @@ -129,7 +129,7 @@ export const simplePatch = (url, patchModel) => { }); }; -/** Wrapper for fetch with error handling and jsonifying. */ +/** Wrapper for delete handling and jsonifying. */ export const simpleDelete = (url) => { return fetch(url, { method: 'DELETE', From f8e7b77d09914adde06a6d2b9fc704bceafc893d Mon Sep 17 00:00:00 2001 From: mseng10 Date: Fri, 14 Feb 2025 22:03:26 -0600 Subject: [PATCH 24/44] New database setup, simple stupid --- server/.gitignore | 4 +- server/requirements.txt | 2 + server/shared/db.py | 88 ++++++++++++++++++++++------------------- 3 files changed, 52 insertions(+), 42 deletions(-) diff --git a/server/.gitignore b/server/.gitignore index 56bb2fe..f672a52 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -6,4 +6,6 @@ venv/ *.pyc *.json -myenv~/ \ No newline at end of file +myenv~/ + +db/ \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt index a61bc59..263e928 100755 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -31,6 +31,7 @@ cycler==0.12.1 debugpy==1.8.5 decorator==5.1.1 defusedxml==0.7.1 +dnspython==2.7.0 exceptiongroup==1.2.2 executing==2.1.0 fastjsonschema==2.20.0 @@ -101,6 +102,7 @@ pure_eval==0.2.3 pycparser==2.22 pyftdi==0.55.4 Pygments==2.18.0 +pymongo==4.11.1 pyparsing==3.1.2 pyserial==3.5 python-dateutil==2.9.0.post0 diff --git a/server/shared/db.py b/server/shared/db.py index f278e7a..5cbe912 100644 --- a/server/shared/db.py +++ b/server/shared/db.py @@ -2,44 +2,50 @@ This is the main source for anything db related. """ import os - -from sqlalchemy import create_engine, func -from sqlalchemy.orm import sessionmaker, scoped_session - -from models import Base - -# Create the SQLAlchemy engine -DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:admin@localhost:5432/postgres") -engine = create_engine(DATABASE_URL) - -# Base.metadata.drop_all(bind=engine) -# Base.metadata.create_all(bind=engine) - -# Create a configured "Session" class -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - -# Create a scoped session -Session = scoped_session(SessionLocal) - -# This allows you to query the database directly using the Base class -Base.query = Session.query_property() - -def safe_sum(column): - return func.coalesce(func.sum(column), 0) - -def get_db(): - """ - Generator function to create and yield a database session. - Use this in FastAPI dependencies or Flask before_request handlers. - """ - db = Session() - try: - yield db - finally: - db.close() - -def init_db(): - # Import all modules here that might define models so that - # they will be registered properly on the metadata. - import models # Make sure to create this file with your SQLAlchemy models - Base.metadata.create_all(bind=engine) \ No newline at end of file +from enum import Enum +from typing import Dict, Any, Optional, List +from bson import ObjectId +from pymongo import MongoClient +from pymongo.collection import Collection +from pymongo.database import Database +from contextlib import contextmanager + +# Create the MongoDB client +MONGODB_URL = os.getenv("MONGODB_URL", "mongodb://localhost:27017") +CLIENT: MongoClient = MongoClient(MONGODB_URL) + +# Get the default database +DB_NAME = os.getenv("DB_NAME", "plnts") +DB: Database = CLIENT[DB_NAME] + +class Table(str, Enum): + PLANT = "plant" + SYSTEM = "system" + GENUS_TYPE = "genus_type" + GENUS = "genus" + SPECIES = "species" + SOIL = "soil" + + def count(self, filter: Dict={})-> int: + return DB[self.value].count_documents(filter) + + def create(self, data: Dict[str, Any]) -> ObjectId: + result = DB[self.value].insert_one(data) + return result.inserted_id + + def get_one(self, id: str) -> Optional[Dict[str, Any]]: + return DB[self.value].find_one({"_id": ObjectId(id)}) + + def get_many(self, query: Dict[str, Any], limit: int = 100) -> List[Dict[str, Any]]: + return list(DB[self.value].find(query).limit(limit)) + + def update(self, id: str, data: Dict[str, Any]) -> bool: + result = DB[self.value].update_one( + {"_id": ObjectId(id)}, + {"$set": data} + ) + return result.modified_count > 0 + + def delete(self, id: str) -> bool: + result = DB[self.value].delete_one({"_id": ObjectId(id)}) + return result.deleted_count > 0 From fe735efd55d82266008af2e66aa71ecc258b2545 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Fri, 14 Feb 2025 22:04:50 -0600 Subject: [PATCH 25/44] Model rework to be with mongo --- server/models/__init__.py | 50 +++++---- server/models/alert.py | 108 +++++++++++--------- server/models/mix.py | 170 +++++++++++++++++-------------- server/models/plant.py | 207 +++++++++++++++----------------------- server/models/system.py | 174 ++++++++++++++++---------------- server/models/todo.py | 126 ++++++++++++++--------- 6 files changed, 428 insertions(+), 407 deletions(-) diff --git a/server/models/__init__.py b/server/models/__init__.py index 708ae91..e2e8a88 100644 --- a/server/models/__init__.py +++ b/server/models/__init__.py @@ -1,41 +1,42 @@ -from sqlalchemy.orm import declarative_base -from sqlalchemy.ext.declarative import declarative_base - +# models/__init__.py from typing import List, Any, Dict, Optional from dataclasses import dataclass - import numpy as np import csv - -Base = declarative_base() +from datetime import datetime +from shared.db import Table +from bson import ObjectId @dataclass class FieldConfig: - """ Configuration for each field stored on a model.""" + """Configuration for each field stored on a model.""" read_only: bool = False create_only: bool = False internal_only: bool = False - # NOTE: Probably add these to NestedFieldConfig object? nested: Optional['ModelConfig'] = None - nested_class:Any = None + nested_class: Any = None nested_identifier: str = None include_nested: bool = False - delete_with_parent: bool = False # New attribute + delete_with_parent: bool = False class ModelConfig: - """ Standard plnts_2 model serializer:) Expected to grow with shared util.""" + """Standard model serializer with MongoDB support""" def __init__(self, fields: Dict[str, FieldConfig], archivable=True): self.fields = fields self.archivable = archivable def serialize(self, obj, depth=0, include_nested=False) -> Dict[str, Any]: - if depth > 5: # Prevent infinite recursion + if depth > 5: return {} + result = {} for k, v in self.fields.items(): if hasattr(obj, k): value = getattr(obj, k) - if v.nested and (include_nested or v.include_nested): + # Handle ObjectId conversion + if isinstance(value, ObjectId): + result[k] = str(value) + elif v.nested and (include_nested or v.include_nested): if isinstance(value, list): result[k] = [v.nested.serialize(item, depth+1, include_nested) for item in value] elif value is not None: @@ -45,8 +46,9 @@ def serialize(self, obj, depth=0, include_nested=False) -> Dict[str, Any]: return result def deserialize(self, data, is_create=False, depth=0) -> Dict[str, Any]: - if depth > 5: # Prevent infinite recursion + if depth > 5: return {} + result = {} for k, v in data.items(): if k in self.fields: @@ -62,21 +64,25 @@ def deserialize(self, data, is_create=False, depth=0) -> Dict[str, Any]: return result class FlexibleModel: - """ A model that can be created from various sources. """ + """Base model with MongoDB support""" + table: Table = None # Override in subclasses + + def __init__(self, **kwargs): + self._id = kwargs.get('_id', ObjectId()) # MongoDB ID + for key, value in kwargs.items(): + setattr(self, key, value) + @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'FlexibleModel': - ret = cls(**data) - return ret + return cls(**data) @classmethod def from_numpy(cls, data: np.ndarray) -> List['FlexibleModel']: if len(data.shape) != 2: raise ValueError("Array must be 2-dimensional") - columns = data.dtype.names if columns is None: raise ValueError("Array must have named fields") - return [cls(**dict(zip(columns, row))) for row in data] @classmethod @@ -88,4 +94,8 @@ def from_request(cls, req: Any) -> 'FlexibleModel': def from_csv(cls, file_path: str) -> List['FlexibleModel']: with open(file_path, 'r', newline='') as csvfile: reader = csv.DictReader(csvfile) - return [cls.from_dict(row) for row in reader] \ No newline at end of file + return [cls.from_dict(row) for row in reader] + + def to_dict(self) -> Dict[str, Any]: + """Convert model to dictionary for MongoDB storage""" + return {k: v for k, v in self.__dict__.items() if not k.startswith('_')} \ No newline at end of file diff --git a/server/models/alert.py b/server/models/alert.py index da962c5..0c350c7 100644 --- a/server/models/alert.py +++ b/server/models/alert.py @@ -1,61 +1,71 @@ """ -Module defining models for plants. +Module defining models for alerts. """ - from datetime import datetime +from bson import ObjectId +from models.plant import DeprecatableMixin +from models import FlexibleModel, ModelConfig, FieldConfig -from sqlalchemy import Column, Integer, String, DateTime, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column +class Alert(DeprecatableMixin, FlexibleModel): + """Alert Base Class""" + collection_name = "alert" -from models.plant import DeprecatableMixin -from models import ModelConfig, FieldConfig, Base - -class Alert(Base, DeprecatableMixin): - """Alert Base Class""" - __tablename__ = "alert" - - id = Column(Integer, primary_key=True) - created_on = Column(DateTime(), default=datetime.now) - updated_on = Column(DateTime(), default=datetime.now, onupdate=datetime.now) - alert_type = Column(String(50)) - - __mapper_args__ = { - 'polymorphic_identity': 'alert', - 'polymorphic_on': alert_type - } - - schema = ModelConfig({ - 'id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'alert_type': FieldConfig(read_only=True) - }) + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._id = kwargs.get('_id', ObjectId()) + self.created_on = kwargs.get('created_on', datetime.now()) + self.updated_on = kwargs.get('updated_on', datetime.now()) + self.alert_type = kwargs.get('alert_type', 'alert') -class PlantAlert(Alert): - """Plant alert model.""" + schema = ModelConfig({ + '_id': FieldConfig(read_only=True), + 'created_on': FieldConfig(read_only=True), + 'updated_on': FieldConfig(read_only=True), + 'alert_type': FieldConfig(read_only=True), + 'deprecated': FieldConfig(), + 'deprecated_on': FieldConfig(), + 'deprecated_cause': FieldConfig() + }) - __tablename__ = "plant_alert" +class PlantAlert(Alert): + """Plant alert model.""" + collection_name = "alert" # Same collection as Alert - id = Column(Integer(), ForeignKey('alert.id'), primary_key=True) - plant_alert_type = Column(String(50)) + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.alert_type = 'plant_alert' # Override alert_type + self.plant_alert_type = kwargs.get('plant_alert_type') + self.plant_id = kwargs.get('plant_id') + self.system_id = kwargs.get('system_id') - plant_id: Mapped[int] = mapped_column( - ForeignKey("plant.id", ondelete="CASCADE") - ) # plant this plant belongs to - system_id: Mapped[int] = mapped_column( - ForeignKey("system.id", ondelete="CASCADE") - ) # System this light belongs to + def __repr__(self): + return "plant_alert" - __mapper_args__ = { - 'polymorphic_identity': 'plant_alert' - } + schema = ModelConfig({ + '_id': FieldConfig(read_only=True), + 'created_on': FieldConfig(read_only=True), + 'updated_on': FieldConfig(read_only=True), + 'alert_type': FieldConfig(read_only=True), + 'plant_alert_type': FieldConfig(read_only=True), + 'plant_id': FieldConfig(read_only=True), + 'system_id': FieldConfig(read_only=True), + 'deprecated': FieldConfig(), + 'deprecated_on': FieldConfig(), + 'deprecated_cause': FieldConfig() + }) - def __repr__(self): - return "plant_alert" + @classmethod + def find_by_plant(cls, db, plant_id: ObjectId): + """Find all alerts for a specific plant""" + return db[cls.collection_name].find({ + 'alert_type': 'plant_alert', + 'plant_id': plant_id + }) - schema = ModelConfig({ - 'id': FieldConfig(read_only=True), - 'plant_alert_type': FieldConfig(read_only=True), - 'plant_id': FieldConfig(read_only=True), - 'system_id': FieldConfig(read_only=True) - }) + @classmethod + def find_by_system(cls, db, system_id: ObjectId): + """Find all alerts for a specific system""" + return db[cls.collection_name].find({ + 'alert_type': 'plant_alert', + 'system_id': system_id + }) \ No newline at end of file diff --git a/server/models/mix.py b/server/models/mix.py index a1c4084..2b5842a 100644 --- a/server/models/mix.py +++ b/server/models/mix.py @@ -1,89 +1,109 @@ -from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean -from sqlalchemy.orm import relationship, Mapped - +""" +Module for soil mix related models. +""" from datetime import datetime -from typing import List - +from typing import List, Dict, Any +from bson import ObjectId from models.plant import DeprecatableMixin -from models import Base, FieldConfig, ModelConfig, FlexibleModel - -class Soil(Base, FlexibleModel): - """Soil. Created on installation.""" - __tablename__ = "soil" +from models import FlexibleModel, ModelConfig, FieldConfig +from shared.db import Table - id = Column(Integer(), primary_key=True) - created_on = Column(DateTime(), default=datetime.now) - description = Column(String(400), nullable=False) - group = Column(String(100), nullable=False) - name = Column(String(100), nullable=False) - - mix_soil_parts: Mapped[List["SoilPart"]] = relationship( - "SoilPart", backref="soil", passive_deletes=True - ) +class Soil(FlexibleModel): + """Soil types available for mixes.""" + table = Table.SOIL - def __repr__(self) -> str: - return f"{self.name}" + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._id = kwargs.get('_id', ObjectId()) + self.created_on = kwargs.get('created_on', datetime.now()) + self.description = kwargs.get('description') + self.group = kwargs.get('group') + self.name = kwargs.get('name') - schema = ModelConfig({ - 'id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'name': FieldConfig(read_only=True), - 'description': FieldConfig(read_only=True), - 'group': FieldConfig(read_only=True) - }) + def __repr__(self) -> str: + return f"{self.name}" -class SoilPart(Base, FlexibleModel): - __tablename__ = 'mix_soil_part' + schema = ModelConfig({ + '_id': FieldConfig(read_only=True), + 'created_on': FieldConfig(read_only=True), + 'name': FieldConfig(read_only=True), + 'description': FieldConfig(read_only=True), + 'group': FieldConfig(read_only=True) + }) - id = Column(Integer(), primary_key=True) - created_on = Column(DateTime(), default=datetime.now) - updated_on = Column(DateTime(), default=datetime.now, onupdate=datetime.now) - mix_id = Column(Integer, ForeignKey('mix.id'), nullable=False) - # mix = relationship("Mix", back_populates="soils") - soil_id = Column(Integer, ForeignKey('soil.id'), nullable=False) - # soil = relationship("Soil", back_populates="mixes") - parts = Column(Integer, default=1, nullable=False) +class Mix(DeprecatableMixin, FlexibleModel): + """Soil mix model with embedded soil parts.""" + collection_name = "mix" - schema = ModelConfig({ - 'id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'mix_id': FieldConfig(), - 'soil_id': FieldConfig(), - 'parts': FieldConfig() - }) + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._id = kwargs.get('_id', ObjectId()) + self.name = kwargs.get('name') + self.description = kwargs.get('description') + self.created_on = kwargs.get('created_on', datetime.now()) + self.updated_on = kwargs.get('updated_on', datetime.now()) + self.experimental = kwargs.get('experimental', False) + + # Embedded soil parts + self.soil_parts = [ + { + 'soil_id': part.get('soil_id'), + 'parts': part.get('parts', 1), + 'created_on': part.get('created_on', datetime.now()), + 'updated_on': part.get('updated_on', datetime.now()) + } + for part in kwargs.get('soil_parts', []) + ] -class Mix(Base, DeprecatableMixin): - """Soil mix model.""" - __tablename__ = "mix" + def __repr__(self) -> str: + return f"{self.name}" - id = Column(Integer(), primary_key=True) - name = Column(String(100), nullable=False) - description = Column(String(400), nullable=True) - created_on = Column(DateTime(), default=datetime.now) - updated_on = Column(DateTime(), default=datetime.now) - experimental = Column(Boolean, default=False, nullable=False) - - # Plants belonging to this mix - plants: Mapped[List["Plant"]] = relationship( - "Plant", backref="mix", passive_deletes=True - ) # Available plants of this mix + def add_soil_part(self, soil_id: ObjectId, parts: int = 1) -> None: + """Add a new soil part to the mix""" + self.soil_parts.append({ + 'soil_id': soil_id, + 'parts': parts, + 'created_on': datetime.now(), + 'updated_on': datetime.now() + }) - soil_parts: Mapped[List["SoilPart"]] = relationship( - "SoilPart", backref="mix", passive_deletes=True - ) # Available tasks of this todo + def remove_soil_part(self, soil_id: ObjectId) -> None: + """Remove a soil part from the mix""" + self.soil_parts = [part for part in self.soil_parts if part['soil_id'] != soil_id] + def update_soil_part(self, soil_id: ObjectId, parts: int) -> None: + """Update the parts count for a soil in the mix""" + for part in self.soil_parts: + if part['soil_id'] == soil_id: + part['parts'] = parts + part['updated_on'] = datetime.now() + break - def __repr__(self) -> str: - return f"{self.name}" + schema = ModelConfig({ + '_id': FieldConfig(read_only=True), + 'created_on': FieldConfig(read_only=True), + 'updated_on': FieldConfig(read_only=True), + 'name': FieldConfig(), + 'description': FieldConfig(), + 'experimental': FieldConfig(), + 'soil_parts': FieldConfig(read_only=False), + 'deprecated': FieldConfig(), + 'deprecated_on': FieldConfig(), + 'deprecated_cause': FieldConfig() + }) - schema = ModelConfig({ - 'id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'name': FieldConfig(), - 'description': FieldConfig(), - 'experimental': FieldConfig(), - 'soil_parts': FieldConfig(nested=SoilPart.schema, nested_class=SoilPart, include_nested=True, delete_with_parent=True, nested_identifier='mix_id'), - # parts: Fieldconfig ? - }) + def to_dict(self) -> Dict[str, Any]: + """Convert to MongoDB document format""" + base_dict = super().to_dict() + # Ensure soil_parts is properly formatted for MongoDB + if 'soil_parts' in base_dict: + base_dict['soil_parts'] = [ + { + 'soil_id': part['soil_id'], + 'parts': part['parts'], + 'created_on': part['created_on'], + 'updated_on': part['updated_on'] + } + for part in base_dict['soil_parts'] + ] + return base_dict \ No newline at end of file diff --git a/server/models/plant.py b/server/models/plant.py index 7ae3391..9c6ba14 100644 --- a/server/models/plant.py +++ b/server/models/plant.py @@ -1,32 +1,20 @@ """ Module defining models for plants. """ - -# Standard library imports from datetime import datetime -from typing import List +from typing import List, Optional import enum +from bson import ObjectId +from shared.db import Table -# Third-party imports -from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey -from sqlalchemy.orm import Mapped, relationship, mapped_column -from sqlalchemy.ext.declarative import declared_attr - -from models import FlexibleModel, ModelConfig, FieldConfig, Base +from models import FlexibleModel, ModelConfig, FieldConfig class DeprecatableMixin: """ In case the model is deprecated.""" - @declared_attr - def deprecated(cls): - return Column(Boolean, default=False, nullable=False) - - @declared_attr - def deprecated_on(cls): - return Column(DateTime(), default=None, nullable=True) - - @declared_attr - def deprecated_cause(cls): - return Column(String(400), nullable=True) + def __init__(self, **kwargs): + self.deprecated = kwargs.get('deprecated', False) + self.deprecated_on = kwargs.get('deprecated_on') + self.deprecated_cause = kwargs.get('deprecated_cause') class PHASES(enum.Enum): ADULT = "Adult" @@ -35,50 +23,35 @@ class PHASES(enum.Enum): LEAD = "Leaf" SEED = "Seed" -class Plant(Base, DeprecatableMixin, FlexibleModel): +class Plant(DeprecatableMixin, FlexibleModel): """Plant model.""" - - __tablename__ = "plant" - - id = Column(Integer(), primary_key=True) - created_on = Column(DateTime(), default=datetime.now) - cost = Column(Integer(), default=0, nullable=False) - system_id: Mapped[int] = mapped_column( - ForeignKey("system.id", ondelete="CASCADE") - ) # System for housing the plant - mix_id: Mapped[int] = mapped_column( - ForeignKey("mix.id", ondelete="CASCADE") - ) # Soil mix for housing the plant - updated_on = Column(DateTime(), default=datetime.now, onupdate=datetime.now) - - # Metrics - phase = Column(String(400), nullable=True) - size = Column(Integer(), default=0, nullable=False) # inches - - # Watering info - watering = Column(Integer(), default=0, nullable=False) # Days - watered_on = Column(DateTime(), default=datetime.now) # Water Info - - species_id = Column(Integer, ForeignKey('plant_species.id'), nullable=False) - species = relationship("PlantSpecies", back_populates="plants") - - # Sure - identity = Column(String(50)) - __mapper_args__ = { - 'polymorphic_identity': 'plant', - 'polymorphic_on': identity - } - - # Misc - plant_alerts: Mapped[List["PlantAlert"]] = relationship( - "PlantAlert", backref="type", passive_deletes=True - ) # Available plants of this type + collection_name = "plant" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._id = kwargs.get('_id', ObjectId()) + self.created_on = kwargs.get('created_on', datetime.now()) + self.cost = kwargs.get('cost', 0) + self.system_id = kwargs.get('system_id') + self.mix_id = kwargs.get('mix_id') + self.updated_on = kwargs.get('updated_on', datetime.now()) + + # Metrics + self.phase = kwargs.get('phase') + self.size = kwargs.get('size', 0) # inches + + # Watering info + self.watering = kwargs.get('watering', 0) # Days + self.watered_on = kwargs.get('watered_on', datetime.now()) + + self.species_id = kwargs.get('species_id') + self.identity = kwargs.get('identity', 'plant') def __repr__(self) -> str: - return f"{self.id}" + return f"{self._id}" schema = ModelConfig({ - 'id': FieldConfig(read_only=True), + '_id': FieldConfig(read_only=True), 'created_on': FieldConfig(read_only=True), 'updated_on': FieldConfig(read_only=True), 'cost': FieldConfig(), @@ -89,65 +62,56 @@ def __repr__(self) -> str: 'phase': FieldConfig(), 'size': FieldConfig(), 'system_id': FieldConfig(), - 'mix_id': FieldConfig() - # TODO: - # 'species': FieldConfig(nested=Type.schema) - # plant_alerts: FieldConfig(nested=PlantAlert.schema) + 'mix_id': FieldConfig(), + 'deprecated': FieldConfig(), + 'deprecated_on': FieldConfig(), + 'deprecated_cause': FieldConfig() }) -# Single Table Inheritance class Batch(Plant): """Batch of plants.""" - # Number of plants - count = Column(Integer(), default=0, nullable=False) - - __mapper_args__ = { - 'polymorphic_identity': 'batch' - } - -class PlantGenusType(Base, FlexibleModel): - __tablename__ = 'plant_genus_type' - - id = Column(Integer, primary_key=True) - created_on = Column(DateTime(), default=datetime.now) - updated_on = Column(DateTime(), default=datetime.now, onupdate=datetime.now) - name = Column(String(50), nullable=False, unique=True) - description = Column(String(200)) - watering = Column(Integer(), nullable=True) # days - - # Relationship to PlantGenus - genera = relationship("PlantGenus", back_populates="genus_type") + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.count = kwargs.get('count', 0) + self.identity = 'batch' + +class PlantGenusType(FlexibleModel): + table = Table.GENUS_TYPE + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._id = kwargs.get('_id', ObjectId()) + self.created_on = kwargs.get('created_on', datetime.now()) + self.updated_on = kwargs.get('updated_on', datetime.now()) + self.name = kwargs.get('name') + self.description = kwargs.get('description') + self.watering = kwargs.get('watering') schema = ModelConfig({ - 'id': FieldConfig(read_only=True), + '_id': FieldConfig(read_only=True), 'created_on': FieldConfig(read_only=True), 'updated_on': FieldConfig(read_only=True), 'name': FieldConfig(read_only=True), 'description': FieldConfig(read_only=True), - 'watering': FieldConfig(), - # 'genera': FieldConfig(nested=PlantGenus.schema, include_nested=True) + 'watering': FieldConfig() }) -class PlantGenus(Base, FlexibleModel): - __tablename__ = 'plant_genus' - - id = Column(Integer, primary_key=True) - created_on = Column(DateTime(), default=datetime.now) - updated_on = Column(DateTime(), default=datetime.now, onupdate=datetime.now) - name = Column(String(50), nullable=False, unique=True) - common_name = Column(String(100)) - description = Column(String(200)) - watering = Column(Integer(), nullable=True) # days - - # Relationship to PlantGenusType - genus_type_id = Column(Integer, ForeignKey('plant_genus_type.id'), nullable=False) - genus_type = relationship("PlantGenusType", back_populates="genera") +class PlantGenus(FlexibleModel): + table = Table.GENUS - # Relationship to PlantSpecies - species = relationship("PlantSpecies", back_populates="genus") + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._id = kwargs.get('_id', ObjectId()) + self.created_on = kwargs.get('created_on', datetime.now()) + self.updated_on = kwargs.get('updated_on', datetime.now()) + self.name = kwargs.get('name') + self.common_name = kwargs.get('common_name') + self.description = kwargs.get('description') + self.watering = kwargs.get('watering') + self.genus_type_id = kwargs.get('genus_type_id') schema = ModelConfig({ - 'id': FieldConfig(read_only=True), + '_id': FieldConfig(read_only=True), 'created_on': FieldConfig(read_only=True), 'updated_on': FieldConfig(read_only=True), 'name': FieldConfig(read_only=True), @@ -155,35 +119,28 @@ class PlantGenus(Base, FlexibleModel): 'description': FieldConfig(read_only=True), 'watering': FieldConfig(), 'genus_type_id': FieldConfig(read_only=True), - 'genus_type': FieldConfig(nested=PlantGenusType.schema, include_nested=True), - # 'species': FieldConfig(nested=Plant.schema) + 'genus_type': FieldConfig(nested=PlantGenusType.schema, include_nested=True) }) -class PlantSpecies(Base, FlexibleModel): - __tablename__ = 'plant_species' +class PlantSpecies(FlexibleModel): + table = Table.SPECIES - id = Column(Integer, primary_key=True) - created_on = Column(DateTime(), default=datetime.now) - updated_on = Column(DateTime(), default=datetime.now, onupdate=datetime.now) - name = Column(String(100), nullable=False, unique=True) - common_name = Column(String(100)) - description = Column(String(500)) - - # Relationship to PlantGenus - genus_id = Column(Integer, ForeignKey('plant_genus.id'), nullable=False) - genus = relationship("PlantGenus", back_populates="species") - - # Relationship to alive plants - plants = relationship("Plant", back_populates="species") + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._id = kwargs.get('_id', ObjectId()) + self.created_on = kwargs.get('created_on', datetime.now()) + self.updated_on = kwargs.get('updated_on', datetime.now()) + self.name = kwargs.get('name') + self.common_name = kwargs.get('common_name') + self.description = kwargs.get('description') + self.genus_id = kwargs.get('genus_id') schema = ModelConfig({ - 'id': FieldConfig(read_only=True), + '_id': FieldConfig(read_only=True), 'created_on': FieldConfig(read_only=True), 'updated_on': FieldConfig(read_only=True), 'name': FieldConfig(read_only=True), 'common_name': FieldConfig(read_only=True), 'description': FieldConfig(read_only=True), - 'genus_id': FieldConfig(read_only=True), - # 'genus': FieldConfig(nested=PlantGenus.schema, include_nested=True) - # 'plants': FieldConfig(nested=Plant.schema) - }) + 'genus_id': FieldConfig(read_only=True) + }) \ No newline at end of file diff --git a/server/models/system.py b/server/models/system.py index 25543dc..1e54892 100644 --- a/server/models/system.py +++ b/server/models/system.py @@ -3,94 +3,88 @@ """ from datetime import datetime from typing import List - -from sqlalchemy import Column, Integer, String, DateTime, ForeignKey -from sqlalchemy.orm import relationship, Mapped, mapped_column - +from bson import ObjectId from models.plant import DeprecatableMixin -from models import FlexibleModel, ModelConfig, FieldConfig, Base - - -class Light(Base, DeprecatableMixin, FlexibleModel): - """Light model.""" - - __tablename__ = "light" - - id = Column(Integer(), primary_key=True) - name = Column(String(100), nullable=False) - created_on = Column(DateTime(), default=datetime.now) - updated_on = Column(DateTime(), default=datetime.now) - cost = Column(Integer(), nullable=False, default=0) - system_id: Mapped[int] = mapped_column( - ForeignKey("system.id", ondelete="CASCADE") - ) # System this light belongs to - - def __repr__(self) -> str: - return f"{self.name}" - - schema = ModelConfig({ - 'id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'name': FieldConfig(), - 'cost': FieldConfig(), - 'system_id': FieldConfig() - }) - - -class System(Base, DeprecatableMixin, FlexibleModel): - """System model.""" - - __tablename__ = "system" - - id = Column(Integer(), primary_key=True) - name = Column(String(100), nullable=False) - description = Column(String(400), nullable=False) - created_on = Column(DateTime(), default=datetime.now) - updated_on = Column(DateTime(), default=datetime.now) - - # Controlled Factors - maybe move to a - target_humidity = Column(Integer(), default=0, nullable=False) # % - target_temperature = Column(Integer(), default=0, nullable=False) # F - - # Latest updates (might break out at some point..) - last_humidity = Column(Integer(), nullable=True) # % - last_temperature = Column(Integer(), nullable=True) # F - - # Internal - container_id = Column(String(64), unique=True, nullable=True) - url = Column(String(200), nullable=False) - - # Plants belonging to this system - plants: Mapped[List["Plant"]] = relationship( - "Plant", backref="system", passive_deletes=True - ) # Available plants of this system - - # Lighting - duration = Column(Integer(), nullable=False) # hours - distance = Column(Integer(), nullable=False) # inches - lights: Mapped[List["Light"]] = relationship( - "Light", backref="system", passive_deletes=True - ) # Available plants of this system - - def __repr__(self) -> str: - return f"{self.name}" - - schema = ModelConfig({ - 'id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'last_humidity': FieldConfig(read_only=True), - 'last_temperature': FieldConfig(read_only=True), - 'container_id': FieldConfig(internal_only=True), - 'is_local': FieldConfig(internal_only=True), - 'url': FieldConfig(internal_only=True), - 'name': FieldConfig(), - 'description': FieldConfig(), - 'target_humidity': FieldConfig(), - 'target_temperature': FieldConfig(), - 'duration': FieldConfig(), - 'distance': FieldConfig(), - # 'plants': FieldConfig(nested=Plant.schema, include_nested=True, delete_with_parent=True) - # 'lights': FieldConfig(nested=Light.schema, include_nested=True, delete_with_parent=True) - }) +from models import FlexibleModel, ModelConfig, FieldConfig + +class Light(DeprecatableMixin, FlexibleModel): + """Light model.""" + collection_name = "light" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._id = kwargs.get('_id', ObjectId()) + self.name = kwargs.get('name') + self.created_on = kwargs.get('created_on', datetime.now()) + self.updated_on = kwargs.get('updated_on', datetime.now()) + self.cost = kwargs.get('cost', 0) + self.system_id = kwargs.get('system_id') + + def __repr__(self) -> str: + return f"{self.name}" + + schema = ModelConfig({ + '_id': FieldConfig(read_only=True), + 'created_on': FieldConfig(read_only=True), + 'updated_on': FieldConfig(read_only=True), + 'name': FieldConfig(), + 'cost': FieldConfig(), + 'system_id': FieldConfig(), + 'deprecated': FieldConfig(), + 'deprecated_on': FieldConfig(), + 'deprecated_cause': FieldConfig() + }) + +class System(DeprecatableMixin, FlexibleModel): + """System model.""" + collection_name = "system" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._id = kwargs.get('_id', ObjectId()) + self.name = kwargs.get('name') + self.description = kwargs.get('description') + self.created_on = kwargs.get('created_on', datetime.now()) + self.updated_on = kwargs.get('updated_on', datetime.now()) + + # Controlled Factors + self.target_humidity = kwargs.get('target_humidity', 0) # % + self.target_temperature = kwargs.get('target_temperature', 0) # F + + # Latest updates + self.last_humidity = kwargs.get('last_humidity') # % + self.last_temperature = kwargs.get('last_temperature') # F + + # Internal + self.container_id = kwargs.get('container_id') + self.url = kwargs.get('url') + + # Lighting + self.duration = kwargs.get('duration') # hours + self.distance = kwargs.get('distance') # inches + + def __repr__(self) -> str: + return f"{self.name}" + + schema = ModelConfig({ + '_id': FieldConfig(read_only=True), + 'created_on': FieldConfig(read_only=True), + 'updated_on': FieldConfig(read_only=True), + 'last_humidity': FieldConfig(read_only=True), + 'last_temperature': FieldConfig(read_only=True), + 'container_id': FieldConfig(internal_only=True), + 'is_local': FieldConfig(internal_only=True), + 'url': FieldConfig(internal_only=True), + 'name': FieldConfig(), + 'description': FieldConfig(), + 'target_humidity': FieldConfig(), + 'target_temperature': FieldConfig(), + 'duration': FieldConfig(), + 'distance': FieldConfig(), + 'deprecated': FieldConfig(), + 'deprecated_on': FieldConfig(), + 'deprecated_cause': FieldConfig() + # Relationships will be handled by querying the related collections + # 'plants': FieldConfig(nested=Plant.schema, include_nested=True, delete_with_parent=True) + # 'lights': FieldConfig(nested=Light.schema, include_nested=True, delete_with_parent=True) + }) \ No newline at end of file diff --git a/server/models/todo.py b/server/models/todo.py index 97d85ac..b8904d3 100644 --- a/server/models/todo.py +++ b/server/models/todo.py @@ -1,70 +1,100 @@ """ -Module defining models for plants. +Module defining models for todos. """ - from datetime import datetime -from typing import List - -from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship - +from typing import List, Dict, Any +from bson import ObjectId from models.plant import DeprecatableMixin -from models import FlexibleModel, ModelConfig, FieldConfig, Base +from models import FlexibleModel, ModelConfig, FieldConfig -class Task(Base, DeprecatableMixin, FlexibleModel): - """Task of a Todo.""" +class Todo(DeprecatableMixin, FlexibleModel): + """TODO model with embedded tasks.""" + collection_name = "todo" - __tablename__ = "task" - - id = Column(Integer(), primary_key=True) - created_on = Column(DateTime(), default=datetime.now) - updated_on = Column(DateTime(), default=datetime.now, onupdate=datetime.now) - description = Column(String(100), nullable=False) - todo_id: Mapped[int] = mapped_column( - ForeignKey("todo.id", ondelete="CASCADE") - ) # Todo this task belongs to - - resolved = Column(Boolean, default=False, nullable=False) - resolved_on = Column(DateTime(), nullable=True) + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._id = kwargs.get('_id', ObjectId()) + self.created_on = kwargs.get('created_on', datetime.now()) + self.updated_on = kwargs.get('updated_on', datetime.now()) + self.due_on = kwargs.get('due_on') + self.name = kwargs.get('name') + self.description = kwargs.get('description') + + # Embedded tasks + self.tasks = [ + { + 'description': task.get('description'), + 'resolved': task.get('resolved', False), + 'resolved_on': task.get('resolved_on'), + 'created_on': task.get('created_on', datetime.now()), + 'updated_on': task.get('updated_on', datetime.now()), + } + for task in kwargs.get('tasks', []) + ] def __repr__(self): - return f"{self.description}" + return f"{self.name}" - schema = ModelConfig({ - 'id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'todo_id': FieldConfig(), - 'description': FieldConfig(), - 'resolved': FieldConfig(), - 'resolved_on': FieldConfig() - }) + def add_task(self, description: str) -> None: + """Add a new task to the todo""" + self.tasks.append({ + 'description': description, + 'resolved': False, + 'resolved_on': None, + 'created_on': datetime.now(), + 'updated_on': datetime.now() + }) -class Todo(Base, DeprecatableMixin, FlexibleModel): - """TOOO model.""" + def resolve_task(self, task_index: int) -> None: + """Mark a task as resolved""" + if 0 <= task_index < len(self.tasks): + self.tasks[task_index]['resolved'] = True + self.tasks[task_index]['resolved_on'] = datetime.now() + self.tasks[task_index]['updated_on'] = datetime.now() - __tablename__ = "todo" + def unresolve_task(self, task_index: int) -> None: + """Mark a task as unresolved""" + if 0 <= task_index < len(self.tasks): + self.tasks[task_index]['resolved'] = False + self.tasks[task_index]['resolved_on'] = None + self.tasks[task_index]['updated_on'] = datetime.now() - id = Column(Integer(), primary_key=True) - created_on = Column(DateTime(), default=datetime.now) - updated_on = Column(DateTime(), default=datetime.now, onupdate=datetime.now) - due_on = Column(DateTime(), default=None, nullable=True) - name = Column(String(100), nullable=False) - description = Column(String(400), nullable=True) + def remove_task(self, task_index: int) -> None: + """Remove a task from the todo""" + if 0 <= task_index < len(self.tasks): + self.tasks.pop(task_index) - tasks: Mapped[List["Task"]] = relationship( - "Task", backref="todo", passive_deletes=True - ) # Available tasks of this todo + def update_task(self, task_index: int, description: str) -> None: + """Update a task's description""" + if 0 <= task_index < len(self.tasks): + self.tasks[task_index]['description'] = description + self.tasks[task_index]['updated_on'] = datetime.now() schema = ModelConfig({ - 'id': FieldConfig(read_only=True), + '_id': FieldConfig(read_only=True), 'created_on': FieldConfig(read_only=True), 'updated_on': FieldConfig(read_only=True), 'due_on': FieldConfig(), 'name': FieldConfig(), 'description': FieldConfig(), - 'tasks': FieldConfig(nested=Task.schema, nested_class=Task, include_nested=True, delete_with_parent=True, nested_identifier='todo_id'), + 'tasks': FieldConfig(read_only=False), + 'deprecated': FieldConfig(), + 'deprecated_on': FieldConfig(), + 'deprecated_cause': FieldConfig() }) - def __repr__(self): - return f"{self.name}" \ No newline at end of file + def to_dict(self) -> Dict[str, Any]: + """Convert to MongoDB document format""" + base_dict = super().to_dict() + if 'tasks' in base_dict: + base_dict['tasks'] = [ + { + 'description': task['description'], + 'resolved': task['resolved'], + 'resolved_on': task['resolved_on'], + 'created_on': task['created_on'], + 'updated_on': task['updated_on'] + } + for task in base_dict['tasks'] + ] + return base_dict \ No newline at end of file From 2e89e72b99b0a499a1bd244a672c8be620923832 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Fri, 14 Feb 2025 22:06:27 -0600 Subject: [PATCH 26/44] Installable setup for mongo --- .gitignore | 1 + server/app/install.py | 92 ++++++------- server/data/installable/plants/genera.csv | 94 +++++++------- .../data/installable/plants/genus_types.csv | 26 ++-- server/data/installable/plants/species.csv | 122 +++++++++--------- 5 files changed, 168 insertions(+), 167 deletions(-) diff --git a/.gitignore b/.gitignore index 3a07fca..3a14764 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea/ +myenv/ todo.txt diff --git a/server/app/install.py b/server/app/install.py index 462c986..da7ddeb 100644 --- a/server/app/install.py +++ b/server/app/install.py @@ -4,70 +4,70 @@ """ import sys import os +from typing import Type, List, Tuple sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from models.plant import PlantGenusType, PlantGenus, PlantSpecies from models.mix import Soil - -from shared.db import init_db -from shared.db import Session - +from models import FlexibleModel +from shared.db import DB, Table from shared.logger import setup_logger import logging # Create a logger for this specific module logger = setup_logger(__name__, logging.DEBUG) -init_db() - -num_threads = 5 - -def create_model(model_path, model_class): +def create_model(model_path: str, model_class: Type[FlexibleModel]): """ Create the provided model from the data path. """ - logger.info(f"Beginning to create model {model_class}") - - db = Session() - - existing_model_count = db.query(model_class).count() - if existing_model_count > 0: - # NOTE: Probably make this more flexible in the future, but rn, just 1 time install (e.g. no rolling upgrades - upgrade.py?) - logger.error(f"Models already exist for {model_class}, exiting.") + table: Table = model_class.table + + logger.info(f"Beginning to create model {table.value}") + + # All or nothing for now + existing_count = table.count() + if existing_count > 0: + logger.error(f"Documents already exist for {table.value}, exiting.") return - for model in model_class.from_csv(model_path): - db.add(model) - - db.commit() - db.close() - - logger.info(f"Successfully created model {model_class}") - + # Load models from CSV and insert them + # Doing this way to create null values in the db in the event some fields get updated + models = model_class.from_csv(model_path) + documents = [model.to_dict() for model in models] + + if documents: + for doc in documents: + table.create(doc) + logger.info(f"Successfully created {len(documents)} documents for {table.value}") + else: + logger.error(f"No documents to create for {table.value}") def create_all_models(): - """ - Create all of our packaged models on installation. - """ - logger.info("Beginning to create model.") - - models_to_create = [ - ("data/installable/soils/soils.csv", Soil), - ("data/installable/plants/genus_types.csv", PlantGenusType), - ("data/installable/plants/genera.csv", PlantGenus), - ("data/installable/plants/species.csv", PlantSpecies), - ] - - for model_path, model_class in models_to_create: - create_model(model_path, model_class) - - logger.info("All models have been created.") - + """ + Create all of our packaged models on installation. + """ + logger.info("Beginning to create models.") + + models_to_create: List[Tuple[str, Type[FlexibleModel]]] = [ + ("data/installable/soils/soils.csv", Soil), + ("data/installable/plants/genus_types.csv", PlantGenusType), + ("data/installable/plants/genera.csv", PlantGenus), + ("data/installable/plants/species.csv", PlantSpecies), + ] + + for model_path, model_class in models_to_create: + create_model(model_path, model_class) + + logger.info("All models have been created.") def install(): - create_all_models() + """ + Initialize the database with required data + """ + logger.info("Installation starting") + create_all_models() + logger.info("Installation complete") if __name__ == "__main__": - logger.info("Initializing installation processing.") - install() - logger.info("Successfully completed installation process.") \ No newline at end of file + install() diff --git a/server/data/installable/plants/genera.csv b/server/data/installable/plants/genera.csv index ab4696e..ca2947d 100644 --- a/server/data/installable/plants/genera.csv +++ b/server/data/installable/plants/genera.csv @@ -1,47 +1,47 @@ -id,name,common_name,genus_type_id -1,Echeveria,Echeveria,1 -2,Sedum,Stonecrop,1 -3,Haworthia,Haworthia,1 -4,Crassula,Jade Plant,1 -5,Kalanchoe,Kalanchoe,1 -6,Sempervivum,Hens and Chicks,1 -7,Mammillaria,Pincushion Cactus,2 -8,Opuntia,Prickly Pear,2 -9,Schlumbergera,Christmas Cactus,2 -10,Rhipsalis,Mistletoe Cactus,2 -11,Monstera,Swiss Cheese Plant,3 -12,Philodendron,Philodendron,3 -13,Ficus,Fig,3 -14,Dracaena,Dragon Tree,3 -15,Dieffenbachia,Dumb Cane,3 -16,Calathea,Prayer Plant,3 -17,Maranta,Maranta,3 -18,Stromanthe,Stromanthe,3 -19,Nephrolepis,Boston Fern,4 -20,Asplenium,Bird's Nest Fern,4 -21,Adiantum,Maidenhair Fern,4 -22,Platycerium,Staghorn Fern,4 -23,Aglaonema,Chinese Evergreen,5 -24,Spathiphyllum,Peace Lily,5 -25,Anthurium,Flamingo Flower,5 -26,Alocasia,Elephant Ear,5 -27,Syngonium,Arrowhead Plant,5 -28,Phalaenopsis,Moth Orchid,6 -29,Saintpaulia,African Violet,6 -30,Begonia,Begonia,6 -31,Hoya,Wax Plant,6 -32,Epipremnum,Pothos,7 -33,Hedera,English Ivy,7 -34,Cissus,Rex Begonia Vine,7 -35,Tradescantia,Wandering Jew,7 -36,Neoregelia,Bromeliad,8 -37,Guzmania,Bromeliad,8 -38,Vriesea,Bromeliad,8 -39,Chamaedorea,Parlor Palm,9 -40,Rhapis,Lady Palm,9 -41,Phoenix,Date Palm,9 -42,Tillandsia,Air Plant,10 -43,Dionaea,Venus Flytrap,11 -44,Nepenthes,Pitcher Plant,11 -45,Paphiopedilum,Slipper Orchid,12 -46,Cattleya,Corsage Orchid,12 \ No newline at end of file +_id,name,common_name,genus_type_id +65cd7a8e9b72e6c8940d5234,Echeveria,Echeveria,65cd7a8e9b72e6c8940d1234 +65cd7a8e9b72e6c8940d5235,Sedum,Stonecrop,65cd7a8e9b72e6c8940d1234 +65cd7a8e9b72e6c8940d5236,Haworthia,Haworthia,65cd7a8e9b72e6c8940d1234 +65cd7a8e9b72e6c8940d5237,Crassula,Jade Plant,65cd7a8e9b72e6c8940d1234 +65cd7a8e9b72e6c8940d5238,Kalanchoe,Kalanchoe,65cd7a8e9b72e6c8940d1234 +65cd7a8e9b72e6c8940d5239,Sempervivum,Hens and Chicks,65cd7a8e9b72e6c8940d1234 +65cd7a8e9b72e6c8940d523a,Mammillaria,Pincushion Cactus,65cd7a8e9b72e6c8940d1235 +65cd7a8e9b72e6c8940d523b,Opuntia,Prickly Pear,65cd7a8e9b72e6c8940d1235 +65cd7a8e9b72e6c8940d523c,Schlumbergera,Christmas Cactus,65cd7a8e9b72e6c8940d1235 +65cd7a8e9b72e6c8940d523d,Rhipsalis,Mistletoe Cactus,65cd7a8e9b72e6c8940d1235 +65cd7a8e9b72e6c8940d523e,Monstera,Swiss Cheese Plant,65cd7a8e9b72e6c8940d1236 +65cd7a8e9b72e6c8940d523f,Philodendron,Philodendron,65cd7a8e9b72e6c8940d1236 +65cd7a8e9b72e6c8940d5240,Ficus,Fig,65cd7a8e9b72e6c8940d1236 +65cd7a8e9b72e6c8940d5241,Dracaena,Dragon Tree,65cd7a8e9b72e6c8940d1236 +65cd7a8e9b72e6c8940d5242,Dieffenbachia,Dumb Cane,65cd7a8e9b72e6c8940d1236 +65cd7a8e9b72e6c8940d5243,Calathea,Prayer Plant,65cd7a8e9b72e6c8940d1236 +65cd7a8e9b72e6c8940d5244,Maranta,Maranta,65cd7a8e9b72e6c8940d1236 +65cd7a8e9b72e6c8940d5245,Stromanthe,Stromanthe,65cd7a8e9b72e6c8940d1236 +65cd7a8e9b72e6c8940d5246,Nephrolepis,Boston Fern,65cd7a8e9b72e6c8940d1237 +65cd7a8e9b72e6c8940d5247,Asplenium,Bird's Nest Fern,65cd7a8e9b72e6c8940d1237 +65cd7a8e9b72e6c8940d5248,Adiantum,Maidenhair Fern,65cd7a8e9b72e6c8940d1237 +65cd7a8e9b72e6c8940d5249,Platycerium,Staghorn Fern,65cd7a8e9b72e6c8940d1237 +65cd7a8e9b72e6c8940d524a,Aglaonema,Chinese Evergreen,65cd7a8e9b72e6c8940d1238 +65cd7a8e9b72e6c8940d524b,Spathiphyllum,Peace Lily,65cd7a8e9b72e6c8940d1238 +65cd7a8e9b72e6c8940d524c,Anthurium,Flamingo Flower,65cd7a8e9b72e6c8940d1238 +65cd7a8e9b72e6c8940d524d,Alocasia,Elephant Ear,65cd7a8e9b72e6c8940d1238 +65cd7a8e9b72e6c8940d524e,Syngonium,Arrowhead Plant,65cd7a8e9b72e6c8940d1238 +65cd7a8e9b72e6c8940d524f,Phalaenopsis,Moth Orchid,65cd7a8e9b72e6c8940d1239 +65cd7a8e9b72e6c8940d5250,Saintpaulia,African Violet,65cd7a8e9b72e6c8940d1239 +65cd7a8e9b72e6c8940d5251,Begonia,Begonia,65cd7a8e9b72e6c8940d1239 +65cd7a8e9b72e6c8940d5252,Hoya,Wax Plant,65cd7a8e9b72e6c8940d1239 +65cd7a8e9b72e6c8940d5253,Epipremnum,Pothos,65cd7a8e9b72e6c8940d123a +65cd7a8e9b72e6c8940d5254,Hedera,English Ivy,65cd7a8e9b72e6c8940d123a +65cd7a8e9b72e6c8940d5255,Cissus,Rex Begonia Vine,65cd7a8e9b72e6c8940d123a +65cd7a8e9b72e6c8940d5256,Tradescantia,Wandering Jew,65cd7a8e9b72e6c8940d123a +65cd7a8e9b72e6c8940d5257,Neoregelia,Bromeliad,65cd7a8e9b72e6c8940d123b +65cd7a8e9b72e6c8940d5258,Guzmania,Bromeliad,65cd7a8e9b72e6c8940d123b +65cd7a8e9b72e6c8940d5259,Vriesea,Bromeliad,65cd7a8e9b72e6c8940d123b +65cd7a8e9b72e6c8940d525a,Chamaedorea,Parlor Palm,65cd7a8e9b72e6c8940d123c +65cd7a8e9b72e6c8940d525b,Rhapis,Lady Palm,65cd7a8e9b72e6c8940d123c +65cd7a8e9b72e6c8940d525c,Phoenix,Date Palm,65cd7a8e9b72e6c8940d123c +65cd7a8e9b72e6c8940d525d,Tillandsia,Air Plant,65cd7a8e9b72e6c8940d123d +65cd7a8e9b72e6c8940d525e,Dionaea,Venus Flytrap,65cd7a8e9b72e6c8940d123d +65cd7a8e9b72e6c8940d525f,Nepenthes,Pitcher Plant,65cd7a8e9b72e6c8940d123d +65cd7a8e9b72e6c8940d5260,Paphiopedilum,Slipper Orchid,65cd7a8e9b72e6c8940d123f +65cd7a8e9b72e6c8940d5261,Cattleya,Corsage Orchid,65cd7a8e9b72e6c8940d123f \ No newline at end of file diff --git a/server/data/installable/plants/genus_types.csv b/server/data/installable/plants/genus_types.csv index 7e72cb9..819e621 100644 --- a/server/data/installable/plants/genus_types.csv +++ b/server/data/installable/plants/genus_types.csv @@ -1,13 +1,13 @@ -id,name,description,watering -1,Succulents,Plants with thick fleshy parts adapted to store water,14 -2,Cacti,Succulent plants with unique areoles and often spines,30 -3,Tropical Foliage,Plants native to tropical regions (often with large decorative leaves), 14 -4,Ferns,Vascular plants that reproduce via spores and have neither seeds nor flowers, 7 -5,Aroids,Plants in the Araceae family (often with distinctive leaf shapes), 14 -6,Flowering Houseplants,Indoor plants that produce flowers, 7 -7,Vines and Climbers,Plants with a climbing or trailing growth habit, 14 -8,Bromeliads,Tropical plants often with rosette growth and colorful leaves or flowers, 14 -9,Palms,Plants with distinctive frond leaves (giving a tropical appearance), 14 -10,Air Plants,Plants that grow without soil (typically from the Tillandsia genus), 3 -11,Carnivorous Plants,Plants that trap and digest insects, 7 -12,Orchids,Plants with complex flowers and often epiphytic growth habits, 10 \ No newline at end of file +_id,name,description,watering +65cd7a8e9b72e6c8940d1234,Succulents,Plants with thick fleshy parts adapted to store water,14 +65cd7a8e9b72e6c8940d1235,Cacti,Succulent plants with unique areoles and often spines,30 +65cd7a8e9b72e6c8940d1236,Tropical Foliage,Plants native to tropical regions (often with large decorative leaves),14 +65cd7a8e9b72e6c8940d1237,Ferns,Vascular plants that reproduce via spores and have neither seeds nor flowers,7 +65cd7a8e9b72e6c8940d1238,Aroids,Plants in the Araceae family (often with distinctive leaf shapes),14 +65cd7a8e9b72e6c8940d1239,Flowering Houseplants,Indoor plants that produce flowers,7 +65cd7a8e9b72e6c8940d123a,Vines and Climbers,Plants with a climbing or trailing growth habit,14 +65cd7a8e9b72e6c8940d123b,Bromeliads,Tropical plants often with rosette growth and colorful leaves or flowers,14 +65cd7a8e9b72e6c8940d123c,Palms,Plants with distinctive frond leaves (giving a tropical appearance),14 +65cd7a8e9b72e6c8940d123d,Air Plants,Plants that grow without soil (typically from the Tillandsia genus),3 +65cd7a8e9b72e6c8940d123e,Carnivorous Plants,Plants that trap and digest insects,7 +65cd7a8e9b72e6c8940d123f,Orchids,Plants with complex flowers and often epiphytic growth habits,10 \ No newline at end of file diff --git a/server/data/installable/plants/species.csv b/server/data/installable/plants/species.csv index 2691707..c6e2f39 100644 --- a/server/data/installable/plants/species.csv +++ b/server/data/installable/plants/species.csv @@ -1,61 +1,61 @@ -id,name,common_name,description,genus_id -1,Echeveria elegans,Mexican Snowball,Forms pale green-blue rosettes,1 -2,Echeveria agavoides,Molded Wax Agave,Forms rosettes with pointed leaves,1 -3,Sedum morganianum,Burro's Tail,Trailing succulent with tear-drop shaped leaves,2 -4,Haworthia fasciata,Zebra Plant,Small succulent with white striped leaves,3 -5,Crassula ovata,Jade Plant,Succulent shrub with oval leaves,4 -6,Kalanchoe blossfeldiana,Flaming Katy,Succulent with clusters of small flowers,5 -7,Sempervivum tectorum,Common Houseleek,Forms rosettes that produce offsets,6 -8,Mammillaria crinita,Pincushion Cactus,Small spherical cactus with fine spines,7 -9,Opuntia microdasys,Bunny Ears Cactus,Paddle-shaped stems with small glochids,8 -10,Schlumbergera truncata,Christmas Cactus,Tropical cactus with flattened stems and colorful flowers,9 -11,Rhipsalis baccifera,Mistletoe Cactus,Epiphytic cactus with thin cylindrical stems,10 -12,Monstera deliciosa,Swiss Cheese Plant,Large-leaved tropical plant with natural leaf holes,11 -13,Monstera adansonii,Swiss Cheese Vine,Smaller version with more holes in leaves,11 -14,Monstera obliqua,Mexican Bread Plant,Rare Monstera with very large leaf holes,11 -15,Philodendron hederaceum,Heartleaf Philodendron,Vining plant with heart-shaped leaves,12 -16,Philodendron bipinnatifidum,Tree Philodendron,Large philodendron with deeply lobed leaves,12 -17,Ficus lyrata,Fiddle Leaf Fig,Popular indoor tree with large violin-shaped leaves,13 -18,Ficus elastica,Rubber Plant,Tree-like plant with large glossy leaves,13 -19,Dracaena marginata,Dragon Tree,Tree-like plant with slender stems and strap-like leaves,14 -20,Dracaena fragrans,Corn Plant,Tropical plant with long strap-like leaves,14 -21,Dieffenbachia seguine,Dumb Cane,Tropical plant with large variegated leaves,15 -22,Calathea ornata,Pinstripe Plant,Tropical plant with pink striped oval leaves,16 -23,Calathea makoyana,Peacock Plant,Tropical plant with oval leaves marked like peacock feathers,16 -24,Maranta leuconeura,Prayer Plant,Low-growing plant with oval leaves that fold at night,17 -25,Stromanthe sanguinea,Triostar,Colorful plant with pink and green variegated leaves,18 -26,Nephrolepis exaltata,Boston Fern,Popular houseplant with arching fronds,19 -27,Asplenium nidus,Bird's Nest Fern,Fern with wide simple fronds growing from a central point,20 -28,Adiantum raddianum,Delta Maidenhair Fern,Delicate fern with small leaflets on black stems,21 -29,Platycerium bifurcatum,Staghorn Fern,Epiphytic fern with distinctive antler-like fronds,22 -30,Aglaonema commutatum,Chinese Evergreen,Low-maintenance plant with variegated leaves,23 -31,Spathiphyllum wallisii,Peace Lily,Tropical plant with white spathe flowers,24 -32,Anthurium andraeanum,Flamingo Flower,Tropical plant with heart-shaped leaves and colorful spathes,25 -33,Alocasia polly,African Mask Plant,Compact alocasia with arrow-shaped leaves,26 -34,Syngonium podophyllum,Arrowhead Plant,Vining plant with arrow-shaped leaves,27 -35,Phalaenopsis amabilis,Moth Orchid,Popular orchid with long-lasting flowers,28 -36,Saintpaulia ionantha,African Violet,Small plant with fuzzy leaves and purple flowers,29 -37,Begonia rex,Rex Begonia,Begonia grown for its colorful foliage,30 -38,Hoya carnosa,Wax Plant,Vining plant with thick waxy leaves and fragrant flowers,31 -39,Epipremnum aureum,Golden Pothos,Vining plant with heart-shaped variegated leaves,32 -40,Hedera helix,English Ivy,Classic vining plant with lobed leaves,33 -41,Cissus discolor,Rex Begonia Vine,Climbing vine with colorful leaves,34 -42,Tradescantia zebrina,Wandering Jew,Trailing plant with purple and silver striped leaves,35 -43,Neoregelia carolinae,Blushing Bromeliad,Bromeliad with red center leaves when blooming,36 -44,Guzmania lingulata,Scarlet Star,Bromeliad with bright red flower spike,37 -45,Vriesea splendens,Flaming Sword,Bromeliad with a bright red paddle-shaped inflorescence,38 -46,Chamaedorea elegans,Parlor Palm,Small palm suitable for indoor spaces,39 -47,Rhapis excelsa,Lady Palm,Multi-stemmed palm with fan-shaped leaves,40 -48,Phoenix roebelenii,Pygmy Date Palm,Small palm with feather-like fronds,41 -49,Tillandsia ionantha,Sky Plant,Small air plant that turns red when blooming,42 -50,Dionaea muscipula,Venus Flytrap,Carnivorous plant with hinged trapping leaves,43 -51,Nepenthes alata,Pitcher Plant,Carnivorous plant with pitcher-shaped traps,44 -52,Paphiopedilum insigne,Slipper Orchid,Orchid with slipper-shaped pouch,45 -53,Cattleya labiata,Corsage Orchid,Large-flowered orchid often used in corsages,46 -54,Echeveria 'Perle von Nürnberg',Pearl of Nurnberg,Echeveria with pale purple-pink leaves,1 -55,Sedum rubrotinctum,Jelly Bean Plant,Succulent with small jelly bean-shaped leaves,2 -56,Haworthia cooperi,Cooper's Haworthia,Small succulent with translucent fleshy leaves,3 -57,Crassula perforata,String of Buttons,Succulent with stacked gray-green leaves,4 -58,Kalanchoe tomentosa,Panda Plant,Succulent with fuzzy silver-green leaves,5 -59,Sempervivum arachnoideum,Cobweb Houseleek,Forms rosettes with fine white hairs resembling a cobweb,6 -60,Mammillaria hahniana,Old Lady Cactus,Cactus covered in soft white hair-like spines,7 \ No newline at end of file +_id,name,common_name,description,genus_id +65cd7a8e9b72e6c8940d9234,Echeveria elegans,Mexican Snowball,Forms pale green-blue rosettes,65cd7a8e9b72e6c8940d5234 +65cd7a8e9b72e6c8940d9235,Echeveria agavoides,Molded Wax Agave,Forms rosettes with pointed leaves,65cd7a8e9b72e6c8940d5234 +65cd7a8e9b72e6c8940d9236,Sedum morganianum,Burro's Tail,Trailing succulent with tear-drop shaped leaves,65cd7a8e9b72e6c8940d5235 +65cd7a8e9b72e6c8940d9237,Haworthia fasciata,Zebra Plant,Small succulent with white striped leaves,65cd7a8e9b72e6c8940d5236 +65cd7a8e9b72e6c8940d9238,Crassula ovata,Jade Plant,Succulent shrub with oval leaves,65cd7a8e9b72e6c8940d5237 +65cd7a8e9b72e6c8940d9239,Kalanchoe blossfeldiana,Flaming Katy,Succulent with clusters of small flowers,65cd7a8e9b72e6c8940d5238 +65cd7a8e9b72e6c8940d923a,Sempervivum tectorum,Common Houseleek,Forms rosettes that produce offsets,65cd7a8e9b72e6c8940d5239 +65cd7a8e9b72e6c8940d923b,Mammillaria crinita,Pincushion Cactus,Small spherical cactus with fine spines,65cd7a8e9b72e6c8940d523a +65cd7a8e9b72e6c8940d923c,Opuntia microdasys,Bunny Ears Cactus,Paddle-shaped stems with small glochids,65cd7a8e9b72e6c8940d523b +65cd7a8e9b72e6c8940d923d,Schlumbergera truncata,Christmas Cactus,Tropical cactus with flattened stems and colorful flowers,65cd7a8e9b72e6c8940d523c +65cd7a8e9b72e6c8940d923e,Rhipsalis baccifera,Mistletoe Cactus,Epiphytic cactus with thin cylindrical stems,65cd7a8e9b72e6c8940d523d +65cd7a8e9b72e6c8940d923f,Monstera deliciosa,Swiss Cheese Plant,Large-leaved tropical plant with natural leaf holes,65cd7a8e9b72e6c8940d523e +65cd7a8e9b72e6c8940d9240,Monstera adansonii,Swiss Cheese Vine,Smaller version with more holes in leaves,65cd7a8e9b72e6c8940d523e +65cd7a8e9b72e6c8940d9241,Monstera obliqua,Mexican Bread Plant,Rare Monstera with very large leaf holes,65cd7a8e9b72e6c8940d523e +65cd7a8e9b72e6c8940d9242,Philodendron hederaceum,Heartleaf Philodendron,Vining plant with heart-shaped leaves,65cd7a8e9b72e6c8940d523f +65cd7a8e9b72e6c8940d9243,Philodendron bipinnatifidum,Tree Philodendron,Large philodendron with deeply lobed leaves,65cd7a8e9b72e6c8940d523f +65cd7a8e9b72e6c8940d9244,Ficus lyrata,Fiddle Leaf Fig,Popular indoor tree with large violin-shaped leaves,65cd7a8e9b72e6c8940d5240 +65cd7a8e9b72e6c8940d9245,Ficus elastica,Rubber Plant,Tree-like plant with large glossy leaves,65cd7a8e9b72e6c8940d5240 +65cd7a8e9b72e6c8940d9246,Dracaena marginata,Dragon Tree,Tree-like plant with slender stems and strap-like leaves,65cd7a8e9b72e6c8940d5241 +65cd7a8e9b72e6c8940d9247,Dracaena fragrans,Corn Plant,Tropical plant with long strap-like leaves,65cd7a8e9b72e6c8940d5241 +65cd7a8e9b72e6c8940d9248,Dieffenbachia seguine,Dumb Cane,Tropical plant with large variegated leaves,65cd7a8e9b72e6c8940d5242 +65cd7a8e9b72e6c8940d9249,Calathea ornata,Pinstripe Plant,Tropical plant with pink striped oval leaves,65cd7a8e9b72e6c8940d5243 +65cd7a8e9b72e6c8940d924a,Calathea makoyana,Peacock Plant,Tropical plant with oval leaves marked like peacock feathers,65cd7a8e9b72e6c8940d5243 +65cd7a8e9b72e6c8940d924b,Maranta leuconeura,Prayer Plant,Low-growing plant with oval leaves that fold at night,65cd7a8e9b72e6c8940d5244 +65cd7a8e9b72e6c8940d924c,Stromanthe sanguinea,Triostar,Colorful plant with pink and green variegated leaves,65cd7a8e9b72e6c8940d5245 +65cd7a8e9b72e6c8940d924d,Nephrolepis exaltata,Boston Fern,Popular houseplant with arching fronds,65cd7a8e9b72e6c8940d5246 +65cd7a8e9b72e6c8940d924e,Asplenium nidus,Bird's Nest Fern,Fern with wide simple fronds growing from a central point,65cd7a8e9b72e6c8940d5247 +65cd7a8e9b72e6c8940d924f,Adiantum raddianum,Delta Maidenhair Fern,Delicate fern with small leaflets on black stems,65cd7a8e9b72e6c8940d5248 +65cd7a8e9b72e6c8940d9250,Platycerium bifurcatum,Staghorn Fern,Epiphytic fern with distinctive antler-like fronds,65cd7a8e9b72e6c8940d5249 +65cd7a8e9b72e6c8940d9251,Aglaonema commutatum,Chinese Evergreen,Low-maintenance plant with variegated leaves,65cd7a8e9b72e6c8940d524a +65cd7a8e9b72e6c8940d9252,Spathiphyllum wallisii,Peace Lily,Tropical plant with white spathe flowers,65cd7a8e9b72e6c8940d524b +65cd7a8e9b72e6c8940d9253,Anthurium andraeanum,Flamingo Flower,Tropical plant with heart-shaped leaves and colorful spathes,65cd7a8e9b72e6c8940d524c +65cd7a8e9b72e6c8940d9254,Alocasia polly,African Mask Plant,Compact alocasia with arrow-shaped leaves,65cd7a8e9b72e6c8940d524d +65cd7a8e9b72e6c8940d9255,Syngonium podophyllum,Arrowhead Plant,Vining plant with arrow-shaped leaves,65cd7a8e9b72e6c8940d524e +65cd7a8e9b72e6c8940d9256,Phalaenopsis amabilis,Moth Orchid,Popular orchid with long-lasting flowers,65cd7a8e9b72e6c8940d524f +65cd7a8e9b72e6c8940d9257,Saintpaulia ionantha,African Violet,Small plant with fuzzy leaves and purple flowers,65cd7a8e9b72e6c8940d5250 +65cd7a8e9b72e6c8940d9258,Begonia rex,Rex Begonia,Begonia grown for its colorful foliage,65cd7a8e9b72e6c8940d5251 +65cd7a8e9b72e6c8940d9259,Hoya carnosa,Wax Plant,Vining plant with thick waxy leaves and fragrant flowers,65cd7a8e9b72e6c8940d5252 +65cd7a8e9b72e6c8940d925a,Epipremnum aureum,Golden Pothos,Vining plant with heart-shaped variegated leaves,65cd7a8e9b72e6c8940d5253 +65cd7a8e9b72e6c8940d925b,Hedera helix,English Ivy,Classic vining plant with lobed leaves,65cd7a8e9b72e6c8940d5254 +65cd7a8e9b72e6c8940d925c,Cissus discolor,Rex Begonia Vine,Climbing vine with colorful leaves,65cd7a8e9b72e6c8940d5255 +65cd7a8e9b72e6c8940d925d,Tradescantia zebrina,Wandering Jew,Trailing plant with purple and silver striped leaves,65cd7a8e9b72e6c8940d5256 +65cd7a8e9b72e6c8940d925e,Neoregelia carolinae,Blushing Bromeliad,Bromeliad with red center leaves when blooming,65cd7a8e9b72e6c8940d5257 +65cd7a8e9b72e6c8940d925f,Guzmania lingulata,Scarlet Star,Bromeliad with bright red flower spike,65cd7a8e9b72e6c8940d5258 +65cd7a8e9b72e6c8940d9260,Vriesea splendens,Flaming Sword,Bromeliad with a bright red paddle-shaped inflorescence,65cd7a8e9b72e6c8940d5259 +65cd7a8e9b72e6c8940d9261,Chamaedorea elegans,Parlor Palm,Small palm suitable for indoor spaces,65cd7a8e9b72e6c8940d525a +65cd7a8e9b72e6c8940d9262,Rhapis excelsa,Lady Palm,Multi-stemmed palm with fan-shaped leaves,65cd7a8e9b72e6c8940d525b +65cd7a8e9b72e6c8940d9263,Phoenix roebelenii,Pygmy Date Palm,Small palm with feather-like fronds,65cd7a8e9b72e6c8940d525c +65cd7a8e9b72e6c8940d9264,Tillandsia ionantha,Sky Plant,Small air plant that turns red when blooming,65cd7a8e9b72e6c8940d525d +65cd7a8e9b72e6c8940d9265,Dionaea muscipula,Venus Flytrap,Carnivorous plant with hinged trapping leaves,65cd7a8e9b72e6c8940d525e +65cd7a8e9b72e6c8940d9266,Nepenthes alata,Pitcher Plant,Carnivorous plant with pitcher-shaped traps,65cd7a8e9b72e6c8940d525f +65cd7a8e9b72e6c8940d9267,Paphiopedilum insigne,Slipper Orchid,Orchid with slipper-shaped pouch,65cd7a8e9b72e6c8940d5260 +65cd7a8e9b72e6c8940d9268,Cattleya labiata,Corsage Orchid,Large-flowered orchid often used in corsages,65cd7a8e9b72e6c8940d5261 +65cd7a8e9b72e6c8940d9269,Echeveria 'Perle von Nürnberg',Pearl of Nurnberg,Echeveria with pale purple-pink leaves,65cd7a8e9b72e6c8940d5234 +65cd7a8e9b72e6c8940d926a,Sedum rubrotinctum,Jelly Bean Plant,Succulent with small jelly bean-shaped leaves,65cd7a8e9b72e6c8940d5235 +65cd7a8e9b72e6c8940d926b,Haworthia cooperi,Cooper's Haworthia,Small succulent with translucent fleshy leaves,65cd7a8e9b72e6c8940d5236 +65cd7a8e9b72e6c8940d926c,Crassula perforata,String of Buttons,Succulent with stacked gray-green leaves,65cd7a8e9b72e6c8940d5235 +65cd7a8e9b72e6c8940d926d,Kalanchoe tomentosa,Panda Plant,Succulent with fuzzy silver-green leaves,65cd7a8e9b72e6c8940d5236 +65cd7a8e9b72e6c8940d926e,Sempervivum arachnoideum,Cobweb Houseleek,Forms rosettes with fine white hairs resembling a cobweb,65cd7a8e9b72e6c8940d5237 +65cd7a8e9b72e6c8940d926f,Mammillaria hahniana,Old Lady Cactus,Cactus covered in soft white hair-like spines,65cd7a8e9b72e6c8940d5238 \ No newline at end of file From 7485d74a285c75e17000360750c183c1378ac37d Mon Sep 17 00:00:00 2001 From: mseng10 Date: Fri, 14 Feb 2025 22:27:32 -0600 Subject: [PATCH 27/44] Enable app.py, setup alerts and enable discover --- server/app/app.py | 64 +++++++++----------- server/models/alert.py | 22 +------ server/shared/db.py | 2 + server/shared/discover.py | 119 ++++++++++++++++++++++---------------- 4 files changed, 101 insertions(+), 106 deletions(-) diff --git a/server/app/app.py b/server/app/app.py index a662a2b..133f152 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -5,17 +5,17 @@ import logging import sys import os -import datetime +from datetime import datetime, timedelta sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from flask import Flask, jsonify from flask_cors import CORS from flask_apscheduler import APScheduler +from models.plant import Plant from models.alert import PlantAlert -from models.todo import Todo -from shared.db import init_db, Session +from shared.db import Table from shared.logger import setup_logger from shared.discover import discover_systems @@ -23,37 +23,34 @@ # Create a logger for this specific module logger = setup_logger(__name__, logging.DEBUG) -# Initialize DB connection -init_db() - # Probably abstract this out to a Role class and have this be in the master role class discover_systems() # Maybe put this into the installable? # Create Flask app app = Flask(__name__) -from routes.system_routes import system_bp, light_bp -from routes.plant_routes import bp as plant_bp -from routes.todo_routes import bp as todo_bp -from routes.mix_routes import bp as mix_bp -from routes.stat_routes import bp as stat_bp -from routes.installable_model_routes import genus_types_bp, species_bp, soils_bp, genus_bp -from routes.alert_routes import bp as alert_bp +# from routes.system_routes import system_bp, light_bp +# from routes.plant_routes import bp as plant_bp +# from routes.todo_routes import bp as todo_bp +# from routes.mix_routes import bp as mix_bp +# from routes.stat_routes import bp as stat_bp +# from routes.installable_model_routes import genus_types_bp, species_bp, soils_bp, genus_bp +# from routes.alert_routes import bp as alert_bp # Models -app.register_blueprint(system_bp) -app.register_blueprint(light_bp) -app.register_blueprint(plant_bp) -app.register_blueprint(todo_bp) -app.register_blueprint(mix_bp) -app.register_blueprint(stat_bp) -app.register_blueprint(alert_bp) +# app.register_blueprint(system_bp) +# app.register_blueprint(light_bp) +# app.register_blueprint(plant_bp) +# app.register_blueprint(todo_bp) +# app.register_blueprint(mix_bp) +# app.register_blueprint(stat_bp) +# app.register_blueprint(alert_bp) # Installables -app.register_blueprint(genus_types_bp) -app.register_blueprint(genus_bp) -app.register_blueprint(species_bp) -app.register_blueprint(soils_bp) +# app.register_blueprint(genus_types_bp) +# app.register_blueprint(genus_bp) +# app.register_blueprint(species_bp) +# app.register_blueprint(soils_bp) CORS(app) @@ -63,12 +60,11 @@ def get_meta(): Get meta data of the application. """ logger.info("Received request to query the meta") - db = Session() + meta = { - "alert_count" : db.query(PlantAlert).filter(PlantAlert.deprecated == False).count(), - "todo_count" : db.query(Todo).filter(Todo.deprecated == False).count() + # "alert_count": db.plant_alerts.count_documents({"deprecated": False}), + "todo_count": Table.TODO.count({"deprecated": False}) } - db.close() logger.info("Successfully generated meta data.") return jsonify(meta) @@ -99,30 +95,26 @@ def manage_plant_alerts(): """ Create different plant alerts. Right now just supports creating watering alerts. """ - session = Session() - existing_plant_alrts = session.query(PlantAlert).filter(PlantAlert.deprecated == False).all() + existing_plant_alrts: PlantAlert = Table.PLANT_ALERT.get_many({"deprecated": False}) existing_plant_alrts_map = {} for existing_plant_alert in existing_plant_alrts: existing_plant_alrts_map[existing_plant_alert.plant_id] = existing_plant_alert - existing_plants = session.query(Plant).filter(Plant.deprecated == False).all() + existing_plants: Plant = Table.PLANT.get_many({"deprecated": False}) now = datetime.now() for plant in existing_plants: end_date = plant.watered_on + timedelta(days=float(plant.watering)) - if end_date < datetime.now() and existing_plant_alrts_map.get(plant.id) is None: + if end_date < now and existing_plant_alrts_map.get(plant.id) is None: new_plant_alert = PlantAlert( plant_id = plant.id, system_id = plant.system_id, plant_alert_type = "water" ) # Create the alert in the db - session.add(new_plant_alert) + Table.PLANT.create(new_plant_alert) existing_plant_alrts_map[new_plant_alert.plant_id] = new_plant_alert - session.commit() - session.close() - if __name__ == "__main__": # Run the Flask app diff --git a/server/models/alert.py b/server/models/alert.py index 0c350c7..82c2ed6 100644 --- a/server/models/alert.py +++ b/server/models/alert.py @@ -5,10 +5,10 @@ from bson import ObjectId from models.plant import DeprecatableMixin from models import FlexibleModel, ModelConfig, FieldConfig +from shared.db import Table class Alert(DeprecatableMixin, FlexibleModel): """Alert Base Class""" - collection_name = "alert" def __init__(self, **kwargs): super().__init__(**kwargs) @@ -29,11 +29,11 @@ def __init__(self, **kwargs): class PlantAlert(Alert): """Plant alert model.""" - collection_name = "alert" # Same collection as Alert + table = Table.PLANT_ALERT # Same collection as Alert def __init__(self, **kwargs): super().__init__(**kwargs) - self.alert_type = 'plant_alert' # Override alert_type + self.alert_type = 'plant_alert' self.plant_alert_type = kwargs.get('plant_alert_type') self.plant_id = kwargs.get('plant_id') self.system_id = kwargs.get('system_id') @@ -53,19 +53,3 @@ def __repr__(self): 'deprecated_on': FieldConfig(), 'deprecated_cause': FieldConfig() }) - - @classmethod - def find_by_plant(cls, db, plant_id: ObjectId): - """Find all alerts for a specific plant""" - return db[cls.collection_name].find({ - 'alert_type': 'plant_alert', - 'plant_id': plant_id - }) - - @classmethod - def find_by_system(cls, db, system_id: ObjectId): - """Find all alerts for a specific system""" - return db[cls.collection_name].find({ - 'alert_type': 'plant_alert', - 'system_id': system_id - }) \ No newline at end of file diff --git a/server/shared/db.py b/server/shared/db.py index 5cbe912..6bd8c95 100644 --- a/server/shared/db.py +++ b/server/shared/db.py @@ -25,6 +25,8 @@ class Table(str, Enum): GENUS = "genus" SPECIES = "species" SOIL = "soil" + TODO = "todo" + PLANT_ALERT = "plant_alert" def count(self, filter: Dict={})-> int: return DB[self.value].count_documents(filter) diff --git a/server/shared/discover.py b/server/shared/discover.py index cf00eea..e5a5c52 100644 --- a/server/shared/discover.py +++ b/server/shared/discover.py @@ -1,10 +1,7 @@ import os -# import docker - -from shared.db import Session +from shared.db import Table from models.system import System - ENVIRONMENT = os.getenv('ENVIRONMENT', 'docker') USE_LOCAL_HARDWARE = os.getenv('USE_LOCAL_HARDWARE', 'false').lower() == 'true' @@ -12,59 +9,79 @@ def discover_systems(): """ Meant to be ran from the master roled node (system) in a cluster of nodes (systems). """ - session = Session() - + if USE_LOCAL_HARDWARE: - local_system = session.query(System).filter_by(container_id='local').first() - if not local_system: + # Check for local system and create new system if needed + local_system = Table.SYSTEM.count({'container_id': 'local'}) + + if local_system > 0: local_system = System( - name='Local System', - description='System created by service. Please update accordingly', - url='local', + name='Local System', + description='System created by service. Please update accordingly', + url='local', container_id='local', target_humidity=-1, target_temperature=-1, duration=-1, distance=-1 ) - session.add(local_system) - - # TODO: Second? Maybe first - # if ENVIRONMENT == 'kubernetes': - # from kubernetes import client, config - # config.load_incluster_config() - # v1 = client.CoreV1Api() - # pods = v1.list_pod_for_all_namespaces(label_selector="app=pi-camera").items - - # for pod in pods: - # pi_id = pod.spec.node_name - # pod_ip = pod.status.pod_ip - # url = f"http://{pod_ip}:5000" - # camera = session.query(Camera).filter_by(name=f"Pi Camera {pi_id}").first() - # if not camera: - # camera = Camera(name=f"Pi Camera {pi_id}", url=url, container_id=pod.metadata.uid) - # session.add(camera) - # else: - # camera.url = url - # camera.container_id = pod.metadata.uid - - # elif ENVIRONMENT == 'docker': - # TODO: First - # client = docker.from_env() - - # for container in client.containers.list(): - # if container.name.startswith('pi-camera-'): - # pi_id = container.name.split('-')[-1] - # ip = container.attrs['NetworkSettings']['Networks']['multi-camera_default']['IPAddress'] - # url = f"http://{ip}:5000" - - # camera = session.query(Camera).filter_by(container_id=container.id).first() - # if not camera: - # camera = Camera(name=f"Pi Camera {pi_id}", url=url, container_id=container.id) - # session.add(camera) - # else: - # camera.url = url - - session.commit() - session.close() + # Insert the new system + Table.SYSTEM.create(local_system.to_dict()) + + # TODO: Support for Kubernetes + # if ENVIRONMENT == 'kubernetes': + # from kubernetes import client, config + # config.load_incluster_config() + # v1 = client.CoreV1Api() + # pods = v1.list_pod_for_all_namespaces(label_selector="app=pi-camera").items + # + # for pod in pods: + # pi_id = pod.spec.node_name + # pod_ip = pod.status.pod_ip + # url = f"http://{pod_ip}:5000" + # + # # Find existing system + # system = db.system.find_one({'container_id': pod.metadata.uid}) + # + # if not system: + # # Create new system + # system = System( + # name=f"Pi Camera {pi_id}", + # url=url, + # container_id=pod.metadata.uid + # ) + # db.system.insert_one(system.to_dict()) + # else: + # # Update existing system + # db.system.update_one( + # {'_id': system['_id']}, + # {'$set': {'url': url, 'container_id': pod.metadata.uid}} + # ) + + # TODO: Support for Docker + # elif ENVIRONMENT == 'docker': + # client = docker.from_env() + # for container in client.containers.list(): + # if container.name.startswith('pi-camera-'): + # pi_id = container.name.split('-')[-1] + # ip = container.attrs['NetworkSettings']['Networks']['multi-camera_default']['IPAddress'] + # url = f"http://{ip}:5000" + # + # # Find existing system + # system = db.system.find_one({'container_id': container.id}) + # + # if not system: + # # Create new system + # system = System( + # name=f"Pi Camera {pi_id}", + # url=url, + # container_id=container.id + # ) + # db.system.insert_one(system.to_dict()) + # else: + # # Update existing system + # db.system.update_one( + # {'_id': system['_id']}, + # {'$set': {'url': url}} + # ) \ No newline at end of file From 06425b723389a6770b862628aaa72f463a65a66f Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sat, 15 Feb 2025 10:41:54 -0600 Subject: [PATCH 28/44] Installable routes, simplify serializer for now --- server/app/app.py | 10 +- server/app/routes/__init__.py | 442 +++++++++++------- server/app/routes/installable_model_routes.py | 13 +- server/models/__init__.py | 60 +-- server/models/plant.py | 55 +-- server/shared/db.py | 2 +- 6 files changed, 287 insertions(+), 295 deletions(-) diff --git a/server/app/app.py b/server/app/app.py index 133f152..316bc79 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -34,7 +34,7 @@ # from routes.todo_routes import bp as todo_bp # from routes.mix_routes import bp as mix_bp # from routes.stat_routes import bp as stat_bp -# from routes.installable_model_routes import genus_types_bp, species_bp, soils_bp, genus_bp +from routes.installable_model_routes import soils_bp, genus_types_bp, species_bp, genus_bp # from routes.alert_routes import bp as alert_bp # Models @@ -47,10 +47,10 @@ # app.register_blueprint(alert_bp) # Installables -# app.register_blueprint(genus_types_bp) -# app.register_blueprint(genus_bp) -# app.register_blueprint(species_bp) -# app.register_blueprint(soils_bp) +app.register_blueprint(genus_types_bp) +app.register_blueprint(genus_bp) +app.register_blueprint(species_bp) +app.register_blueprint(soils_bp) CORS(app) diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py index 5e753a6..81b2d50 100644 --- a/server/app/routes/__init__.py +++ b/server/app/routes/__init__.py @@ -1,178 +1,282 @@ from flask import Blueprint, request, jsonify -from sqlalchemy.orm import joinedload, contains_eager -from typing import List, Callable, Any - -from shared.db import Session +from typing import List, Callable, Any, Type, Dict, Optional +from bson import ObjectId from shared.logger import logger +from models import FlexibleModel +from enum import Enum + +# @dataclass +# class FieldType: +# """Configuration for each field stored on a model.""" +# read_only: bool = False +# create_only: bool = False +# internal_only: bool = False +# nested: Optional['FieldType'] = None +# nested_class: str = None +# include_nested: bool = False +# delete_with_parent: bool = False + +# class Schema(Enum): -from models import ModelConfig +# PLANT = { +# '_id': FieldType(read_only=True), +# 'created_on': FieldType(read_only=True), +# 'updated_on': FieldType(read_only=True), +# 'cost': FieldType(), +# 'species_id': FieldType(), +# 'watered_on': FieldType(), +# 'watering': FieldType(), +# 'identity': FieldType(), +# 'phase': FieldType(), +# 'size': FieldType(), +# 'system_id': FieldType(), +# 'mix_id': FieldType(), +# 'deprecated': FieldType(), +# 'deprecated_on': FieldType(), +# 'deprecated_cause': FieldType() +# } +# PLANT_GENUS_TYPE = { +# '_id': FieldType(read_only=True), +# 'created_on': FieldType(read_only=True), +# 'updated_on': FieldType(read_only=True), +# 'name': FieldType(read_only=True), +# 'common_name': FieldType(read_only=True), +# 'description': FieldType(read_only=True), +# 'genus_id': FieldType(read_only=True) +# } +# PLANT_GENUS = { +# '_id': FieldType(read_only=True), +# 'created_on': FieldType(read_only=True), +# 'updated_on': FieldType(read_only=True), +# 'name': FieldType(read_only=True), +# 'common_name': FieldType(read_only=True), +# 'description': FieldType(read_only=True), +# 'watering': FieldType(), +# 'genus_type_id': FieldType(read_only=True) +# } +# SPECIES = { +# '_id': FieldType(read_only=True), +# 'created_on': FieldType(read_only=True), +# 'updated_on': FieldType(read_only=True), +# 'name': FieldType(read_only=True), +# 'common_name': FieldType(read_only=True), +# 'description': FieldType(read_only=True), +# 'genus_id': FieldType(read_only=True) +# } +# TODO = { +# '_id': FieldType(read_only=True), +# 'created_on': FieldType(read_only=True), +# 'updated_on': FieldType(read_only=True), +# 'due_on': FieldType(), +# 'name': FieldType(), +# 'description': FieldType(), +# 'tasks': FieldType(nested=True), +# 'deprecated': FieldType(), +# 'deprecated_on': FieldType(), +# 'deprecated_cause': FieldType(), +# 'tasks': FieldType(nested=True, nested_class='TASK') +# } +# TASK = { +# 'description': FieldType(), +# 'created_on': FieldType(read_only=True), +# 'updated_on': FieldType(read_only=True), +# } +# SOIL = { +# '_id': FieldType(read_only=True), +# 'created_on': FieldType(read_only=True), +# 'name': FieldType(read_only=True), +# 'description': FieldType(read_only=True), +# 'group': FieldType(read_only=True) +# } + + +# """Standard model serializer with MongoDB support""" +# def __init__(self, fields: Dict[str, FieldType]): +# self.fields = fields + +# def serialize(self, obj, depth=0, include_nested=False) -> Dict[str, Any]: +# if depth > 5: +# return {} + +# result = {} +# for k, v in self.fields.items(): +# if hasattr(obj, k): +# value = getattr(obj, k) +# # Handle ObjectId conversion +# if isinstance(value, ObjectId): +# result[k] = str(value) +# elif v.nested: +# nested_schema: Schema = getattr(Schema, v.nested) +# if isinstance(value, list): +# result[k] = [nested_schema.serialize(item, depth+1, include_nested) for item in value] +# elif value is not None: +# result[k] = nested_schema.serialize(value, depth+1, include_nested) +# elif not v.internal_only: +# result[k] = value +# return result + +# def deserialize(self, data: Dict, is_create=False, depth=0) -> Dict[str, Any]: +# if depth > 5: +# return {} + +# result = {} +# for k, v in data.items(): +# if k in self.fields: +# field_config = self.fields[k] +# if not field_config.read_only and (is_create or not field_config.create_only): +# if field_config.nested: +# nested_schema: Schema = getattr(Schema, v.nested) +# if isinstance(v, list): +# result[k] = [nested_schema.deserialize(item, is_create, depth+1) for item in v] +# elif v is not None: +# result[k] = nested_schema.deserialize(v, is_create, depth+1) +# elif not field_config.internal_only: +# result[k] = v +# return result class GenericCRUD: - def __init__(self, model, config: ModelConfig): - self.model = model - self.config = config - - def get(self, id: Any): - include_nested = request.args.get('include_nested', 'false').lower() == 'true' - with Session() as sess: - query = sess.query(self.model) - if include_nested: - for field, config in self.config.fields.items(): - if config.nested and config.include_nested: - relationship_attr = getattr(self.model, field) - query = query.options(joinedload(relationship_attr)) - item = query.get(id) + def __init__(self, model): + self.model: Type[FlexibleModel] = model + + def get(self, id: str): + try: + item = self.model.table.get_one(id) # Not a huge fan of this, maybe revisit if item is None: - logger.error(f"Could not find {id}?") + logger.error(f"Could not find {id}") return jsonify({"error": "Not found"}), 404 - return jsonify(self.config.serialize(item, include_nested=include_nested)) + data_model = self.model(**item) + return jsonify(data_model.to_dict()) + except Exception as e: + logger.error(f"Error in get: {str(e)}") + return jsonify({"error": str(e)}), 500 def get_many(self): - include_nested = request.args.get('include_nested', 'false').lower() == 'true' + try: + items = self.model.table.get_many({}) - with Session() as sess: - query = sess.query(self.model) - if include_nested: - for field, config in self.config.fields.items(): - if config.nested and config.include_nested: - relationship_attr = getattr(self.model, field) - query = query.outerjoin(relationship_attr).options(contains_eager(relationship_attr)) + return jsonify([ + self.model(**item).to_dict() + for item in items + ]) + except Exception as e: + logger.error(f"Error in get_many: {str(e)}") + return jsonify({"error": str(e)}), 500 - for field, config in self.config.fields.items(): - if field in request.args: - query = query.filter(getattr(self.model, field) == request.args[field]) + # def create(self): + # try: + # data = self.config.deserialize(request.json, is_create=True) + + # with get_db() as db: + # # Create main document + # item = self.model(**data) + # result = db[self.collection_name].insert_one(item.to_dict()) + # item._id = result.inserted_id - items = query.all() - ret = [self.config.serialize(item, include_nested=include_nested) for item in items] - return jsonify(ret) + # # Handle nested documents + # for field, config in self.config.fields.items(): + # if config.nested and field in data: + # nested_data = data[field] + # if isinstance(nested_data, list): + # nested_ids = [] + # for nested_item in nested_data: + # nested_obj = config.nested_class(**nested_item) + # nested_obj.parent_id = item._id + # nested_result = db[config.nested_class.collection_name].insert_one(nested_obj.to_dict()) + # nested_ids.append(nested_result.inserted_id) + # # Update main document with nested references + # db[self.collection_name].update_one( + # {'_id': item._id}, + # {'$set': {field: nested_ids}} + # ) - def create(self): - try: - # Deserialize the input data - data = self.config.deserialize(request.json, is_create=True) - - with Session() as sess: - # Create the main object - item = self.model() - - nested_creations = [] - for key, value in data.items(): - if key in self.config.fields: - field_config = self.config.fields[key] - if field_config.nested: - nested_creations.append((field_config, value)) - else: - setattr(item, key, value) - - sess.add(item) - sess.commit() - for field_config, nested_creation in nested_creations: - if isinstance(nested_creation, list): - # Handle list of nested objects - for nested_data in value: - nested_item = field_config.nested_class() - for nested_key, nested_value in nested_data.items(): - setattr(nested_item, nested_key, nested_value) - setattr(nested_item, field_config.nested_identifier, item.id) - sess.add(nested_item) - elif isinstance(nested_creation, dict): - nested_item = field_config.nested_class() - for nested_key, nested_value in nested_creation.items(): - setattr(nested_item, nested_key, nested_value) - setattr(nested_item, field_config.nested_identifier, item.id) - sess.add(nested_item) - - - sess.commit() - - # Refresh the item to ensure all relationships are loaded - sess.refresh(item) + # return jsonify(self.config.serialize(item, include_nested=True)), 201 + + # except Exception as e: + # logger.error(f"Error in create: {str(e)}") + # return jsonify({"error": str(e)}), 400 + + # def update(self, id: str): + # with get_db() as db: + # try: + # update_data = self.config.deserialize(request.json) + # result = db[self.collection_name].update_one( + # {'_id': ObjectId(id)}, + # {'$set': update_data} + # ) - return jsonify(self.config.serialize(item, include_nested=True)), 201 - except Exception as e: - print(f"Error in create: {str(e)}") - return jsonify({"error": str(e)}), 400 + # if result.matched_count == 0: + # return jsonify({"error": "Not found"}), 404 - def update(self, id: Any): - with Session() as sess: - item = sess.query(self.model).get(id) - if item is None: - return jsonify({"error": "Not found"}), 404 - try: - update_data = self.model.from_request(request) - for key, value in self.config.deserialize(self.config.serialize(update_data)).items(): - setattr(item, key, value) - sess.commit() - return jsonify(self.config.serialize(item)) - except Exception as e: - sess.rollback() - return jsonify({"error": str(e)}), 400 - - def delete(self, id: Any): - with Session() as sess: - item = sess.query(self.model).get(id) - if item is None: - return jsonify({"error": "Not found"}), 404 + # updated_item = db[self.collection_name].find_one({'_id': ObjectId(id)}) + # return jsonify(self.config.serialize(self.model(**updated_item))) - # Handle nested deletions - for field_name, field_config in self.config.fields.items(): - if field_config.nested and field_config.delete_with_parent: - nested_items = getattr(item, field_name) - if nested_items is not None: - if isinstance(nested_items, list): - for nested_item in nested_items: - sess.delete(nested_item) - else: - sess.delete(nested_items) - - sess.delete(item) - sess.commit() - return '', 204 - - def delete_many(self, ids: List[Any], cause: str): - with Session() as sess: - deleted_count = 0 - for id in ids: - item = sess.query(self.model).get(id) - if item is None: - logger.info(f"Could not find {self.model} with id: {id}") - continue # Skip if item not found - - # Handle nested deletions - for field_name, field_config in self.config.fields.items(): - if field_config.nested and field_config.delete_with_parent: - nested_items = getattr(item, field_name) - if nested_items is not None: - if isinstance(nested_items, list): - for nested_item in nested_items: - sess.delete(nested_item) - else: - sess.delete(nested_items) - - sess.delete(item) - deleted_count += 1 - - sess.commit() - return deleted_count - - def delete_many(self): - data = request.json - ids = data.get('ids', []) - cause = data.get('cause', '') - - if not ids: - return jsonify({'error': 'No ids provided'}), 400 - - if not cause: - logger.info("No cause specified for delete many") + # except Exception as e: + # logger.error(f"Error in update: {str(e)}") + # return jsonify({"error": str(e)}), 400 - try: - model_instance = YourModelClass() # Replace with your actual model instance - deleted_count = model_instance.delete_many(ids, cause) - return jsonify({'message': f'Successfully deleted {deleted_count} items', 'cause': cause}), 200 - except Exception as e: - return jsonify({'error': str(e)}), 500 + # def delete(self, id: str): + # with get_db() as db: + # try: + # # Find the item first to handle nested deletions + # item = db[self.collection_name].find_one({'_id': ObjectId(id)}) + # if not item: + # return jsonify({"error": "Not found"}), 404 + + # # Handle nested deletions + # for field, config in self.config.fields.items(): + # if config.nested and config.delete_with_parent and field in item: + # nested_ids = item[field] + # if nested_ids: + # db[config.nested_class.collection_name].delete_many( + # {'_id': {'$in': [ObjectId(nid) for nid in nested_ids]}} + # ) + # # Delete main document + # db[self.collection_name].delete_one({'_id': ObjectId(id)}) + # return '', 204 + + # except Exception as e: + # logger.error(f"Error in delete: {str(e)}") + # return jsonify({"error": str(e)}), 500 + + # def delete_many(self): + # data = request.json + # ids = data.get('ids', []) + # cause = data.get('cause', '') + + # if not ids: + # return jsonify({'error': 'No ids provided'}), 400 + + # with get_db() as db: + # try: + # object_ids = [ObjectId(id) for id in ids] + + # # Find all items first to handle nested deletions + # items = list(db[self.collection_name].find({'_id': {'$in': object_ids}})) + + # deleted_count = 0 + # for item in items: + # # Handle nested deletions + # for field, config in self.config.fields.items(): + # if config.nested and config.delete_with_parent and field in item: + # nested_ids = item[field] + # if nested_ids: + # db[config.nested_class.collection_name].delete_many( + # {'_id': {'$in': [ObjectId(nid) for nid in nested_ids]}} + # ) + # deleted_count += 1 + + # # Delete main documents + # db[self.collection_name].delete_many({'_id': {'$in': object_ids}}) + + # return jsonify({ + # 'message': f'Successfully deleted {deleted_count} items', + # 'cause': cause + # }), 200 + + # except Exception as e: + # logger.error(f"Error in delete_many: {str(e)}") + # return jsonify({'error': str(e)}), 500 class APIBuilder: @@ -194,24 +298,22 @@ def wrapper(): return wrapper if 'GET' in methods: - blueprint.route(f'/{resource_name}//', methods=['GET'])(create_wrapper('get')) + blueprint.route(f'/{resource_name}//', methods=['GET'])(create_wrapper('get')) if 'GET_MANY' in methods: blueprint.route(f'/{resource_name}/', methods=['GET'])(create_wrapper('get_many')) - if 'POST' in methods: - blueprint.route(f'/{resource_name}/', methods=['POST'])(create_wrapper('create')) - if 'PATCH' in methods: - blueprint.route(f'/{resource_name}//', methods=['PATCH'])(create_wrapper('update')) - if 'DELETE' in methods: - blueprint.route(f'/{resource_name}//', methods=['DELETE'])(create_wrapper('delete')) - if 'DELETE_MANY' in methods: - blueprint.route(f'/{resource_name}//', methods=['DELETE'])(create_wrapper('delete_many')) + # if 'POST' in methods: + # blueprint.route(f'/{resource_name}/', methods=['POST'])(create_wrapper('create')) + # if 'PATCH' in methods: + # blueprint.route(f'/{resource_name}//', methods=['PATCH'])(create_wrapper('update')) + # if 'DELETE' in methods: + # blueprint.route(f'/{resource_name}//', methods=['DELETE'])(create_wrapper('delete')) + # if 'DELETE_MANY' in methods: + # blueprint.route(f'/{resource_name}/', methods=['DELETE'])(create_wrapper('delete_many')) @staticmethod def register_custom_route(blueprint: Blueprint, route: str, methods: List[str]): - """ Custom route on this bp. """ + """Custom route on this bp.""" def decorator(handler: Callable): blueprint.route(f'/{route}', methods=methods)(handler) return handler - - # This allows the method to be used both as a decorator and a regular method - return decorator + return decorator \ No newline at end of file diff --git a/server/app/routes/installable_model_routes.py b/server/app/routes/installable_model_routes.py index 9df819a..37d4b58 100644 --- a/server/app/routes/installable_model_routes.py +++ b/server/app/routes/installable_model_routes.py @@ -5,21 +5,18 @@ from models.mix import Soil genus_types_bp = Blueprint('genus_types', __name__) - -genus_types_crud = GenericCRUD(PlantGenusType, PlantGenusType.schema) -genus_crud = GenericCRUD(PlantGenus, PlantGenus.schema) - +genus_types_crud = GenericCRUD(PlantGenusType) APIBuilder.register_resource(genus_types_bp, 'genus_types', genus_types_crud, methods=["GET", "GET_MANY"]) -APIBuilder.register_resource(genus_types_bp, 'genera', genus_crud, methods=["GET", "GET_MANY"]) species_bp = Blueprint('species', __name__) -species_crud = GenericCRUD(PlantSpecies, PlantSpecies.schema) +species_crud = GenericCRUD(PlantSpecies) APIBuilder.register_resource(species_bp, 'species', species_crud, methods=["GET", "GET_MANY"]) genus_bp = Blueprint('genera', __name__) -genus_crud = GenericCRUD(PlantGenus, PlantGenus.schema) +genus_crud = GenericCRUD(PlantGenus) APIBuilder.register_resource(genus_bp, 'genera', genus_crud, methods=["GET", "GET_MANY"]) soils_bp = Blueprint('soils', __name__) -soils_crud = GenericCRUD(Soil, Soil.schema) +soils_crud = GenericCRUD(Soil) APIBuilder.register_resource(soils_bp, 'soils', soils_crud, methods=["GET", "GET_MANY"]) + diff --git a/server/models/__init__.py b/server/models/__init__.py index e2e8a88..1ba6ce9 100644 --- a/server/models/__init__.py +++ b/server/models/__init__.py @@ -1,68 +1,10 @@ # models/__init__.py -from typing import List, Any, Dict, Optional -from dataclasses import dataclass +from typing import List, Any, Dict import numpy as np import csv -from datetime import datetime from shared.db import Table from bson import ObjectId -@dataclass -class FieldConfig: - """Configuration for each field stored on a model.""" - read_only: bool = False - create_only: bool = False - internal_only: bool = False - nested: Optional['ModelConfig'] = None - nested_class: Any = None - nested_identifier: str = None - include_nested: bool = False - delete_with_parent: bool = False - -class ModelConfig: - """Standard model serializer with MongoDB support""" - def __init__(self, fields: Dict[str, FieldConfig], archivable=True): - self.fields = fields - self.archivable = archivable - - def serialize(self, obj, depth=0, include_nested=False) -> Dict[str, Any]: - if depth > 5: - return {} - - result = {} - for k, v in self.fields.items(): - if hasattr(obj, k): - value = getattr(obj, k) - # Handle ObjectId conversion - if isinstance(value, ObjectId): - result[k] = str(value) - elif v.nested and (include_nested or v.include_nested): - if isinstance(value, list): - result[k] = [v.nested.serialize(item, depth+1, include_nested) for item in value] - elif value is not None: - result[k] = v.nested.serialize(value, depth+1, include_nested) - elif not v.internal_only: - result[k] = value - return result - - def deserialize(self, data, is_create=False, depth=0) -> Dict[str, Any]: - if depth > 5: - return {} - - result = {} - for k, v in data.items(): - if k in self.fields: - field_config = self.fields[k] - if not field_config.read_only and (is_create or not field_config.create_only): - if field_config.nested: - if isinstance(v, list): - result[k] = [field_config.nested.deserialize(item, is_create, depth+1) for item in v] - elif v is not None: - result[k] = field_config.nested.deserialize(v, is_create, depth+1) - elif not field_config.internal_only: - result[k] = v - return result - class FlexibleModel: """Base model with MongoDB support""" table: Table = None # Override in subclasses diff --git a/server/models/plant.py b/server/models/plant.py index 9c6ba14..64dea6b 100644 --- a/server/models/plant.py +++ b/server/models/plant.py @@ -2,12 +2,12 @@ Module defining models for plants. """ from datetime import datetime -from typing import List, Optional +from typing import List import enum from bson import ObjectId from shared.db import Table -from models import FlexibleModel, ModelConfig, FieldConfig +from models import FlexibleModel class DeprecatableMixin: """ In case the model is deprecated.""" @@ -25,7 +25,7 @@ class PHASES(enum.Enum): class Plant(DeprecatableMixin, FlexibleModel): """Plant model.""" - collection_name = "plant" + table = Table.PLANT def __init__(self, **kwargs): super().__init__(**kwargs) @@ -50,24 +50,6 @@ def __init__(self, **kwargs): def __repr__(self) -> str: return f"{self._id}" - schema = ModelConfig({ - '_id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'cost': FieldConfig(), - 'species_id': FieldConfig(), - 'watered_on': FieldConfig(), - 'watering': FieldConfig(), - 'identity': FieldConfig(), - 'phase': FieldConfig(), - 'size': FieldConfig(), - 'system_id': FieldConfig(), - 'mix_id': FieldConfig(), - 'deprecated': FieldConfig(), - 'deprecated_on': FieldConfig(), - 'deprecated_cause': FieldConfig() - }) - class Batch(Plant): """Batch of plants.""" def __init__(self, **kwargs): @@ -87,15 +69,6 @@ def __init__(self, **kwargs): self.description = kwargs.get('description') self.watering = kwargs.get('watering') - schema = ModelConfig({ - '_id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'name': FieldConfig(read_only=True), - 'description': FieldConfig(read_only=True), - 'watering': FieldConfig() - }) - class PlantGenus(FlexibleModel): table = Table.GENUS @@ -110,18 +83,6 @@ def __init__(self, **kwargs): self.watering = kwargs.get('watering') self.genus_type_id = kwargs.get('genus_type_id') - schema = ModelConfig({ - '_id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'name': FieldConfig(read_only=True), - 'common_name': FieldConfig(read_only=True), - 'description': FieldConfig(read_only=True), - 'watering': FieldConfig(), - 'genus_type_id': FieldConfig(read_only=True), - 'genus_type': FieldConfig(nested=PlantGenusType.schema, include_nested=True) - }) - class PlantSpecies(FlexibleModel): table = Table.SPECIES @@ -134,13 +95,3 @@ def __init__(self, **kwargs): self.common_name = kwargs.get('common_name') self.description = kwargs.get('description') self.genus_id = kwargs.get('genus_id') - - schema = ModelConfig({ - '_id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'name': FieldConfig(read_only=True), - 'common_name': FieldConfig(read_only=True), - 'description': FieldConfig(read_only=True), - 'genus_id': FieldConfig(read_only=True) - }) \ No newline at end of file diff --git a/server/shared/db.py b/server/shared/db.py index 6bd8c95..b4b5bcc 100644 --- a/server/shared/db.py +++ b/server/shared/db.py @@ -38,7 +38,7 @@ def create(self, data: Dict[str, Any]) -> ObjectId: def get_one(self, id: str) -> Optional[Dict[str, Any]]: return DB[self.value].find_one({"_id": ObjectId(id)}) - def get_many(self, query: Dict[str, Any], limit: int = 100) -> List[Dict[str, Any]]: + def get_many(self, query: Dict[str, Any]={}, limit: int = 100) -> List[Dict[str, Any]]: return list(DB[self.value].find(query).limit(limit)) def update(self, id: str, data: Dict[str, Any]) -> bool: From c17278656ac187a866eae936c11f597da6db8498 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sat, 15 Feb 2025 10:56:28 -0600 Subject: [PATCH 29/44] alert routes --- server/app/routes/__init__.py | 39 +++++++++-------------- server/app/routes/alert_routes.py | 2 +- server/models/alert.py | 52 +++++++------------------------ server/shared/db.py | 4 +-- 4 files changed, 27 insertions(+), 70 deletions(-) diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py index 81b2d50..fd2fc55 100644 --- a/server/app/routes/__init__.py +++ b/server/app/routes/__init__.py @@ -214,30 +214,19 @@ def get_many(self): # logger.error(f"Error in update: {str(e)}") # return jsonify({"error": str(e)}), 400 - # def delete(self, id: str): - # with get_db() as db: - # try: - # # Find the item first to handle nested deletions - # item = db[self.collection_name].find_one({'_id': ObjectId(id)}) - # if not item: - # return jsonify({"error": "Not found"}), 404 - - # # Handle nested deletions - # for field, config in self.config.fields.items(): - # if config.nested and config.delete_with_parent and field in item: - # nested_ids = item[field] - # if nested_ids: - # db[config.nested_class.collection_name].delete_many( - # {'_id': {'$in': [ObjectId(nid) for nid in nested_ids]}} - # ) - - # # Delete main document - # db[self.collection_name].delete_one({'_id': ObjectId(id)}) - # return '', 204 + def delete(self, id: str): + try: + # TODO: Double dipping here + item = self.model.table.get_one(id) + if not item: + return jsonify({"error": "Not found"}), 404 + + self.model.table.delete(id) + return '', 204 - # except Exception as e: - # logger.error(f"Error in delete: {str(e)}") - # return jsonify({"error": str(e)}), 500 + except Exception as e: + logger.error(f"Error in delete: {str(e)}") + return jsonify({"error": str(e)}), 500 # def delete_many(self): # data = request.json @@ -305,8 +294,8 @@ def wrapper(): # blueprint.route(f'/{resource_name}/', methods=['POST'])(create_wrapper('create')) # if 'PATCH' in methods: # blueprint.route(f'/{resource_name}//', methods=['PATCH'])(create_wrapper('update')) - # if 'DELETE' in methods: - # blueprint.route(f'/{resource_name}//', methods=['DELETE'])(create_wrapper('delete')) + if 'DELETE' in methods: + blueprint.route(f'/{resource_name}//', methods=['DELETE'])(create_wrapper('delete')) # if 'DELETE_MANY' in methods: # blueprint.route(f'/{resource_name}/', methods=['DELETE'])(create_wrapper('delete_many')) diff --git a/server/app/routes/alert_routes.py b/server/app/routes/alert_routes.py index 0ceb547..5e5849c 100644 --- a/server/app/routes/alert_routes.py +++ b/server/app/routes/alert_routes.py @@ -4,5 +4,5 @@ from routes import GenericCRUD, APIBuilder bp = Blueprint('alerts', __name__) -alert_crud = GenericCRUD(Alert, Alert.schema) +alert_crud = GenericCRUD(Alert) APIBuilder.register_resource(bp, 'alerts', alert_crud, ["GET", "GET_MANY", "DELETE"]) diff --git a/server/models/alert.py b/server/models/alert.py index 82c2ed6..968d29e 100644 --- a/server/models/alert.py +++ b/server/models/alert.py @@ -2,54 +2,24 @@ Module defining models for alerts. """ from datetime import datetime -from bson import ObjectId from models.plant import DeprecatableMixin -from models import FlexibleModel, ModelConfig, FieldConfig +from models import FlexibleModel from shared.db import Table +import enum + + +class AlertTypes(enum.Enum): + WATER = "Water" class Alert(DeprecatableMixin, FlexibleModel): - """Alert Base Class""" + """Alert Base Class""" + + table = Table.ALERT - def __init__(self, **kwargs): + def __init__(self, **kwargs): super().__init__(**kwargs) self._id = kwargs.get('_id', ObjectId()) self.created_on = kwargs.get('created_on', datetime.now()) self.updated_on = kwargs.get('updated_on', datetime.now()) self.alert_type = kwargs.get('alert_type', 'alert') - - schema = ModelConfig({ - '_id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'alert_type': FieldConfig(read_only=True), - 'deprecated': FieldConfig(), - 'deprecated_on': FieldConfig(), - 'deprecated_cause': FieldConfig() - }) - -class PlantAlert(Alert): - """Plant alert model.""" - table = Table.PLANT_ALERT # Same collection as Alert - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.alert_type = 'plant_alert' - self.plant_alert_type = kwargs.get('plant_alert_type') - self.plant_id = kwargs.get('plant_id') - self.system_id = kwargs.get('system_id') - - def __repr__(self): - return "plant_alert" - - schema = ModelConfig({ - '_id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'alert_type': FieldConfig(read_only=True), - 'plant_alert_type': FieldConfig(read_only=True), - 'plant_id': FieldConfig(read_only=True), - 'system_id': FieldConfig(read_only=True), - 'deprecated': FieldConfig(), - 'deprecated_on': FieldConfig(), - 'deprecated_cause': FieldConfig() - }) + self.model_id = kwargs.get('alert_type', 'alert') diff --git a/server/shared/db.py b/server/shared/db.py index b4b5bcc..5a2b160 100644 --- a/server/shared/db.py +++ b/server/shared/db.py @@ -6,9 +6,7 @@ from typing import Dict, Any, Optional, List from bson import ObjectId from pymongo import MongoClient -from pymongo.collection import Collection from pymongo.database import Database -from contextlib import contextmanager # Create the MongoDB client MONGODB_URL = os.getenv("MONGODB_URL", "mongodb://localhost:27017") @@ -26,7 +24,7 @@ class Table(str, Enum): SPECIES = "species" SOIL = "soil" TODO = "todo" - PLANT_ALERT = "plant_alert" + ALERT = "alert" def count(self, filter: Dict={})-> int: return DB[self.value].count_documents(filter) From 43faddffe7f0c998adb0cd69bc0a4b73dd2c7368 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sat, 15 Feb 2025 14:54:31 -0600 Subject: [PATCH 30/44] Transfer the rest of the model routes --- server/app/app.py | 47 +++++----- server/app/routes/__init__.py | 133 ++++++++++------------------- server/app/routes/mix_routes.py | 2 +- server/app/routes/plant_routes.py | 80 ++++++++--------- server/app/routes/stat_routes.py | 33 ++----- server/app/routes/system_routes.py | 125 +++++++++++++-------------- server/app/routes/todo_routes.py | 9 +- server/models/mix.py | 34 +++----- server/models/system.py | 69 ++++++++------- server/models/todo.py | 20 +---- 10 files changed, 231 insertions(+), 321 deletions(-) diff --git a/server/app/app.py b/server/app/app.py index 316bc79..4b5cd2b 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -1,7 +1,7 @@ """ Running webserver. """ - +from typing import List import logging import sys import os @@ -13,7 +13,7 @@ from flask_apscheduler import APScheduler from models.plant import Plant -from models.alert import PlantAlert +from models.alert import Alert, AlertTypes from shared.db import Table @@ -29,22 +29,22 @@ # Create Flask app app = Flask(__name__) -# from routes.system_routes import system_bp, light_bp -# from routes.plant_routes import bp as plant_bp -# from routes.todo_routes import bp as todo_bp -# from routes.mix_routes import bp as mix_bp -# from routes.stat_routes import bp as stat_bp +from routes.system_routes import system_bp, light_bp +from routes.plant_routes import bp as plant_bp +from routes.todo_routes import bp as todo_bp +from routes.mix_routes import bp as mix_bp +from routes.stat_routes import bp as stat_bp from routes.installable_model_routes import soils_bp, genus_types_bp, species_bp, genus_bp -# from routes.alert_routes import bp as alert_bp +from routes.alert_routes import bp as alert_bp # Models -# app.register_blueprint(system_bp) -# app.register_blueprint(light_bp) -# app.register_blueprint(plant_bp) -# app.register_blueprint(todo_bp) -# app.register_blueprint(mix_bp) -# app.register_blueprint(stat_bp) -# app.register_blueprint(alert_bp) +app.register_blueprint(system_bp) +app.register_blueprint(light_bp) +app.register_blueprint(plant_bp) +app.register_blueprint(todo_bp) +app.register_blueprint(mix_bp) +app.register_blueprint(stat_bp) +app.register_blueprint(alert_bp) # Installables app.register_blueprint(genus_types_bp) @@ -96,24 +96,23 @@ def manage_plant_alerts(): Create different plant alerts. Right now just supports creating watering alerts. """ - existing_plant_alrts: PlantAlert = Table.PLANT_ALERT.get_many({"deprecated": False}) + existing_plant_alrts: Alert = Table.ALERT.get_many() existing_plant_alrts_map = {} for existing_plant_alert in existing_plant_alrts: existing_plant_alrts_map[existing_plant_alert.plant_id] = existing_plant_alert - existing_plants: Plant = Table.PLANT.get_many({"deprecated": False}) + existing_plants: List[Plant] = Table.PLANT.get_many({"deprecated": False}) # Sure... now = datetime.now() for plant in existing_plants: end_date = plant.watered_on + timedelta(days=float(plant.watering)) - if end_date < now and existing_plant_alrts_map.get(plant.id) is None: - new_plant_alert = PlantAlert( - plant_id = plant.id, - system_id = plant.system_id, - plant_alert_type = "water" + if end_date < now and existing_plant_alrts_map.get(plant._id) is None: + new_plant_alert = Alert( + model_id = plant._id, + alert_type = AlertTypes.WATER ) # Create the alert in the db - Table.PLANT.create(new_plant_alert) - existing_plant_alrts_map[new_plant_alert.plant_id] = new_plant_alert + Table.ALERT.create(new_plant_alert) + existing_plant_alrts_map[new_plant_alert.model_id] = new_plant_alert if __name__ == "__main__": diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py index fd2fc55..1313db1 100644 --- a/server/app/routes/__init__.py +++ b/server/app/routes/__init__.py @@ -1,6 +1,5 @@ from flask import Blueprint, request, jsonify -from typing import List, Callable, Any, Type, Dict, Optional -from bson import ObjectId +from typing import List, Callable, Type from shared.logger import logger from models import FlexibleModel from enum import Enum @@ -162,57 +161,32 @@ def get_many(self): logger.error(f"Error in get_many: {str(e)}") return jsonify({"error": str(e)}), 500 - # def create(self): - # try: - # data = self.config.deserialize(request.json, is_create=True) - - # with get_db() as db: - # # Create main document - # item = self.model(**data) - # result = db[self.collection_name].insert_one(item.to_dict()) - # item._id = result.inserted_id - - # # Handle nested documents - # for field, config in self.config.fields.items(): - # if config.nested and field in data: - # nested_data = data[field] - # if isinstance(nested_data, list): - # nested_ids = [] - # for nested_item in nested_data: - # nested_obj = config.nested_class(**nested_item) - # nested_obj.parent_id = item._id - # nested_result = db[config.nested_class.collection_name].insert_one(nested_obj.to_dict()) - # nested_ids.append(nested_result.inserted_id) - # # Update main document with nested references - # db[self.collection_name].update_one( - # {'_id': item._id}, - # {'$set': {field: nested_ids}} - # ) + def create(self): + try: + item = self.model(**request.json) + result = self.model.table.create(item.to_dict()) + item._id = result - # return jsonify(self.config.serialize(item, include_nested=True)), 201 + return jsonify(item.to_dict()), 201 - # except Exception as e: - # logger.error(f"Error in create: {str(e)}") - # return jsonify({"error": str(e)}), 400 + except Exception as e: + logger.error(f"Error in create: {str(e)}") + return jsonify({"error": str(e)}), 400 - # def update(self, id: str): - # with get_db() as db: - # try: - # update_data = self.config.deserialize(request.json) - # result = db[self.collection_name].update_one( - # {'_id': ObjectId(id)}, - # {'$set': update_data} - # ) - - # if result.matched_count == 0: - # return jsonify({"error": "Not found"}), 404 + def update(self, id: str): + try: + item = self.model(**request.json) + result = self.model.table.update(id, item.to_dict()) # should probably be to flattened json + + if not result: + return jsonify({"error": "Not found"}), 404 - # updated_item = db[self.collection_name].find_one({'_id': ObjectId(id)}) - # return jsonify(self.config.serialize(self.model(**updated_item))) + updated_item = self.model.table.get_one(id) + return jsonify(self.model(**updated_item).to_dict()) - # except Exception as e: - # logger.error(f"Error in update: {str(e)}") - # return jsonify({"error": str(e)}), 400 + except Exception as e: + logger.error(f"Error in update: {str(e)}") + return jsonify({"error": str(e)}), 400 def delete(self, id: str): try: @@ -228,44 +202,25 @@ def delete(self, id: str): logger.error(f"Error in delete: {str(e)}") return jsonify({"error": str(e)}), 500 - # def delete_many(self): - # data = request.json - # ids = data.get('ids', []) - # cause = data.get('cause', '') + def delete_many(self): + data = request.json + ids = data.get('ids', []) - # if not ids: - # return jsonify({'error': 'No ids provided'}), 400 + if not ids: + return jsonify({'error': 'No ids provided'}), 400 - # with get_db() as db: - # try: - # object_ids = [ObjectId(id) for id in ids] - - # # Find all items first to handle nested deletions - # items = list(db[self.collection_name].find({'_id': {'$in': object_ids}})) - - # deleted_count = 0 - # for item in items: - # # Handle nested deletions - # for field, config in self.config.fields.items(): - # if config.nested and config.delete_with_parent and field in item: - # nested_ids = item[field] - # if nested_ids: - # db[config.nested_class.collection_name].delete_many( - # {'_id': {'$in': [ObjectId(nid) for nid in nested_ids]}} - # ) - # deleted_count += 1 + try: + deleted_count = 0 + for id in ids: + result = self.model.table.delete(id) + if result: + deleted_count+=1 + return jsonify({ + 'message': f'Successfully deleted {deleted_count} items'}), 200 - # # Delete main documents - # db[self.collection_name].delete_many({'_id': {'$in': object_ids}}) - - # return jsonify({ - # 'message': f'Successfully deleted {deleted_count} items', - # 'cause': cause - # }), 200 - - # except Exception as e: - # logger.error(f"Error in delete_many: {str(e)}") - # return jsonify({'error': str(e)}), 500 + except Exception as e: + logger.error(f"Error in delete_many: {str(e)}") + return jsonify({'error': str(e)}), 500 class APIBuilder: @@ -290,14 +245,14 @@ def wrapper(): blueprint.route(f'/{resource_name}//', methods=['GET'])(create_wrapper('get')) if 'GET_MANY' in methods: blueprint.route(f'/{resource_name}/', methods=['GET'])(create_wrapper('get_many')) - # if 'POST' in methods: - # blueprint.route(f'/{resource_name}/', methods=['POST'])(create_wrapper('create')) - # if 'PATCH' in methods: - # blueprint.route(f'/{resource_name}//', methods=['PATCH'])(create_wrapper('update')) + if 'POST' in methods: + blueprint.route(f'/{resource_name}/', methods=['POST'])(create_wrapper('create')) + if 'PATCH' in methods: + blueprint.route(f'/{resource_name}//', methods=['PATCH'])(create_wrapper('update')) if 'DELETE' in methods: blueprint.route(f'/{resource_name}//', methods=['DELETE'])(create_wrapper('delete')) - # if 'DELETE_MANY' in methods: - # blueprint.route(f'/{resource_name}/', methods=['DELETE'])(create_wrapper('delete_many')) + if 'DELETE_MANY' in methods: + blueprint.route(f'/{resource_name}/', methods=['DELETE'])(create_wrapper('delete_many')) @staticmethod def register_custom_route(blueprint: Blueprint, route: str, methods: List[str]): diff --git a/server/app/routes/mix_routes.py b/server/app/routes/mix_routes.py index bcda870..084adab 100644 --- a/server/app/routes/mix_routes.py +++ b/server/app/routes/mix_routes.py @@ -4,5 +4,5 @@ from routes import GenericCRUD, APIBuilder bp = Blueprint('mixes', __name__) -mix_crud = GenericCRUD(Mix, Mix.schema) +mix_crud = GenericCRUD(Mix) APIBuilder.register_resource(bp, 'mixes', mix_crud, ["GET", "GET_MANY", "POST", "DELETE"]) diff --git a/server/app/routes/plant_routes.py b/server/app/routes/plant_routes.py index 871c185..4967413 100644 --- a/server/app/routes/plant_routes.py +++ b/server/app/routes/plant_routes.py @@ -4,46 +4,46 @@ from routes import GenericCRUD, APIBuilder bp = Blueprint('plants', __name__) -plant_crud = GenericCRUD(Plant, Plant.schema) +plant_crud = GenericCRUD(Plant) APIBuilder.register_resource(bp, 'plants', plant_crud) -@APIBuilder.register_custom_route(bp, 'deprecate/', ['GET']) -def deprecate_plants(): - """ - Deprecate the specified plants. - """ - logger.info("Received request to deprecate the specified plants") - - deprecate_ids = [int(id) for id in request.get_json()["ids"]] - cause = request.get_json()["cause"] - - db = Session() - plants = db.query(Plant).filter(Plant.id.in_(watering_ids)).all() - now = datetime.now() - for plant in plants: - plant.deprecated = True - plant.deprecated_on = datetime.now() - plant.deprecated_cause = cause +# @APIBuilder.register_custom_route(bp, 'deprecate/', ['GET']) +# def deprecate_plants(): +# """ +# Deprecate the specified plants. +# """ +# logger.info("Received request to deprecate the specified plants") + +# deprecate_ids = [int(id) for id in request.get_json()["ids"]] +# cause = request.get_json()["cause"] + +# db = Session() +# plants = db.query(Plant).filter(Plant.id.in_(watering_ids)).all() +# now = datetime.now() +# for plant in plants: +# plant.deprecated = True +# plant.deprecated_on = datetime.now() +# plant.deprecated_cause = cause - db.commit() - db.close() - - return jsonify({"message": f"{len(plants)} Plants deprecated successfully:("}), 201 - -@APIBuilder.register_custom_route(bp, "water/", ["POST"]) -def water_plants(): - """ - Water the specified plants. - """ - logger.info("Received request to water the specified plants") - watering_ids = [int(id) for id in request.get_json()["ids"]] - db = Session() - plants = db.query(Plant).filter(Plant.id.in_(watering_ids)).all() - now = datetime.now() - for plant in plants: - plant.watered_on = now - plant.updated_on = now - db.commit() - db.close() - - return jsonify({"message": f"{len(plants)} Plants watered successfully"}), 201 +# db.commit() +# db.close() + +# return jsonify({"message": f"{len(plants)} Plants deprecated successfully:("}), 201 + +# @APIBuilder.register_custom_route(bp, "water/", ["POST"]) +# def water_plants(): +# """ +# Water the specified plants. +# """ +# logger.info("Received request to water the specified plants") +# watering_ids = [int(id) for id in request.get_json()["ids"]] +# db = Session() +# plants = db.query(Plant).filter(Plant.id.in_(watering_ids)).all() +# now = datetime.now() +# for plant in plants: +# plant.watered_on = now +# plant.updated_on = now +# db.commit() +# db.close() + +# return jsonify({"message": f"{len(plants)} Plants watered successfully"}), 201 diff --git a/server/app/routes/stat_routes.py b/server/app/routes/stat_routes.py index be11244..a403774 100644 --- a/server/app/routes/stat_routes.py +++ b/server/app/routes/stat_routes.py @@ -1,13 +1,8 @@ from flask import Blueprint, jsonify -from shared.db import Session, safe_sum +from shared.db import Table from shared.logger import logger -from models.plant import Plant -from models.system import System -from models.system import Light - -# Standard Bluepriint... -# NOTE: We should probably change this to be more standardized at some point? Elected not to for now. +# Standard Blueprint bp = Blueprint('stats', __name__, url_prefix='/stats') @bp.route("/", methods=["GET"]) @@ -17,25 +12,15 @@ def stats(): """ logger.info("Received request to query the stats") - db = Session() - - total_cost = 0 - total_cost += db.query(safe_sum(Plant.cost)).scalar() - total_cost += db.query(safe_sum(Light.cost)).scalar() - - total_active_cost = 0 - total_active_cost += db.query(safe_sum(Plant.cost)).filter(Plant.deprecated == False).scalar() - total_active_cost += db.query(safe_sum(Light.cost)).filter(Light.deprecated == False).scalar() - + # Extract values safely from aggregation results stats = { - "total_plants" : db.query(Plant).count(), - "total_active_plants" : db.query(Plant).filter(Plant.deprecated == False).count(), - "total_deprecated_plants": db.query(Plant).filter(Plant.deprecated == True).count(), - "total_active_systems": db.query(System).filter(System.deprecated == False).count(), - "total_cost": total_cost, - "total_active_cost": total_active_cost + "total_plants": Table.PLANT.count({}), + "total_active_plants": Table.PLANT.count({'deprecated': False}), + "total_deprecated_plants": Table.PLANT.count({'deprecated': True}), # sure.... + "total_active_systems": Table.SYSTEM.count({'deprecated': False}), + "total_cost": 0, + "total_active_cost": 0 } - db.close() logger.info("Successfully generated statistical data.") return jsonify(stats) \ No newline at end of file diff --git a/server/app/routes/system_routes.py b/server/app/routes/system_routes.py index ce1fc10..2e885e5 100644 --- a/server/app/routes/system_routes.py +++ b/server/app/routes/system_routes.py @@ -1,92 +1,89 @@ from flask import Blueprint, jsonify -from shared.db import Session from shared.logger import logger -from models.plant import Plant -from models.alert import PlantAlert from models.system import System, Light from routes import GenericCRUD, APIBuilder system_bp = Blueprint('systems', __name__) -system_crud = GenericCRUD(System, System.schema) +system_crud = GenericCRUD(System) APIBuilder.register_resource(system_bp, 'systems', system_crud) -@APIBuilder.register_custom_route(system_bp, '/systems//plants/', ['GET']) -def get_systems_plants(system_id): - """ - Get system's plants. - """ - logger.info("Received request to get a system's plants") +# @APIBuilder.register_custom_route(system_bp, '/systems//plants/', ['GET']) +# def get_systems_plants(system_id): +# """ +# Get system's plants. +# """ +# logger.info("Received request to get a system's plants") - db = Session() - plants = db.query(Plant).filter(Plant.system_id == system_id).all() - db.close() +# db = Session() +# plants = db.query(Plant).filter(Plant.system_id == system_id).all() +# db.close() - return jsonify([Plant.schema.serialize(plant) for plant in plants]) +# return jsonify([Plant.schema.serialize(plant) for plant in plants]) -@APIBuilder.register_custom_route(system_bp, "/systems//alerts/", ["GET"]) -def get_systems_alerts(system_id): - """ - Get system's alerts. - """ - logger.info("Received request to get a system's alerts") +# @APIBuilder.register_custom_route(system_bp, "/systems//alerts/", ["GET"]) +# def get_systems_alerts(system_id): +# """ +# Get system's alerts. +# """ +# logger.info("Received request to get a system's alerts") - db = Session() - plant_alerts = db.query(PlantAlert).filter(PlantAlert.system_id == system_id).all() - db.close() +# db = Session() +# plant_alerts = db.query(PlantAlert).filter(PlantAlert.system_id == system_id).all() +# db.close() - return jsonify([PlantAlert.schema.serialize(plant_alert) for plant_alert in plant_alerts]) +# return jsonify([PlantAlert.schema.serialize(plant_alert) for plant_alert in plant_alerts]) -@APIBuilder.register_custom_route(system_bp, "/systems//video_feed/", ["GET"]) -def get_video_feed(system_id): - session = Session() - system = session.query(System).get(system_id) - session.close() +# @APIBuilder.register_custom_route(system_bp, "/systems//video_feed/", ["GET"]) +# def get_video_feed(system_id): +# session = Session() +# system = session.query(System).get(system_id) +# session.close() - if not system: - return "Invalid system ID", 400 +# if not system: +# return "Invalid system ID", 400 - if system.container_id == 'local': - return Response(generate_frames(), - mimetype='multipart/x-mixed-replace; boundary=frame') +# if system.container_id == 'local': +# return Response(generate_frames(), +# mimetype='multipart/x-mixed-replace; boundary=frame') - # Essentially a proxy - def generate(): - resp = requests.get(f"{system.url}/video_feed", stream=True) - for chunk in resp.iter_content(chunk_size=1024): - yield chunk +# # Essentially a proxy +# def generate(): +# resp = requests.get(f"{system.url}/video_feed", stream=True) +# for chunk in resp.iter_content(chunk_size=1024): +# yield chunk - return Response(generate(), - mimetype='multipart/x-mixed-replace; boundary=frame') +# return Response(generate(), +# mimetype='multipart/x-mixed-replace; boundary=frame') -@APIBuilder.register_custom_route(system_bp, "/systems//sensor_data/", ["GET"]) -def get_sensor_data(system_id): - session = Session() - system = session.query(System).get(system_id) +# @APIBuilder.register_custom_route(system_bp, "/systems//sensor_data/", ["GET"]) +# def get_sensor_data(system_id): +# session = Session() +# system = session.query(System).get(system_id) - if not system: - session.close() - return "Invalid camera ID", 400 +# if not system: +# session.close() +# return "Invalid camera ID", 400 - try: - if system.url == 'local': - data = read_sensor() - else: - resp = requests.get(f"{system.url}/sensor_data") - data = resp.json() +# try: +# if system.url == 'local': +# data = read_sensor() +# else: +# resp = requests.get(f"{system.url}/sensor_data") +# data = resp.json() - system.last_temperature = data['temperature'] - system.last_humidity = data['humidity'] - system.updated_on = datetime.utcnow() +# system.last_temperature = data['temperature'] +# system.last_humidity = data['humidity'] +# system.updated_on = datetime.utcnow() - session.commit() - session.close() +# session.commit() +# session.close() - return jsonify(data) - except Exception as e: - session.close() - return jsonify({"error": str(e)}), 500 +# return jsonify(data) +# except Exception as e: +# session.close() +# return jsonify({"error": str(e)}), 500 light_bp = Blueprint('lights', __name__) -light_crud = GenericCRUD(Light, Light.schema) +light_crud = GenericCRUD(Light) APIBuilder.register_resource(light_bp, 'lights', light_crud, ["GET", "GET_MANY", "POST"]) diff --git a/server/app/routes/todo_routes.py b/server/app/routes/todo_routes.py index d1d1f20..82976de 100644 --- a/server/app/routes/todo_routes.py +++ b/server/app/routes/todo_routes.py @@ -1,13 +1,8 @@ from flask import Blueprint from routes import GenericCRUD, APIBuilder -from models.todo import Todo, Task +from models.todo import Todo bp = Blueprint('todos', __name__) -tasks_bp = Blueprint('tasks', __name__) - -todo_crud = GenericCRUD(Todo, Todo.schema) -task_crud = GenericCRUD(Task, Task.schema) - +todo_crud = GenericCRUD(Todo) APIBuilder.register_resource(bp, 'todos', todo_crud) -APIBuilder.register_resource(tasks_bp, 'tasks', task_crud) diff --git a/server/models/mix.py b/server/models/mix.py index 2b5842a..99c22b9 100644 --- a/server/models/mix.py +++ b/server/models/mix.py @@ -5,7 +5,7 @@ from typing import List, Dict, Any from bson import ObjectId from models.plant import DeprecatableMixin -from models import FlexibleModel, ModelConfig, FieldConfig +from models import FlexibleModel from shared.db import Table class Soil(FlexibleModel): @@ -23,14 +23,6 @@ def __init__(self, **kwargs): def __repr__(self) -> str: return f"{self.name}" - schema = ModelConfig({ - '_id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'name': FieldConfig(read_only=True), - 'description': FieldConfig(read_only=True), - 'group': FieldConfig(read_only=True) - }) - class Mix(DeprecatableMixin, FlexibleModel): """Soil mix model with embedded soil parts.""" collection_name = "mix" @@ -79,18 +71,18 @@ def update_soil_part(self, soil_id: ObjectId, parts: int) -> None: part['updated_on'] = datetime.now() break - schema = ModelConfig({ - '_id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'name': FieldConfig(), - 'description': FieldConfig(), - 'experimental': FieldConfig(), - 'soil_parts': FieldConfig(read_only=False), - 'deprecated': FieldConfig(), - 'deprecated_on': FieldConfig(), - 'deprecated_cause': FieldConfig() - }) +# schema = ModelConfig({ +# '_id': FieldConfig(read_only=True), +# 'created_on': FieldConfig(read_only=True), +# 'updated_on': FieldConfig(read_only=True), +# 'name': FieldConfig(), +# 'description': FieldConfig(), +# 'experimental': FieldConfig(), +# 'soil_parts': FieldConfig(read_only=False), +# 'deprecated': FieldConfig(), +# 'deprecated_on': FieldConfig(), +# 'deprecated_cause': FieldConfig() +# }) def to_dict(self) -> Dict[str, Any]: """Convert to MongoDB document format""" diff --git a/server/models/system.py b/server/models/system.py index 1e54892..6cb0420 100644 --- a/server/models/system.py +++ b/server/models/system.py @@ -2,10 +2,9 @@ Module defining models for system. """ from datetime import datetime -from typing import List from bson import ObjectId from models.plant import DeprecatableMixin -from models import FlexibleModel, ModelConfig, FieldConfig +from models import FlexibleModel class Light(DeprecatableMixin, FlexibleModel): """Light model.""" @@ -23,17 +22,17 @@ def __init__(self, **kwargs): def __repr__(self) -> str: return f"{self.name}" - schema = ModelConfig({ - '_id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'name': FieldConfig(), - 'cost': FieldConfig(), - 'system_id': FieldConfig(), - 'deprecated': FieldConfig(), - 'deprecated_on': FieldConfig(), - 'deprecated_cause': FieldConfig() - }) +# schema = ModelConfig({ +# '_id': FieldConfig(read_only=True), +# 'created_on': FieldConfig(read_only=True), +# 'updated_on': FieldConfig(read_only=True), +# 'name': FieldConfig(), +# 'cost': FieldConfig(), +# 'system_id': FieldConfig(), +# 'deprecated': FieldConfig(), +# 'deprecated_on': FieldConfig(), +# 'deprecated_cause': FieldConfig() +# }) class System(DeprecatableMixin, FlexibleModel): """System model.""" @@ -66,25 +65,25 @@ def __init__(self, **kwargs): def __repr__(self) -> str: return f"{self.name}" - schema = ModelConfig({ - '_id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'last_humidity': FieldConfig(read_only=True), - 'last_temperature': FieldConfig(read_only=True), - 'container_id': FieldConfig(internal_only=True), - 'is_local': FieldConfig(internal_only=True), - 'url': FieldConfig(internal_only=True), - 'name': FieldConfig(), - 'description': FieldConfig(), - 'target_humidity': FieldConfig(), - 'target_temperature': FieldConfig(), - 'duration': FieldConfig(), - 'distance': FieldConfig(), - 'deprecated': FieldConfig(), - 'deprecated_on': FieldConfig(), - 'deprecated_cause': FieldConfig() - # Relationships will be handled by querying the related collections - # 'plants': FieldConfig(nested=Plant.schema, include_nested=True, delete_with_parent=True) - # 'lights': FieldConfig(nested=Light.schema, include_nested=True, delete_with_parent=True) - }) \ No newline at end of file +# schema = ModelConfig({ +# '_id': FieldConfig(read_only=True), +# 'created_on': FieldConfig(read_only=True), +# 'updated_on': FieldConfig(read_only=True), +# 'last_humidity': FieldConfig(read_only=True), +# 'last_temperature': FieldConfig(read_only=True), +# 'container_id': FieldConfig(internal_only=True), +# 'is_local': FieldConfig(internal_only=True), +# 'url': FieldConfig(internal_only=True), +# 'name': FieldConfig(), +# 'description': FieldConfig(), +# 'target_humidity': FieldConfig(), +# 'target_temperature': FieldConfig(), +# 'duration': FieldConfig(), +# 'distance': FieldConfig(), +# 'deprecated': FieldConfig(), +# 'deprecated_on': FieldConfig(), +# 'deprecated_cause': FieldConfig() +# # Relationships will be handled by querying the related collections +# # 'plants': FieldConfig(nested=Plant.schema, include_nested=True, delete_with_parent=True) +# # 'lights': FieldConfig(nested=Light.schema, include_nested=True, delete_with_parent=True) +# }) \ No newline at end of file diff --git a/server/models/todo.py b/server/models/todo.py index b8904d3..8321f47 100644 --- a/server/models/todo.py +++ b/server/models/todo.py @@ -2,14 +2,15 @@ Module defining models for todos. """ from datetime import datetime -from typing import List, Dict, Any +from typing import Dict, Any from bson import ObjectId from models.plant import DeprecatableMixin -from models import FlexibleModel, ModelConfig, FieldConfig +from models import FlexibleModel +from shared.db import Table class Todo(DeprecatableMixin, FlexibleModel): """TODO model with embedded tasks.""" - collection_name = "todo" + table = Table.TODO def __init__(self, **kwargs): super().__init__(**kwargs) @@ -70,19 +71,6 @@ def update_task(self, task_index: int, description: str) -> None: self.tasks[task_index]['description'] = description self.tasks[task_index]['updated_on'] = datetime.now() - schema = ModelConfig({ - '_id': FieldConfig(read_only=True), - 'created_on': FieldConfig(read_only=True), - 'updated_on': FieldConfig(read_only=True), - 'due_on': FieldConfig(), - 'name': FieldConfig(), - 'description': FieldConfig(), - 'tasks': FieldConfig(read_only=False), - 'deprecated': FieldConfig(), - 'deprecated_on': FieldConfig(), - 'deprecated_cause': FieldConfig() - }) - def to_dict(self) -> Dict[str, Any]: """Convert to MongoDB document format""" base_dict = super().to_dict() From b0d564c49af78ac03685d5df9057043549e50565 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sat, 15 Feb 2025 22:24:17 -0600 Subject: [PATCH 31/44] Finally --- server/app/app.py | 15 +- server/app/install.py | 25 +- server/app/routes/__init__.py | 372 +++++++++++------- server/app/routes/alert_routes.py | 8 +- server/app/routes/installable_model_routes.py | 22 +- server/app/routes/mix_routes.py | 8 +- server/app/routes/plant_routes.py | 8 +- server/app/routes/system_routes.py | 54 ++- server/app/routes/todo_routes.py | 12 +- server/models/__init__.py | 21 +- server/models/alert.py | 10 +- server/models/mix.py | 87 ++-- server/models/plant.py | 31 +- server/models/system.py | 40 +- server/models/todo.py | 77 +--- server/shared/db.py | 54 ++- server/shared/discover.py | 7 +- 17 files changed, 414 insertions(+), 437 deletions(-) diff --git a/server/app/app.py b/server/app/app.py index 4b5cd2b..4eaae39 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -24,7 +24,8 @@ logger = setup_logger(__name__, logging.DEBUG) # Probably abstract this out to a Role class and have this be in the master role class -discover_systems() # Maybe put this into the installable? +# Maybe put this into the installable? +discover_systems() # Create Flask app app = Flask(__name__) @@ -62,7 +63,7 @@ def get_meta(): logger.info("Received request to query the meta") meta = { - # "alert_count": db.plant_alerts.count_documents({"deprecated": False}), + "alert_count": Table.ALERT.count({"deprecated": False}), "todo_count": Table.TODO.count({"deprecated": False}) } @@ -86,6 +87,16 @@ def get_notebook(): # Serve the HTML return body +# Print details of the running endpoints +for rule in app.url_map.iter_rules(): + methods = ','.join(sorted(rule.methods)) + arguments = ','.join(sorted(rule.arguments)) + logger.debug(f"Endpoint: {rule.endpoint}") + logger.debug(f" URL: {rule}") + logger.debug(f" Methods: {methods}") + logger.debug(f" Arguments: {arguments}") + logger.debug("---") + scheduler = APScheduler() scheduler.init_app(app) scheduler.start() diff --git a/server/app/install.py b/server/app/install.py index da7ddeb..cae256e 100644 --- a/server/app/install.py +++ b/server/app/install.py @@ -17,11 +17,10 @@ # Create a logger for this specific module logger = setup_logger(__name__, logging.DEBUG) -def create_model(model_path: str, model_class: Type[FlexibleModel]): +def create_model(model_path: str, table: Table): """ Create the provided model from the data path. """ - table: Table = model_class.table logger.info(f"Beginning to create model {table.value}") @@ -33,13 +32,11 @@ def create_model(model_path: str, model_class: Type[FlexibleModel]): # Load models from CSV and insert them # Doing this way to create null values in the db in the event some fields get updated - models = model_class.from_csv(model_path) - documents = [model.to_dict() for model in models] - - if documents: - for doc in documents: + models: List[FlexibleModel] = table.model_class.from_csv(model_path) + if models: + for doc in models: table.create(doc) - logger.info(f"Successfully created {len(documents)} documents for {table.value}") + logger.info(f"Successfully created {len(models)} documents for {table.value}") else: logger.error(f"No documents to create for {table.value}") @@ -49,11 +46,11 @@ def create_all_models(): """ logger.info("Beginning to create models.") - models_to_create: List[Tuple[str, Type[FlexibleModel]]] = [ - ("data/installable/soils/soils.csv", Soil), - ("data/installable/plants/genus_types.csv", PlantGenusType), - ("data/installable/plants/genera.csv", PlantGenus), - ("data/installable/plants/species.csv", PlantSpecies), + models_to_create: List[Tuple[str, Table]] = [ + ("data/installable/soils/soils.csv", Table.SOIL), + ("data/installable/plants/genus_types.csv", Table.GENUS_TYPE), + ("data/installable/plants/genera.csv", Table.GENUS), + ("data/installable/plants/species.csv", Table.SPECIES), ] for model_path, model_class in models_to_create: @@ -70,4 +67,4 @@ def install(): logger.info("Installation complete") if __name__ == "__main__": - install() + install() diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py index 1313db1..661138d 100644 --- a/server/app/routes/__init__.py +++ b/server/app/routes/__init__.py @@ -1,160 +1,232 @@ from flask import Blueprint, request, jsonify -from typing import List, Callable, Type +from typing import List, Callable, Type, Dict, Optional, Any from shared.logger import logger from models import FlexibleModel from enum import Enum +from dataclasses import dataclass +from bson import ObjectId +from shared.db import Table -# @dataclass -# class FieldType: -# """Configuration for each field stored on a model.""" -# read_only: bool = False -# create_only: bool = False -# internal_only: bool = False -# nested: Optional['FieldType'] = None -# nested_class: str = None -# include_nested: bool = False -# delete_with_parent: bool = False - -# class Schema(Enum): - -# PLANT = { -# '_id': FieldType(read_only=True), -# 'created_on': FieldType(read_only=True), -# 'updated_on': FieldType(read_only=True), -# 'cost': FieldType(), -# 'species_id': FieldType(), -# 'watered_on': FieldType(), -# 'watering': FieldType(), -# 'identity': FieldType(), -# 'phase': FieldType(), -# 'size': FieldType(), -# 'system_id': FieldType(), -# 'mix_id': FieldType(), -# 'deprecated': FieldType(), -# 'deprecated_on': FieldType(), -# 'deprecated_cause': FieldType() -# } -# PLANT_GENUS_TYPE = { -# '_id': FieldType(read_only=True), -# 'created_on': FieldType(read_only=True), -# 'updated_on': FieldType(read_only=True), -# 'name': FieldType(read_only=True), -# 'common_name': FieldType(read_only=True), -# 'description': FieldType(read_only=True), -# 'genus_id': FieldType(read_only=True) -# } -# PLANT_GENUS = { -# '_id': FieldType(read_only=True), -# 'created_on': FieldType(read_only=True), -# 'updated_on': FieldType(read_only=True), -# 'name': FieldType(read_only=True), -# 'common_name': FieldType(read_only=True), -# 'description': FieldType(read_only=True), -# 'watering': FieldType(), -# 'genus_type_id': FieldType(read_only=True) -# } -# SPECIES = { -# '_id': FieldType(read_only=True), -# 'created_on': FieldType(read_only=True), -# 'updated_on': FieldType(read_only=True), -# 'name': FieldType(read_only=True), -# 'common_name': FieldType(read_only=True), -# 'description': FieldType(read_only=True), -# 'genus_id': FieldType(read_only=True) -# } -# TODO = { -# '_id': FieldType(read_only=True), -# 'created_on': FieldType(read_only=True), -# 'updated_on': FieldType(read_only=True), -# 'due_on': FieldType(), -# 'name': FieldType(), -# 'description': FieldType(), -# 'tasks': FieldType(nested=True), -# 'deprecated': FieldType(), -# 'deprecated_on': FieldType(), -# 'deprecated_cause': FieldType(), -# 'tasks': FieldType(nested=True, nested_class='TASK') -# } -# TASK = { -# 'description': FieldType(), -# 'created_on': FieldType(read_only=True), -# 'updated_on': FieldType(read_only=True), -# } -# SOIL = { -# '_id': FieldType(read_only=True), -# 'created_on': FieldType(read_only=True), -# 'name': FieldType(read_only=True), -# 'description': FieldType(read_only=True), -# 'group': FieldType(read_only=True) -# } - - -# """Standard model serializer with MongoDB support""" -# def __init__(self, fields: Dict[str, FieldType]): -# self.fields = fields - -# def serialize(self, obj, depth=0, include_nested=False) -> Dict[str, Any]: -# if depth > 5: -# return {} - -# result = {} -# for k, v in self.fields.items(): -# if hasattr(obj, k): -# value = getattr(obj, k) -# # Handle ObjectId conversion -# if isinstance(value, ObjectId): -# result[k] = str(value) -# elif v.nested: -# nested_schema: Schema = getattr(Schema, v.nested) -# if isinstance(value, list): -# result[k] = [nested_schema.serialize(item, depth+1, include_nested) for item in value] -# elif value is not None: -# result[k] = nested_schema.serialize(value, depth+1, include_nested) -# elif not v.internal_only: -# result[k] = value -# return result - -# def deserialize(self, data: Dict, is_create=False, depth=0) -> Dict[str, Any]: -# if depth > 5: -# return {} +@dataclass +class SchemaField: + """Configuration for each field stored on a model.""" + read_only: bool = False + internal_only: bool = False + nested: Optional['SchemaField'] = None + nested_schema: str = None + nested_class:object = None + +class Schema(Enum): + + PLANT = { + '_id': SchemaField(read_only=True), + 'created_on': SchemaField(read_only=True), + 'updated_on': SchemaField(read_only=True), + 'cost': SchemaField(), + 'species_id': SchemaField(), + 'watered_on': SchemaField(), + 'watering': SchemaField(), + 'identity': SchemaField(), + 'phase': SchemaField(), + 'size': SchemaField(), + 'system_id': SchemaField(), + 'mix_id': SchemaField(), + 'deprecated': SchemaField(), + 'deprecated_on': SchemaField(), + 'deprecated_cause': SchemaField() + } + PLANT_GENUS_TYPE = { + '_id': SchemaField(read_only=True), + 'created_on': SchemaField(read_only=True), + 'updated_on': SchemaField(read_only=True), + 'name': SchemaField(read_only=True), + 'common_name': SchemaField(read_only=True), + 'description': SchemaField(read_only=True), + 'genus_id': SchemaField(read_only=True) + } + PLANT_GENUS = { + '_id': SchemaField(read_only=True), + 'created_on': SchemaField(read_only=True), + 'updated_on': SchemaField(read_only=True), + 'name': SchemaField(read_only=True), + 'common_name': SchemaField(read_only=True), + 'description': SchemaField(read_only=True), + 'watering': SchemaField(), + 'genus_type_id': SchemaField(read_only=True) + } + SPECIES = { + '_id': SchemaField(read_only=True), + 'created_on': SchemaField(read_only=True), + 'updated_on': SchemaField(read_only=True), + 'name': SchemaField(read_only=True), + 'common_name': SchemaField(read_only=True), + 'description': SchemaField(read_only=True), + 'genus_id': SchemaField(read_only=True) + } + TODO = { + '_id': SchemaField(read_only=True), + 'created_on': SchemaField(read_only=True), + 'updated_on': SchemaField(read_only=True), + 'due_on': SchemaField(), + 'name': SchemaField(), + 'description': SchemaField(), + 'tasks': SchemaField(nested=True), + 'deprecated': SchemaField(), + 'deprecated_on': SchemaField(), + 'deprecated_cause': SchemaField(), + 'tasks': SchemaField(nested=True, nested_schema='TASK') + } + TASK = { + 'description': SchemaField(), + 'created_on': SchemaField(read_only=True), + 'updated_on': SchemaField(read_only=True), + } + SOIL = { + "_id": SchemaField(read_only=True), + 'created_on': SchemaField(read_only=True), + 'name': SchemaField(read_only=True), + 'description': SchemaField(read_only=True), + 'group': SchemaField(read_only=True) + } + LIGHT = { + '_id': SchemaField(read_only=True), + 'created_on': SchemaField(read_only=True), + 'updated_on': SchemaField(read_only=True), + 'name': SchemaField(), + 'cost': SchemaField(), + 'system_id': SchemaField(), + 'deprecated': SchemaField(), + 'deprecated_on': SchemaField(), + 'deprecated_cause': SchemaField() + } + SYSTEM = { + '_id': SchemaField(read_only=True), + 'created_on': SchemaField(read_only=True), + 'updated_on': SchemaField(read_only=True), + 'last_humidity': SchemaField(read_only=True), + 'last_temperature': SchemaField(read_only=True), + 'container_id': SchemaField(internal_only=True), + 'is_local': SchemaField(internal_only=True), + 'url': SchemaField(internal_only=True), + 'name': SchemaField(), + 'description': SchemaField(), + 'target_humidity': SchemaField(), + 'target_temperature': SchemaField(), + 'duration': SchemaField(), + 'distance': SchemaField(), + 'deprecated': SchemaField(), + 'deprecated_on': SchemaField(), + 'deprecated_cause': SchemaField() + } + ALERT = { + 'id': SchemaField(read_only=True), + 'created_on': SchemaField(read_only=True), + 'updated_on': SchemaField(read_only=True), + 'alert_type': SchemaField(read_only=True), + 'model_id': SchemaField(read_only=True) + } + MIX = { + '_id': SchemaField(read_only=True), + 'created_on': SchemaField(read_only=True), + 'updated_on': SchemaField(read_only=True), + 'name': SchemaField(), + 'description': SchemaField(), + 'experimental': SchemaField(), + 'soil_parts': SchemaField(nested=True, nested_class='SOIL_PART'), + 'deprecated': SchemaField(), + 'deprecated_on': SchemaField(), + 'deprecated_cause': SchemaField() + } + SOIL_PART = { + 'created_on': SchemaField(read_only=True), + 'updated_on': SchemaField(read_only=True), + 'soil_id': SchemaField(), + 'parts': SchemaField() + } + + + """Standard model serializer with MongoDB support""" + def __init__(self, fields: Dict[str, SchemaField]): + self.fields = fields + + def read(self, obj: FlexibleModel, depth=0, include_nested=False) -> Dict[str, Any]: + if depth > 5: + return {} -# result = {} -# for k, v in data.items(): -# if k in self.fields: -# field_config = self.fields[k] -# if not field_config.read_only and (is_create or not field_config.create_only): -# if field_config.nested: -# nested_schema: Schema = getattr(Schema, v.nested) -# if isinstance(v, list): -# result[k] = [nested_schema.deserialize(item, is_create, depth+1) for item in v] -# elif v is not None: -# result[k] = nested_schema.deserialize(v, is_create, depth+1) -# elif not field_config.internal_only: -# result[k] = v -# return result + result = {} + for k, v in self.fields.items(): + if hasattr(obj, k): + value = getattr(obj, k) + if isinstance(value, ObjectId): + result[k] = str(value) + elif v.nested: + nested_schema: Schema = getattr(Schema, v.nested_schema) + if isinstance(value, list): + result[k] = [nested_schema.read(item, depth+1, include_nested) for item in value] + elif value is not None: + result[k] = nested_schema.read(value, depth+1, include_nested) + elif not v.internal_only: + result[k] = value + return result + + def patch(self, model: FlexibleModel, data:Dict[str, Any], depth=0): + """ """ + if depth > 5: + return model + + for field_name, new_value in data.items(): + if field_name not in self.fields: + continue + + field_config: SchemaField = self.fields[field_name] + + if field_config.nested and not field_config.internal_only: + nested_schema: Schema = getattr(Schema, field_config.nested_schema) + + if isinstance(new_value, list): + # Handle list of nested objects + current_value = getattr(model, field_name, []) + setattr(model, field_name, [ + nested_schema.patch(current_item, new_item, depth + 1) + if current_item is not None else nested_schema.patch(None, new_item, depth + 1) + for current_item, new_item in zip(current_value, new_value) + ]) + elif new_value is not None: + # Handle single nested object + current_value = getattr(model, field_name) + setattr(model, field_name, + nested_schema.patch(current_value, new_value, depth + 1) + if current_value is not None else nested_schema.patch(None, new_value, depth + 1) + ) + elif not field_config.internal_only: + setattr(model, field_name, new_value) + + def create(self, model_clazz: Type[FlexibleModel], data:Dict[str, Any]): + return model_clazz(**data) class GenericCRUD: - def __init__(self, model): - self.model: Type[FlexibleModel] = model + def __init__(self, table, schema): + self.table: Table = table + self.schema: Schema = schema def get(self, id: str): try: - item = self.model.table.get_one(id) # Not a huge fan of this, maybe revisit + item = self.table.get_one(id) # Not a huge fan of this, maybe revisit if item is None: logger.error(f"Could not find {id}") return jsonify({"error": "Not found"}), 404 - data_model = self.model(**item) - return jsonify(data_model.to_dict()) + + return jsonify(self.schema.read(item)) except Exception as e: logger.error(f"Error in get: {str(e)}") return jsonify({"error": str(e)}), 500 def get_many(self): try: - items = self.model.table.get_many({}) + items = self.table.get_many() return jsonify([ - self.model(**item).to_dict() + self.schema.read(item) for item in items ]) except Exception as e: @@ -163,11 +235,13 @@ def get_many(self): def create(self): try: - item = self.model(**request.json) - result = self.model.table.create(item.to_dict()) + + item = self.schema.create(self.table.model_class, request.json) + + result = self.table.create(item) item._id = result - return jsonify(item.to_dict()), 201 + return jsonify(self.schema.read(item)), 201 except Exception as e: logger.error(f"Error in create: {str(e)}") @@ -175,14 +249,13 @@ def create(self): def update(self, id: str): try: - item = self.model(**request.json) - result = self.model.table.update(id, item.to_dict()) # should probably be to flattened json - - if not result: + db_model = self.table.get_one(id) + if not db_model: return jsonify({"error": "Not found"}), 404 + + self.schema.patch(db_model, request.json) - updated_item = self.model.table.get_one(id) - return jsonify(self.model(**updated_item).to_dict()) + return jsonify(db_model.to_dict()) except Exception as e: logger.error(f"Error in update: {str(e)}") @@ -191,11 +264,11 @@ def update(self, id: str): def delete(self, id: str): try: # TODO: Double dipping here - item = self.model.table.get_one(id) + item = self.table.get_one(id) if not item: return jsonify({"error": "Not found"}), 404 - self.model.table.delete(id) + self.table.delete(id) return '', 204 except Exception as e: @@ -212,7 +285,7 @@ def delete_many(self): try: deleted_count = 0 for id in ids: - result = self.model.table.delete(id) + result = self.table.delete(id) if result: deleted_count+=1 return jsonify({ @@ -222,10 +295,9 @@ def delete_many(self): logger.error(f"Error in delete_many: {str(e)}") return jsonify({'error': str(e)}), 500 - class APIBuilder: @staticmethod - def register_resource( + def register_blueprint( blueprint: Blueprint, resource_name: str, crud: GenericCRUD, @@ -258,6 +330,6 @@ def wrapper(): def register_custom_route(blueprint: Blueprint, route: str, methods: List[str]): """Custom route on this bp.""" def decorator(handler: Callable): - blueprint.route(f'/{route}', methods=methods)(handler) + blueprint.route(route, methods=methods)(handler) return handler return decorator \ No newline at end of file diff --git a/server/app/routes/alert_routes.py b/server/app/routes/alert_routes.py index 5e5849c..7adfcde 100644 --- a/server/app/routes/alert_routes.py +++ b/server/app/routes/alert_routes.py @@ -1,8 +1,8 @@ from flask import Blueprint -from models.alert import Alert -from routes import GenericCRUD, APIBuilder +from shared.db import Table +from routes import GenericCRUD, APIBuilder, Schema bp = Blueprint('alerts', __name__) -alert_crud = GenericCRUD(Alert) -APIBuilder.register_resource(bp, 'alerts', alert_crud, ["GET", "GET_MANY", "DELETE"]) +alert_crud = GenericCRUD(Table.ALERT, Schema.ALERT) +APIBuilder.register_blueprint(bp, 'alerts', alert_crud, ["GET", "GET_MANY", "DELETE"]) diff --git a/server/app/routes/installable_model_routes.py b/server/app/routes/installable_model_routes.py index 37d4b58..f0cdc44 100644 --- a/server/app/routes/installable_model_routes.py +++ b/server/app/routes/installable_model_routes.py @@ -1,22 +1,22 @@ from flask import Blueprint -from routes import GenericCRUD, APIBuilder -from models.plant import PlantGenusType, PlantGenus, PlantSpecies -from models.mix import Soil +from shared.db import Table + +from routes import GenericCRUD, APIBuilder, Schema genus_types_bp = Blueprint('genus_types', __name__) -genus_types_crud = GenericCRUD(PlantGenusType) -APIBuilder.register_resource(genus_types_bp, 'genus_types', genus_types_crud, methods=["GET", "GET_MANY"]) +genus_types_crud = GenericCRUD(Table.GENUS_TYPE, Schema.PLANT_GENUS_TYPE) +APIBuilder.register_blueprint(genus_types_bp, 'genus_types', genus_types_crud, methods=["GET", "GET_MANY"]) species_bp = Blueprint('species', __name__) -species_crud = GenericCRUD(PlantSpecies) -APIBuilder.register_resource(species_bp, 'species', species_crud, methods=["GET", "GET_MANY"]) +species_crud = GenericCRUD(Table.SPECIES, Schema.SPECIES) +APIBuilder.register_blueprint(species_bp, 'species', species_crud, methods=["GET", "GET_MANY"]) genus_bp = Blueprint('genera', __name__) -genus_crud = GenericCRUD(PlantGenus) -APIBuilder.register_resource(genus_bp, 'genera', genus_crud, methods=["GET", "GET_MANY"]) +genus_crud = GenericCRUD(Table.GENUS, Schema.PLANT_GENUS) +APIBuilder.register_blueprint(genus_bp, 'genera', genus_crud, methods=["GET", "GET_MANY"]) soils_bp = Blueprint('soils', __name__) -soils_crud = GenericCRUD(Soil) -APIBuilder.register_resource(soils_bp, 'soils', soils_crud, methods=["GET", "GET_MANY"]) +soils_crud = GenericCRUD(Table.SOIL, Schema.SOIL) +APIBuilder.register_blueprint(soils_bp, 'soils', soils_crud, methods=["GET", "GET_MANY"]) diff --git a/server/app/routes/mix_routes.py b/server/app/routes/mix_routes.py index 084adab..93ef5c7 100644 --- a/server/app/routes/mix_routes.py +++ b/server/app/routes/mix_routes.py @@ -1,8 +1,8 @@ from flask import Blueprint -from models.mix import Mix -from routes import GenericCRUD, APIBuilder +from shared.db import Table +from routes import GenericCRUD, APIBuilder, Schema bp = Blueprint('mixes', __name__) -mix_crud = GenericCRUD(Mix) -APIBuilder.register_resource(bp, 'mixes', mix_crud, ["GET", "GET_MANY", "POST", "DELETE"]) +mix_crud = GenericCRUD(Table.MIX, Schema.MIX) +APIBuilder.register_blueprint(bp, 'mixes', mix_crud, ["GET", "GET_MANY", "POST", "DELETE", "PATCH"]) diff --git a/server/app/routes/plant_routes.py b/server/app/routes/plant_routes.py index 4967413..ac468be 100644 --- a/server/app/routes/plant_routes.py +++ b/server/app/routes/plant_routes.py @@ -1,11 +1,11 @@ from flask import Blueprint, jsonify, request -from models.plant import Plant -from routes import GenericCRUD, APIBuilder +from shared.db import Table +from routes import GenericCRUD, APIBuilder, Schema bp = Blueprint('plants', __name__) -plant_crud = GenericCRUD(Plant) -APIBuilder.register_resource(bp, 'plants', plant_crud) +plant_crud = GenericCRUD(Table.PLANT, Schema.PLANT) +APIBuilder.register_blueprint(bp, 'plants', plant_crud) # @APIBuilder.register_custom_route(bp, 'deprecate/', ['GET']) # def deprecate_plants(): diff --git a/server/app/routes/system_routes.py b/server/app/routes/system_routes.py index 2e885e5..de4d2e5 100644 --- a/server/app/routes/system_routes.py +++ b/server/app/routes/system_routes.py @@ -1,38 +1,36 @@ from flask import Blueprint, jsonify from shared.logger import logger -from models.system import System, Light -from routes import GenericCRUD, APIBuilder +from shared.db import Table +from bson import ObjectId + +from routes import GenericCRUD, APIBuilder, Schema system_bp = Blueprint('systems', __name__) -system_crud = GenericCRUD(System) -APIBuilder.register_resource(system_bp, 'systems', system_crud) +system_crud = GenericCRUD(Table.SYSTEM, Schema.SYSTEM) +APIBuilder.register_blueprint(system_bp, 'systems', system_crud) -# @APIBuilder.register_custom_route(system_bp, '/systems//plants/', ['GET']) -# def get_systems_plants(system_id): -# """ -# Get system's plants. -# """ -# logger.info("Received request to get a system's plants") - -# db = Session() -# plants = db.query(Plant).filter(Plant.system_id == system_id).all() -# db.close() +@APIBuilder.register_custom_route(system_bp, "/systems//plants/", methods=['GET']) +def get_systems_plants(id): + """ + Get system's plants. + """ + logger.info("Received request to get a system's plants") -# return jsonify([Plant.schema.serialize(plant) for plant in plants]) + plants = Table.PLANT.get_many({'system_id': ObjectId(id)}) -# @APIBuilder.register_custom_route(system_bp, "/systems//alerts/", ["GET"]) -# def get_systems_alerts(system_id): -# """ -# Get system's alerts. -# """ -# logger.info("Received request to get a system's alerts") - -# db = Session() -# plant_alerts = db.query(PlantAlert).filter(PlantAlert.system_id == system_id).all() -# db.close() + return jsonify([Schema.PLANT.read(plant) for plant in plants]) -# return jsonify([PlantAlert.schema.serialize(plant_alert) for plant_alert in plant_alerts]) +@APIBuilder.register_custom_route(system_bp, "/systems//alerts/", ["GET"]) +def get_systems_alerts(id): + """ + Get system's alerts. + """ + logger.info("Received request to get a system's alerts") + + alerts = Table.ALERT.get_many({'model_id': ObjectId(id)}) + + return jsonify([Schema.ALERT.read(alert) for alert in alerts]) # @APIBuilder.register_custom_route(system_bp, "/systems//video_feed/", ["GET"]) # def get_video_feed(system_id): @@ -85,5 +83,5 @@ # return jsonify({"error": str(e)}), 500 light_bp = Blueprint('lights', __name__) -light_crud = GenericCRUD(Light) -APIBuilder.register_resource(light_bp, 'lights', light_crud, ["GET", "GET_MANY", "POST"]) +light_crud = GenericCRUD(Table.LIGHT, Schema.LIGHT) +APIBuilder.register_blueprint(light_bp, 'lights', light_crud, ["GET", "GET_MANY", "POST"]) diff --git a/server/app/routes/todo_routes.py b/server/app/routes/todo_routes.py index 82976de..4d5677d 100644 --- a/server/app/routes/todo_routes.py +++ b/server/app/routes/todo_routes.py @@ -1,8 +1,12 @@ from flask import Blueprint -from routes import GenericCRUD, APIBuilder -from models.todo import Todo +from routes import GenericCRUD, APIBuilder, Schema +from shared.db import Table bp = Blueprint('todos', __name__) -todo_crud = GenericCRUD(Todo) -APIBuilder.register_resource(bp, 'todos', todo_crud) +todo_crud = GenericCRUD(Table.TODO, Schema.TODO) +APIBuilder.register_blueprint(bp, 'todos', todo_crud) + +# @APIBuilder.register_custom_route(bp, "/todos//tasks/", methods=['POST']) +# def get_systems_plants(id, task_id): + diff --git a/server/models/__init__.py b/server/models/__init__.py index 1ba6ce9..e2d352a 100644 --- a/server/models/__init__.py +++ b/server/models/__init__.py @@ -2,12 +2,18 @@ from typing import List, Any, Dict import numpy as np import csv -from shared.db import Table from bson import ObjectId +class Fields: + + @staticmethod + def object_id(value): + if isinstance(value,str): + return ObjectId(value) + return value + class FlexibleModel: """Base model with MongoDB support""" - table: Table = None # Override in subclasses def __init__(self, **kwargs): self._id = kwargs.get('_id', ObjectId()) # MongoDB ID @@ -38,6 +44,15 @@ def from_csv(cls, file_path: str) -> List['FlexibleModel']: reader = csv.DictReader(csvfile) return [cls.from_dict(row) for row in reader] + # Overridable method def to_dict(self) -> Dict[str, Any]: """Convert model to dictionary for MongoDB storage""" - return {k: v for k, v in self.__dict__.items() if not k.startswith('_')} \ No newline at end of file + return {k: v for k, v in self.__dict__.items()} + + +class DeprecatableMixin: + """ In case the model is deprecated.""" + def __init__(self, **kwargs): + self.deprecated = kwargs.get('deprecated', False) + self.deprecated_on = kwargs.get('deprecated_on') + self.deprecated_cause = kwargs.get('deprecated_cause') \ No newline at end of file diff --git a/server/models/alert.py b/server/models/alert.py index 968d29e..83f84ab 100644 --- a/server/models/alert.py +++ b/server/models/alert.py @@ -2,9 +2,7 @@ Module defining models for alerts. """ from datetime import datetime -from models.plant import DeprecatableMixin -from models import FlexibleModel -from shared.db import Table +from models import FlexibleModel, DeprecatableMixin, Fields import enum @@ -14,12 +12,10 @@ class AlertTypes(enum.Enum): class Alert(DeprecatableMixin, FlexibleModel): """Alert Base Class""" - table = Table.ALERT - def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = kwargs.get('_id', ObjectId()) + self._id = Fields.object_id(kwargs.get('_id', ObjectId())) self.created_on = kwargs.get('created_on', datetime.now()) self.updated_on = kwargs.get('updated_on', datetime.now()) self.alert_type = kwargs.get('alert_type', 'alert') - self.model_id = kwargs.get('alert_type', 'alert') + self.model_id = Fields.object_id(kwargs.get('alert_type', 'alert')) diff --git a/server/models/mix.py b/server/models/mix.py index 99c22b9..e38a6c4 100644 --- a/server/models/mix.py +++ b/server/models/mix.py @@ -2,19 +2,16 @@ Module for soil mix related models. """ from datetime import datetime -from typing import List, Dict, Any +from typing import Dict, Any, List from bson import ObjectId -from models.plant import DeprecatableMixin -from models import FlexibleModel -from shared.db import Table +from models import FlexibleModel, DeprecatableMixin, Fields class Soil(FlexibleModel): """Soil types available for mixes.""" - table = Table.SOIL def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = kwargs.get('_id', ObjectId()) + self._id = Fields.object_id(kwargs.get('_id', ObjectId())) self.created_on = kwargs.get('created_on', datetime.now()) self.description = kwargs.get('description') self.group = kwargs.get('group') @@ -23,79 +20,41 @@ def __init__(self, **kwargs): def __repr__(self) -> str: return f"{self.name}" +class SoilPart(FlexibleModel): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._id = Fields.object_id(kwargs.get('_id', ObjectId())) + self.soil_id = Fields.object_id(kwargs.get('soil_id')) + self.created_on = kwargs.get('created_on', datetime.now()) + self.updated_on = kwargs.get('updated_on', datetime.now()) + self.parts = kwargs.get('parts') + + class Mix(DeprecatableMixin, FlexibleModel): """Soil mix model with embedded soil parts.""" - collection_name = "mix" def __init__(self, **kwargs): - super().__init__(**kwargs) - self._id = kwargs.get('_id', ObjectId()) - self.name = kwargs.get('name') - self.description = kwargs.get('description') - self.created_on = kwargs.get('created_on', datetime.now()) - self.updated_on = kwargs.get('updated_on', datetime.now()) - self.experimental = kwargs.get('experimental', False) + super().__init__(**kwargs) + self._id = Fields.object_id(kwargs.get('_id', ObjectId())) + self.name = kwargs.get('name') + self.description = kwargs.get('description') + self.created_on = kwargs.get('created_on', datetime.now()) + self.updated_on = kwargs.get('updated_on', datetime.now()) + self.experimental = kwargs.get('experimental', False) # Embedded soil parts - self.soil_parts = [ - { - 'soil_id': part.get('soil_id'), - 'parts': part.get('parts', 1), - 'created_on': part.get('created_on', datetime.now()), - 'updated_on': part.get('updated_on', datetime.now()) - } - for part in kwargs.get('soil_parts', []) - ] + self.parts: List[SoilPart] = [SoilPart(sp) for sp in kwargs.get('parts', [])] + def __repr__(self) -> str: return f"{self.name}" - def add_soil_part(self, soil_id: ObjectId, parts: int = 1) -> None: - """Add a new soil part to the mix""" - self.soil_parts.append({ - 'soil_id': soil_id, - 'parts': parts, - 'created_on': datetime.now(), - 'updated_on': datetime.now() - }) - - def remove_soil_part(self, soil_id: ObjectId) -> None: - """Remove a soil part from the mix""" - self.soil_parts = [part for part in self.soil_parts if part['soil_id'] != soil_id] - - def update_soil_part(self, soil_id: ObjectId, parts: int) -> None: - """Update the parts count for a soil in the mix""" - for part in self.soil_parts: - if part['soil_id'] == soil_id: - part['parts'] = parts - part['updated_on'] = datetime.now() - break - -# schema = ModelConfig({ -# '_id': FieldConfig(read_only=True), -# 'created_on': FieldConfig(read_only=True), -# 'updated_on': FieldConfig(read_only=True), -# 'name': FieldConfig(), -# 'description': FieldConfig(), -# 'experimental': FieldConfig(), -# 'soil_parts': FieldConfig(read_only=False), -# 'deprecated': FieldConfig(), -# 'deprecated_on': FieldConfig(), -# 'deprecated_cause': FieldConfig() -# }) - def to_dict(self) -> Dict[str, Any]: """Convert to MongoDB document format""" base_dict = super().to_dict() - # Ensure soil_parts is properly formatted for MongoDB if 'soil_parts' in base_dict: base_dict['soil_parts'] = [ - { - 'soil_id': part['soil_id'], - 'parts': part['parts'], - 'created_on': part['created_on'], - 'updated_on': part['updated_on'] - } + part.to_dict() for part in base_dict['soil_parts'] ] return base_dict \ No newline at end of file diff --git a/server/models/plant.py b/server/models/plant.py index 64dea6b..76eca9b 100644 --- a/server/models/plant.py +++ b/server/models/plant.py @@ -2,19 +2,10 @@ Module defining models for plants. """ from datetime import datetime -from typing import List import enum from bson import ObjectId -from shared.db import Table -from models import FlexibleModel - -class DeprecatableMixin: - """ In case the model is deprecated.""" - def __init__(self, **kwargs): - self.deprecated = kwargs.get('deprecated', False) - self.deprecated_on = kwargs.get('deprecated_on') - self.deprecated_cause = kwargs.get('deprecated_cause') +from models import FlexibleModel, DeprecatableMixin, Fields class PHASES(enum.Enum): ADULT = "Adult" @@ -25,15 +16,14 @@ class PHASES(enum.Enum): class Plant(DeprecatableMixin, FlexibleModel): """Plant model.""" - table = Table.PLANT def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = kwargs.get('_id', ObjectId()) + self._id = Fields.object_id(kwargs.get('_id', ObjectId())) self.created_on = kwargs.get('created_on', datetime.now()) self.cost = kwargs.get('cost', 0) self.system_id = kwargs.get('system_id') - self.mix_id = kwargs.get('mix_id') + self.mix_id = Fields.object_id(kwargs.get('mix_id')) self.updated_on = kwargs.get('updated_on', datetime.now()) # Metrics @@ -44,7 +34,7 @@ def __init__(self, **kwargs): self.watering = kwargs.get('watering', 0) # Days self.watered_on = kwargs.get('watered_on', datetime.now()) - self.species_id = kwargs.get('species_id') + self.species_id = Fields.object_id(kwargs.get('species_id')) self.identity = kwargs.get('identity', 'plant') def __repr__(self) -> str: @@ -58,11 +48,10 @@ def __init__(self, **kwargs): self.identity = 'batch' class PlantGenusType(FlexibleModel): - table = Table.GENUS_TYPE def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = kwargs.get('_id', ObjectId()) + self._id = Fields.object_id(kwargs.get('_id', ObjectId())) self.created_on = kwargs.get('created_on', datetime.now()) self.updated_on = kwargs.get('updated_on', datetime.now()) self.name = kwargs.get('name') @@ -70,28 +59,26 @@ def __init__(self, **kwargs): self.watering = kwargs.get('watering') class PlantGenus(FlexibleModel): - table = Table.GENUS def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = kwargs.get('_id', ObjectId()) + self._id = Fields.object_id(kwargs.get('_id', ObjectId())) self.created_on = kwargs.get('created_on', datetime.now()) self.updated_on = kwargs.get('updated_on', datetime.now()) self.name = kwargs.get('name') self.common_name = kwargs.get('common_name') self.description = kwargs.get('description') self.watering = kwargs.get('watering') - self.genus_type_id = kwargs.get('genus_type_id') + self.genus_type_id = Fields.object_id(Fields.object_id(kwargs.get('genus_type_id'))) class PlantSpecies(FlexibleModel): - table = Table.SPECIES def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = kwargs.get('_id', ObjectId()) + self._id = Fields.object_id(kwargs.get('_id', ObjectId())) self.created_on = kwargs.get('created_on', datetime.now()) self.updated_on = kwargs.get('updated_on', datetime.now()) self.name = kwargs.get('name') self.common_name = kwargs.get('common_name') self.description = kwargs.get('description') - self.genus_id = kwargs.get('genus_id') + self.genus_id = Fields.object_id(kwargs.get('genus_id')) diff --git a/server/models/system.py b/server/models/system.py index 6cb0420..974ac4f 100644 --- a/server/models/system.py +++ b/server/models/system.py @@ -3,12 +3,11 @@ """ from datetime import datetime from bson import ObjectId -from models.plant import DeprecatableMixin +from models import FlexibleModel, DeprecatableMixin from models import FlexibleModel class Light(DeprecatableMixin, FlexibleModel): """Light model.""" - collection_name = "light" def __init__(self, **kwargs): super().__init__(**kwargs) @@ -22,21 +21,8 @@ def __init__(self, **kwargs): def __repr__(self) -> str: return f"{self.name}" -# schema = ModelConfig({ -# '_id': FieldConfig(read_only=True), -# 'created_on': FieldConfig(read_only=True), -# 'updated_on': FieldConfig(read_only=True), -# 'name': FieldConfig(), -# 'cost': FieldConfig(), -# 'system_id': FieldConfig(), -# 'deprecated': FieldConfig(), -# 'deprecated_on': FieldConfig(), -# 'deprecated_cause': FieldConfig() -# }) - class System(DeprecatableMixin, FlexibleModel): """System model.""" - collection_name = "system" def __init__(self, **kwargs): super().__init__(**kwargs) @@ -64,26 +50,4 @@ def __init__(self, **kwargs): def __repr__(self) -> str: return f"{self.name}" - -# schema = ModelConfig({ -# '_id': FieldConfig(read_only=True), -# 'created_on': FieldConfig(read_only=True), -# 'updated_on': FieldConfig(read_only=True), -# 'last_humidity': FieldConfig(read_only=True), -# 'last_temperature': FieldConfig(read_only=True), -# 'container_id': FieldConfig(internal_only=True), -# 'is_local': FieldConfig(internal_only=True), -# 'url': FieldConfig(internal_only=True), -# 'name': FieldConfig(), -# 'description': FieldConfig(), -# 'target_humidity': FieldConfig(), -# 'target_temperature': FieldConfig(), -# 'duration': FieldConfig(), -# 'distance': FieldConfig(), -# 'deprecated': FieldConfig(), -# 'deprecated_on': FieldConfig(), -# 'deprecated_cause': FieldConfig() -# # Relationships will be handled by querying the related collections -# # 'plants': FieldConfig(nested=Plant.schema, include_nested=True, delete_with_parent=True) -# # 'lights': FieldConfig(nested=Light.schema, include_nested=True, delete_with_parent=True) -# }) \ No newline at end of file + \ No newline at end of file diff --git a/server/models/todo.py b/server/models/todo.py index 8321f47..1127652 100644 --- a/server/models/todo.py +++ b/server/models/todo.py @@ -2,15 +2,25 @@ Module defining models for todos. """ from datetime import datetime -from typing import Dict, Any +from typing import Dict, Any, List from bson import ObjectId -from models.plant import DeprecatableMixin -from models import FlexibleModel -from shared.db import Table +from models import FlexibleModel, DeprecatableMixin + + +class Task(DeprecatableMixin, FlexibleModel): + """TODO Item""" + def __init__(self, **kwargs): + super().__init__(**kwargs) + print(kwargs.get('_id')) + self._id = kwargs.get('_id', ObjectId()) + self.description = kwargs.get('description') + self.created_on = kwargs.get('created_on', datetime.now()) + self.updated_on = kwargs.get('updated_on', datetime.now()) + self.resolved_on = kwargs.get('resolved_on') + self.resolved = kwargs.get('resolved', False) class Todo(DeprecatableMixin, FlexibleModel): """TODO model with embedded tasks.""" - table = Table.TODO def __init__(self, **kwargs): super().__init__(**kwargs) @@ -22,67 +32,18 @@ def __init__(self, **kwargs): self.description = kwargs.get('description') # Embedded tasks - self.tasks = [ - { - 'description': task.get('description'), - 'resolved': task.get('resolved', False), - 'resolved_on': task.get('resolved_on'), - 'created_on': task.get('created_on', datetime.now()), - 'updated_on': task.get('updated_on', datetime.now()), - } - for task in kwargs.get('tasks', []) - ] + print(kwargs.get('tasks', [])) + self.tasks: List[Task] = [Task(**task) for task in kwargs.get('tasks', [])] def __repr__(self): return f"{self.name}" - def add_task(self, description: str) -> None: - """Add a new task to the todo""" - self.tasks.append({ - 'description': description, - 'resolved': False, - 'resolved_on': None, - 'created_on': datetime.now(), - 'updated_on': datetime.now() - }) - - def resolve_task(self, task_index: int) -> None: - """Mark a task as resolved""" - if 0 <= task_index < len(self.tasks): - self.tasks[task_index]['resolved'] = True - self.tasks[task_index]['resolved_on'] = datetime.now() - self.tasks[task_index]['updated_on'] = datetime.now() - - def unresolve_task(self, task_index: int) -> None: - """Mark a task as unresolved""" - if 0 <= task_index < len(self.tasks): - self.tasks[task_index]['resolved'] = False - self.tasks[task_index]['resolved_on'] = None - self.tasks[task_index]['updated_on'] = datetime.now() - - def remove_task(self, task_index: int) -> None: - """Remove a task from the todo""" - if 0 <= task_index < len(self.tasks): - self.tasks.pop(task_index) - - def update_task(self, task_index: int, description: str) -> None: - """Update a task's description""" - if 0 <= task_index < len(self.tasks): - self.tasks[task_index]['description'] = description - self.tasks[task_index]['updated_on'] = datetime.now() - def to_dict(self) -> Dict[str, Any]: """Convert to MongoDB document format""" base_dict = super().to_dict() if 'tasks' in base_dict: base_dict['tasks'] = [ - { - 'description': task['description'], - 'resolved': task['resolved'], - 'resolved_on': task['resolved_on'], - 'created_on': task['created_on'], - 'updated_on': task['updated_on'] - } - for task in base_dict['tasks'] + task.to_dict() + for task in self.tasks ] return base_dict \ No newline at end of file diff --git a/server/shared/db.py b/server/shared/db.py index 5a2b160..d81aff4 100644 --- a/server/shared/db.py +++ b/server/shared/db.py @@ -3,7 +3,15 @@ """ import os from enum import Enum -from typing import Dict, Any, Optional, List +from typing import Dict, Any, Optional, List, Type + +from models import FlexibleModel +from models.alert import Alert +from models.mix import Mix, Soil +from models.plant import Plant, PlantGenus, PlantGenusType, PlantSpecies +from models.system import System, Light +from models.todo import Todo + from bson import ObjectId from pymongo import MongoClient from pymongo.database import Database @@ -16,36 +24,42 @@ DB_NAME = os.getenv("DB_NAME", "plnts") DB: Database = CLIENT[DB_NAME] -class Table(str, Enum): - PLANT = "plant" - SYSTEM = "system" - GENUS_TYPE = "genus_type" - GENUS = "genus" - SPECIES = "species" - SOIL = "soil" - TODO = "todo" - ALERT = "alert" +class Table(Enum): + PLANT = ("plant", Plant) + SYSTEM = ("system", System) + GENUS_TYPE = ("genus_type", PlantGenusType) + GENUS = ("genus", PlantGenus) + SPECIES = ("species", PlantSpecies) + SOIL = ("soil", Soil) + TODO = ("todo", Todo) + ALERT = ("alert", Alert) + MIX = ("mix", Mix) + LIGHT = ("light", Light) + + def __init__(self, table_name: str, model_class: Type[FlexibleModel]) -> None: + self.table_name = table_name + self.model_class = model_class def count(self, filter: Dict={})-> int: - return DB[self.value].count_documents(filter) + return DB[self.table_name].count_documents(filter) - def create(self, data: Dict[str, Any]) -> ObjectId: - result = DB[self.value].insert_one(data) + def create(self, data: FlexibleModel) -> ObjectId: + result = DB[self.table_name].insert_one(data.to_dict()) return result.inserted_id - def get_one(self, id: str) -> Optional[Dict[str, Any]]: - return DB[self.value].find_one({"_id": ObjectId(id)}) + def get_one(self, id: str) -> Optional[Type[FlexibleModel]]: + return self.model_class(**DB[self.table_name].find_one({"_id": ObjectId(id)})) def get_many(self, query: Dict[str, Any]={}, limit: int = 100) -> List[Dict[str, Any]]: - return list(DB[self.value].find(query).limit(limit)) + return [self.model_class(**item) for item in list(DB[self.table_name].find(query).limit(limit))] - def update(self, id: str, data: Dict[str, Any]) -> bool: - result = DB[self.value].update_one( + def update(self, id: str, data: FlexibleModel) -> bool: + result = DB[self.table_name].update_one( {"_id": ObjectId(id)}, - {"$set": data} + {"$set": data.to_dict()} ) return result.modified_count > 0 def delete(self, id: str) -> bool: - result = DB[self.value].delete_one({"_id": ObjectId(id)}) + result = DB[self.table_name].delete_one({"_id": ObjectId(id)}) return result.deleted_count > 0 diff --git a/server/shared/discover.py b/server/shared/discover.py index e5a5c52..a7c013d 100644 --- a/server/shared/discover.py +++ b/server/shared/discover.py @@ -3,7 +3,7 @@ from models.system import System ENVIRONMENT = os.getenv('ENVIRONMENT', 'docker') -USE_LOCAL_HARDWARE = os.getenv('USE_LOCAL_HARDWARE', 'false').lower() == 'true' +USE_LOCAL_HARDWARE = os.getenv('USE_LOCAL_HARDWARE', 'true').lower() == 'true' def discover_systems(): """ @@ -13,8 +13,7 @@ def discover_systems(): if USE_LOCAL_HARDWARE: # Check for local system and create new system if needed local_system = Table.SYSTEM.count({'container_id': 'local'}) - - if local_system > 0: + if local_system == 0: local_system = System( name='Local System', description='System created by service. Please update accordingly', @@ -27,7 +26,7 @@ def discover_systems(): ) # Insert the new system - Table.SYSTEM.create(local_system.to_dict()) + Table.SYSTEM.create(local_system) # TODO: Support for Kubernetes # if ENVIRONMENT == 'kubernetes': From 87bd667de0eb3a719ff273bb6251be7fc73b1a41 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 16 Feb 2025 10:50:26 -0600 Subject: [PATCH 32/44] Black formatting and id renaming --- server/adaptor/adaptor.py | 16 +- server/adaptor/test_adaptor.py | 8 +- server/app/app.py | 45 ++- server/app/install.py | 51 +-- server/app/routes/__init__.py | 355 ++++++++++-------- server/app/routes/alert_routes.py | 4 +- server/app/routes/installable_model_routes.py | 25 +- server/app/routes/mix_routes.py | 6 +- server/app/routes/plant_routes.py | 6 +- server/app/routes/stat_routes.py | 13 +- server/app/routes/system_routes.py | 43 ++- server/app/routes/todo_routes.py | 5 +- server/models/__init__.py | 32 +- server/models/alert.py | 13 +- server/models/mix.py | 80 ++-- server/models/plant.py | 86 +++-- server/models/system.py | 81 ++-- server/models/todo.py | 43 ++- server/shared/adaptor.py | 26 +- server/shared/db.py | 15 +- server/shared/discover.py | 129 +++---- server/shared/logger.py | 8 +- 22 files changed, 594 insertions(+), 496 deletions(-) diff --git a/server/adaptor/adaptor.py b/server/adaptor/adaptor.py index 9c294d0..c871c42 100644 --- a/server/adaptor/adaptor.py +++ b/server/adaptor/adaptor.py @@ -4,6 +4,7 @@ import logging import sys import os + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from flask import Flask, jsonify, Response @@ -16,16 +17,19 @@ app = Flask(__name__) -@app.route('/video_feed') + +@app.route("/video_feed") def video_feed(): """ Return this server's camera (defaulting to single camera support for now). """ logger.info("Received request to read local camera") - return Response(generate_frames(), - mimetype='multipart/x-mixed-replace; boundary=frame') + return Response( + generate_frames(), mimetype="multipart/x-mixed-replace; boundary=frame" + ) + -@app.route('/sensor_data') +@app.route("/sensor_data") def sensor_data(): """ Return this server's dht sensor data (defaulting to single dht support for now). @@ -33,9 +37,9 @@ def sensor_data(): logger.info("Received request to read local dht sensor") return jsonify(read_sensor()) + CORS(app) if __name__ == "__main__": - # Run the Flask app - app.run(host='0.0.0.0', port=8003) + app.run(host="0.0.0.0", port=8003) diff --git a/server/adaptor/test_adaptor.py b/server/adaptor/test_adaptor.py index 2df37cc..ed69401 100644 --- a/server/adaptor/test_adaptor.py +++ b/server/adaptor/test_adaptor.py @@ -1,19 +1,21 @@ import cv2 + def test_camera(): camera = cv2.VideoCapture(0) if not camera.isOpened(): print("Failed to open camera") return - + ret, frame = camera.read() if not ret: print("Failed to capture frame") else: cv2.imwrite("test_frame.jpg", frame) print("Frame captured and saved as test_frame.jpg") - + camera.release() + if __name__ == "__main__": - test_camera() \ No newline at end of file + test_camera() diff --git a/server/app/app.py b/server/app/app.py index 4eaae39..308772e 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -6,6 +6,7 @@ import sys import os from datetime import datetime, timedelta + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from flask import Flask, jsonify @@ -35,7 +36,12 @@ from routes.todo_routes import bp as todo_bp from routes.mix_routes import bp as mix_bp from routes.stat_routes import bp as stat_bp -from routes.installable_model_routes import soils_bp, genus_types_bp, species_bp, genus_bp +from routes.installable_model_routes import ( + soils_bp, + genus_types_bp, + species_bp, + genus_bp, +) from routes.alert_routes import bp as alert_bp # Models @@ -55,6 +61,7 @@ CORS(app) + @app.route("/meta/", methods=["GET"]) def get_meta(): """ @@ -64,33 +71,35 @@ def get_meta(): meta = { "alert_count": Table.ALERT.count({"deprecated": False}), - "todo_count": Table.TODO.count({"deprecated": False}) + "todo_count": Table.TODO.count({"deprecated": False}), } logger.info("Successfully generated meta data.") return jsonify(meta) + @app.route("/notebook/", methods=["GET"]) def get_notebook(): """ Get the jupyter notebook for this. """ # Read the notebook - with open("notebook", 'r', encoding='utf-8') as f: + with open("notebook", "r", encoding="utf-8") as f: notebook_content = nbformat.read(f, as_version=4) - + # Convert the notebook to HTML html_exporter = HTMLExporter() - html_exporter.template_name = 'classic' + html_exporter.template_name = "classic" (body, _) = html_exporter.from_notebook_node(notebook_content) - + # Serve the HTML return body + # Print details of the running endpoints for rule in app.url_map.iter_rules(): - methods = ','.join(sorted(rule.methods)) - arguments = ','.join(sorted(rule.arguments)) + methods = ",".join(sorted(rule.methods)) + arguments = ",".join(sorted(rule.arguments)) logger.debug(f"Endpoint: {rule.endpoint}") logger.debug(f" URL: {rule}") logger.debug(f" Methods: {methods}") @@ -101,7 +110,8 @@ def get_notebook(): scheduler.init_app(app) scheduler.start() -@scheduler.task('cron', id='nightly', minute='*') + +@scheduler.task("cron", id="nightly", minute="*") def manage_plant_alerts(): """ Create different plant alerts. Right now just supports creating watering alerts. @@ -112,22 +122,21 @@ def manage_plant_alerts(): for existing_plant_alert in existing_plant_alrts: existing_plant_alrts_map[existing_plant_alert.plant_id] = existing_plant_alert - existing_plants: List[Plant] = Table.PLANT.get_many({"deprecated": False}) # Sure... + existing_plants: List[Plant] = Table.PLANT.get_many( + {"deprecated": False} + ) # Sure... now = datetime.now() for plant in existing_plants: end_date = plant.watered_on + timedelta(days=float(plant.watering)) - if end_date < now and existing_plant_alrts_map.get(plant._id) is None: - new_plant_alert = Alert( - model_id = plant._id, - alert_type = AlertTypes.WATER - ) + if end_date < now and existing_plant_alrts_map.get(plant.id) is None: + new_plant_alert = Alert(model_id=plant.id, alert_type=AlertTypes.WATER) # Create the alert in the db Table.ALERT.create(new_plant_alert) existing_plant_alrts_map[new_plant_alert.model_id] = new_plant_alert -if __name__ == "__main__": +if __name__ == "__main__": # Run the Flask app - app.run(host='0.0.0.0', port=8002) + app.run(host="0.0.0.0", port=8002) - #app.run(debug=True) + # app.run(debug=True) diff --git a/server/app/install.py b/server/app/install.py index cae256e..a1fb078 100644 --- a/server/app/install.py +++ b/server/app/install.py @@ -5,6 +5,7 @@ import sys import os from typing import Type, List, Tuple + sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from models.plant import PlantGenusType, PlantGenus, PlantSpecies @@ -17,13 +18,14 @@ # Create a logger for this specific module logger = setup_logger(__name__, logging.DEBUG) + def create_model(model_path: str, table: Table): """ Create the provided model from the data path. """ logger.info(f"Beginning to create model {table.value}") - + # All or nothing for now existing_count = table.count() if existing_count > 0: @@ -40,31 +42,34 @@ def create_model(model_path: str, table: Table): else: logger.error(f"No documents to create for {table.value}") + def create_all_models(): - """ - Create all of our packaged models on installation. - """ - logger.info("Beginning to create models.") - - models_to_create: List[Tuple[str, Table]] = [ - ("data/installable/soils/soils.csv", Table.SOIL), - ("data/installable/plants/genus_types.csv", Table.GENUS_TYPE), - ("data/installable/plants/genera.csv", Table.GENUS), - ("data/installable/plants/species.csv", Table.SPECIES), - ] - - for model_path, model_class in models_to_create: - create_model(model_path, model_class) - - logger.info("All models have been created.") + """ + Create all of our packaged models on installation. + """ + logger.info("Beginning to create models.") + + models_to_create: List[Tuple[str, Table]] = [ + ("data/installable/soils/soils.csv", Table.SOIL), + ("data/installable/plants/genus_types.csv", Table.GENUS_TYPE), + ("data/installable/plants/genera.csv", Table.GENUS), + ("data/installable/plants/species.csv", Table.SPECIES), + ] + + for model_path, model_class in models_to_create: + create_model(model_path, model_class) + + logger.info("All models have been created.") + def install(): - """ - Initialize the database with required data - """ - logger.info("Installation starting") - create_all_models() - logger.info("Installation complete") + """ + Initialize the database with required data + """ + logger.info("Installation starting") + create_all_models() + logger.info("Installation complete") + if __name__ == "__main__": install() diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py index 661138d..9af49d3 100644 --- a/server/app/routes/__init__.py +++ b/server/app/routes/__init__.py @@ -7,152 +7,156 @@ from bson import ObjectId from shared.db import Table + @dataclass class SchemaField: """Configuration for each field stored on a model.""" + read_only: bool = False internal_only: bool = False - nested: Optional['SchemaField'] = None + nested: Optional["SchemaField"] = None nested_schema: str = None - nested_class:object = None + nested_class: object = None -class Schema(Enum): +class Schema(Enum): PLANT = { - '_id': SchemaField(read_only=True), - 'created_on': SchemaField(read_only=True), - 'updated_on': SchemaField(read_only=True), - 'cost': SchemaField(), - 'species_id': SchemaField(), - 'watered_on': SchemaField(), - 'watering': SchemaField(), - 'identity': SchemaField(), - 'phase': SchemaField(), - 'size': SchemaField(), - 'system_id': SchemaField(), - 'mix_id': SchemaField(), - 'deprecated': SchemaField(), - 'deprecated_on': SchemaField(), - 'deprecated_cause': SchemaField() + "id": SchemaField(read_only=True), + "created_on": SchemaField(read_only=True), + "updated_on": SchemaField(read_only=True), + "cost": SchemaField(), + "species_id": SchemaField(), + "watered_on": SchemaField(), + "watering": SchemaField(), + "identity": SchemaField(), + "phase": SchemaField(), + "size": SchemaField(), + "system_id": SchemaField(), + "mix_id": SchemaField(), + "deprecated": SchemaField(), + "deprecated_on": SchemaField(), + "deprecated_cause": SchemaField(), } PLANT_GENUS_TYPE = { - '_id': SchemaField(read_only=True), - 'created_on': SchemaField(read_only=True), - 'updated_on': SchemaField(read_only=True), - 'name': SchemaField(read_only=True), - 'common_name': SchemaField(read_only=True), - 'description': SchemaField(read_only=True), - 'genus_id': SchemaField(read_only=True) + "id": SchemaField(read_only=True), + "created_on": SchemaField(read_only=True), + "updated_on": SchemaField(read_only=True), + "name": SchemaField(read_only=True), + "common_name": SchemaField(read_only=True), + "description": SchemaField(read_only=True), + "genus_id": SchemaField(read_only=True), } PLANT_GENUS = { - '_id': SchemaField(read_only=True), - 'created_on': SchemaField(read_only=True), - 'updated_on': SchemaField(read_only=True), - 'name': SchemaField(read_only=True), - 'common_name': SchemaField(read_only=True), - 'description': SchemaField(read_only=True), - 'watering': SchemaField(), - 'genus_type_id': SchemaField(read_only=True) + "id": SchemaField(read_only=True), + "created_on": SchemaField(read_only=True), + "updated_on": SchemaField(read_only=True), + "name": SchemaField(read_only=True), + "common_name": SchemaField(read_only=True), + "description": SchemaField(read_only=True), + "watering": SchemaField(), + "genus_type_id": SchemaField(read_only=True), } SPECIES = { - '_id': SchemaField(read_only=True), - 'created_on': SchemaField(read_only=True), - 'updated_on': SchemaField(read_only=True), - 'name': SchemaField(read_only=True), - 'common_name': SchemaField(read_only=True), - 'description': SchemaField(read_only=True), - 'genus_id': SchemaField(read_only=True) + "id": SchemaField(read_only=True), + "created_on": SchemaField(read_only=True), + "updated_on": SchemaField(read_only=True), + "name": SchemaField(read_only=True), + "common_name": SchemaField(read_only=True), + "description": SchemaField(read_only=True), + "genus_id": SchemaField(read_only=True), } TODO = { - '_id': SchemaField(read_only=True), - 'created_on': SchemaField(read_only=True), - 'updated_on': SchemaField(read_only=True), - 'due_on': SchemaField(), - 'name': SchemaField(), - 'description': SchemaField(), - 'tasks': SchemaField(nested=True), - 'deprecated': SchemaField(), - 'deprecated_on': SchemaField(), - 'deprecated_cause': SchemaField(), - 'tasks': SchemaField(nested=True, nested_schema='TASK') + "id": SchemaField(read_only=True), + "created_on": SchemaField(read_only=True), + "updated_on": SchemaField(read_only=True), + "due_on": SchemaField(), + "name": SchemaField(), + "description": SchemaField(), + "tasks": SchemaField(nested=True), + "deprecated": SchemaField(), + "deprecated_on": SchemaField(), + "deprecated_cause": SchemaField(), + "tasks": SchemaField(nested=True, nested_schema="TASK"), } TASK = { - 'description': SchemaField(), - 'created_on': SchemaField(read_only=True), - 'updated_on': SchemaField(read_only=True), + "id": SchemaField(read_only=True), + "description": SchemaField(), + "created_on": SchemaField(read_only=True), + "updated_on": SchemaField(read_only=True), } SOIL = { - "_id": SchemaField(read_only=True), - 'created_on': SchemaField(read_only=True), - 'name': SchemaField(read_only=True), - 'description': SchemaField(read_only=True), - 'group': SchemaField(read_only=True) - } + "id": SchemaField(read_only=True), + "created_on": SchemaField(read_only=True), + "name": SchemaField(read_only=True), + "description": SchemaField(read_only=True), + "group": SchemaField(read_only=True), + } LIGHT = { - '_id': SchemaField(read_only=True), - 'created_on': SchemaField(read_only=True), - 'updated_on': SchemaField(read_only=True), - 'name': SchemaField(), - 'cost': SchemaField(), - 'system_id': SchemaField(), - 'deprecated': SchemaField(), - 'deprecated_on': SchemaField(), - 'deprecated_cause': SchemaField() + "id": SchemaField(read_only=True), + "created_on": SchemaField(read_only=True), + "updated_on": SchemaField(read_only=True), + "name": SchemaField(), + "cost": SchemaField(), + "system_id": SchemaField(), + "deprecated": SchemaField(), + "deprecated_on": SchemaField(), + "deprecated_cause": SchemaField(), } SYSTEM = { - '_id': SchemaField(read_only=True), - 'created_on': SchemaField(read_only=True), - 'updated_on': SchemaField(read_only=True), - 'last_humidity': SchemaField(read_only=True), - 'last_temperature': SchemaField(read_only=True), - 'container_id': SchemaField(internal_only=True), - 'is_local': SchemaField(internal_only=True), - 'url': SchemaField(internal_only=True), - 'name': SchemaField(), - 'description': SchemaField(), - 'target_humidity': SchemaField(), - 'target_temperature': SchemaField(), - 'duration': SchemaField(), - 'distance': SchemaField(), - 'deprecated': SchemaField(), - 'deprecated_on': SchemaField(), - 'deprecated_cause': SchemaField() + "id": SchemaField(read_only=True), + "created_on": SchemaField(read_only=True), + "updated_on": SchemaField(read_only=True), + "last_humidity": SchemaField(read_only=True), + "last_temperature": SchemaField(read_only=True), + "container_id": SchemaField(internal_only=True), + "is_local": SchemaField(internal_only=True), + "url": SchemaField(internal_only=True), + "name": SchemaField(), + "description": SchemaField(), + "target_humidity": SchemaField(), + "target_temperature": SchemaField(), + "duration": SchemaField(), + "distance": SchemaField(), + "deprecated": SchemaField(), + "deprecated_on": SchemaField(), + "deprecated_cause": SchemaField(), } ALERT = { - 'id': SchemaField(read_only=True), - 'created_on': SchemaField(read_only=True), - 'updated_on': SchemaField(read_only=True), - 'alert_type': SchemaField(read_only=True), - 'model_id': SchemaField(read_only=True) + "id": SchemaField(read_only=True), + "created_on": SchemaField(read_only=True), + "updated_on": SchemaField(read_only=True), + "alert_type": SchemaField(read_only=True), + "model_id": SchemaField(read_only=True), } MIX = { - '_id': SchemaField(read_only=True), - 'created_on': SchemaField(read_only=True), - 'updated_on': SchemaField(read_only=True), - 'name': SchemaField(), - 'description': SchemaField(), - 'experimental': SchemaField(), - 'soil_parts': SchemaField(nested=True, nested_class='SOIL_PART'), - 'deprecated': SchemaField(), - 'deprecated_on': SchemaField(), - 'deprecated_cause': SchemaField() - } + "id": SchemaField(read_only=True), + "created_on": SchemaField(read_only=True), + "updated_on": SchemaField(read_only=True), + "name": SchemaField(), + "description": SchemaField(), + "experimental": SchemaField(), + "soil_parts": SchemaField(nested=True, nested_class="SOIL_PART"), + "deprecated": SchemaField(), + "deprecated_on": SchemaField(), + "deprecated_cause": SchemaField(), + } SOIL_PART = { - 'created_on': SchemaField(read_only=True), - 'updated_on': SchemaField(read_only=True), - 'soil_id': SchemaField(), - 'parts': SchemaField() + "created_on": SchemaField(read_only=True), + "updated_on": SchemaField(read_only=True), + "soil_id": SchemaField(), + "parts": SchemaField(), + "id": SchemaField(read_only=True), } - """Standard model serializer with MongoDB support""" + def __init__(self, fields: Dict[str, SchemaField]): self.fields = fields def read(self, obj: FlexibleModel, depth=0, include_nested=False) -> Dict[str, Any]: if depth > 5: return {} - + result = {} for k, v in self.fields.items(): if hasattr(obj, k): @@ -162,14 +166,18 @@ def read(self, obj: FlexibleModel, depth=0, include_nested=False) -> Dict[str, A elif v.nested: nested_schema: Schema = getattr(Schema, v.nested_schema) if isinstance(value, list): - result[k] = [nested_schema.read(item, depth+1, include_nested) for item in value] + result[k] = [ + nested_schema.read(item, depth + 1, include_nested) + for item in value + ] elif value is not None: - result[k] = nested_schema.read(value, depth+1, include_nested) + result[k] = nested_schema.read(value, depth + 1, include_nested) elif not v.internal_only: result[k] = value + print(result) return result - def patch(self, model: FlexibleModel, data:Dict[str, Any], depth=0): + def patch(self, model: FlexibleModel, data: Dict[str, Any], depth=0): """ """ if depth > 5: return model @@ -182,40 +190,49 @@ def patch(self, model: FlexibleModel, data:Dict[str, Any], depth=0): if field_config.nested and not field_config.internal_only: nested_schema: Schema = getattr(Schema, field_config.nested_schema) - + if isinstance(new_value, list): # Handle list of nested objects current_value = getattr(model, field_name, []) - setattr(model, field_name, [ - nested_schema.patch(current_item, new_item, depth + 1) - if current_item is not None else nested_schema.patch(None, new_item, depth + 1) - for current_item, new_item in zip(current_value, new_value) - ]) + setattr( + model, + field_name, + [ + nested_schema.patch(current_item, new_item, depth + 1) + if current_item is not None + else nested_schema.patch(None, new_item, depth + 1) + for current_item, new_item in zip(current_value, new_value) + ], + ) elif new_value is not None: # Handle single nested object current_value = getattr(model, field_name) - setattr(model, field_name, + setattr( + model, + field_name, nested_schema.patch(current_value, new_value, depth + 1) - if current_value is not None else nested_schema.patch(None, new_value, depth + 1) + if current_value is not None + else nested_schema.patch(None, new_value, depth + 1), ) elif not field_config.internal_only: setattr(model, field_name, new_value) - def create(self, model_clazz: Type[FlexibleModel], data:Dict[str, Any]): + def create(self, model_clazz: Type[FlexibleModel], data: Dict[str, Any]): return model_clazz(**data) + class GenericCRUD: def __init__(self, table, schema): self.table: Table = table self.schema: Schema = schema - def get(self, id: str): + def get(self, id: str): try: - item = self.table.get_one(id) # Not a huge fan of this, maybe revisit + item = self.table.get_one(id) # Not a huge fan of this, maybe revisit if item is None: logger.error(f"Could not find {id}") return jsonify({"error": "Not found"}), 404 - + return jsonify(self.schema.read(item)) except Exception as e: logger.error(f"Error in get: {str(e)}") @@ -225,21 +242,17 @@ def get_many(self): try: items = self.table.get_many() - return jsonify([ - self.schema.read(item) - for item in items - ]) + return jsonify([self.schema.read(item) for item in items]) except Exception as e: logger.error(f"Error in get_many: {str(e)}") return jsonify({"error": str(e)}), 500 def create(self): try: - item = self.schema.create(self.table.model_class, request.json) result = self.table.create(item) - item._id = result + item.id = result return jsonify(self.schema.read(item)), 201 @@ -249,10 +262,10 @@ def create(self): def update(self, id: str): try: - db_model = self.table.get_one(id) + db_model = self.table.get_one(id) if not db_model: return jsonify({"error": "Not found"}), 404 - + self.schema.patch(db_model, request.json) return jsonify(db_model.to_dict()) @@ -267,9 +280,9 @@ def delete(self, id: str): item = self.table.get_one(id) if not item: return jsonify({"error": "Not found"}), 404 - + self.table.delete(id) - return '', 204 + return "", 204 except Exception as e: logger.error(f"Error in delete: {str(e)}") @@ -277,59 +290,87 @@ def delete(self, id: str): def delete_many(self): data = request.json - ids = data.get('ids', []) + ids = data.get("ids", []) if not ids: - return jsonify({'error': 'No ids provided'}), 400 + return jsonify({"error": "No ids provided"}), 400 - try: + try: deleted_count = 0 for id in ids: result = self.table.delete(id) if result: - deleted_count+=1 - return jsonify({ - 'message': f'Successfully deleted {deleted_count} items'}), 200 + deleted_count += 1 + return ( + jsonify({"message": f"Successfully deleted {deleted_count} items"}), + 200, + ) except Exception as e: logger.error(f"Error in delete_many: {str(e)}") - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 + class APIBuilder: @staticmethod def register_blueprint( - blueprint: Blueprint, - resource_name: str, + blueprint: Blueprint, + resource_name: str, crud: GenericCRUD, - methods: List[str] = ['GET', 'GET_MANY', 'POST', 'PATCH', 'DELETE', 'DELETE_MANY'] + methods: List[str] = [ + "GET", + "GET_MANY", + "POST", + "PATCH", + "DELETE", + "DELETE_MANY", + ], ): def create_wrapper(operation): - if operation in ['get', 'update', 'delete']: + if operation in ["get", "update", "delete"]: + def wrapper(id): return getattr(crud, operation)(id) + else: + def wrapper(): return getattr(crud, operation)() + wrapper.__name__ = f"{resource_name}_{operation}_wrapper" return wrapper - if 'GET' in methods: - blueprint.route(f'/{resource_name}//', methods=['GET'])(create_wrapper('get')) - if 'GET_MANY' in methods: - blueprint.route(f'/{resource_name}/', methods=['GET'])(create_wrapper('get_many')) - if 'POST' in methods: - blueprint.route(f'/{resource_name}/', methods=['POST'])(create_wrapper('create')) - if 'PATCH' in methods: - blueprint.route(f'/{resource_name}//', methods=['PATCH'])(create_wrapper('update')) - if 'DELETE' in methods: - blueprint.route(f'/{resource_name}//', methods=['DELETE'])(create_wrapper('delete')) - if 'DELETE_MANY' in methods: - blueprint.route(f'/{resource_name}/', methods=['DELETE'])(create_wrapper('delete_many')) + if "GET" in methods: + blueprint.route(f"/{resource_name}//", methods=["GET"])( + create_wrapper("get") + ) + if "GET_MANY" in methods: + blueprint.route(f"/{resource_name}/", methods=["GET"])( + create_wrapper("get_many") + ) + if "POST" in methods: + blueprint.route(f"/{resource_name}/", methods=["POST"])( + create_wrapper("create") + ) + if "PATCH" in methods: + blueprint.route(f"/{resource_name}//", methods=["PATCH"])( + create_wrapper("update") + ) + if "DELETE" in methods: + blueprint.route(f"/{resource_name}//", methods=["DELETE"])( + create_wrapper("delete") + ) + if "DELETE_MANY" in methods: + blueprint.route(f"/{resource_name}/", methods=["DELETE"])( + create_wrapper("delete_many") + ) @staticmethod def register_custom_route(blueprint: Blueprint, route: str, methods: List[str]): """Custom route on this bp.""" + def decorator(handler: Callable): blueprint.route(route, methods=methods)(handler) return handler - return decorator \ No newline at end of file + + return decorator diff --git a/server/app/routes/alert_routes.py b/server/app/routes/alert_routes.py index 7adfcde..5bc8080 100644 --- a/server/app/routes/alert_routes.py +++ b/server/app/routes/alert_routes.py @@ -3,6 +3,6 @@ from shared.db import Table from routes import GenericCRUD, APIBuilder, Schema -bp = Blueprint('alerts', __name__) +bp = Blueprint("alerts", __name__) alert_crud = GenericCRUD(Table.ALERT, Schema.ALERT) -APIBuilder.register_blueprint(bp, 'alerts', alert_crud, ["GET", "GET_MANY", "DELETE"]) +APIBuilder.register_blueprint(bp, "alerts", alert_crud, ["GET", "GET_MANY", "DELETE"]) diff --git a/server/app/routes/installable_model_routes.py b/server/app/routes/installable_model_routes.py index f0cdc44..8df5661 100644 --- a/server/app/routes/installable_model_routes.py +++ b/server/app/routes/installable_model_routes.py @@ -4,19 +4,26 @@ from routes import GenericCRUD, APIBuilder, Schema -genus_types_bp = Blueprint('genus_types', __name__) +genus_types_bp = Blueprint("genus_types", __name__) genus_types_crud = GenericCRUD(Table.GENUS_TYPE, Schema.PLANT_GENUS_TYPE) -APIBuilder.register_blueprint(genus_types_bp, 'genus_types', genus_types_crud, methods=["GET", "GET_MANY"]) +APIBuilder.register_blueprint( + genus_types_bp, "genus_types", genus_types_crud, methods=["GET", "GET_MANY"] +) -species_bp = Blueprint('species', __name__) +species_bp = Blueprint("species", __name__) species_crud = GenericCRUD(Table.SPECIES, Schema.SPECIES) -APIBuilder.register_blueprint(species_bp, 'species', species_crud, methods=["GET", "GET_MANY"]) +APIBuilder.register_blueprint( + species_bp, "species", species_crud, methods=["GET", "GET_MANY"] +) -genus_bp = Blueprint('genera', __name__) +genus_bp = Blueprint("genera", __name__) genus_crud = GenericCRUD(Table.GENUS, Schema.PLANT_GENUS) -APIBuilder.register_blueprint(genus_bp, 'genera', genus_crud, methods=["GET", "GET_MANY"]) +APIBuilder.register_blueprint( + genus_bp, "genera", genus_crud, methods=["GET", "GET_MANY"] +) -soils_bp = Blueprint('soils', __name__) +soils_bp = Blueprint("soils", __name__) soils_crud = GenericCRUD(Table.SOIL, Schema.SOIL) -APIBuilder.register_blueprint(soils_bp, 'soils', soils_crud, methods=["GET", "GET_MANY"]) - +APIBuilder.register_blueprint( + soils_bp, "soils", soils_crud, methods=["GET", "GET_MANY"] +) diff --git a/server/app/routes/mix_routes.py b/server/app/routes/mix_routes.py index 93ef5c7..01a5039 100644 --- a/server/app/routes/mix_routes.py +++ b/server/app/routes/mix_routes.py @@ -3,6 +3,8 @@ from shared.db import Table from routes import GenericCRUD, APIBuilder, Schema -bp = Blueprint('mixes', __name__) +bp = Blueprint("mixes", __name__) mix_crud = GenericCRUD(Table.MIX, Schema.MIX) -APIBuilder.register_blueprint(bp, 'mixes', mix_crud, ["GET", "GET_MANY", "POST", "DELETE", "PATCH"]) +APIBuilder.register_blueprint( + bp, "mixes", mix_crud, ["GET", "GET_MANY", "POST", "DELETE", "PATCH"] +) diff --git a/server/app/routes/plant_routes.py b/server/app/routes/plant_routes.py index ac468be..ecc7ffe 100644 --- a/server/app/routes/plant_routes.py +++ b/server/app/routes/plant_routes.py @@ -3,9 +3,9 @@ from shared.db import Table from routes import GenericCRUD, APIBuilder, Schema -bp = Blueprint('plants', __name__) +bp = Blueprint("plants", __name__) plant_crud = GenericCRUD(Table.PLANT, Schema.PLANT) -APIBuilder.register_blueprint(bp, 'plants', plant_crud) +APIBuilder.register_blueprint(bp, "plants", plant_crud) # @APIBuilder.register_custom_route(bp, 'deprecate/', ['GET']) # def deprecate_plants(): @@ -24,7 +24,7 @@ # plant.deprecated = True # plant.deprecated_on = datetime.now() # plant.deprecated_cause = cause - + # db.commit() # db.close() diff --git a/server/app/routes/stat_routes.py b/server/app/routes/stat_routes.py index a403774..ea19a65 100644 --- a/server/app/routes/stat_routes.py +++ b/server/app/routes/stat_routes.py @@ -3,7 +3,8 @@ from shared.logger import logger # Standard Blueprint -bp = Blueprint('stats', __name__, url_prefix='/stats') +bp = Blueprint("stats", __name__, url_prefix="/stats") + @bp.route("/", methods=["GET"]) def stats(): @@ -15,12 +16,12 @@ def stats(): # Extract values safely from aggregation results stats = { "total_plants": Table.PLANT.count({}), - "total_active_plants": Table.PLANT.count({'deprecated': False}), - "total_deprecated_plants": Table.PLANT.count({'deprecated': True}), # sure.... - "total_active_systems": Table.SYSTEM.count({'deprecated': False}), + "total_active_plants": Table.PLANT.count({"deprecated": False}), + "total_deprecated_plants": Table.PLANT.count({"deprecated": True}), # sure.... + "total_active_systems": Table.SYSTEM.count({"deprecated": False}), "total_cost": 0, - "total_active_cost": 0 + "total_active_cost": 0, } logger.info("Successfully generated statistical data.") - return jsonify(stats) \ No newline at end of file + return jsonify(stats) diff --git a/server/app/routes/system_routes.py b/server/app/routes/system_routes.py index de4d2e5..c3d0f9f 100644 --- a/server/app/routes/system_routes.py +++ b/server/app/routes/system_routes.py @@ -6,21 +6,25 @@ from routes import GenericCRUD, APIBuilder, Schema -system_bp = Blueprint('systems', __name__) +system_bp = Blueprint("systems", __name__) system_crud = GenericCRUD(Table.SYSTEM, Schema.SYSTEM) -APIBuilder.register_blueprint(system_bp, 'systems', system_crud) +APIBuilder.register_blueprint(system_bp, "systems", system_crud) -@APIBuilder.register_custom_route(system_bp, "/systems//plants/", methods=['GET']) + +@APIBuilder.register_custom_route( + system_bp, "/systems//plants/", methods=["GET"] +) def get_systems_plants(id): """ Get system's plants. """ logger.info("Received request to get a system's plants") - plants = Table.PLANT.get_many({'system_id': ObjectId(id)}) + plants = Table.PLANT.get_many({"system_id": ObjectId(id)}) return jsonify([Schema.PLANT.read(plant) for plant in plants]) + @APIBuilder.register_custom_route(system_bp, "/systems//alerts/", ["GET"]) def get_systems_alerts(id): """ @@ -28,60 +32,63 @@ def get_systems_alerts(id): """ logger.info("Received request to get a system's alerts") - alerts = Table.ALERT.get_many({'model_id': ObjectId(id)}) - + alerts = Table.ALERT.get_many({"model_id": ObjectId(id)}) + return jsonify([Schema.ALERT.read(alert) for alert in alerts]) + # @APIBuilder.register_custom_route(system_bp, "/systems//video_feed/", ["GET"]) # def get_video_feed(system_id): # session = Session() # system = session.query(System).get(system_id) # session.close() - + # if not system: # return "Invalid system ID", 400 - + # if system.container_id == 'local': # return Response(generate_frames(), # mimetype='multipart/x-mixed-replace; boundary=frame') - + # # Essentially a proxy # def generate(): # resp = requests.get(f"{system.url}/video_feed", stream=True) # for chunk in resp.iter_content(chunk_size=1024): # yield chunk -# return Response(generate(), -# mimetype='multipart/x-mixed-replace; boundary=frame') +# return Response(generate(), +# mimetype='multipart/x-mixed-replace; boundary=frame') # @APIBuilder.register_custom_route(system_bp, "/systems//sensor_data/", ["GET"]) # def get_sensor_data(system_id): # session = Session() # system = session.query(System).get(system_id) - + # if not system: # session.close() # return "Invalid camera ID", 400 - + # try: # if system.url == 'local': # data = read_sensor() # else: # resp = requests.get(f"{system.url}/sensor_data") # data = resp.json() - + # system.last_temperature = data['temperature'] # system.last_humidity = data['humidity'] # system.updated_on = datetime.utcnow() - + # session.commit() # session.close() - + # return jsonify(data) # except Exception as e: # session.close() # return jsonify({"error": str(e)}), 500 -light_bp = Blueprint('lights', __name__) +light_bp = Blueprint("lights", __name__) light_crud = GenericCRUD(Table.LIGHT, Schema.LIGHT) -APIBuilder.register_blueprint(light_bp, 'lights', light_crud, ["GET", "GET_MANY", "POST"]) +APIBuilder.register_blueprint( + light_bp, "lights", light_crud, ["GET", "GET_MANY", "POST"] +) diff --git a/server/app/routes/todo_routes.py b/server/app/routes/todo_routes.py index 4d5677d..0cba142 100644 --- a/server/app/routes/todo_routes.py +++ b/server/app/routes/todo_routes.py @@ -3,10 +3,9 @@ from routes import GenericCRUD, APIBuilder, Schema from shared.db import Table -bp = Blueprint('todos', __name__) +bp = Blueprint("todos", __name__) todo_crud = GenericCRUD(Table.TODO, Schema.TODO) -APIBuilder.register_blueprint(bp, 'todos', todo_crud) +APIBuilder.register_blueprint(bp, "todos", todo_crud) # @APIBuilder.register_custom_route(bp, "/todos//tasks/", methods=['POST']) # def get_systems_plants(id, task_id): - diff --git a/server/models/__init__.py b/server/models/__init__.py index e2d352a..6d45534 100644 --- a/server/models/__init__.py +++ b/server/models/__init__.py @@ -4,28 +4,29 @@ import csv from bson import ObjectId -class Fields: +class Fields: @staticmethod def object_id(value): - if isinstance(value,str): + if isinstance(value, str): return ObjectId(value) return value + class FlexibleModel: """Base model with MongoDB support""" - + def __init__(self, **kwargs): - self._id = kwargs.get('_id', ObjectId()) # MongoDB ID + self.id = kwargs.get("_id", ObjectId()) # MongoDB ID for key, value in kwargs.items(): setattr(self, key, value) @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'FlexibleModel': + def from_dict(cls, data: Dict[str, Any]) -> "FlexibleModel": return cls(**data) @classmethod - def from_numpy(cls, data: np.ndarray) -> List['FlexibleModel']: + def from_numpy(cls, data: np.ndarray) -> List["FlexibleModel"]: if len(data.shape) != 2: raise ValueError("Array must be 2-dimensional") columns = data.dtype.names @@ -34,25 +35,26 @@ def from_numpy(cls, data: np.ndarray) -> List['FlexibleModel']: return [cls(**dict(zip(columns, row))) for row in data] @classmethod - def from_request(cls, req: Any) -> 'FlexibleModel': + def from_request(cls, req: Any) -> "FlexibleModel": data = req.json if req.is_json else req.form.to_dict() return cls.from_dict(data) @classmethod - def from_csv(cls, file_path: str) -> List['FlexibleModel']: - with open(file_path, 'r', newline='') as csvfile: + def from_csv(cls, file_path: str) -> List["FlexibleModel"]: + with open(file_path, "r", newline="") as csvfile: reader = csv.DictReader(csvfile) return [cls.from_dict(row) for row in reader] # Overridable method def to_dict(self) -> Dict[str, Any]: """Convert model to dictionary for MongoDB storage""" - return {k: v for k, v in self.__dict__.items()} - + return {("_id" if k == "id" else k): v for k, v in self.__dict__.items()} + class DeprecatableMixin: - """ In case the model is deprecated.""" + """In case the model is deprecated.""" + def __init__(self, **kwargs): - self.deprecated = kwargs.get('deprecated', False) - self.deprecated_on = kwargs.get('deprecated_on') - self.deprecated_cause = kwargs.get('deprecated_cause') \ No newline at end of file + self.deprecated = kwargs.get("deprecated", False) + self.deprecated_on = kwargs.get("deprecated_on") + self.deprecated_cause = kwargs.get("deprecated_cause") diff --git a/server/models/alert.py b/server/models/alert.py index 83f84ab..d03fd48 100644 --- a/server/models/alert.py +++ b/server/models/alert.py @@ -9,13 +9,14 @@ class AlertTypes(enum.Enum): WATER = "Water" + class Alert(DeprecatableMixin, FlexibleModel): """Alert Base Class""" def __init__(self, **kwargs): - super().__init__(**kwargs) - self._id = Fields.object_id(kwargs.get('_id', ObjectId())) - self.created_on = kwargs.get('created_on', datetime.now()) - self.updated_on = kwargs.get('updated_on', datetime.now()) - self.alert_type = kwargs.get('alert_type', 'alert') - self.model_id = Fields.object_id(kwargs.get('alert_type', 'alert')) + super().__init__(**kwargs) + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) + self.created_on = kwargs.get("created_on", datetime.now()) + self.updated_on = kwargs.get("updated_on", datetime.now()) + self.alert_type = kwargs.get("alert_type", "alert") + self.model_id = Fields.object_id(kwargs.get("alert_type", "alert")) diff --git a/server/models/mix.py b/server/models/mix.py index e38a6c4..128cf22 100644 --- a/server/models/mix.py +++ b/server/models/mix.py @@ -6,55 +6,55 @@ from bson import ObjectId from models import FlexibleModel, DeprecatableMixin, Fields + class Soil(FlexibleModel): - """Soil types available for mixes.""" + """Soil types available for mixes.""" - def __init__(self, **kwargs): - super().__init__(**kwargs) - self._id = Fields.object_id(kwargs.get('_id', ObjectId())) - self.created_on = kwargs.get('created_on', datetime.now()) - self.description = kwargs.get('description') - self.group = kwargs.get('group') - self.name = kwargs.get('name') + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) + self.created_on = kwargs.get("created_on", datetime.now()) + self.description = kwargs.get("description") + self.group = kwargs.get("group") + self.name = kwargs.get("name") + + def __repr__(self) -> str: + return f"{self.name}" - def __repr__(self) -> str: - return f"{self.name}" class SoilPart(FlexibleModel): def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = Fields.object_id(kwargs.get('_id', ObjectId())) - self.soil_id = Fields.object_id(kwargs.get('soil_id')) - self.created_on = kwargs.get('created_on', datetime.now()) - self.updated_on = kwargs.get('updated_on', datetime.now()) - self.parts = kwargs.get('parts') + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) + self.soil_id = Fields.object_id(kwargs.get("soil_id")) + self.created_on = kwargs.get("created_on", datetime.now()) + self.updated_on = kwargs.get("updated_on", datetime.now()) + self.parts = kwargs.get("parts") class Mix(DeprecatableMixin, FlexibleModel): - """Soil mix model with embedded soil parts.""" + """Soil mix model with embedded soil parts.""" - def __init__(self, **kwargs): + def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = Fields.object_id(kwargs.get('_id', ObjectId())) - self.name = kwargs.get('name') - self.description = kwargs.get('description') - self.created_on = kwargs.get('created_on', datetime.now()) - self.updated_on = kwargs.get('updated_on', datetime.now()) - self.experimental = kwargs.get('experimental', False) - - # Embedded soil parts - self.parts: List[SoilPart] = [SoilPart(sp) for sp in kwargs.get('parts', [])] - - - def __repr__(self) -> str: - return f"{self.name}" - - def to_dict(self) -> Dict[str, Any]: - """Convert to MongoDB document format""" - base_dict = super().to_dict() - if 'soil_parts' in base_dict: - base_dict['soil_parts'] = [ - part.to_dict() - for part in base_dict['soil_parts'] - ] - return base_dict \ No newline at end of file + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) + self.name = kwargs.get("name") + self.description = kwargs.get("description") + self.created_on = kwargs.get("created_on", datetime.now()) + self.updated_on = kwargs.get("updated_on", datetime.now()) + self.experimental = kwargs.get("experimental", False) + + # Embedded soil parts + self.parts: List[SoilPart] = [SoilPart(sp) for sp in kwargs.get("parts", [])] + + def __repr__(self) -> str: + return f"{self.name}" + + def to_dict(self) -> Dict[str, Any]: + """Convert to MongoDB document format""" + base_dict = super().to_dict() + if "soil_parts" in base_dict: + base_dict["soil_parts"] = [ + part.to_dict() for part in base_dict["soil_parts"] + ] + return base_dict diff --git a/server/models/plant.py b/server/models/plant.py index 76eca9b..ff33c5e 100644 --- a/server/models/plant.py +++ b/server/models/plant.py @@ -7,6 +7,7 @@ from models import FlexibleModel, DeprecatableMixin, Fields + class PHASES(enum.Enum): ADULT = "Adult" CUTTING = "Cutting" @@ -14,71 +15,76 @@ class PHASES(enum.Enum): LEAD = "Leaf" SEED = "Seed" + class Plant(DeprecatableMixin, FlexibleModel): """Plant model.""" def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = Fields.object_id(kwargs.get('_id', ObjectId())) - self.created_on = kwargs.get('created_on', datetime.now()) - self.cost = kwargs.get('cost', 0) - self.system_id = kwargs.get('system_id') - self.mix_id = Fields.object_id(kwargs.get('mix_id')) - self.updated_on = kwargs.get('updated_on', datetime.now()) - + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) + self.created_on = kwargs.get("created_on", datetime.now()) + self.cost = kwargs.get("cost", 0) + self.system_id = kwargs.get("system_id") + self.mix_id = Fields.object_id(kwargs.get("mix_id")) + self.updated_on = kwargs.get("updated_on", datetime.now()) + # Metrics - self.phase = kwargs.get('phase') - self.size = kwargs.get('size', 0) # inches + self.phase = kwargs.get("phase") + self.size = kwargs.get("size", 0) # inches # Watering info - self.watering = kwargs.get('watering', 0) # Days - self.watered_on = kwargs.get('watered_on', datetime.now()) + self.watering = kwargs.get("watering", 0) # Days + self.watered_on = kwargs.get("watered_on", datetime.now()) - self.species_id = Fields.object_id(kwargs.get('species_id')) - self.identity = kwargs.get('identity', 'plant') + self.species_id = Fields.object_id(kwargs.get("species_id")) + self.identity = kwargs.get("identity", "plant") def __repr__(self) -> str: - return f"{self._id}" + return f"{self.id}" + class Batch(Plant): """Batch of plants.""" + def __init__(self, **kwargs): super().__init__(**kwargs) - self.count = kwargs.get('count', 0) - self.identity = 'batch' + self.count = kwargs.get("count", 0) + self.identity = "batch" -class PlantGenusType(FlexibleModel): +class PlantGenusType(FlexibleModel): def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = Fields.object_id(kwargs.get('_id', ObjectId())) - self.created_on = kwargs.get('created_on', datetime.now()) - self.updated_on = kwargs.get('updated_on', datetime.now()) - self.name = kwargs.get('name') - self.description = kwargs.get('description') - self.watering = kwargs.get('watering') + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) + self.created_on = kwargs.get("created_on", datetime.now()) + self.updated_on = kwargs.get("updated_on", datetime.now()) + self.name = kwargs.get("name") + self.description = kwargs.get("description") + self.watering = kwargs.get("watering") -class PlantGenus(FlexibleModel): +class PlantGenus(FlexibleModel): def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = Fields.object_id(kwargs.get('_id', ObjectId())) - self.created_on = kwargs.get('created_on', datetime.now()) - self.updated_on = kwargs.get('updated_on', datetime.now()) - self.name = kwargs.get('name') - self.common_name = kwargs.get('common_name') - self.description = kwargs.get('description') - self.watering = kwargs.get('watering') - self.genus_type_id = Fields.object_id(Fields.object_id(kwargs.get('genus_type_id'))) + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) + self.created_on = kwargs.get("created_on", datetime.now()) + self.updated_on = kwargs.get("updated_on", datetime.now()) + self.name = kwargs.get("name") + self.common_name = kwargs.get("common_name") + self.description = kwargs.get("description") + self.watering = kwargs.get("watering") + self.genus_type_id = Fields.object_id( + Fields.object_id(kwargs.get("genus_type_id")) + ) -class PlantSpecies(FlexibleModel): +class PlantSpecies(FlexibleModel): def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = Fields.object_id(kwargs.get('_id', ObjectId())) - self.created_on = kwargs.get('created_on', datetime.now()) - self.updated_on = kwargs.get('updated_on', datetime.now()) - self.name = kwargs.get('name') - self.common_name = kwargs.get('common_name') - self.description = kwargs.get('description') - self.genus_id = Fields.object_id(kwargs.get('genus_id')) + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) + self.created_on = kwargs.get("created_on", datetime.now()) + self.updated_on = kwargs.get("updated_on", datetime.now()) + self.name = kwargs.get("name") + self.common_name = kwargs.get("common_name") + self.description = kwargs.get("description") + self.genus_id = Fields.object_id(kwargs.get("genus_id")) diff --git a/server/models/system.py b/server/models/system.py index 974ac4f..2ea4aa6 100644 --- a/server/models/system.py +++ b/server/models/system.py @@ -6,48 +6,49 @@ from models import FlexibleModel, DeprecatableMixin from models import FlexibleModel + class Light(DeprecatableMixin, FlexibleModel): - """Light model.""" + """Light model.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.id = kwargs.get("_id", ObjectId()) + self.name = kwargs.get("name") + self.created_on = kwargs.get("created_on", datetime.now()) + self.updated_on = kwargs.get("updated_on", datetime.now()) + self.cost = kwargs.get("cost", 0) + self.system_id = kwargs.get("system_id") - def __init__(self, **kwargs): - super().__init__(**kwargs) - self._id = kwargs.get('_id', ObjectId()) - self.name = kwargs.get('name') - self.created_on = kwargs.get('created_on', datetime.now()) - self.updated_on = kwargs.get('updated_on', datetime.now()) - self.cost = kwargs.get('cost', 0) - self.system_id = kwargs.get('system_id') + def __repr__(self) -> str: + return f"{self.name}" - def __repr__(self) -> str: - return f"{self.name}" class System(DeprecatableMixin, FlexibleModel): - """System model.""" - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self._id = kwargs.get('_id', ObjectId()) - self.name = kwargs.get('name') - self.description = kwargs.get('description') - self.created_on = kwargs.get('created_on', datetime.now()) - self.updated_on = kwargs.get('updated_on', datetime.now()) - - # Controlled Factors - self.target_humidity = kwargs.get('target_humidity', 0) # % - self.target_temperature = kwargs.get('target_temperature', 0) # F - - # Latest updates - self.last_humidity = kwargs.get('last_humidity') # % - self.last_temperature = kwargs.get('last_temperature') # F - - # Internal - self.container_id = kwargs.get('container_id') - self.url = kwargs.get('url') - - # Lighting - self.duration = kwargs.get('duration') # hours - self.distance = kwargs.get('distance') # inches - - def __repr__(self) -> str: - return f"{self.name}" - \ No newline at end of file + """System model.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.id = kwargs.get("_id", ObjectId()) + self.name = kwargs.get("name") + self.description = kwargs.get("description") + self.created_on = kwargs.get("created_on", datetime.now()) + self.updated_on = kwargs.get("updated_on", datetime.now()) + + # Controlled Factors + self.target_humidity = kwargs.get("target_humidity", 0) # % + self.target_temperature = kwargs.get("target_temperature", 0) # F + + # Latest updates + self.last_humidity = kwargs.get("last_humidity") # % + self.last_temperature = kwargs.get("last_temperature") # F + + # Internal + self.container_id = kwargs.get("container_id") + self.url = kwargs.get("url") + + # Lighting + self.duration = kwargs.get("duration") # hours + self.distance = kwargs.get("distance") # inches + + def __repr__(self) -> str: + return f"{self.name}" diff --git a/server/models/todo.py b/server/models/todo.py index 1127652..e5f5e72 100644 --- a/server/models/todo.py +++ b/server/models/todo.py @@ -9,31 +9,33 @@ class Task(DeprecatableMixin, FlexibleModel): """TODO Item""" + def __init__(self, **kwargs): super().__init__(**kwargs) - print(kwargs.get('_id')) - self._id = kwargs.get('_id', ObjectId()) - self.description = kwargs.get('description') - self.created_on = kwargs.get('created_on', datetime.now()) - self.updated_on = kwargs.get('updated_on', datetime.now()) - self.resolved_on = kwargs.get('resolved_on') - self.resolved = kwargs.get('resolved', False) + print(kwargs.get("_id")) + self.id = kwargs.get("_id", ObjectId()) + self.description = kwargs.get("description") + self.created_on = kwargs.get("created_on", datetime.now()) + self.updated_on = kwargs.get("updated_on", datetime.now()) + self.resolved_on = kwargs.get("resolved_on") + self.resolved = kwargs.get("resolved", False) + class Todo(DeprecatableMixin, FlexibleModel): """TODO model with embedded tasks.""" def __init__(self, **kwargs): super().__init__(**kwargs) - self._id = kwargs.get('_id', ObjectId()) - self.created_on = kwargs.get('created_on', datetime.now()) - self.updated_on = kwargs.get('updated_on', datetime.now()) - self.due_on = kwargs.get('due_on') - self.name = kwargs.get('name') - self.description = kwargs.get('description') - + self.id = kwargs.get("_id", ObjectId()) + self.created_on = kwargs.get("created_on", datetime.now()) + self.updated_on = kwargs.get("updated_on", datetime.now()) + self.due_on = kwargs.get("due_on") + self.name = kwargs.get("name") + self.description = kwargs.get("description") + # Embedded tasks - print(kwargs.get('tasks', [])) - self.tasks: List[Task] = [Task(**task) for task in kwargs.get('tasks', [])] + print(kwargs.get("tasks", [])) + self.tasks: List[Task] = [Task(**task) for task in kwargs.get("tasks", [])] def __repr__(self): return f"{self.name}" @@ -41,9 +43,6 @@ def __repr__(self): def to_dict(self) -> Dict[str, Any]: """Convert to MongoDB document format""" base_dict = super().to_dict() - if 'tasks' in base_dict: - base_dict['tasks'] = [ - task.to_dict() - for task in self.tasks - ] - return base_dict \ No newline at end of file + if "tasks" in base_dict: + base_dict["tasks"] = [task.to_dict() for task in self.tasks] + return base_dict diff --git a/server/shared/adaptor.py b/server/shared/adaptor.py index 5b9ff07..2609fbf 100644 --- a/server/shared/adaptor.py +++ b/server/shared/adaptor.py @@ -6,8 +6,9 @@ import adafruit_dht from shared.logger import logger + def generate_frames(id=0): - """ Access the camera of this application. """ + """Access the camera of this application.""" try: camera = cv2.VideoCapture(id) if not camera.isOpened(): @@ -19,43 +20,44 @@ def generate_frames(id=0): if not success: logger.error("Failed to read frame from camera") break - - ret, buffer = cv2.imencode('.jpg', frame) + + ret, buffer = cv2.imencode(".jpg", frame) if not ret: logger.error("Failed to encode frame") break - + frame = buffer.tobytes() - yield (b'--frame\r\n' - b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') - + yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n") + except Exception as e: logger.exception(f"Error in generate_frames: {str(e)}") - + finally: if camera.isOpened(): camera.release() + def read_sensor(retries=5, delay_seconds=2): - """ Read a DHT22 sensor's data (humidity and temperature). """ + """Read a DHT22 sensor's data (humidity and temperature).""" # NOTE: Should make this a normal import. import board + dht_device = adafruit_dht.DHT22(board.D4) if dht_device == None: dht_device = adafruit_dht.DHT22(pin.D4) if dht_device == None: logger.error("Could not establish connection to dht sensor") - + for _ in range(retries): try: return { "temperature": dht_device.temperature, - "humidity": ht_device.humidity + "humidity": ht_device.humidity, } except RuntimeError: # Reading doesn't always work! Just print an error and we'll try again print("Sensor read failure, retrying...") time.sleep(delay_seconds) - + return None, None diff --git a/server/shared/db.py b/server/shared/db.py index d81aff4..4975c6a 100644 --- a/server/shared/db.py +++ b/server/shared/db.py @@ -24,6 +24,7 @@ DB_NAME = os.getenv("DB_NAME", "plnts") DB: Database = CLIENT[DB_NAME] + class Table(Enum): PLANT = ("plant", Plant) SYSTEM = ("system", System) @@ -40,7 +41,7 @@ def __init__(self, table_name: str, model_class: Type[FlexibleModel]) -> None: self.table_name = table_name self.model_class = model_class - def count(self, filter: Dict={})-> int: + def count(self, filter: Dict = {}) -> int: return DB[self.table_name].count_documents(filter) def create(self, data: FlexibleModel) -> ObjectId: @@ -50,13 +51,17 @@ def create(self, data: FlexibleModel) -> ObjectId: def get_one(self, id: str) -> Optional[Type[FlexibleModel]]: return self.model_class(**DB[self.table_name].find_one({"_id": ObjectId(id)})) - def get_many(self, query: Dict[str, Any]={}, limit: int = 100) -> List[Dict[str, Any]]: - return [self.model_class(**item) for item in list(DB[self.table_name].find(query).limit(limit))] + def get_many( + self, query: Dict[str, Any] = {}, limit: int = 100 + ) -> List[Dict[str, Any]]: + return [ + self.model_class(**item) + for item in list(DB[self.table_name].find(query).limit(limit)) + ] def update(self, id: str, data: FlexibleModel) -> bool: result = DB[self.table_name].update_one( - {"_id": ObjectId(id)}, - {"$set": data.to_dict()} + {"_id": ObjectId(id)}, {"$set": data.to_dict()} ) return result.modified_count > 0 diff --git a/server/shared/discover.py b/server/shared/discover.py index a7c013d..542628b 100644 --- a/server/shared/discover.py +++ b/server/shared/discover.py @@ -2,8 +2,9 @@ from shared.db import Table from models.system import System -ENVIRONMENT = os.getenv('ENVIRONMENT', 'docker') -USE_LOCAL_HARDWARE = os.getenv('USE_LOCAL_HARDWARE', 'true').lower() == 'true' +ENVIRONMENT = os.getenv("ENVIRONMENT", "docker") +USE_LOCAL_HARDWARE = os.getenv("USE_LOCAL_HARDWARE", "true").lower() == "true" + def discover_systems(): """ @@ -12,75 +13,75 @@ def discover_systems(): if USE_LOCAL_HARDWARE: # Check for local system and create new system if needed - local_system = Table.SYSTEM.count({'container_id': 'local'}) + local_system = Table.SYSTEM.count({"container_id": "local"}) if local_system == 0: local_system = System( - name='Local System', - description='System created by service. Please update accordingly', - url='local', - container_id='local', + name="Local System", + description="System created by service. Please update accordingly", + url="local", + container_id="local", target_humidity=-1, target_temperature=-1, duration=-1, - distance=-1 + distance=-1, ) - + # Insert the new system Table.SYSTEM.create(local_system) - # TODO: Support for Kubernetes - # if ENVIRONMENT == 'kubernetes': - # from kubernetes import client, config - # config.load_incluster_config() - # v1 = client.CoreV1Api() - # pods = v1.list_pod_for_all_namespaces(label_selector="app=pi-camera").items - # - # for pod in pods: - # pi_id = pod.spec.node_name - # pod_ip = pod.status.pod_ip - # url = f"http://{pod_ip}:5000" - # - # # Find existing system - # system = db.system.find_one({'container_id': pod.metadata.uid}) - # - # if not system: - # # Create new system - # system = System( - # name=f"Pi Camera {pi_id}", - # url=url, - # container_id=pod.metadata.uid - # ) - # db.system.insert_one(system.to_dict()) - # else: - # # Update existing system - # db.system.update_one( - # {'_id': system['_id']}, - # {'$set': {'url': url, 'container_id': pod.metadata.uid}} - # ) + # TODO: Support for Kubernetes + # if ENVIRONMENT == 'kubernetes': + # from kubernetes import client, config + # config.load_incluster_config() + # v1 = client.CoreV1Api() + # pods = v1.list_pod_for_all_namespaces(label_selector="app=pi-camera").items + # + # for pod in pods: + # pi_id = pod.spec.node_name + # pod_ip = pod.status.pod_ip + # url = f"http://{pod_ip}:5000" + # + # # Find existing system + # system = db.system.find_one({'container_id': pod.metadata.uid}) + # + # if not system: + # # Create new system + # system = System( + # name=f"Pi Camera {pi_id}", + # url=url, + # container_id=pod.metadata.uid + # ) + # db.system.insert_one(system.to_dict()) + # else: + # # Update existing system + # db.system.update_one( + # {'_id': system['_id']}, + # {'$set': {'url': url, 'container_id': pod.metadata.uid}} + # ) - # TODO: Support for Docker - # elif ENVIRONMENT == 'docker': - # client = docker.from_env() - # for container in client.containers.list(): - # if container.name.startswith('pi-camera-'): - # pi_id = container.name.split('-')[-1] - # ip = container.attrs['NetworkSettings']['Networks']['multi-camera_default']['IPAddress'] - # url = f"http://{ip}:5000" - # - # # Find existing system - # system = db.system.find_one({'container_id': container.id}) - # - # if not system: - # # Create new system - # system = System( - # name=f"Pi Camera {pi_id}", - # url=url, - # container_id=container.id - # ) - # db.system.insert_one(system.to_dict()) - # else: - # # Update existing system - # db.system.update_one( - # {'_id': system['_id']}, - # {'$set': {'url': url}} - # ) \ No newline at end of file + # TODO: Support for Docker + # elif ENVIRONMENT == 'docker': + # client = docker.from_env() + # for container in client.containers.list(): + # if container.name.startswith('pi-camera-'): + # pi_id = container.name.split('-')[-1] + # ip = container.attrs['NetworkSettings']['Networks']['multi-camera_default']['IPAddress'] + # url = f"http://{ip}:5000" + # + # # Find existing system + # system = db.system.find_one({'container_id': container.id}) + # + # if not system: + # # Create new system + # system = System( + # name=f"Pi Camera {pi_id}", + # url=url, + # container_id=container.id + # ) + # db.system.insert_one(system.to_dict()) + # else: + # # Update existing system + # db.system.update_one( + # {'_id': system['_id']}, + # {'$set': {'url': url}} + # ) diff --git a/server/shared/logger.py b/server/shared/logger.py index 068b268..32089d6 100644 --- a/server/shared/logger.py +++ b/server/shared/logger.py @@ -1,6 +1,7 @@ import logging import sys + def setup_logger(name=__name__, level=logging.INFO): """ Set up and configure a logger. @@ -20,12 +21,14 @@ def setup_logger(name=__name__, level=logging.INFO): # Create handlers c_handler = logging.StreamHandler(sys.stdout) - f_handler = logging.FileHandler(f'{__name__}.log') + f_handler = logging.FileHandler(f"{__name__}.log") c_handler.setLevel(level) f_handler.setLevel(level) # Create formatters and add it to handlers - log_format = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + log_format = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) c_handler.setFormatter(log_format) f_handler.setFormatter(log_format) @@ -35,5 +38,6 @@ def setup_logger(name=__name__, level=logging.INFO): return logger + # Default logger logger = setup_logger() From 1d0ec1952e97081b340277200f83fbbfd2be9bf6 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 16 Feb 2025 10:56:34 -0600 Subject: [PATCH 33/44] Print available cron jobs for app server --- server/app/app.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/app/app.py b/server/app/app.py index 308772e..426e3a0 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -97,6 +97,8 @@ def get_notebook(): # Print details of the running endpoints +logger.debug("------------------------------------------------------------") +logger.debug("Printing all available endpoints for this api server:") for rule in app.url_map.iter_rules(): methods = ",".join(sorted(rule.methods)) arguments = ",".join(sorted(rule.arguments)) @@ -106,6 +108,8 @@ def get_notebook(): logger.debug(f" Arguments: {arguments}") logger.debug("---") +logger.debug("------------------------------------------------------------") + scheduler = APScheduler() scheduler.init_app(app) scheduler.start() @@ -135,6 +139,12 @@ def manage_plant_alerts(): existing_plant_alrts_map[new_plant_alert.model_id] = new_plant_alert +logger.debug("------------------------------------------------------------") +logger.debug("Printing all available cron jobs for this api server:") +for job in scheduler.get_jobs(): + print(f"Job: {job.name}, Trigger: {job.trigger}, Next run: {job.next_run_time}") +logger.debug("------------------------------------------------------------") + if __name__ == "__main__": # Run the Flask app app.run(host="0.0.0.0", port=8002) From 473ed58a107bcabb4d481f04305a3ee0e54b45e6 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 16 Feb 2025 10:59:42 -0600 Subject: [PATCH 34/44] Remove print statements --- server/app/app.py | 4 +++- server/app/routes/__init__.py | 7 ++++--- server/models/__init__.py | 2 +- server/models/system.py | 7 +++---- server/models/todo.py | 8 +++----- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/server/app/app.py b/server/app/app.py index 426e3a0..d30b448 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -142,7 +142,9 @@ def manage_plant_alerts(): logger.debug("------------------------------------------------------------") logger.debug("Printing all available cron jobs for this api server:") for job in scheduler.get_jobs(): - print(f"Job: {job.name}, Trigger: {job.trigger}, Next run: {job.next_run_time}") + logger.debug( + f"Job: {job.name}, Trigger: {job.trigger}, Next run: {job.next_run_time}" + ) logger.debug("------------------------------------------------------------") if __name__ == "__main__": diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py index 9af49d3..f97f17a 100644 --- a/server/app/routes/__init__.py +++ b/server/app/routes/__init__.py @@ -154,6 +154,7 @@ def __init__(self, fields: Dict[str, SchemaField]): self.fields = fields def read(self, obj: FlexibleModel, depth=0, include_nested=False) -> Dict[str, Any]: + """Read a flexible model and serialize it for the client.""" if depth > 5: return {} @@ -174,11 +175,10 @@ def read(self, obj: FlexibleModel, depth=0, include_nested=False) -> Dict[str, A result[k] = nested_schema.read(value, depth + 1, include_nested) elif not v.internal_only: result[k] = value - print(result) return result def patch(self, model: FlexibleModel, data: Dict[str, Any], depth=0): - """ """ + """Patch a flexible model with json data from another.""" if depth > 5: return model @@ -218,11 +218,12 @@ def patch(self, model: FlexibleModel, data: Dict[str, Any], depth=0): setattr(model, field_name, new_value) def create(self, model_clazz: Type[FlexibleModel], data: Dict[str, Any]): + """Create a model from json from the client.""" return model_clazz(**data) class GenericCRUD: - def __init__(self, table, schema): + def __init__(self, table: Table, schema: Schema): self.table: Table = table self.schema: Schema = schema diff --git a/server/models/__init__.py b/server/models/__init__.py index 6d45534..952deb3 100644 --- a/server/models/__init__.py +++ b/server/models/__init__.py @@ -17,7 +17,7 @@ class FlexibleModel: """Base model with MongoDB support""" def __init__(self, **kwargs): - self.id = kwargs.get("_id", ObjectId()) # MongoDB ID + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) for key, value in kwargs.items(): setattr(self, key, value) diff --git a/server/models/system.py b/server/models/system.py index 2ea4aa6..b04abca 100644 --- a/server/models/system.py +++ b/server/models/system.py @@ -3,8 +3,7 @@ """ from datetime import datetime from bson import ObjectId -from models import FlexibleModel, DeprecatableMixin -from models import FlexibleModel +from models import FlexibleModel, DeprecatableMixin, Fields class Light(DeprecatableMixin, FlexibleModel): @@ -12,7 +11,7 @@ class Light(DeprecatableMixin, FlexibleModel): def __init__(self, **kwargs): super().__init__(**kwargs) - self.id = kwargs.get("_id", ObjectId()) + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) self.name = kwargs.get("name") self.created_on = kwargs.get("created_on", datetime.now()) self.updated_on = kwargs.get("updated_on", datetime.now()) @@ -28,7 +27,7 @@ class System(DeprecatableMixin, FlexibleModel): def __init__(self, **kwargs): super().__init__(**kwargs) - self.id = kwargs.get("_id", ObjectId()) + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) self.name = kwargs.get("name") self.description = kwargs.get("description") self.created_on = kwargs.get("created_on", datetime.now()) diff --git a/server/models/todo.py b/server/models/todo.py index e5f5e72..ca56ef2 100644 --- a/server/models/todo.py +++ b/server/models/todo.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Dict, Any, List from bson import ObjectId -from models import FlexibleModel, DeprecatableMixin +from models import FlexibleModel, DeprecatableMixin, Fields class Task(DeprecatableMixin, FlexibleModel): @@ -12,8 +12,7 @@ class Task(DeprecatableMixin, FlexibleModel): def __init__(self, **kwargs): super().__init__(**kwargs) - print(kwargs.get("_id")) - self.id = kwargs.get("_id", ObjectId()) + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) self.description = kwargs.get("description") self.created_on = kwargs.get("created_on", datetime.now()) self.updated_on = kwargs.get("updated_on", datetime.now()) @@ -26,7 +25,7 @@ class Todo(DeprecatableMixin, FlexibleModel): def __init__(self, **kwargs): super().__init__(**kwargs) - self.id = kwargs.get("_id", ObjectId()) + self.id = Fields.object_id(kwargs.get("_id", ObjectId())) self.created_on = kwargs.get("created_on", datetime.now()) self.updated_on = kwargs.get("updated_on", datetime.now()) self.due_on = kwargs.get("due_on") @@ -34,7 +33,6 @@ def __init__(self, **kwargs): self.description = kwargs.get("description") # Embedded tasks - print(kwargs.get("tasks", [])) self.tasks: List[Task] = [Task(**task) for task in kwargs.get("tasks", [])] def __repr__(self): From 0cc6d75ee7de76d694a5ddb0cf2449fe3a87a443 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 16 Feb 2025 11:32:13 -0600 Subject: [PATCH 35/44] Miscellanous javascript changes --- client/src/hooks/usePlants.js | 1 - client/src/pages/mix/MixCreate.js | 1 - client/src/pages/plant/PlantUpdate.js | 3 +-- client/src/pages/plant/Plants.js | 9 ++++----- client/src/pages/system/SystemUpdate.js | 2 +- client/src/pages/todo/TodoUpdate.js | 2 +- server/app/app.py | 2 +- server/models/alert.py | 1 + 8 files changed, 9 insertions(+), 12 deletions(-) diff --git a/client/src/hooks/usePlants.js b/client/src/hooks/usePlants.js index 6eba9dc..1c7e97a 100644 --- a/client/src/hooks/usePlants.js +++ b/client/src/hooks/usePlants.js @@ -75,7 +75,6 @@ export const usePlants = (initialPlants) => { /** Update a system with a new version. */ const updatePlant = async (updatedPlant) => { - console.log(updatedPlant); const id = updatedPlant.id; setIsLoading(true); setError(null); diff --git a/client/src/pages/mix/MixCreate.js b/client/src/pages/mix/MixCreate.js index 6774b9b..5b32703 100644 --- a/client/src/pages/mix/MixCreate.js +++ b/client/src/pages/mix/MixCreate.js @@ -38,7 +38,6 @@ const MixCreate = () => { }); } }); - console.log(soils_json) setExperimental(false); await createMix({ name, description, experimental, soil_parts: soils_json }); navigate("/"); diff --git a/client/src/pages/plant/PlantUpdate.js b/client/src/pages/plant/PlantUpdate.js index f9b8436..950ea51 100644 --- a/client/src/pages/plant/PlantUpdate.js +++ b/client/src/pages/plant/PlantUpdate.js @@ -28,7 +28,6 @@ const PlantUpdate = ({ plantProp }) => { useEffect(() => { const initializeForm = (plant) => { - console.log(plant.system_id); if (plant) { setMix(mixes.find(_m => _m.id === plant.mix_id)); setSelectedSpecies(species.find(_s => _s.id === plant.species_id)); @@ -43,7 +42,7 @@ const PlantUpdate = ({ plantProp }) => { if (plantProp) { initializeForm(plantProp); } else if (plants.length > 0 && id) { - const plant = plants.find(_p => _p.id === parseInt(id)); + const plant = plants.find(_p => _p.id === id); if (plant) { initializeForm(plant); } diff --git a/client/src/pages/plant/Plants.js b/client/src/pages/plant/Plants.js index 92a2783..1c0a343 100644 --- a/client/src/pages/plant/Plants.js +++ b/client/src/pages/plant/Plants.js @@ -63,7 +63,7 @@ const Plants = ({ initialPlants }) => { {selectedPlants.length === 1 && ( - navigate(`/plants/${selectedPlants[0].id}`, { plantProp: selectedPlants[0] })}> + navigate(`/plants/${selectedPlants[0].id}`)}> )} @@ -97,10 +97,9 @@ const Plants = ({ initialPlants }) => { checkboxSelection disableRowSelectionOnClick onRowSelectionModelChange={(newSelectionModel) => { - const newSelectedPlants = newSelectionModel.map(index => plants[index-1]); - - console.log(newSelectedPlants) - setSelectedPlants(newSelectedPlants); + const newSelectedPlants = newSelectionModel.map(id => + plants.find(plant => plant.id === id)); + setSelectedPlants(newSelectedPlants); }} slots={{ toolbar: CustomToolbar }} /> diff --git a/client/src/pages/system/SystemUpdate.js b/client/src/pages/system/SystemUpdate.js index d1afd6d..ec38ac3 100644 --- a/client/src/pages/system/SystemUpdate.js +++ b/client/src/pages/system/SystemUpdate.js @@ -47,7 +47,7 @@ const SystemUpdate = ({ systemProp }) => { if (systemProp) { initializeForm(systemProp); } else if (systems.length > 0 && id) { - const system = systems.find(_t => _t.id === parseInt(id)); + const system = systems.find(_t => _t.id === id); if (system) { initializeForm(system); } diff --git a/client/src/pages/todo/TodoUpdate.js b/client/src/pages/todo/TodoUpdate.js index 403fe57..163dab4 100644 --- a/client/src/pages/todo/TodoUpdate.js +++ b/client/src/pages/todo/TodoUpdate.js @@ -27,7 +27,7 @@ const TodoUpdate = ({ todoProp }) => { if (todoProp) { initializeForm(todoProp); } else if (todos.length > 0 && id) { - const todo = todos.find(_t => _t.id === parseInt(id)); + const todo = todos.find(_t => _t.id === id); if (todo) { initializeForm(todo); } diff --git a/server/app/app.py b/server/app/app.py index d30b448..12eedc9 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -133,7 +133,7 @@ def manage_plant_alerts(): for plant in existing_plants: end_date = plant.watered_on + timedelta(days=float(plant.watering)) if end_date < now and existing_plant_alrts_map.get(plant.id) is None: - new_plant_alert = Alert(model_id=plant.id, alert_type=AlertTypes.WATER) + new_plant_alert:Alert = Alert(model_id=plant.id, alert_type=AlertTypes.WATER.value) # Create the alert in the db Table.ALERT.create(new_plant_alert) existing_plant_alrts_map[new_plant_alert.model_id] = new_plant_alert diff --git a/server/models/alert.py b/server/models/alert.py index d03fd48..7cf5e1c 100644 --- a/server/models/alert.py +++ b/server/models/alert.py @@ -4,6 +4,7 @@ from datetime import datetime from models import FlexibleModel, DeprecatableMixin, Fields import enum +from bson import ObjectId class AlertTypes(enum.Enum): From 4461f822755c86d08f555307ba807dda708be3ad Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 16 Feb 2025 12:20:20 -0600 Subject: [PATCH 36/44] Defect fixes --- client/src/pages/plant/PlantUpdate.js | 7 ++++--- client/src/pages/system/SystemUpdate.js | 21 +++++++++++---------- client/src/pages/todo/TodoUpdate.js | 1 + server/app/routes/__init__.py | 13 +++++++------ server/models/mix.py | 6 +++--- server/shared/db.py | 13 ++++++++----- 6 files changed, 34 insertions(+), 27 deletions(-) diff --git a/client/src/pages/plant/PlantUpdate.js b/client/src/pages/plant/PlantUpdate.js index 950ea51..51898e8 100644 --- a/client/src/pages/plant/PlantUpdate.js +++ b/client/src/pages/plant/PlantUpdate.js @@ -53,14 +53,15 @@ const PlantUpdate = ({ plantProp }) => { event.preventDefault(); const updatedPlant = { id, - size, - cost, + size: parseInt(size), + cost: parseInt(cost), mix_id: mix.id, system_id: system.id, species_id: selectedSpecies.id, - watering, + watering: parseInt(watering), phase }; + console.log(updatedPlant) try { await updatePlant(updatedPlant); navigate("/plants"); diff --git a/client/src/pages/system/SystemUpdate.js b/client/src/pages/system/SystemUpdate.js index ec38ac3..fbd563a 100644 --- a/client/src/pages/system/SystemUpdate.js +++ b/client/src/pages/system/SystemUpdate.js @@ -18,8 +18,8 @@ const SystemUpdate = ({ systemProp }) => { const { systems, isLoading, error, updateSystem } = useSystems(); // Form Fields - const [humidity, setHumidity] = useState(60); - const [temperature, setTempurature] = useState(68); + const [target_humidity, setHumidity] = useState(60); + const [target_temperature, setTempurature] = useState(68); const [duration, setDuration] = useState(12); const [distance, setDistance] = useState(24); const [name, setName] = useState(''); @@ -33,8 +33,8 @@ const SystemUpdate = ({ systemProp }) => { if (system) { setName(system.name); setDescription(system.description); - setHumidity(system.humidity); - setTempurature(system.temperature); + setHumidity(system.target_humidity); + setTempurature(system.target_temperature); setDuration(system.duration); setDistance(system.distance); // TODO: Sync light data @@ -58,12 +58,13 @@ const SystemUpdate = ({ systemProp }) => { event.preventDefault(); const light = lightModel; const updatedSystem = { + id, name, description, - humidity, - temperature, - duration, - distance, + target_humidity: parseInt(target_humidity), + target_temperature: parseInt(target_temperature), + duration: parseInt(duration), + distance: parseInt(distance), light }; try { @@ -111,7 +112,7 @@ const SystemUpdate = ({ systemProp }) => { { { event.preventDefault(); const due_on = dueOn.toISOString() const updatedTodo = { + id, name, description, due_on diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py index f97f17a..ff89958 100644 --- a/server/app/routes/__init__.py +++ b/server/app/routes/__init__.py @@ -135,7 +135,7 @@ class Schema(Enum): "name": SchemaField(), "description": SchemaField(), "experimental": SchemaField(), - "soil_parts": SchemaField(nested=True, nested_class="SOIL_PART"), + "soil_parts": SchemaField(nested=True, nested_schema="SOIL_PART"), "deprecated": SchemaField(), "deprecated_on": SchemaField(), "deprecated_cause": SchemaField(), @@ -153,7 +153,7 @@ class Schema(Enum): def __init__(self, fields: Dict[str, SchemaField]): self.fields = fields - def read(self, obj: FlexibleModel, depth=0, include_nested=False) -> Dict[str, Any]: + def read(self, obj: FlexibleModel, depth=0) -> Dict[str, Any]: """Read a flexible model and serialize it for the client.""" if depth > 5: return {} @@ -168,11 +168,11 @@ def read(self, obj: FlexibleModel, depth=0, include_nested=False) -> Dict[str, A nested_schema: Schema = getattr(Schema, v.nested_schema) if isinstance(value, list): result[k] = [ - nested_schema.read(item, depth + 1, include_nested) + nested_schema.read(item, depth + 1) for item in value ] elif value is not None: - result[k] = nested_schema.read(value, depth + 1, include_nested) + result[k] = nested_schema.read(value, depth + 1) elif not v.internal_only: result[k] = value return result @@ -242,7 +242,6 @@ def get(self, id: str): def get_many(self): try: items = self.table.get_many() - return jsonify([self.schema.read(item) for item in items]) except Exception as e: logger.error(f"Error in get_many: {str(e)}") @@ -250,7 +249,7 @@ def get_many(self): def create(self): try: - item = self.schema.create(self.table.model_class, request.json) + item: FlexibleModel = self.schema.create(self.table.model_class, request.json) result = self.table.create(item) item.id = result @@ -269,6 +268,8 @@ def update(self, id: str): self.schema.patch(db_model, request.json) + self.table.update(id, db_model) + return jsonify(db_model.to_dict()) except Exception as e: diff --git a/server/models/mix.py b/server/models/mix.py index 128cf22..09676f4 100644 --- a/server/models/mix.py +++ b/server/models/mix.py @@ -45,7 +45,7 @@ def __init__(self, **kwargs): self.experimental = kwargs.get("experimental", False) # Embedded soil parts - self.parts: List[SoilPart] = [SoilPart(sp) for sp in kwargs.get("parts", [])] + self.soil_parts: List[SoilPart] = [SoilPart(**sp) for sp in kwargs.get("soil_parts", [])] def __repr__(self) -> str: return f"{self.name}" @@ -53,8 +53,8 @@ def __repr__(self) -> str: def to_dict(self) -> Dict[str, Any]: """Convert to MongoDB document format""" base_dict = super().to_dict() - if "soil_parts" in base_dict: + if len(self.soil_parts)>0: base_dict["soil_parts"] = [ - part.to_dict() for part in base_dict["soil_parts"] + part.to_dict() for part in self.soil_parts ] return base_dict diff --git a/server/shared/db.py b/server/shared/db.py index 4975c6a..5bdb315 100644 --- a/server/shared/db.py +++ b/server/shared/db.py @@ -54,14 +54,17 @@ def get_one(self, id: str) -> Optional[Type[FlexibleModel]]: def get_many( self, query: Dict[str, Any] = {}, limit: int = 100 ) -> List[Dict[str, Any]]: - return [ - self.model_class(**item) - for item in list(DB[self.table_name].find(query).limit(limit)) - ] + ret = [] + for item in DB[self.table_name].find(query).limit(limit): + ret.append(self.model_class(**item)) + return ret def update(self, id: str, data: FlexibleModel) -> bool: + # Temp Solution + set = data.to_dict() + del set['_id'] result = DB[self.table_name].update_one( - {"_id": ObjectId(id)}, {"$set": data.to_dict()} + {"_id": ObjectId(id)}, {"$set": set} ) return result.modified_count > 0 From c097e6d3a1502d9f448061b18ea9f498e60ad530 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 16 Feb 2025 12:51:37 -0600 Subject: [PATCH 37/44] Resolve todo tasks endpoints and UI --- client/src/api.js | 4 ++- client/src/hooks/useTodos.js | 32 +++++++++++++++++++++++- client/src/pages/todo/Todos.js | 12 +++------ server/app/routes/__init__.py | 2 ++ server/app/routes/todo_routes.py | 43 ++++++++++++++++++++++++++++++-- 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/client/src/api.js b/client/src/api.js index 4720d75..ab531d4 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -60,7 +60,9 @@ export const APIS = { generaCreate: "/genus_types/{id}/tasks/{eid}/" }, task: { - updateOne: "/todos/{id}/tasks/{eid}/" + resolve: "/todos/{id}/tasks/{eid}/resolve", + unresolve: "/todos/{id}/tasks/{eid}/unresolve" + }, } diff --git a/client/src/hooks/useTodos.js b/client/src/hooks/useTodos.js index 3004209..d7308f4 100644 --- a/client/src/hooks/useTodos.js +++ b/client/src/hooks/useTodos.js @@ -104,5 +104,35 @@ export const useTasks = (initialTasks) => { setIsLoading(false)) }; - return { tasks, isLoading, error, deprecateTask, updateTask }; + const resolveTask = async (todo_id, task_id) => { + setIsLoading(true); + setError(null); + simplePost(apiBuilder(APIS.task.resolve).setId(todo_id).setEmbedId(task_id).get(), null) + .then(data => + setTasks(prevTasks => prevTasks.map(task => + task.id === task_id ? { ...task, ...data } : task + ))) + .catch(error => { + setError(error); + }) + .finally(() => + setIsLoading(false)) + }; + + const unresolveTask = async (todo_id, task_id) => { + setIsLoading(true); + setError(null); + simplePost(apiBuilder(APIS.task.unresolve).setId(todo_id).setEmbedId(task_id).get(), null) + .then(data => + setTasks(prevTasks => prevTasks.map(task => + task.id === task_id ? { ...task, ...data } : task + ))) + .catch(error => { + setError(error); + }) + .finally(() => + setIsLoading(false)) + }; + + return { tasks, isLoading, error, deprecateTask, resolveTask, unresolveTask }; }; \ No newline at end of file diff --git a/client/src/pages/todo/Todos.js b/client/src/pages/todo/Todos.js index 6d1b32f..2aa1e45 100644 --- a/client/src/pages/todo/Todos.js +++ b/client/src/pages/todo/Todos.js @@ -25,17 +25,13 @@ import { NoData, ServerError, Loading } from '../../elements/Page'; const TodoCard = ({ todo, onResolve }) => { const navigate = useNavigate(); const isPastDue = dayjs(todo.due_on).isBefore(dayjs(), 'day'); - const { tasks, updateTask } = useTasks(todo.tasks); + const { tasks, resolveTask, unresolveTask } = useTasks(todo.tasks); const handleToggle = (task) => () => { if (task.resolved) { - task.resolved = false; - task.resolved_on = null; - updateTask(todo.id, task); + unresolveTask(todo.id, task.id); } else { - task.resolved = true; - task.resolved_on = new Date(); - updateTask(todo.id, task); + resolveTask(todo.id, task.id) } }; @@ -75,7 +71,7 @@ const TodoCard = ({ todo, onResolve }) => { edge="end" onChange={(handleToggle(task))} checked={task.resolved} - color='primary' + color='success' /> } > diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py index ff89958..8bffdb6 100644 --- a/server/app/routes/__init__.py +++ b/server/app/routes/__init__.py @@ -83,6 +83,8 @@ class Schema(Enum): "description": SchemaField(), "created_on": SchemaField(read_only=True), "updated_on": SchemaField(read_only=True), + "resolved": SchemaField(), + "resolved_on": SchemaField() } SOIL = { "id": SchemaField(read_only=True), diff --git a/server/app/routes/todo_routes.py b/server/app/routes/todo_routes.py index 0cba142..861aabf 100644 --- a/server/app/routes/todo_routes.py +++ b/server/app/routes/todo_routes.py @@ -2,10 +2,49 @@ from routes import GenericCRUD, APIBuilder, Schema from shared.db import Table +from flask import Blueprint, request, jsonify +from bson import ObjectId +from datetime import datetime + +from models.todo import Todo + bp = Blueprint("todos", __name__) todo_crud = GenericCRUD(Table.TODO, Schema.TODO) APIBuilder.register_blueprint(bp, "todos", todo_crud) -# @APIBuilder.register_custom_route(bp, "/todos//tasks/", methods=['POST']) -# def get_systems_plants(id, task_id): +@APIBuilder.register_custom_route(bp, "/todos//tasks//resolve", methods=['POST']) +def resolve_task(id, task_id): + todo: Todo = Table.TODO.get_one(id) + if todo is None: + return jsonify({"error": "Not found"}), 404 + + task = next((model for model in todo.tasks if model.id == ObjectId(task_id)), None) + + task.resolved = True + task.resolved_on = datetime.now() + task.updated_on = datetime.now() + + todo.updated_on = datetime.now() + + Table.TODO.update(id, todo) + + return jsonify(Schema.TASK.read(task)), 201 + +@APIBuilder.register_custom_route(bp, "/todos//tasks//unresolve", methods=['POST']) +def unresolve_task(id, task_id): + todo: Todo = Table.TODO.get_one(id) + if todo is None: + return jsonify({"error": "Not found"}), 404 + + task = next((model for model in todo.tasks if model.id == ObjectId(task_id)), None) + + task.resolved = False + task.resolved_on = None + task.updated_on = datetime.now() + + todo.updated_on = datetime.now() + + Table.TODO.update(id, todo) + + return jsonify(Schema.TASK.read(task)), 201 From 386b64ed036b287b8153618a9b3106a93889e3d7 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 16 Feb 2025 17:29:43 -0600 Subject: [PATCH 38/44] Mix update and cleanup --- client/src/App.js | 2 + client/src/pages/mix/MixUpdate.js | 214 ++++++++++++++++++++++++++++++ server/app/routes/__init__.py | 2 + server/models/plant.py | 14 +- 4 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 client/src/pages/mix/MixUpdate.js diff --git a/client/src/App.js b/client/src/App.js index 17db357..411b9d4 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -26,6 +26,7 @@ import SystemUpdate from './pages/system/SystemUpdate'; import TodoCreate from './pages/todo/TodoCreate'; import TodoUpdate from './pages/todo/TodoUpdate'; import MixCreate from './pages/mix/MixCreate'; +import MixUpdate from './pages/mix/MixUpdate'; import Stats from './pages/stat/Stats'; import Mixes from './pages/mix/Mixes'; @@ -159,6 +160,7 @@ function App() { } /> } /> + } /> diff --git a/client/src/pages/mix/MixUpdate.js b/client/src/pages/mix/MixUpdate.js new file mode 100644 index 0000000..003bf3a --- /dev/null +++ b/client/src/pages/mix/MixUpdate.js @@ -0,0 +1,214 @@ +import React, { useState } from 'react'; +import { useNavigate, useParams } from "react-router-dom"; +import Box from '@mui/material/Box'; +import { FormTextInput, TextAreaInput } from '../../elements/Form'; +import { useMixes, useSoils } from '../../hooks/useMix'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import IconFactory from '../../elements/IconFactory'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import Autocomplete from '@mui/material/Autocomplete'; +import AddSharpIcon from '@mui/icons-material/AddSharp'; +import RemoveSharpIcon from '@mui/icons-material/RemoveSharp'; +import TextField from '@mui/material/TextField'; +import List from '@mui/material/List'; +import { ServerError } from '../../elements/Page'; + +const MixUpdate = () => { + const { id } = useParams(); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [experimental, setExperimental] = useState(false); + const [soilsByParts, setSoilsByParts] = useState([{"soil": "", "parts": 1}]); + + const navigate = useNavigate(); + const { error, createMix , setError} = useMixes(); + const { soils } = useSoils(); + + const handleSubmit = async (event) => { + event.preventDefault(); + setError(null); + + try { + const soils_json = []; + soilsByParts.forEach((soilByPart) => { + if (soilByPart.soil !== "") { + soils_json.push({ + soil_id: soilByPart.soil.id, + parts: soilByPart.parts + }); + } + }); + console.log(soils_json) + setExperimental(false); + await createMix({ id: id, name, description, experimental, soil_parts: soils_json }); + navigate("/"); + } catch (err) { + setError("Failed to create mix. Please try again."); + } + }; + + const handleCancel = () => { + navigate("/"); + }; + + const createSoildByPart = () => { + setSoilsByParts(prevSoilsByParts => { + return [ + ...prevSoilsByParts, + { soil: null, parts: 1 } // Default to 1 part, null soil + ]; + }); + }; + + const updateSoilByPartsCount = (index, delta) => { + setSoilsByParts(prevSoilsByParts => { + const newSoilsByParts = [...prevSoilsByParts]; + const newParts = (newSoilsByParts[index].parts || 0) + delta; + if (newParts > 0) { + newSoilsByParts[index] = { + ...newSoilsByParts[index], + parts: newParts + }; + + return newSoilsByParts; + } + + return prevSoilsByParts; + }); + }; + + const updateSoilByParts = (index, soilByPart) => { + setSoilsByParts(prevSoilsByParts => { + const newSoilsByParts = [...prevSoilsByParts]; + if (index === newSoilsByParts.length) { + return [...newSoilsByParts, soilByPart]; + } else { + newSoilsByParts[index] = { + ...newSoilsByParts[index], + soil: soilByPart.soil, + parts: soilByPart.parts + }; + + return newSoilsByParts; + } + }); + }; + // if (isLoading) return ; + if (error) return ; + + return ( + + +
+ +
+
+
+
+
+
+
+
+
+
Data

from 16th April, 2014
+
+ + + + + + + + + + +
+
+ + + {error &&

{error}

} +
+
+ + + {soilsByParts.map((soilByPart, index) => { + return ( + + option.name)} + onChange={(event, newValue) => { + const selectedSoil = soils.find(soil => soil.name === newValue); + updateSoilByParts(index, { + ...soilByPart, + soil: selectedSoil + }); + }} + renderInput={(params) => ( + + )} + /> + + updateSoilByPartsCount(index, -1)}> + + +

{soilByPart.parts}

+ updateSoilByPartsCount(index, 1)}> + + +
+
+ )})} +
+ + createSoildByPart()}> + + + +
+
+
+
+ ); +}; + +export default MixUpdate; \ No newline at end of file diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py index 8bffdb6..1d9cd00 100644 --- a/server/app/routes/__init__.py +++ b/server/app/routes/__init__.py @@ -36,6 +36,8 @@ class Schema(Enum): "deprecated": SchemaField(), "deprecated_on": SchemaField(), "deprecated_cause": SchemaField(), + "batch": SchemaField(), + "batch_count": SchemaField() } PLANT_GENUS_TYPE = { "id": SchemaField(read_only=True), diff --git a/server/models/plant.py b/server/models/plant.py index ff33c5e..5234c76 100644 --- a/server/models/plant.py +++ b/server/models/plant.py @@ -37,21 +37,13 @@ def __init__(self, **kwargs): self.watered_on = kwargs.get("watered_on", datetime.now()) self.species_id = Fields.object_id(kwargs.get("species_id")) - self.identity = kwargs.get("identity", "plant") + + self.batch = kwargs.get("batch", False) + self.batch_count = kwargs.get("batch_count", False) def __repr__(self) -> str: return f"{self.id}" - -class Batch(Plant): - """Batch of plants.""" - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.count = kwargs.get("count", 0) - self.identity = "batch" - - class PlantGenusType(FlexibleModel): def __init__(self, **kwargs): super().__init__(**kwargs) From 5f58248e7efc8ef0cfa679ffeab98e1bd5556ae5 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Sun, 16 Feb 2025 18:02:42 -0600 Subject: [PATCH 39/44] Mix details page, light create fix, misc defects --- client/src/pages/mix/Mixes.js | 35 ++++++++++++++++++-- client/src/pages/system/light/LightCreate.js | 2 +- server/models/plant.py | 4 +-- server/models/system.py | 2 +- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/client/src/pages/mix/Mixes.js b/client/src/pages/mix/Mixes.js index f73c0a5..5d19a93 100644 --- a/client/src/pages/mix/Mixes.js +++ b/client/src/pages/mix/Mixes.js @@ -1,7 +1,12 @@ import React from 'react'; import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; import Card from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import Divider from '@mui/material/Divider'; import { CardActionArea, CardHeader } from '@mui/material'; import Avatar from '@mui/material/Avatar'; import IconButton from '@mui/material/IconButton'; @@ -10,13 +15,18 @@ import { CARD_STYLE, AVATAR_STYLE } from '../../constants'; import { EditSharp } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; import { NoData, ServerError, Loading } from '../../elements/Page'; -import { useMixes } from '../../hooks/useMix'; +import { useMixes, useSoilParts, useSoils } from '../../hooks/useMix'; import DeleteOutlineSharpIcon from '@mui/icons-material/DeleteOutlineSharp'; import PieChartOutlineSharpIcon from '@mui/icons-material/PieChartOutlineSharp'; +import Typography from '@mui/material/Typography'; + const MixCard = ({ mix, deprecateMix }) => { const navigate = useNavigate(); + const { soilParts } = useSoilParts(mix.soil_parts); + const { soils } = useSoils(); + return ( <> @@ -31,7 +41,28 @@ const MixCard = ({ mix, deprecateMix }) => { subheader={mix.created_on} /> - {mix.description} + + + {mix.description} + + + + {soilParts && soilParts.map((sp) => ( +
+ + 0 ? soils.find(s => s.id === sp.soil_id).name : ""} + secondaryAction={sp.parts} + /> + + +
+ ))} +
+
navigate(`/mixes/${mix.id}`)}> diff --git a/client/src/pages/system/light/LightCreate.js b/client/src/pages/system/light/LightCreate.js index a5700cc..c8681cd 100644 --- a/client/src/pages/system/light/LightCreate.js +++ b/client/src/pages/system/light/LightCreate.js @@ -22,7 +22,7 @@ const NewLightForm = () => { try { await createLight({ name, - cost, + cost: parseInt(cost), system_id: system.id }); navigate("/"); diff --git a/server/models/plant.py b/server/models/plant.py index 5234c76..6abc8bf 100644 --- a/server/models/plant.py +++ b/server/models/plant.py @@ -65,9 +65,7 @@ def __init__(self, **kwargs): self.common_name = kwargs.get("common_name") self.description = kwargs.get("description") self.watering = kwargs.get("watering") - self.genus_type_id = Fields.object_id( - Fields.object_id(kwargs.get("genus_type_id")) - ) + self.genus_type_id = Fields.object_id(kwargs.get("genus_type_id")) class PlantSpecies(FlexibleModel): diff --git a/server/models/system.py b/server/models/system.py index b04abca..6e72539 100644 --- a/server/models/system.py +++ b/server/models/system.py @@ -16,7 +16,7 @@ def __init__(self, **kwargs): self.created_on = kwargs.get("created_on", datetime.now()) self.updated_on = kwargs.get("updated_on", datetime.now()) self.cost = kwargs.get("cost", 0) - self.system_id = kwargs.get("system_id") + self.system_id = Fields.object_id(kwargs.get("system_id")) def __repr__(self) -> str: return f"{self.name}" From 386f829472f1de437a025ff43b2bc12d14d25c0c Mon Sep 17 00:00:00 2001 From: mseng10 Date: Mon, 17 Feb 2025 00:53:19 -0600 Subject: [PATCH 40/44] Historical tracking --- client/src/hooks/useMix.js | 11 ++++- client/src/pages/mix/MixUpdate.js | 57 ++++++++++++++-------- client/src/pages/todo/TodoUpdate.js | 76 ++++++++++++++++++++++++++++- server/app/app.py | 1 + server/app/routes/__init__.py | 62 +++++++++++++++-------- server/models/__init__.py | 19 ++++++++ server/models/alert.py | 4 +- server/models/mix.py | 4 +- server/models/plant.py | 4 +- server/models/system.py | 6 +-- server/models/todo.py | 6 +-- server/shared/db.py | 24 +++++++-- 12 files changed, 217 insertions(+), 57 deletions(-) diff --git a/client/src/hooks/useMix.js b/client/src/hooks/useMix.js index 6bdef28..e3d874b 100644 --- a/client/src/hooks/useMix.js +++ b/client/src/hooks/useMix.js @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { simpleFetch, simplePost, apiBuilder, APIS } from '../api'; +import { simpleFetch, simplePost, apiBuilder, simplePatch, APIS } from '../api'; export const useMixes = () => { const [mixes, setMixes] = useState([]); @@ -34,7 +34,7 @@ export const useMixes = () => { const updateMix = async (updatedMix) => { setIsLoading(true); setError(null); - simplePost(apiBuilder(APIS.mix.updateOne).setId(updatedMix.id).get(), updatedMix) + simplePatch(apiBuilder(APIS.mix.updateOne).setId(updatedMix.id).get(), updatedMix) .then(data => setMixes(prevMixes => prevMixes.map(mix => mix.id === updatedMix.id ? { ...mix, ...data } : mix @@ -65,6 +65,13 @@ export const useMixes = () => { return { mixes, isLoading, error, setError, createMix, updateMix, deprecateMix }; }; +export const useSoilParts = (initialParts) => { + const [soilParts, setSoilParts] = useState(initialParts); + const [isLoading, setIsLoading] = useState(true); + + return { soilParts, isLoading, setSoilParts, setIsLoading }; +}; + /** Query a all soil matters. */ export const useSoils = () => { const [soils, setSoils] = useState([]); diff --git a/client/src/pages/mix/MixUpdate.js b/client/src/pages/mix/MixUpdate.js index 003bf3a..c3d3eb4 100644 --- a/client/src/pages/mix/MixUpdate.js +++ b/client/src/pages/mix/MixUpdate.js @@ -1,8 +1,8 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useNavigate, useParams } from "react-router-dom"; import Box from '@mui/material/Box'; import { FormTextInput, TextAreaInput } from '../../elements/Form'; -import { useMixes, useSoils } from '../../hooks/useMix'; +import { useMixes, useSoils, useSoilParts } from '../../hooks/useMix'; import IconButton from '@mui/material/IconButton'; import Stack from '@mui/material/Stack'; import IconFactory from '../../elements/IconFactory'; @@ -20,11 +20,32 @@ const MixUpdate = () => { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [experimental, setExperimental] = useState(false); - const [soilsByParts, setSoilsByParts] = useState([{"soil": "", "parts": 1}]); const navigate = useNavigate(); - const { error, createMix , setError} = useMixes(); + const { mixes, error, updateMix , setError} = useMixes(); const { soils } = useSoils(); + const {soilParts, setSoilParts} = useSoilParts(); + + useEffect(() => { + const initializeForm = (mix) => { + if (mix) { + setName(mix.name); + setDescription(mix.description); + let soil_parts = mix.soil_parts + soil_parts.forEach((soilPart) => { + soilPart.soil = soils.find(soil => soil.id === soilPart.soil_id); + }); + setSoilParts(soil_parts) + } + }; + + if (mixes.length > 0 && id) { + const mix = mixes.find(_t => _t.id === id); + if (mix) { + initializeForm(mix); + } + } + }, [mixes, soils, id]); const handleSubmit = async (event) => { event.preventDefault(); @@ -32,17 +53,16 @@ const MixUpdate = () => { try { const soils_json = []; - soilsByParts.forEach((soilByPart) => { - if (soilByPart.soil !== "") { + soilParts.forEach((soilPart) => { + if (soilPart.soil !== "") { soils_json.push({ - soil_id: soilByPart.soil.id, - parts: soilByPart.parts + soil_id: soilPart.soil.id, + parts: soilPart.parts }); } }); - console.log(soils_json) setExperimental(false); - await createMix({ id: id, name, description, experimental, soil_parts: soils_json }); + await updateMix({ id: id, name, description, experimental, soil_parts: soils_json }); navigate("/"); } catch (err) { setError("Failed to create mix. Please try again."); @@ -50,11 +70,11 @@ const MixUpdate = () => { }; const handleCancel = () => { - navigate("/"); + navigate("/mixes"); }; const createSoildByPart = () => { - setSoilsByParts(prevSoilsByParts => { + setSoilParts(prevSoilsByParts => { return [ ...prevSoilsByParts, { soil: null, parts: 1 } // Default to 1 part, null soil @@ -63,7 +83,7 @@ const MixUpdate = () => { }; const updateSoilByPartsCount = (index, delta) => { - setSoilsByParts(prevSoilsByParts => { + setSoilParts(prevSoilsByParts => { const newSoilsByParts = [...prevSoilsByParts]; const newParts = (newSoilsByParts[index].parts || 0) + delta; if (newParts > 0) { @@ -80,7 +100,7 @@ const MixUpdate = () => { }; const updateSoilByParts = (index, soilByPart) => { - setSoilsByParts(prevSoilsByParts => { + setSoilParts(prevSoilsByParts => { const newSoilsByParts = [...prevSoilsByParts]; if (index === newSoilsByParts.length) { return [...newSoilsByParts, soilByPart]; @@ -112,7 +132,6 @@ const MixUpdate = () => {
-
Data

from 16th April, 2014
@@ -149,7 +168,7 @@ const MixUpdate = () => { - {soilsByParts.map((soilByPart, index) => { + {soilParts && soilParts.map((soilPart, index) => { return ( { }} color="primary" disableClearable - value={soilByPart.soil ? soilByPart.soil.name : ''} + value={soilPart.soil ? soilPart.soil.name : ''} options={soils.map((option) => option.name)} onChange={(event, newValue) => { const selectedSoil = soils.find(soil => soil.name === newValue); updateSoilByParts(index, { - ...soilByPart, + ...soilPart, soil: selectedSoil }); }} @@ -184,7 +203,7 @@ const MixUpdate = () => { updateSoilByPartsCount(index, -1)}> -

{soilByPart.parts}

+

{soilPart.parts}

updateSoilByPartsCount(index, 1)}> diff --git a/client/src/pages/todo/TodoUpdate.js b/client/src/pages/todo/TodoUpdate.js index 9a2ffae..02a67c0 100644 --- a/client/src/pages/todo/TodoUpdate.js +++ b/client/src/pages/todo/TodoUpdate.js @@ -5,6 +5,12 @@ import { FormButton, FormTextInput, DateSelector, TextAreaInput } from '../../el import { useTodos } from '../../hooks/useTodos'; import dayjs from 'dayjs'; import { ServerError } from '../../elements/Page'; +import Stack from '@mui/material/Stack'; +import IconFactory from '../../elements/IconFactory'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import RemoveSharpIcon from '@mui/icons-material/RemoveSharp'; +import List from '@mui/material/List'; +import IconButton from '@mui/material/IconButton'; const TodoUpdate = ({ todoProp }) => { const { id } = useParams(); @@ -14,6 +20,8 @@ const TodoUpdate = ({ todoProp }) => { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [dueOn, setDueOn] = useState(dayjs()); + const [tasks, setTasks] = useState([]); + useEffect(() => { const initializeForm = (todo) => { @@ -21,7 +29,9 @@ const TodoUpdate = ({ todoProp }) => { setName(todo.name); setDescription(todo.description); setDueOn(dayjs(todo.due_on)); + setTasks(todo.tasks) } + }; if (todoProp) { @@ -52,6 +62,36 @@ const TodoUpdate = ({ todoProp }) => { } }; + const addTask = () => { + setTasks(prevTasks => { + return [ + ...prevTasks, + { description: "" } + ]; + }); + }; + + + const removeTask = () => { + setTasks(prevTasks => { + return [ + ...prevTasks, + { description: "" } + ]; + }); + }; + + + const updateTask = (description, index) => { + if (tasks.length == 0) { + setTasks([]); + } else { + setTasks(prevTasks => prevTasks.map((task, _i) => + _i === index ? { ...task, description: description } : task + )); + } + }; + const handleCancel = () => { navigate("/todos"); }; @@ -61,8 +101,9 @@ const TodoUpdate = ({ todoProp }) => { return ( - +
+ { /> {error &&

{error}

} +
+ + + {tasks && tasks.map((task, index) => { + return ( + + updateTask(value, index))} + /> + + removeTask(index)}> + + + + + )})} + + + addTask()}> + + + +
diff --git a/server/app/app.py b/server/app/app.py index 12eedc9..c5cf00a 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -30,6 +30,7 @@ # Create Flask app app = Flask(__name__) +app.config['DEBUG'] = True from routes.system_routes import system_bp, light_bp from routes.plant_routes import bp as plant_bp diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py index 1d9cd00..fc23950 100644 --- a/server/app/routes/__init__.py +++ b/server/app/routes/__init__.py @@ -1,7 +1,7 @@ from flask import Blueprint, request, jsonify from typing import List, Callable, Type, Dict, Optional, Any from shared.logger import logger -from models import FlexibleModel +from models import FlexibleModel, DeprecatableMixin from enum import Enum from dataclasses import dataclass from bson import ObjectId @@ -191,7 +191,6 @@ def patch(self, model: FlexibleModel, data: Dict[str, Any], depth=0): continue field_config: SchemaField = self.fields[field_name] - if field_config.nested and not field_config.internal_only: nested_schema: Schema = getattr(Schema, field_config.nested_schema) @@ -219,7 +218,11 @@ def patch(self, model: FlexibleModel, data: Dict[str, Any], depth=0): else nested_schema.patch(None, new_value, depth + 1), ) elif not field_config.internal_only: - setattr(model, field_name, new_value) + new_value_formatted = new_value + if isinstance(new_value_formatted, ObjectId): + new_value_formatted = str(new_value_formatted) + + setattr(model, field_name, new_value_formatted) def create(self, model_clazz: Type[FlexibleModel], data: Dict[str, Any]): """Create a model from json from the client.""" @@ -272,9 +275,10 @@ def update(self, id: str): self.schema.patch(db_model, request.json) - self.table.update(id, db_model) + if not self.table.update(id, db_model): + return jsonify({"error": str("")}), 400 - return jsonify(db_model.to_dict()) + return self.schema.read(db_model) except Exception as e: logger.error(f"Error in update: {str(e)}") @@ -293,27 +297,36 @@ def delete(self, id: str): except Exception as e: logger.error(f"Error in delete: {str(e)}") return jsonify({"error": str(e)}), 500 + + def banish(self, id: str): + try: + item = self.table.get_one(id) + if not item: + return jsonify({"error": "Not found"}), 404 - def delete_many(self): - data = request.json - ids = data.get("ids", []) + if not self.table.banish(id): + return jsonify({"error": "Unknown, fix it"}), 404 + return "", 201 - if not ids: - return jsonify({"error": "No ids provided"}), 400 + except Exception as e: + logger.error(f"Error in delete: {str(e)}") + return jsonify({"error": str(e)}), 500 + def deprecate(self): try: - deleted_count = 0 - for id in ids: - result = self.table.delete(id) - if result: - deleted_count += 1 - return ( - jsonify({"message": f"Successfully deleted {deleted_count} items"}), - 200, - ) + item = self.table.get_one(id) + if not item: + return jsonify({"error": "Not found"}), 404 + elif not isinstance(item, DeprecatableMixin): + return jsonify({"error": "Not found"}), 400 + + item.deprecate() + + self.table.deprecate(item) # Yes an update + return "", 201 except Exception as e: - logger.error(f"Error in delete_many: {str(e)}") + logger.error(f"Error in delete: {str(e)}") return jsonify({"error": str(e)}), 500 @@ -330,6 +343,7 @@ def register_blueprint( "PATCH", "DELETE", "DELETE_MANY", + "BANISH" ], ): def create_wrapper(operation): @@ -366,6 +380,14 @@ def wrapper(): blueprint.route(f"/{resource_name}//", methods=["DELETE"])( create_wrapper("delete") ) + if "DEPRECATE" in methods: + blueprint.route(f"/{resource_name}//deprecate/", methods=["POST"])( + create_wrapper("deprecate") + ) + if "BANISH" in methods: + blueprint.route(f"/{resource_name}//", methods=["DELETE"])( + create_wrapper("banish") + ) if "DELETE_MANY" in methods: blueprint.route(f"/{resource_name}/", methods=["DELETE"])( create_wrapper("delete_many") diff --git a/server/models/__init__.py b/server/models/__init__.py index 952deb3..a6bbbb5 100644 --- a/server/models/__init__.py +++ b/server/models/__init__.py @@ -3,6 +3,7 @@ import numpy as np import csv from bson import ObjectId +from datetime import datetime, timedelta class Fields: @@ -58,3 +59,21 @@ def __init__(self, **kwargs): self.deprecated = kwargs.get("deprecated", False) self.deprecated_on = kwargs.get("deprecated_on") self.deprecated_cause = kwargs.get("deprecated_cause") + + def deprecate(self, cause): + self.deprecate = True + self.deprecated_on = datetime.now() + self.deprecated_cause = cause + +class BanishableMixin: + """In case the model is deprecated.""" + + def __init__(self, **kwargs): + self.banished = kwargs.get("banished", False) + self.banished_on = kwargs.get("banished_on") + self.banished_cause = kwargs.get("banished_cause") + + def banish(self, cause): + self.banished = True + self.banished_on = datetime.now() + self.banished_cause = cause diff --git a/server/models/alert.py b/server/models/alert.py index 7cf5e1c..3ee13b6 100644 --- a/server/models/alert.py +++ b/server/models/alert.py @@ -2,7 +2,7 @@ Module defining models for alerts. """ from datetime import datetime -from models import FlexibleModel, DeprecatableMixin, Fields +from models import FlexibleModel, BanishableMixin, Fields import enum from bson import ObjectId @@ -11,7 +11,7 @@ class AlertTypes(enum.Enum): WATER = "Water" -class Alert(DeprecatableMixin, FlexibleModel): +class Alert(BanishableMixin, FlexibleModel): """Alert Base Class""" def __init__(self, **kwargs): diff --git a/server/models/mix.py b/server/models/mix.py index 09676f4..c43741e 100644 --- a/server/models/mix.py +++ b/server/models/mix.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Dict, Any, List from bson import ObjectId -from models import FlexibleModel, DeprecatableMixin, Fields +from models import FlexibleModel, BanishableMixin, Fields class Soil(FlexibleModel): @@ -32,7 +32,7 @@ def __init__(self, **kwargs): self.parts = kwargs.get("parts") -class Mix(DeprecatableMixin, FlexibleModel): +class Mix(BanishableMixin, FlexibleModel): """Soil mix model with embedded soil parts.""" def __init__(self, **kwargs): diff --git a/server/models/plant.py b/server/models/plant.py index 6abc8bf..71b3aee 100644 --- a/server/models/plant.py +++ b/server/models/plant.py @@ -5,7 +5,7 @@ import enum from bson import ObjectId -from models import FlexibleModel, DeprecatableMixin, Fields +from models import FlexibleModel, BanishableMixin, Fields class PHASES(enum.Enum): @@ -16,7 +16,7 @@ class PHASES(enum.Enum): SEED = "Seed" -class Plant(DeprecatableMixin, FlexibleModel): +class Plant(BanishableMixin, FlexibleModel): """Plant model.""" def __init__(self, **kwargs): diff --git a/server/models/system.py b/server/models/system.py index 6e72539..5fe19ac 100644 --- a/server/models/system.py +++ b/server/models/system.py @@ -3,10 +3,10 @@ """ from datetime import datetime from bson import ObjectId -from models import FlexibleModel, DeprecatableMixin, Fields +from models import FlexibleModel, BanishableMixin, Fields -class Light(DeprecatableMixin, FlexibleModel): +class Light(BanishableMixin, FlexibleModel): """Light model.""" def __init__(self, **kwargs): @@ -22,7 +22,7 @@ def __repr__(self) -> str: return f"{self.name}" -class System(DeprecatableMixin, FlexibleModel): +class System(BanishableMixin, FlexibleModel): """System model.""" def __init__(self, **kwargs): diff --git a/server/models/todo.py b/server/models/todo.py index ca56ef2..eeeb4c0 100644 --- a/server/models/todo.py +++ b/server/models/todo.py @@ -4,10 +4,10 @@ from datetime import datetime from typing import Dict, Any, List from bson import ObjectId -from models import FlexibleModel, DeprecatableMixin, Fields +from models import FlexibleModel, BanishableMixin, Fields -class Task(DeprecatableMixin, FlexibleModel): +class Task(FlexibleModel): """TODO Item""" def __init__(self, **kwargs): @@ -20,7 +20,7 @@ def __init__(self, **kwargs): self.resolved = kwargs.get("resolved", False) -class Todo(DeprecatableMixin, FlexibleModel): +class Todo(BanishableMixin, FlexibleModel): """TODO model with embedded tasks.""" def __init__(self, **kwargs): diff --git a/server/shared/db.py b/server/shared/db.py index 5bdb315..a08bb48 100644 --- a/server/shared/db.py +++ b/server/shared/db.py @@ -5,7 +5,7 @@ from enum import Enum from typing import Dict, Any, Optional, List, Type -from models import FlexibleModel +from models import FlexibleModel, DeprecatableMixin, BanishableMixin from models.alert import Alert from models.mix import Mix, Soil from models.plant import Plant, PlantGenus, PlantGenusType, PlantSpecies @@ -23,7 +23,7 @@ # Get the default database DB_NAME = os.getenv("DB_NAME", "plnts") DB: Database = CLIENT[DB_NAME] - +HIST: Database = CLIENT[DB_NAME+"_hist"] class Table(Enum): PLANT = ("plant", Plant) @@ -60,14 +60,32 @@ def get_many( return ret def update(self, id: str, data: FlexibleModel) -> bool: - # Temp Solution set = data.to_dict() del set['_id'] result = DB[self.table_name].update_one( {"_id": ObjectId(id)}, {"$set": set} ) return result.modified_count > 0 + + def deprecate(self, data: FlexibleModel) -> bool: + if not isinstance(data, DeprecatableMixin): + return False + + data.deprecate() + return self.update(data.id, data) def delete(self, id: str) -> bool: result = DB[self.table_name].delete_one({"_id": ObjectId(id)}) return result.deleted_count > 0 + + def banish(self, id: str) -> bool: + banishable: FlexibleModel = self.get_one(id) + if not isinstance(banishable, BanishableMixin): + return False + + banishable.banish() + if not self.delete(id): + return False + + result = HIST[self.table_name].insert_one(banishable.to_dict()) + return result.inserted_id is not None From e7c590d651cd5499dbf671fee9361fbe7d963b0a Mon Sep 17 00:00:00 2001 From: mseng10 Date: Mon, 17 Feb 2025 12:48:21 -0600 Subject: [PATCH 41/44] Modify routes to handle banishment --- client/src/api.js | 17 +++++++++-------- client/src/hooks/useAlerts.js | 4 ++-- client/src/hooks/useMix.js | 8 ++++---- client/src/hooks/useSystems.js | 4 ++-- server/app/routes/alert_routes.py | 2 +- server/app/routes/mix_routes.py | 2 +- server/app/routes/plant_routes.py | 2 +- server/app/routes/system_routes.py | 2 +- server/app/routes/todo_routes.py | 2 +- 9 files changed, 22 insertions(+), 21 deletions(-) diff --git a/client/src/api.js b/client/src/api.js index ab531d4..89d3855 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -6,7 +6,7 @@ export const APIS = { create: "/plants/", getOne: "/plants/{id}/", updateOne: "/plants/{id}/", - deprecateOne: "/plants/{id}/deprecate/", + deleteOne: "/plants/{id}/", waterMany: "/plants/water/", deprecateMany: "/plants/deprecate/" }, @@ -15,7 +15,7 @@ export const APIS = { create: "/systems/", getOne: "/systems/{id}/", updateOne: "/systems/{id}/", - deprecateOne: "/systems/{id}/deprecate/", + deleteOne: "/systems/{id}/", plants: "/systems/{id}/plants/", alerts: "/systems/{id}/alerts/" }, @@ -28,11 +28,14 @@ export const APIS = { }, alert: { getAll: "/alerts/", - deprecateOne: "/alerts/{id}/deprecate" + deleteOne: "/alerts/{id}/", + deleteOne: "/todos/{id}/", }, light: { getAll: "/lights/", - create: "/lights/" + create: "/lights/", + deleteOne: "/todos/{id}/", + }, meta: { getOne: "/meta/" @@ -44,7 +47,7 @@ export const APIS = { getAll: "/mixes/", create: "/mixes/", updateOne: "/mixes/{id}/", - deprecateOne: "/mixes/{id}/deprecate/" + deleteOne: "/todos/{id}/", }, soil: { getAll: "/soils/" @@ -62,7 +65,6 @@ export const APIS = { task: { resolve: "/todos/{id}/tasks/{eid}/resolve", unresolve: "/todos/{id}/tasks/{eid}/unresolve" - }, } @@ -141,7 +143,6 @@ export const simpleDelete = (url) => { if (!response.ok) { throw new Error('Network response was not ok'); } - - return response.json(); + return ""; }); }; diff --git a/client/src/hooks/useAlerts.js b/client/src/hooks/useAlerts.js index 453cf83..745fffd 100644 --- a/client/src/hooks/useAlerts.js +++ b/client/src/hooks/useAlerts.js @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { simpleFetch, simplePost, APIS, apiBuilder } from '../api'; +import { simpleFetch, simplePost, APIS, apiBuilder, simpleDelete } from '../api'; export const useAlerts = (initialAlerts = []) => { const [alerts, setAlerts] = useState(initialAlerts); @@ -18,7 +18,7 @@ export const useAlerts = (initialAlerts = []) => { const resolveAlert = async (id) => { setIsLoading(true); setError(null); - simplePost(apiBuilder(APIS.alert.deprecateOne).setId(id).get()) + simpleDelete(apiBuilder(APIS.alert.deleteOne).setId(id).get()) .then(() => setAlerts(prevAlerts => prevAlerts.filter(alert => alert.id !== id diff --git a/client/src/hooks/useMix.js b/client/src/hooks/useMix.js index e3d874b..a67d0f2 100644 --- a/client/src/hooks/useMix.js +++ b/client/src/hooks/useMix.js @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { simpleFetch, simplePost, apiBuilder, simplePatch, APIS } from '../api'; +import { simpleFetch, simplePost, apiBuilder, simplePatch, APIS, simpleDelete } from '../api'; export const useMixes = () => { const [mixes, setMixes] = useState([]); @@ -47,10 +47,10 @@ export const useMixes = () => { }; /** Deprecate the mix */ - const deprecateMix = async (id) => { + const deleteMix = async (id) => { setIsLoading(true); setError(null); - simplePost(apiBuilder(APIS.plant.deprecateOne).setId(id).get()) + simpleDelete(apiBuilder(APIS.plant.deleteOne).setId(id).get()) .then(() => setMixes(prevMixes => prevMixes.filter(mix => mix.id !== id @@ -62,7 +62,7 @@ export const useMixes = () => { setIsLoading(false)) }; - return { mixes, isLoading, error, setError, createMix, updateMix, deprecateMix }; + return { mixes, isLoading, error, setError, createMix, updateMix, deleteMix }; }; export const useSoilParts = (initialParts) => { diff --git a/client/src/hooks/useSystems.js b/client/src/hooks/useSystems.js index f3d861a..333983c 100644 --- a/client/src/hooks/useSystems.js +++ b/client/src/hooks/useSystems.js @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { simpleFetch, simplePost, simplePatch, apiBuilder, APIS } from '../api'; +import { simpleFetch, simplePost, simplePatch, apiBuilder, APIS, simpleDelete } from '../api'; /** Query and api functionality for all systems. */ export const useSystems = () => { @@ -42,7 +42,7 @@ export const useSystems = () => { const deprecateSystem = async (id) => { setIsLoading(true); setError(null); - simplePost(apiBuilder(APIS.system.deprecateOne).setId(id).get()) + simpleDelete(apiBuilder(APIS.system.deleteOne).setId(id).get()) .then(() => setSystems(prevSystems => prevSystems.filter(system => system.id !== id diff --git a/server/app/routes/alert_routes.py b/server/app/routes/alert_routes.py index 5bc8080..610befe 100644 --- a/server/app/routes/alert_routes.py +++ b/server/app/routes/alert_routes.py @@ -5,4 +5,4 @@ bp = Blueprint("alerts", __name__) alert_crud = GenericCRUD(Table.ALERT, Schema.ALERT) -APIBuilder.register_blueprint(bp, "alerts", alert_crud, ["GET", "GET_MANY", "DELETE"]) +APIBuilder.register_blueprint(bp, "alerts", alert_crud, ["GET", "GET_MANY", "DELETE", "BANISH"]) diff --git a/server/app/routes/mix_routes.py b/server/app/routes/mix_routes.py index 01a5039..08d9581 100644 --- a/server/app/routes/mix_routes.py +++ b/server/app/routes/mix_routes.py @@ -6,5 +6,5 @@ bp = Blueprint("mixes", __name__) mix_crud = GenericCRUD(Table.MIX, Schema.MIX) APIBuilder.register_blueprint( - bp, "mixes", mix_crud, ["GET", "GET_MANY", "POST", "DELETE", "PATCH"] + bp, "mixes", mix_crud, ["GET", "GET_MANY", "POST", "BANISH", "PATCH"] ) diff --git a/server/app/routes/plant_routes.py b/server/app/routes/plant_routes.py index ecc7ffe..920e241 100644 --- a/server/app/routes/plant_routes.py +++ b/server/app/routes/plant_routes.py @@ -5,7 +5,7 @@ bp = Blueprint("plants", __name__) plant_crud = GenericCRUD(Table.PLANT, Schema.PLANT) -APIBuilder.register_blueprint(bp, "plants", plant_crud) +APIBuilder.register_blueprint(bp, "plants", plant_crud, ["GET", "GET_MANY", "POST", "PATCH", "BANISH"]) # @APIBuilder.register_custom_route(bp, 'deprecate/', ['GET']) # def deprecate_plants(): diff --git a/server/app/routes/system_routes.py b/server/app/routes/system_routes.py index c3d0f9f..11ff37a 100644 --- a/server/app/routes/system_routes.py +++ b/server/app/routes/system_routes.py @@ -8,7 +8,7 @@ system_bp = Blueprint("systems", __name__) system_crud = GenericCRUD(Table.SYSTEM, Schema.SYSTEM) -APIBuilder.register_blueprint(system_bp, "systems", system_crud) +APIBuilder.register_blueprint(system_bp, "systems", system_crud, ["GET", "GET_MANY", "POST", "PATCH", "BANISH"]) @APIBuilder.register_custom_route( diff --git a/server/app/routes/todo_routes.py b/server/app/routes/todo_routes.py index 861aabf..659787d 100644 --- a/server/app/routes/todo_routes.py +++ b/server/app/routes/todo_routes.py @@ -11,7 +11,7 @@ bp = Blueprint("todos", __name__) todo_crud = GenericCRUD(Table.TODO, Schema.TODO) -APIBuilder.register_blueprint(bp, "todos", todo_crud) +APIBuilder.register_blueprint(bp, "todos", todo_crud, ["GET", "GET_MANY", "POST", "PATCH", "DELETE"]) @APIBuilder.register_custom_route(bp, "/todos//tasks//resolve", methods=['POST']) def resolve_task(id, task_id): From ac33b511a32ea83d1015e0ce68d075941e454167 Mon Sep 17 00:00:00 2001 From: mseng10 Date: Mon, 17 Feb 2025 13:00:25 -0600 Subject: [PATCH 42/44] Docker setup and black run --- docker-compose.yml | 49 ++++++++++++++++++------------ server/app/Dockerfile | 13 +++++--- server/app/app.py | 6 ++-- server/app/routes/__init__.py | 19 ++++++------ server/app/routes/alert_routes.py | 2 +- server/app/routes/plant_routes.py | 4 ++- server/app/routes/system_routes.py | 10 ++++-- server/app/routes/todo_routes.py | 22 +++++++++----- server/models/__init__.py | 1 + server/models/mix.py | 10 +++--- server/models/plant.py | 1 + server/shared/db.py | 24 +++++++-------- 12 files changed, 98 insertions(+), 63 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c123308..c50919f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,24 +1,23 @@ version: '3.8' - services: app: - build: + build: context: ./server dockerfile: app/Dockerfile ports: - "8002:5000" environment: - - DATABASE_URL=postgresql://postgres:admin@db:5432/plnts + - MONGODB_URL=mongodb://mongo1:27017 + - MONGODB_URL_HIST=mongodb://mongo1:27018 - USE_LOCAL_HARDWARE=true - ENVIRONMENT=docker - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=admin - - POSTGRES_DB=plnts volumes: - ./server/shared:/app/shared - ./server/app:/app/app depends_on: - db: + mongo1: + condition: service_healthy + mongo2: condition: service_healthy networks: - app-network @@ -31,27 +30,38 @@ services: - "8003:5000" environment: - ENVIRONMENT=docker + - MONGODB_URL=mongodb://mongo2:27017/plnts volumes: - ./server/shared:/app/shared - ./server/adaptor:/app/adaptor networks: - app-network - db: - image: postgres:13 + mongo1: + image: mongo:latest volumes: - - postgres_data:/var/lib/postgresql/data - environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=admin - - POSTGRES_DB=plnts + - mongo_data_1:/data/db + ports: + - "27017:27017" healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] interval: 5s timeout: 5s retries: 5 + networks: + - app-network + + mongo2: + image: mongo:latest + volumes: + - mongo_data_2:/data/db ports: - - "8001:5432" + - "27018:27017" + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 5s + timeout: 5s + retries: 5 networks: - app-network @@ -59,8 +69,8 @@ services: build: context: ./client dockerfile: Dockerfile - args: - - NODE_ENV=${NODE_ENV:-development} + args: + - NODE_ENV=${NODE_ENV:-development} ports: - "3000:3000" environment: @@ -88,7 +98,8 @@ services: - app-network volumes: - postgres_data: + mongo_data_1: + mongo_data_2: networks: app-network: diff --git a/server/app/Dockerfile b/server/app/Dockerfile index 9ecfa25..dc14f6b 100755 --- a/server/app/Dockerfile +++ b/server/app/Dockerfile @@ -1,19 +1,24 @@ FROM python:3.9 - WORKDIR /app COPY ../requirements.txt . RUN pip install -r requirements.txt -RUN apt-get update && apt-get install -y postgresql-client +# Remove PostgreSQL client and install MongoDB tools instead +RUN apt-get update && apt-get install -y \ + wget gnupg \ + && wget -qO - https://www.mongodb.org/static/pgp/server-7.0.asc | apt-key add - \ + && echo "deb http://repo.mongodb.org/apt/debian bullseye/mongodb-org/7.0 main" | tee /etc/apt/sources.list.d/mongodb-org-7.0.list \ + && apt-get update \ + && apt-get install -y mongodb-mongosh mongodb-database-tools \ + && rm -rf /var/lib/apt/lists/* + COPY . . COPY ../shared . - RUN chmod -R 755 /app COPY app/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh EXPOSE 5000 - ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/server/app/app.py b/server/app/app.py index c5cf00a..f4a318d 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -30,7 +30,7 @@ # Create Flask app app = Flask(__name__) -app.config['DEBUG'] = True +app.config["DEBUG"] = True from routes.system_routes import system_bp, light_bp from routes.plant_routes import bp as plant_bp @@ -134,7 +134,9 @@ def manage_plant_alerts(): for plant in existing_plants: end_date = plant.watered_on + timedelta(days=float(plant.watering)) if end_date < now and existing_plant_alrts_map.get(plant.id) is None: - new_plant_alert:Alert = Alert(model_id=plant.id, alert_type=AlertTypes.WATER.value) + new_plant_alert: Alert = Alert( + model_id=plant.id, alert_type=AlertTypes.WATER.value + ) # Create the alert in the db Table.ALERT.create(new_plant_alert) existing_plant_alrts_map[new_plant_alert.model_id] = new_plant_alert diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py index fc23950..3b34505 100644 --- a/server/app/routes/__init__.py +++ b/server/app/routes/__init__.py @@ -37,7 +37,7 @@ class Schema(Enum): "deprecated_on": SchemaField(), "deprecated_cause": SchemaField(), "batch": SchemaField(), - "batch_count": SchemaField() + "batch_count": SchemaField(), } PLANT_GENUS_TYPE = { "id": SchemaField(read_only=True), @@ -86,7 +86,7 @@ class Schema(Enum): "created_on": SchemaField(read_only=True), "updated_on": SchemaField(read_only=True), "resolved": SchemaField(), - "resolved_on": SchemaField() + "resolved_on": SchemaField(), } SOIL = { "id": SchemaField(read_only=True), @@ -172,8 +172,7 @@ def read(self, obj: FlexibleModel, depth=0) -> Dict[str, Any]: nested_schema: Schema = getattr(Schema, v.nested_schema) if isinstance(value, list): result[k] = [ - nested_schema.read(item, depth + 1) - for item in value + nested_schema.read(item, depth + 1) for item in value ] elif value is not None: result[k] = nested_schema.read(value, depth + 1) @@ -256,7 +255,9 @@ def get_many(self): def create(self): try: - item: FlexibleModel = self.schema.create(self.table.model_class, request.json) + item: FlexibleModel = self.schema.create( + self.table.model_class, request.json + ) result = self.table.create(item) item.id = result @@ -297,7 +298,7 @@ def delete(self, id: str): except Exception as e: logger.error(f"Error in delete: {str(e)}") return jsonify({"error": str(e)}), 500 - + def banish(self, id: str): try: item = self.table.get_one(id) @@ -319,10 +320,10 @@ def deprecate(self): return jsonify({"error": "Not found"}), 404 elif not isinstance(item, DeprecatableMixin): return jsonify({"error": "Not found"}), 400 - + item.deprecate() - self.table.deprecate(item) # Yes an update + self.table.deprecate(item) # Yes an update return "", 201 except Exception as e: @@ -343,7 +344,7 @@ def register_blueprint( "PATCH", "DELETE", "DELETE_MANY", - "BANISH" + "BANISH", ], ): def create_wrapper(operation): diff --git a/server/app/routes/alert_routes.py b/server/app/routes/alert_routes.py index 610befe..b4dac7a 100644 --- a/server/app/routes/alert_routes.py +++ b/server/app/routes/alert_routes.py @@ -5,4 +5,4 @@ bp = Blueprint("alerts", __name__) alert_crud = GenericCRUD(Table.ALERT, Schema.ALERT) -APIBuilder.register_blueprint(bp, "alerts", alert_crud, ["GET", "GET_MANY", "DELETE", "BANISH"]) +APIBuilder.register_blueprint(bp, "alerts", alert_crud, ["GET", "GET_MANY", "BANISH"]) diff --git a/server/app/routes/plant_routes.py b/server/app/routes/plant_routes.py index 920e241..4a6cc6f 100644 --- a/server/app/routes/plant_routes.py +++ b/server/app/routes/plant_routes.py @@ -5,7 +5,9 @@ bp = Blueprint("plants", __name__) plant_crud = GenericCRUD(Table.PLANT, Schema.PLANT) -APIBuilder.register_blueprint(bp, "plants", plant_crud, ["GET", "GET_MANY", "POST", "PATCH", "BANISH"]) +APIBuilder.register_blueprint( + bp, "plants", plant_crud, ["GET", "GET_MANY", "POST", "PATCH", "BANISH"] +) # @APIBuilder.register_custom_route(bp, 'deprecate/', ['GET']) # def deprecate_plants(): diff --git a/server/app/routes/system_routes.py b/server/app/routes/system_routes.py index 11ff37a..fb1ef41 100644 --- a/server/app/routes/system_routes.py +++ b/server/app/routes/system_routes.py @@ -8,7 +8,9 @@ system_bp = Blueprint("systems", __name__) system_crud = GenericCRUD(Table.SYSTEM, Schema.SYSTEM) -APIBuilder.register_blueprint(system_bp, "systems", system_crud, ["GET", "GET_MANY", "POST", "PATCH", "BANISH"]) +APIBuilder.register_blueprint( + system_bp, "systems", system_crud, ["GET", "GET_MANY", "POST", "PATCH", "BANISH"] +) @APIBuilder.register_custom_route( @@ -25,7 +27,9 @@ def get_systems_plants(id): return jsonify([Schema.PLANT.read(plant) for plant in plants]) -@APIBuilder.register_custom_route(system_bp, "/systems//alerts/", ["GET"]) +@APIBuilder.register_custom_route( + system_bp, "/systems//alerts/", methods=["GET"] +) def get_systems_alerts(id): """ Get system's alerts. @@ -90,5 +94,5 @@ def get_systems_alerts(id): light_bp = Blueprint("lights", __name__) light_crud = GenericCRUD(Table.LIGHT, Schema.LIGHT) APIBuilder.register_blueprint( - light_bp, "lights", light_crud, ["GET", "GET_MANY", "POST"] + light_bp, "lights", light_crud, ["GET", "GET_MANY", "POST", "BANISH"] ) diff --git a/server/app/routes/todo_routes.py b/server/app/routes/todo_routes.py index 659787d..3f666f4 100644 --- a/server/app/routes/todo_routes.py +++ b/server/app/routes/todo_routes.py @@ -11,38 +11,46 @@ bp = Blueprint("todos", __name__) todo_crud = GenericCRUD(Table.TODO, Schema.TODO) -APIBuilder.register_blueprint(bp, "todos", todo_crud, ["GET", "GET_MANY", "POST", "PATCH", "DELETE"]) +APIBuilder.register_blueprint( + bp, "todos", todo_crud, ["GET", "GET_MANY", "POST", "PATCH", "DELETE"] +) -@APIBuilder.register_custom_route(bp, "/todos//tasks//resolve", methods=['POST']) + +@APIBuilder.register_custom_route( + bp, "/todos//tasks//resolve", methods=["POST"] +) def resolve_task(id, task_id): todo: Todo = Table.TODO.get_one(id) if todo is None: return jsonify({"error": "Not found"}), 404 - + task = next((model for model in todo.tasks if model.id == ObjectId(task_id)), None) task.resolved = True task.resolved_on = datetime.now() task.updated_on = datetime.now() - + todo.updated_on = datetime.now() Table.TODO.update(id, todo) return jsonify(Schema.TASK.read(task)), 201 -@APIBuilder.register_custom_route(bp, "/todos//tasks//unresolve", methods=['POST']) + +@APIBuilder.register_custom_route( + bp, "/todos//tasks//unresolve", methods=["POST"] +) def unresolve_task(id, task_id): todo: Todo = Table.TODO.get_one(id) if todo is None: return jsonify({"error": "Not found"}), 404 - + task = next((model for model in todo.tasks if model.id == ObjectId(task_id)), None) task.resolved = False task.resolved_on = None task.updated_on = datetime.now() - + todo.updated_on = datetime.now() Table.TODO.update(id, todo) diff --git a/server/models/__init__.py b/server/models/__init__.py index a6bbbb5..eb0fed9 100644 --- a/server/models/__init__.py +++ b/server/models/__init__.py @@ -65,6 +65,7 @@ def deprecate(self, cause): self.deprecated_on = datetime.now() self.deprecated_cause = cause + class BanishableMixin: """In case the model is deprecated.""" diff --git a/server/models/mix.py b/server/models/mix.py index c43741e..09cf865 100644 --- a/server/models/mix.py +++ b/server/models/mix.py @@ -45,7 +45,9 @@ def __init__(self, **kwargs): self.experimental = kwargs.get("experimental", False) # Embedded soil parts - self.soil_parts: List[SoilPart] = [SoilPart(**sp) for sp in kwargs.get("soil_parts", [])] + self.soil_parts: List[SoilPart] = [ + SoilPart(**sp) for sp in kwargs.get("soil_parts", []) + ] def __repr__(self) -> str: return f"{self.name}" @@ -53,8 +55,6 @@ def __repr__(self) -> str: def to_dict(self) -> Dict[str, Any]: """Convert to MongoDB document format""" base_dict = super().to_dict() - if len(self.soil_parts)>0: - base_dict["soil_parts"] = [ - part.to_dict() for part in self.soil_parts - ] + if len(self.soil_parts) > 0: + base_dict["soil_parts"] = [part.to_dict() for part in self.soil_parts] return base_dict diff --git a/server/models/plant.py b/server/models/plant.py index 71b3aee..645acc6 100644 --- a/server/models/plant.py +++ b/server/models/plant.py @@ -44,6 +44,7 @@ def __init__(self, **kwargs): def __repr__(self) -> str: return f"{self.id}" + class PlantGenusType(FlexibleModel): def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/server/shared/db.py b/server/shared/db.py index a08bb48..b64e01a 100644 --- a/server/shared/db.py +++ b/server/shared/db.py @@ -20,10 +20,12 @@ MONGODB_URL = os.getenv("MONGODB_URL", "mongodb://localhost:27017") CLIENT: MongoClient = MongoClient(MONGODB_URL) -# Get the default database -DB_NAME = os.getenv("DB_NAME", "plnts") -DB: Database = CLIENT[DB_NAME] -HIST: Database = CLIENT[DB_NAME+"_hist"] +MONGODB_URL_HIST = os.getenv("MONGODB_URL", "mongodb://localhost:27017") +CLIENT_HIST: MongoClient = MongoClient(MONGODB_URL) + +DB: Database = CLIENT["plnts"] +HIST: Database = CLIENT["plnts_hist"] + class Table(Enum): PLANT = ("plant", Plant) @@ -61,23 +63,21 @@ def get_many( def update(self, id: str, data: FlexibleModel) -> bool: set = data.to_dict() - del set['_id'] - result = DB[self.table_name].update_one( - {"_id": ObjectId(id)}, {"$set": set} - ) + del set["_id"] + result = DB[self.table_name].update_one({"_id": ObjectId(id)}, {"$set": set}) return result.modified_count > 0 - + def deprecate(self, data: FlexibleModel) -> bool: if not isinstance(data, DeprecatableMixin): return False - + data.deprecate() return self.update(data.id, data) def delete(self, id: str) -> bool: result = DB[self.table_name].delete_one({"_id": ObjectId(id)}) return result.deleted_count > 0 - + def banish(self, id: str) -> bool: banishable: FlexibleModel = self.get_one(id) if not isinstance(banishable, BanishableMixin): @@ -86,6 +86,6 @@ def banish(self, id: str) -> bool: banishable.banish() if not self.delete(id): return False - + result = HIST[self.table_name].insert_one(banishable.to_dict()) return result.inserted_id is not None From 6565b3dbf035b4061e2344fdf4cbcd6f8c968f3a Mon Sep 17 00:00:00 2001 From: mseng10 Date: Mon, 17 Feb 2025 13:30:43 -0600 Subject: [PATCH 43/44] Fix bugs --- server/app/app.py | 8 +++----- server/app/routes/__init__.py | 2 +- server/models/__init__.py | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/server/app/app.py b/server/app/app.py index f4a318d..e844f43 100755 --- a/server/app/app.py +++ b/server/app/app.py @@ -71,8 +71,8 @@ def get_meta(): logger.info("Received request to query the meta") meta = { - "alert_count": Table.ALERT.count({"deprecated": False}), - "todo_count": Table.TODO.count({"deprecated": False}), + "alert_count": Table.ALERT.count(), + "todo_count": Table.TODO.count(), } logger.info("Successfully generated meta data.") @@ -127,9 +127,7 @@ def manage_plant_alerts(): for existing_plant_alert in existing_plant_alrts: existing_plant_alrts_map[existing_plant_alert.plant_id] = existing_plant_alert - existing_plants: List[Plant] = Table.PLANT.get_many( - {"deprecated": False} - ) # Sure... + existing_plants: List[Plant] = Table.PLANT.get_many() now = datetime.now() for plant in existing_plants: end_date = plant.watered_on + timedelta(days=float(plant.watering)) diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py index 3b34505..5023dc3 100644 --- a/server/app/routes/__init__.py +++ b/server/app/routes/__init__.py @@ -348,7 +348,7 @@ def register_blueprint( ], ): def create_wrapper(operation): - if operation in ["get", "update", "delete"]: + if operation in ["get", "update", "delete", "banish"]: def wrapper(id): return getattr(crud, operation)(id) diff --git a/server/models/__init__.py b/server/models/__init__.py index eb0fed9..ca647a5 100644 --- a/server/models/__init__.py +++ b/server/models/__init__.py @@ -1,5 +1,5 @@ # models/__init__.py -from typing import List, Any, Dict +from typing import List, Any, Dict, Optional import numpy as np import csv from bson import ObjectId @@ -74,7 +74,7 @@ def __init__(self, **kwargs): self.banished_on = kwargs.get("banished_on") self.banished_cause = kwargs.get("banished_cause") - def banish(self, cause): + def banish(self, cause: Optional[str]=None): self.banished = True self.banished_on = datetime.now() self.banished_cause = cause From de4e6a8f32aafe6c220a96e451716432bb8b9c9c Mon Sep 17 00:00:00 2001 From: mseng10 Date: Mon, 17 Feb 2025 13:32:30 -0600 Subject: [PATCH 44/44] NGINX and Kubernetes initial deploy --- client/.eslintrc.json | 39 -------------------------------- nginx/nginx.conf | 52 ++++++++++++++++++++++++++++++++++++++----- scripts/k8s-deploy.sh | 19 ++++++++++++++++ 3 files changed, 65 insertions(+), 45 deletions(-) delete mode 100644 client/.eslintrc.json create mode 100644 scripts/k8s-deploy.sh diff --git a/client/.eslintrc.json b/client/.eslintrc.json deleted file mode 100644 index 03dd135..0000000 --- a/client/.eslintrc.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "env": { - "browser": true, - "es2021": true, - "jest": true - }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended" - ], - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": "latest", - "sourceType": "module" - }, - "plugins": [ - "react" - ], - "rules": { - "react/react-in-jsx-scope": "off", - "linebreak-style": 0, - "no-undefined": "error", - "no-var": "error", - "prefer-const": "error", - "func-names": "error", - "id-length": "error", - "newline-before-return": "error", - "space-before-blocks": "error", - "no-alert": "error", - "react/prop-types": 0 - }, - "settings": { - "react": { - "version": "detect" - } - } -} \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf index cef669e..8646698 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -1,13 +1,53 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + http { - upstream backend_servers { - server 203.0.113.10:80; # Kubernetes backend service - server 203.0.113.20:5000; # Non-Kubernetes backend server - } + include /etc/nginx/mime.types; + default_type application/octet-stream; + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + sendfile on; + keepalive_timeout 65; server { listen 80; - location /api/ { - proxy_pass http://backend_servers; + server_name localhost; + + location / { + proxy_pass http://client:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Check if we're in production mode + if ($NODE_ENV = "production") { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } + } + + location /api { + proxy_pass http://backend:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Development-only location block for WebSocket support + location /sockjs-node { + proxy_pass http://client:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; } } } \ No newline at end of file diff --git a/scripts/k8s-deploy.sh b/scripts/k8s-deploy.sh new file mode 100644 index 0000000..dcbaea9 --- /dev/null +++ b/scripts/k8s-deploy.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Build and push Docker images +docker build -t your-registry/backend:latest ./backend +docker build -t your-registry/frontend:latest ./frontend +docker push your-registry/backend:latest +docker push your-registry/frontend:latest + +# Apply Kubernetes configurations +kubectl apply -k ./k8s + +# Set up Nginx reverse proxy on non-Kubernetes server +ssh user@non-kubernetes-server 'sudo apt-get update && sudo apt-get install -y nginx' +scp ./nginx/nginx.conf user@non-kubernetes-server:/etc/nginx/nginx.conf +ssh user@non-kubernetes-server 'sudo systemctl restart nginx' + +# Set up WireGuard VPN +scp ./vpn/wireguard-config.conf user@server:/etc/wireguard/wg0.conf +ssh user@server 'sudo systemctl start wg-quick@wg0' \ No newline at end of file