diff --git a/.gitignore b/.gitignore index 266c348..9e13a11 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -*.venv \ No newline at end of file +*.venv +*__pycache__/ +*.bat \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5f66e67..a8661c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Copy the server code into the container -COPY server.py . +COPY ./app . # Make port 8765 available to the world outside this container EXPOSE 8765 diff --git a/Makefile b/Makefile index 75907dd..34a4c71 100644 --- a/Makefile +++ b/Makefile @@ -13,5 +13,7 @@ build: run: build docker run -it --rm \ --name $(SERVER_CONTAINER_NAME) \ + -e DB_NAME=$(DB_NAME) \ + -e MONGO_URL=$(MONGO_URL) \ --network host \ $(SERVER_IMAGE) diff --git a/app/internal/db_handler.py b/app/internal/db_handler.py new file mode 100644 index 0000000..d86cb0b --- /dev/null +++ b/app/internal/db_handler.py @@ -0,0 +1,251 @@ +import motor.motor_asyncio +import asyncio +from typing import Optional, Dict, Any, List + +class MongoHandler: + """ + A basic asynchronous handler for MongoDB operations using motor. + Designed to be used with asyncio. + """ + def __init__(self, db_name: str, collection_name: str, mongo_uri: str = "mongodb://localhost:27017/"): + """ + Initializes the MongoDB handler. + + Args: + db_name (str): The name of the database to connect to. + collection_name (str): The name of the collection to use. + mongo_uri (str): The MongoDB connection string URI. + Defaults to the standard local URI. + """ + self.mongo_uri = mongo_uri + self.db_name = db_name + self.collection_name = collection_name + self._client: Optional[motor.motor_asyncio.AsyncIOMotorClient] = None + self._db: Optional[motor.motor_asyncio.AsyncIOMotorDatabase] = None + self._collection: Optional[motor.motor_asyncio.AsyncIOMotorCollection] = None + + async def connect(self): + """Establishes an asynchronous connection to MongoDB.""" + if self._client is None: + try: + self._client = motor.motor_asyncio.AsyncIOMotorClient(self.mongo_uri) + # The ismaster command is cheap and does not require auth. + # It is used to confirm that the client can connect to the deployment. + await self._client.admin.command('ismaster') + self._db = self._client[self.db_name] + self._collection = self._db[self.collection_name] + print(f"Connected to MongoDB: Database '{self.db_name}', Collection '{self.collection_name}'") + except Exception as e: + print(f"Failed to connect to MongoDB at {self.mongo_uri}: {e}") + self._client = None # Ensure client is None if connection fails + raise # Re-raise the exception after printing + + async def close(self): + """Closes the MongoDB connection.""" + if self._client: + self._client.close() + self._client = None + self._db = None + self._collection = None + print("MongoDB connection closed.") + + async def __aenter__(self): + """Allows using the handler with async with.""" + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Ensures the connection is closed when exiting async with.""" + await self.close() + + async def insert_one(self, document: Dict[str, Any]) -> Any: + """ + Inserts a single document into the collection. + + Args: + document (Dict[str, Any]): The document to insert. + + Returns: + Any: The result of the insert operation (InsertOneResult). + """ + if self._collection is None: + raise RuntimeError("MongoDB connection not established. Call connect() first or use async with.") + print(f"Inserting document into '{self.collection_name}'...") + result = await self._collection.insert_one(document) + print(f"Inserted document with ID: {result.inserted_id}") + return result + + async def find_one(self, query: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Finds a single document matching the query. + + Args: + query (Dict[str, Any]): The query document. + + Returns: + Optional[Dict[str, Any]]: The found document, or None if not found. + """ + if self._collection is None: + raise RuntimeError("MongoDB connection not established. Call connect() first or use async with.") + print(f"Finding one document in '{self.collection_name}' with query: {query}") + document = await self._collection.find_one(query) + return document + + async def find(self, query: Dict[str, Any] = None) -> List[Dict[str, Any]]: + """ + Finds multiple documents matching the query. + + Args: + query (Dict[str, Any], optional): The query document. Defaults to None (find all). + + Returns: + List[Dict[str, Any]]: A list of matching documents. + """ + if self._collection is None: + raise RuntimeError("MongoDB connection not established. Call connect() first or use async with.") + if query is None: + query = {} + print(f"Finding documents in '{self.collection_name}' with query: {query}") + # Use list comprehension to iterate through the cursor asynchronously + documents = [doc async for doc in self._collection.find(query)] + print(f"Found {len(documents)} documents.") + return documents + + async def update_one(self, query: Dict[str, Any], update: Dict[str, Any], upsert: bool = False) -> Any: + """ + Updates a single document matching the query. + + Args: + query (Dict[str, Any]): The query document. + update (Dict[str, Any]): The update operations to apply. + upsert (bool): If True, insert a new document if no match is found. + + Returns: + Any: The result of the update operation (UpdateResult). + """ + if self._collection is None: + raise RuntimeError("MongoDB connection not established. Call connect() first or use async with.") + print(f"Updating one document in '{self.collection_name}' with query: {query}, update: {update}") + result = await self._collection.update_one(query, update, upsert=upsert) + print(f"Matched {result.matched_count}, Modified {result.modified_count}, Upserted ID: {result.upserted_id}") + return result + + async def delete_one(self, query: Dict[str, Any]) -> Any: + """ + Deletes a single document matching the query. + + Args: + query (Dict[str, Any]): The query document. + + Returns: + Any: The result of the delete operation (DeleteResult). + """ + if self._collection is None: + raise RuntimeError("MongoDB connection not established. Call connect() first or use async with.") + print(f"Deleting one document from '{self.collection_name}' with query: {query}") + result = await self._collection.delete_one(query) + print(f"Deleted count: {result.deleted_count}") + return result + + async def delete_many(self, query: Dict[str, Any]) -> Any: + """ + Deletes multiple documents matching the query. + + Args: + query (Dict[str, Any]): The query document. + + Returns: + Any: The result of the delete operation (DeleteResult). + """ + if self._collection is None: + raise RuntimeError("MongoDB connection not established. Call connect() first or use async with.") + print(f"Deleting many documents from '{self.collection_name}' with query: {query}") + result = await self._collection.delete_many(query) + print(f"Deleted count: {result.deleted_count}") + return result + + +# --- Example Usage --- +async def example_mongo_usage(): + """Demonstrates how to use the MongoHandler.""" + # Ensure you have a MongoDB server running, default is localhost:27017 + db_name = "radio_app_db" + collection_name = "channels" + + # Using async with ensures the connection is closed automatically + async with MongoHandler(db_name, collection_name) as mongo: + # --- Insert Example --- + print("\n--- Inserting a document ---") + channel_data = { + "_id": "channel_3", # You can specify _id or let MongoDB generate one + "name": "Emergency Services", + "frequency_khz": 453000, + "location": "Countywide", + "avail_on_nodes": ["client-xyz987"], + "description": "Monitor for emergency broadcasts." + } + try: + insert_result = await mongo.insert_one(channel_data) + print(f"Insert successful: {insert_result.inserted_id}") + except Exception as e: + print(f"Insert failed: {e}") + + + # --- Find One Example --- + print("\n--- Finding one document ---") + query = {"_id": "channel_3"} + found_channel = await mongo.find_one(query) + if found_channel: + print("Found document:", found_channel) + else: + print("Document not found.") + + # --- Find Many Example --- + print("\n--- Finding all documents ---") + all_channels = await mongo.find() # Empty query finds all + print("All documents:", all_channels) + + # --- Update Example --- + print("\n--- Updating a document ---") + update_query = {"_id": "channel_3"} + update_data = {"$set": {"location": "Statewide", "avail_on_nodes": ["client-xyz987", "client-newnode1"]}} + update_result = await mongo.update_one(update_query, update_data) + print(f"Update successful: Matched {update_result.matched_count}, Modified {update_result.modified_count}") + + print("\n--- Finding the updated document ---") + updated_channel = await mongo.find_one(update_query) + print("Updated document:", updated_channel) + + # --- Delete Example --- + print("\n--- Deleting a document ---") + delete_query = {"_id": "channel_3"} + delete_result = await mongo.delete_one(delete_query) + print(f"Delete successful: Deleted count {delete_result.deleted_count}") + + print("\n--- Verifying deletion ---") + deleted_channel = await mongo.find_one(delete_query) + if deleted_channel: + print("Document still found (deletion failed).") + else: + print("Document successfully deleted.") + + # --- Insert another for delete_many example --- + temp_doc1 = {"_id": "temp_1", "tag": "temp"} + temp_doc2 = {"_id": "temp_2", "tag": "temp"} + await mongo.insert_one(temp_doc1) + await mongo.insert_one(temp_doc2) + + # --- Delete Many Example --- + print("\n--- Deleting many documents ---") + delete_many_query = {"tag": "temp"} + delete_many_result = await mongo.delete_many(delete_many_query) + print(f"Delete many successful: Deleted count {delete_many_result.deleted_count}") + + +# To run the example usage: +# 1. Ensure you have a MongoDB server running locally on the default port (27017). +# 2. Save the code as mongodb_handler.py. +# 3. Run from your terminal: python -m asyncio mongodb_handler.py +if __name__ == "__main__": + # Running the example directly requires running within an asyncio loop + asyncio.run(example_mongo_usage()) diff --git a/app/internal/db_wrappers.py b/app/internal/db_wrappers.py new file mode 100644 index 0000000..c89ae66 --- /dev/null +++ b/app/internal/db_wrappers.py @@ -0,0 +1,300 @@ +import os +import typing +import asyncio +from uuid import uuid4 +from typing import Optional, List, Dict, Any +from enum import Enum +from internal.db_handler import MongoHandler + +# Init vars +DB_NAME = os.getenv("DB_NAME", "default_db") +MONGO_URL = os.getenv("MONGO_URL", "mongodb://10.10.202.4:27017/") + +SYSTEM_DB_COLLECTION_NAME = "radio_systems" + +# --- Types +class DemodTypes(str, Enum): + P25 = "P25" + DMR = "DMR" + ANALOG = "NBFM" + +class TalkgroupTag: + """Represents a talkgroup tag.""" + def __init__(self, talkgroup: str, tagDec: int): + self.talkgroup = talkgroup + self.tagDec = tagDec + + # Add a method to convert to a dictionary, useful for sending as JSON + def to_dict(self) -> Dict[str, Any]: + return {"talkgroup": self.talkgroup, "tagDec": self.tagDec} + +class System: + """ + A basic data model for a channel/system entry in a radio system. + """ + + def __init__(self, + _id: str, + _type: DemodTypes, + name: str, + frequency_khz: List[int], + location: str, + avail_on_nodes: List[str], + description: Optional[str] = "", + tags: Optional[List[TalkgroupTag]] = None, + whitelist: Optional[List[int]] = None): + """ + Initializes a System object. + + Args: + _id: A unique identifier for the entry (e.g., MongoDB ObjectId string). + _type: The demodulation type (P25, NBFM, etc.). + name: The name of the channel/system. + frequency_khz: The frequency in kilohertz. + location: The geographical location or coverage area. + avail_on_nodes: A list of node identifiers where this is available. + description: A brief description. + """ + self._id: str = _id + self.type: DemodTypes = _type + self.name: str = name + self.frequency_khz: List[int] = frequency_khz + self.location: str = location + self.avail_on_nodes: List[str] = avail_on_nodes + self.description: str = description or "" + self.tags: List[TalkgroupTag] = tags or None + self.whitelist: List[int] = whitelist or None + + def __repr__(self) -> str: + """ + Provides a developer-friendly string representation of the object. + """ + # Use self.type.value for string representation of the enum + return (f"System(_id='{self._id}', type='{self.type.value}', name='{self.name}', " + f"frequency_khz={self.frequency_khz}, location='{self.location}', " + f"avail_on_nodes={self.avail_on_nodes}, description='{self.description}'," + f" tags='{self.tags}', whitelist='{self.whitelist}')") + + def to_dict(self) -> Dict[str, Any]: + """ + Converts the System object to a dictionary suitable for MongoDB. + Converts the DemodTypes enum to its string value. + """ + return { + "_id": self._id, + "type": self.type.value, # Store the enum value (string) + "name": self.name, + "frequency_khz": self.frequency_khz, + "location": self.location, + "avail_on_nodes": self.avail_on_nodes, + "description": self.description, + "tags": self.tags, + "whitelist": self.whitelist, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "System": + """ + Creates a System object from a dictionary (e.g., from MongoDB). + Converts the 'type' string back to a DemodTypes enum member. + """ + # Ensure required keys exist and handle potential type mismatches if necessary + # Convert the type string back to the DemodTypes enum + system_type = DemodTypes(data.get("type")) if data.get("type") else None # Handle missing or invalid type + + if system_type is None: + # Handle error: could raise an exception or return None/default + # For this example, let's raise an error if type is missing/invalid + raise ValueError(f"Invalid or missing 'type' in document data: {data}") + + + return cls( + _id=data.get("_id"), + _type=system_type, + name=data.get("name", ""), # Provide default empty string if name is missing + frequency_khz=data.get("frequency_khz", 0), # Provide default 0 if missing + location=data.get("location", ""), + avail_on_nodes=data.get("avail_on_nodes", []), # Provide default empty list + description=data.get("description", ""), + tags=data.get("tags", None), + whitelist=data.get("whitelist", None) + ) + + +# --- System class --- +class SystemDbController(): + def __init__(self): + # Init the handler + self.db_h = MongoHandler(DB_NAME, SYSTEM_DB_COLLECTION_NAME, MONGO_URL) + + async def create_system(self, system_data: Dict[str, Any]) -> Optional[System]: + """ + Creates a new system entry in the database. + + Args: + system_data: A dictionary containing the data for the new system. + + Returns: + The created System object if successful, None otherwise. + """ + print("\n--- Creating a document ---") + try: + # Check if the data to be inserted has an ID + if not system_data.get("_id"): + system_data['_id'] = uuid4() + + inserted_result = None + inserted_id = None + async with self.db_h as db: + insert_result = await self.db_h.insert_one(system_data) + inserted_id = insert_result.inserted_id + + if inserted_id: + print(f"Insert successful with ID: {inserted_id}") + # Fetch the inserted document to get the complete data including the generated _id + query = {"_id": inserted_id} + + inserted_doc = None + async with self.db_h as db: + inserted_doc = await db.find_one(query) + + if inserted_doc: + # Convert the fetched dictionary back to a System object + return System.from_dict(inserted_doc) + else: + print("Insert acknowledged but no ID returned.") + return None + + except Exception as e: + print(f"Create failed: {e}") + return None + + async def find_system(self, query: Dict[str, Any]) -> Optional[System]: + """ + Finds a single system entry in the database. + + Args: + query: A dictionary representing the query criteria. + + Returns: + A System object if found, None otherwise. + """ + print("\n--- Finding one document ---") + try: + found_doc = None + async with self.db_h as db: + found_doc = await db.find_one(query) + + if found_doc: + print("Found document (raw dict):", found_doc) + # Convert the dictionary result to a System object + return System.from_dict(found_doc) + else: + print("Document not found.") + return None + except Exception as e: + print(f"Find failed: {e}") + return None + + async def find_systems(self, query: Dict[str, Any]) -> Optional[List[System]]: + """ + Finds one or more system entries in the database. + + Args: + query: A dictionary representing the query criteria. + + Returns: + A list of System object(s) if found, None otherwise. + """ + print("\n--- Finding documents ---") + try: + found_docs = None + async with self.db_h as db: + found_docs = await db.find(query) + + if found_docs: + print("Found document (raw dict):", found_docs) + # Convert the dictionary results to a System object + converted_systems = [] + for doc in found_docs: + converted_systems.append(System.from_dict(doc)) + + print("YURB", found_docs, converted_systems) + + return converted_systems if len(converted_systems) > 0 else None + else: + print("Document not found.") + return None + except Exception as e: + print(f"Find failed: {e}") + return None + + async def find_all_systems(self, query: Dict[str, Any] = {}) -> List[System]: + """ + Finds multiple system entries in the database. + + Args: + query: A dictionary representing the query criteria (default is empty to find all). + + Returns: + A list of System objects. + """ + print("\n--- Finding multiple documents ---") + try: + found_docs = None + async with self.db_h as db: + found_docs = await db.find(query) + + if found_docs: + print(f"Found {len(found_docs)} documents (raw dicts).") + # Convert the list of dictionaries to a list of System objects + return [System.from_dict(doc) for doc in found_docs] + else: + print("No documents found.") + return [] + except Exception as e: + print(f"Find all failed: {e}") + return [] + + async def update_system(self, query: Dict[str, Any], update_data: Dict[str, Any]) -> Optional[int]: + """ + Updates a single system entry in the database. + + Args: + query: A dictionary representing the query criteria to find the document. + update_data: A dictionary representing the update operations (e.g., using $set). + + Returns: + The number of modified documents if successful, None otherwise. + """ + print("\n--- Updating a document ---") + try: + update_result = None + async with self.db_h as db: + update_result = await db.update_one(query, update_data) + + print(f"Update result: Matched {update_result.matched_count}, Modified {update_result.modified_count}") + return update_result.modified_count + except Exception as e: + print(f"Update failed: {e}") + return None + + async def delete_system(self, query: Dict[str, Any]) -> Optional[int]: + """ + Deletes a single system entry from the database. + Args: + query: A dictionary representing the query criteria to find the document to delete. + Returns: + The number of deleted documents if successful, None otherwise. + """ + print("\n--- Deleting a document ---") + try: + delete_result = None + async with self.db_h as db: + delete_result = await self.db_h.delete_one(query) + + print(f"Delete result: Deleted count {delete_result.deleted_count}") + return delete_result.deleted_count + except Exception as e: + print(f"Delete failed: {e}") + return None diff --git a/app/routers/systems.py b/app/routers/systems.py new file mode 100644 index 0000000..d9d3e8f --- /dev/null +++ b/app/routers/systems.py @@ -0,0 +1,259 @@ +from quart import Blueprint, jsonify, request, abort +from internal.db_wrappers import System, SystemDbController +from werkzeug.exceptions import HTTPException + +systems_bp = Blueprint('systems', __name__) +db_h = SystemDbController() + + +@systems_bp.route("/", methods=['POST']) +async def create_system_route(): + """API endpoint to create a new system.""" + print("\n--- Handling POST /systems ---") + try: + # In Quart, you need to explicitly get the JSON request body + request_data = await request.get_json() + + if not request_data: + abort(400, "Request body must be JSON") # Bad Request + + if '_id' in request_data: + id_search_result = await db_h.find_system({"_id": request_data["_id"]}) + if id_search_result: + # If _id is provided and exists, return conflict + abort(409, f"System with ID '{request_data['_id']}' already exists") + + # Check if name exists (optional, depending on requirements) + if 'name' in request_data: + name_search_result = await db_h.find_system({"name": request_data["name"]}) + if name_search_result: + abort(409, f"System with name '{request_data['name']}' already exists") + + # Check if frequency_khz exists (optional, depending on requirements) + if 'frequency_khz' in request_data: + freq_search_result = await db_h.find_system({"frequency_khz": request_data["frequency_khz"]}) + if freq_search_result: + abort(409, f"System with frequency '{request_data['frequency_khz']}' already exists") + + created_system = await db_h.create_system(request_data) + + if created_system: + print("Created new system:", created_system) + return jsonify(created_system), 201 + else: + abort(500, "Failed to create system in the database.") + except HTTPException: + raise + except Exception as e: + print(f"Error creating system: {e}") + # Catch any other unexpected errors + abort(500, f"Internal server error: {e}") + + +@systems_bp.route('/', methods=['GET']) +async def list_systems_route(): + """API endpoint to get a list of all systems.""" + print("\n--- Handling GET /systems ---") + try: + all_systems = await db_h.find_all_systems() + + return jsonify([system.to_dict() for system in all_systems]), 200 # 200 OK status code + except HTTPException: + raise + except Exception as e: + print(f"Error listing systems: {e}") + abort(500, f"Internal server error: {e}") + + +@systems_bp.route('/', methods=['GET']) +async def get_system_route(system_id: str): + """API endpoint to get details for a specific system by ID.""" + print(f"\n--- Handling GET /systems/{system_id} ---") + try: + # Fix the query dictionary syntax + system = await db_h.find_system({'_id': system_id}) + + if system: + # system is a System object, jsonify will convert it + return jsonify(system.to_dict()), 200 # 200 OK + else: + # If system is None, it means the document was not found + abort(404, f"System with ID '{system_id}' not found") # 404 Not Found + except HTTPException: + raise + except Exception as e: + print(f"Error getting system details for ID {system_id}: {e}") + abort(500, f"Internal server error: {e}") + + +@systems_bp.route('/client/', methods=['GET']) +async def get_system_by_client_route(client_id: str): + """API endpoint to get details for a specific system by ID.""" + print(f"\n--- Handling GET /systems/client/{client_id} ---") + try: + # Fix the query dictionary syntax + systems = await db_h.find_systems({'avail_on_nodes': client_id}) + + if systems: + # system is a System object, jsonify will convert it + return jsonify([system.to_dict() for system in systems]), 200 # 200 OK + else: + # If system is None, it means the document was not found + abort(404, f"Client with ID '{client_id}' not found") # 404 Not Found + except HTTPException: + raise + except Exception as e: + print(f"Error getting system details for client ID {client_id}: {e}") + abort(500, f"Internal server error: {e}") + + +@systems_bp.route('/', methods=['PUT']) +async def update_system_route(system_id: str, updated_system_data): + try: + update_system = await db_h.update_system({"_id", system_id}, updated_system_data) + + if update_system: + print("Updated system:", update_system) + return jsonify(update_system), 201 + else: + abort(500, "Failed to update system in the database.") + + except HTTPException: + raise + except Exception as e: + print(f"Error updating system: {e}") + # Catch any other unexpected errors + abort(500, f"Internal server error: {e}") + + +@systems_bp.route('/', methods=['DELETE']) +async def delete_system_route(system_id: str): + try: + deleted_system = await db_h.delete_system({"_id", system_id}) + + if deleted_system: + print("Deleted system:", deleted_system) + return jsonify(deleted_system), 201 + else: + abort(500, "Failed to delete system in the database.") + + except HTTPException: + raise + except Exception as e: + print(f"Error deleting system: {e}") + # Catch any other unexpected errors + abort(500, f"Internal server error: {e}") + + +@systems_bp.route('//assign', methods=['POST']) +async def assign_client_to_system_route(system_id: str): + """ + API endpoint to assign a client ID to a system's available_on_nodes list. + Uses MongoDB $addToSet to add the client ID if not already present. + Expects JSON body: {"client_id": "..."} + """ + print(f"\n--- Handling POST /systems/{system_id}/assign ---") + try: + request_data = await request.get_json() + + if not request_data or 'client_id' not in request_data: + abort(400, "Request body must contain 'client_id'") + + client_id = request_data['client_id'] + + if not isinstance(client_id, str) or not client_id: + abort(400, "'client_id' must be a non-empty string") + + # First, check if the system exists + existing_system = await db_h.find_system({"_id": system_id}) + if existing_system is None: + abort(404, f"System with ID '{system_id}' not found") + + # Use $addToSet to add the client_id to the avail_on_nodes array + # $addToSet only adds the element if it's not already in the array + update_query = {"_id": system_id} + update_data = {"$addToSet": {"avail_on_nodes": client_id}} + + update_result = await db_h.update_system(update_query, update_data) + + if update_result > 0: + print(f"Client '{client_id}' assigned to system '{system_id}'.") + status = "client_assigned" + else: + print(f"Client '{client_id}' was already assigned to system '{system_id}'.") + status = "already_assigned" + + updated_system = await db_h.find_system({"_id": system_id}) + if updated_system: + return jsonify({ + "status": status, + "system": updated_system.to_dict() # Return dict representation + }), 200 # 200 OK + else: + # Should not happen if update_result.matched_count was 1, but handle defensively + print(f"Update matched but couldn't fetch updated system {system_id}.") + abort(500, "Failed to fetch system state after assignment attempt.") + + except HTTPException: + raise + except Exception as e: + print(f"Error during system assignment: {e}") + abort(500, f"Internal server error: {e}") + + +@systems_bp.route('//dismiss', methods=['POST']) +async def dismiss_client_from_system_route(system_id: str): + """ + API endpoint to dismiss (remove) a client ID from a system's available_on_nodes list. + Uses MongoDB $pull to remove the client ID if present. + Expects JSON body: {"client_id": "..."} + """ + print(f"\n--- Handling POST /systems/{system_id}/deassign ---") + try: + request_data = await request.get_json() + + if not request_data or 'client_id' not in request_data: + abort(400, "Request body must contain 'client_id'") + + client_id = request_data['client_id'] + + if not isinstance(client_id, str) or not client_id: + abort(400, "'client_id' must be a non-empty string") + + # First, check if the system exists + existing_system = await db_h.find_system({"_id": system_id}) + if existing_system is None: + abort(404, f"System with ID '{system_id}' not found") + + # Use $pull to remove the client_id from the avail_on_nodes array + # $pull removes all occurrences of the value + update_query = {"_id": system_id} + update_data = {"$pull": {"avail_on_nodes": client_id}} + + update_result = await db_h.update_system(update_query, update_data) + + if update_result > 0: + print(f"Client '{client_id}' dismissed from system '{system_id}'.") + status = "client_deassigned" + else: + print(f"Client '{client_id}' was not found in avail_on_nodes for system '{system_id}'.") + status = "not_assigned" + # Note: update_result.matched_count will be 1 even if modified_count is 0 + + # Optionally fetch the updated document to return its current state + updated_system = await db_h.find_system({"_id": system_id}) + if updated_system: + return jsonify({ + "status": status, + "system": updated_system.to_dict() # Return dict representation + }), 200 # 200 OK + else: + # Should not happen if update_result.matched_count was 1, but handle defensively + print(f"Update matched but couldn't fetch updated system {system_id}.") + abort(500, "Failed to fetch system state after de-assignment attempt.") + + except HTTPException: + raise + except Exception as e: + print(f"Error during system de-assignment: {e}") + abort(500, f"Internal server error: {e}") diff --git a/server.py b/app/server.py similarity index 86% rename from server.py rename to app/server.py index a8ed9d7..3cdfa62 100644 --- a/server.py +++ b/app/server.py @@ -2,7 +2,8 @@ import asyncio import websockets import json import uuid -from quart import Quart, jsonify, request # Import necessary Quart components +from quart import Quart, jsonify, request +from routers.systems import systems_bp # --- WebSocket Server Components --- # Dictionary to store active clients: {client_id: websocket} @@ -46,7 +47,6 @@ async def send_command_to_all_clients(command_name, *args): print(f"Failed to send to client {client_id}: connection closed.") await unregister_client(client_id) - async def websocket_server_handler(websocket): """Handles incoming WebSocket connections and messages from clients.""" client_id = None @@ -85,10 +85,10 @@ channels = { "channel_1": { "id": "channel_1", "name": "Local News Radio", - "frequency_list_khz": [98500], - "decode_mode": "p25", + "frequency_list_khz": ["98500"], + "decode_mode": "P25", "location": "Cityville", - "tags": None, + "tags": [], "tag_whitelist": [1,2,3], "avail_on_nodes": ["client-abc123", "client-def456"], # List of client IDs that can tune this channel "description": "Your source for local news and weather." @@ -97,9 +97,9 @@ channels = { "id": "channel_2", "name": "Music Mix FM", "frequency_list_khz": [101300], - "decode_mode": "p25", + "decode_mode": "P25", "location": "Townsville", - "tags": None, + "tags": [], "tag_whitelist": [6,7,8], "avail_on_nodes": ["client-def456", "client-ghi789"], "description": "Playing the hits, all day long." @@ -117,7 +117,7 @@ websocket_server_instance = None async def startup_websocket_server(): """Starts the WebSocket server when the Quart app starts.""" global websocket_server_instance - websocket_server_address = "localhost" + websocket_server_address = "0.0.0.0" websocket_server_port = 8765 # Start the WebSocket server task @@ -138,31 +138,25 @@ async def shutdown_websocket_server(): print("WebSocket server shut down.") +app.register_blueprint(systems_bp, url_prefix="/systems") + @app.route('/') async def index(): return "Welcome to the Radio App Server API!" -@app.route('/channels', methods=['GET']) -async def get_channels(): - """API endpoint to get a list of all channels.""" - # Return a list of channel IDs and names for a summary view - channel_summary = [{"id": ch["id"], "name": ch["name"]} for ch in channels.values()] - return jsonify(channel_summary) - -@app.route('/channels/', methods=['GET']) -async def get_channel_details(channel_id): - """API endpoint to get details for a specific channel.""" - channel = channels.get(channel_id) - if channel: - return jsonify(channel) - else: - return jsonify({"error": "Channel not found"}), 404 - @app.route('/clients', methods=['GET']) async def list_clients(): """API endpoint to list currently connected client IDs.""" return jsonify(list(active_clients.keys())) +@app.route('/request_token', methods=['POST']) +async def request_token(): + """API endpoint to list currently connected client IDs.""" + # TODO - Add DB logic + return jsonify({ + "token": "MTE5NjAwNTM2ODYzNjExMjk3Nw.GuCMXg.24iNNofNNumq46FIj68zMe9RmQgugAgfrvelEA" + }) + # --- Example API endpoint to trigger a WebSocket command --- # This is a simple example. More complex logic might be needed # to validate commands or arguments before sending. @@ -176,8 +170,8 @@ async def api_send_command(client_id, command_name): if client_id not in active_clients: return jsonify({"error": f"Client {client_id} not found"}), 404 - if command_name not in ["print_message", "set_status", "run_task"]: # Basic validation - return jsonify({"error": f"Unknown command: {command_name}"}), 400 + # if command_name not in ["join_server", "leave_server", "set_status", "run_task"]: # Basic validation + # return jsonify({"error": f"Unknown command: {command_name}"}), 400 try: request_data = await request.get_json() @@ -200,7 +194,7 @@ if __name__ == "__main__": # We removed asyncio.run(main()) and the main() function itself. print("Starting Quart API server...") app.run( - host="localhost", + host="0.0.0.0", port=5000, debug=False # Set to True for development ) diff --git a/requirements.txt b/requirements.txt index 036cbbc..f173fc1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ websockets -quart \ No newline at end of file +quart +motor +fastapi \ No newline at end of file