diff --git a/Docker flask mongo website documentation.docx b/Docker flask mongo website documentation.docx new file mode 100644 index 0000000..54f93fb Binary files /dev/null and b/Docker flask mongo website documentation.docx differ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f7aa775 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8080 + +CMD ["python", "app.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b060c74 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# I will create everything needed for the application +./prepare-app.sh + +# I will start the application +./start-app.sh + +# The app should be available at: +# http://localhost:8080 + +# I will open a web browser and work with the application. + +# I will pause the application +./stop-app.sh + +# I will delete everything related to the application +./remove-app.sh \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..9740f68 --- /dev/null +++ b/app.py @@ -0,0 +1,380 @@ +# Imports the os module so the app can read environment variables +# such as SECRET_KEY, MONGO_URI, and PORT. +import os + +# Imports time so the code can wait and retry when MongoDB is not ready yet. +import time + +# Imports datetime so the app can store the current date and time +# when a new product is created. +from datetime import datetime + +# Flask imports: +# - Flask creates the web application +# - render_template loads HTML files from the templates folder +# - request reads form data sent from the browser +# - redirect sends the user to another page +# - url_for builds a URL from a route function name +# - flash stores short success/error messages to show on the next page +from flask import Flask, render_template, request, redirect, url_for, flash + +# PyMongo imports: +# - MongoClient connects Python to MongoDB +# - ASCENDING is used when creating an index in ascending order +from pymongo import MongoClient, ASCENDING + +# Import the specific error raised when trying to insert a duplicate value +# into a field that has a unique index. +from pymongo.errors import DuplicateKeyError + +# Imports ObjectId so MongoDB document IDs in the URL can be converted +# into the correct MongoDB ID type for queries. +from bson.objectid import ObjectId + +# Creates the Flask application object. +app = Flask(__name__) + +# Sets the secret key used by Flask for flash messages and sessions. +# It first tries to read SECRET_KEY from the environment. +# If not found, it uses "dev-secret-key" as a default. +app.secret_key = os.getenv("SECRET_KEY", "dev-secret-key") + +# Reads the MongoDB connection string from an environment variable. +# If none is provided, it uses the default Docker service connection. +MONGO_URI = os.getenv("MONGO_URI", "mongodb://admin:admin123@mongo:27017/") + +# Reads the MongoDB database name from the environment. +# Defaults to "appdb". +MONGO_DB_NAME = os.getenv("MONGO_DB_NAME", "appdb") + +# Reads the MongoDB collection name from the environment. +# Defaults to "products". +MONGO_COLLECTION_NAME = os.getenv("MONGO_COLLECTION_NAME", "products") + +# Reads the port number for the Flask app from the environment. +# Defaults to 8080 and converts it into an integer. +PORT = int(os.getenv("PORT", "8080")) + +# These variables will be filled later after MongoDB is connected. +# mongo_client will store the MongoClient object. +# products_collection will store the products collection object. +mongo_client = None +products_collection = None + + +# This function connects the application to MongoDB. +# It retries multiple times because, in Docker, MongoDB may take a few +# seconds to start after the Flask app container starts. +def connect_to_mongo(): + # Tells Python that these names refer to the global variables above, + # not new local variables inside the function. + global mongo_client, products_collection + + # Try up to 30 times to connect to MongoDB. + for attempt in range(30): + try: + # Create the MongoDB client. + # serverSelectionTimeoutMS=2000 means each failed attempt times out + # after 2 seconds instead of hanging too long. + mongo_client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=2000) + + # Ping the database to confirm it is reachable. + mongo_client.admin.command("ping") + + # Select the database configured in MONGO_DB_NAME. + db = mongo_client[MONGO_DB_NAME] + + # Select the collection configured in MONGO_COLLECTION_NAME. + products_collection = db[MONGO_COLLECTION_NAME] + + # Create a unique index on product_id. + # This prevents two products from having the same product_id. + products_collection.create_index( + [("product_id", ASCENDING)], + unique=True + ) + + # If connection and setup succeed, exit the function. + return + + except Exception as e: + # If connection fails, print a message showing which retry attempt failed. + print(f"MongoDB not ready yet (attempt {attempt + 1}/30): {e}") + + # Wait 2 seconds before trying again. + time.sleep(2) + + # If all 30 attempts fail, stop the app with an error. + raise RuntimeError("Could not connect to MongoDB after multiple attempts.") + + +# Converts a form value into a float. +# Used for fields like price and rating. +def parse_float(value): + # If value is None, replace it with an empty string, then strip spaces. + value = (value or "").strip() + + # If the field was left empty, return None instead of crashing. + if value == "": + return None + + # Convert the value to a float. + return float(value) + + +# Converts a form value into an integer. +# Used for product_id. +def parse_int(value): + # If value is None, replace it with an empty string, then strip spaces. + value = (value or "").strip() + + # If the field was left empty, return None. + if value == "": + return None + + # Convert the value to an integer. + return int(value) + + +# Converts a comma-separated string of tags into a Python list. +# Example: "gaming, pc, accessories" becomes ["gaming", "pc", "accessories"]. +def parse_tags(value): + # Replace None with empty string and trim spaces. + value = (value or "").strip() + + # If the field is empty, return None. + if not value: + return None + + # Split on commas, trim spaces around each tag, and discard empty tags. + return [tag.strip() for tag in value.split(",") if tag.strip()] + + +# Converts a raw MongoDB document into a cleaner dictionary for HTML templates. +# This makes values easier to display and avoids issues with ObjectId or None values. +def normalize_product(product): + return { + # Convert MongoDB's ObjectId into a normal string so it can be used in URLs. + "_id": str(product.get("_id")), + + # Read product_id, defaulting to empty string if missing. + "product_id": product.get("product_id", ""), + + # Read product name. + "name": product.get("name", ""), + + # Read category. + "category": product.get("category", ""), + + # If price is None, use empty string so the form field looks blank. + # Otherwise keep the real price value. + "price": "" if product.get("price") is None else product.get("price"), + + # Make sure in_stock is always a real boolean. + "in_stock": bool(product.get("in_stock", False)), + + # If rating is None, use empty string for cleaner display. + "rating": "" if product.get("rating") is None else product.get("rating"), + + # Always return tags as a list. + "tags": product.get("tags") or [], + + # Also create a comma-separated version of tags for text input fields. + "tags_text": ", ".join(product.get("tags") or []), + + # Format created_at as a readable date-time string if it exists. + "created_at": product.get("created_at").strftime("%Y-%m-%d %H:%M:%S") + if product.get("created_at") else "", + + # Read supplier name and country. + "supplier_name": product.get("supplier_name", ""), + "supplier_country": product.get("supplier_country", "") + } + + +# This route handles visits to the home page "/". +# It loads all products from MongoDB and shows them in index.html. +@app.route("/") +def index(): + # Find all documents in the collection and sort them by product_id ascending. + raw_products = list(products_collection.find().sort("product_id", 1)) + + # Normalize every MongoDB document before sending it to the template. + products = [normalize_product(p) for p in raw_products] + + # Render the HTML page and pass the products list into it. + return render_template("index.html", products=products) + + +# This route handles form submissions for creating a new product. +# It only accepts POST requests. +@app.route("/create", methods=["POST"]) +def create_product(): + try: + # Build a new product dictionary from submitted form values. + product = { + # Convert product_id to integer. + "product_id": parse_int(request.form.get("product_id")), + + # Read the required name field and trim spaces. + "name": request.form.get("name", "").strip(), + + # Optional text fields become None if left blank. + "category": request.form.get("category", "").strip() or None, + + # Convert numeric fields. + "price": parse_float(request.form.get("price")), + + # Checkbox is "on" when checked. + "in_stock": request.form.get("in_stock") == "on", + + # Convert rating to float. + "rating": parse_float(request.form.get("rating")), + + # Convert tags from text to list. + "tags": parse_tags(request.form.get("tags")), + + # Store the current UTC time as the creation time. + "created_at": datetime.utcnow(), + + # Optional supplier fields. + "supplier_name": request.form.get("supplier_name", "").strip() or None, + "supplier_country": request.form.get("supplier_country", "").strip() or None + } + + # Validate that product_id was provided. + if not product["product_id"]: + flash("Product ID is required.", "error") + return redirect(url_for("index")) + + # Validate that name was provided. + if not product["name"]: + flash("Name is required.", "error") + return redirect(url_for("index")) + + # Insert the new product into MongoDB. + products_collection.insert_one(product) + + # Store a success message to show on the next page load. + flash("Product created.", "success") + + except DuplicateKeyError: + # Happens when another product already has the same product_id. + flash("That product_id already exists.", "error") + + except ValueError: + # Happens when user enters invalid numbers in numeric fields. + flash("Invalid number entered for product_id, price, or rating.", "error") + + except Exception as e: + # Catch-all for any other error. + flash(f"Create failed: {e}", "error") + + # Return the user to the home page after processing. + return redirect(url_for("index")) + + +# This route opens the edit page for a specific product. +# The product's MongoDB _id is passed in the URL. +@app.route("/edit/") +def edit_product(product_id): + try: + # Look up the product by its MongoDB ObjectId. + product = products_collection.find_one({"_id": ObjectId(product_id)}) + + # If no product is found, show an error and go back home. + if not product: + flash("Product not found.", "error") + return redirect(url_for("index")) + + # Render edit.html with the normalized product data. + return render_template("edit.html", product=normalize_product(product)) + + except Exception: + # Happens if product_id is not a valid ObjectId or something else fails. + flash("Invalid product id.", "error") + return redirect(url_for("index")) + + +# This route handles updating an existing product. +# It accepts POST data from the edit form. +@app.route("/update/", methods=["POST"]) +def update_product(product_id): + try: + # Build a dictionary of updated field values from the form. + updated_product = { + "name": request.form.get("name", "").strip(), + "category": request.form.get("category", "").strip() or None, + "price": parse_float(request.form.get("price")), + "in_stock": request.form.get("in_stock") == "on", + "rating": parse_float(request.form.get("rating")), + "tags": parse_tags(request.form.get("tags")), + "supplier_name": request.form.get("supplier_name", "").strip() or None, + "supplier_country": request.form.get("supplier_country", "").strip() or None + } + + # Require the name field. + if not updated_product["name"]: + flash("Name is required.", "error") + return redirect(url_for("edit_product", product_id=product_id)) + + # Update the matching document in MongoDB. + # $set changes only the listed fields instead of replacing the whole document. + result = products_collection.update_one( + {"_id": ObjectId(product_id)}, + {"$set": updated_product} + ) + + # If no document matched the given ID, show an error. + if result.matched_count == 0: + flash("Product not found.", "error") + else: + # Otherwise report success. + flash("Product updated.", "success") + + except ValueError: + # Numeric parsing failed. + flash("Invalid number entered for price or rating.", "error") + + except Exception as e: + # Any other problem during update. + flash(f"Update failed: {e}", "error") + + # Return to the main page after update. + return redirect(url_for("index")) + + +# This route deletes a product. +# It accepts POST requests so deletion is triggered by a form submission. +@app.route("/delete/", methods=["POST"]) +def delete_product(product_id): + try: + # Delete the document whose _id matches the URL parameter. + result = products_collection.delete_one({"_id": ObjectId(product_id)}) + + # If nothing was deleted, the product was not found. + if result.deleted_count == 0: + flash("Product not found.", "error") + else: + # Otherwise show a success message. + flash("Product deleted.", "success") + + except Exception as e: + # Handle invalid ObjectId or any other delete error. + flash(f"Delete failed: {e}", "error") + + # Return the user to the home page. + return redirect(url_for("index")) + + +# This block only runs when this file is executed directly. +# It will not run if the app is imported from another file. +if __name__ == "__main__": + # Connect to MongoDB before starting the web server. + connect_to_mongo() + + # Start the Flask development server. + # host="0.0.0.0" makes it accessible from outside the container. + # port=PORT uses the configured port. + # debug=False disables Flask debug mode. + app.run(host="0.0.0.0", port=PORT, debug=False) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c46d741 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,90 @@ +# Docker Compose file format version +# 3.9 is a commonly used Compose file version +# version: "3.9" + +# Defines all containers (services) that belong to this application +services: + + # MongoDB database service + mongo: + # Uses the official MongoDB image, version 7 + image: mongo:7 + + # Gives the container a fixed name so it is easier to identify in Docker + container_name: mongo-crud-db + + # Automatically restarts the container if it stops because of a failure + restart: on-failure + + # Environment variables passed into the MongoDB container + environment: + # Creates the MongoDB root/admin username on first startup + MONGO_INITDB_ROOT_USERNAME: admin + + # Creates the MongoDB root/admin password on first startup + MONGO_INITDB_ROOT_PASSWORD: admin123 + + # Connects this container to the custom Docker network below + networks: + - mongo-crud-net + + # Mounts a named Docker volume into MongoDB's data folder + # This keeps database data persistent even if the container is recreated + volumes: + - mongo_crud_data:/data/db + + # Flask web application service + web: + # Builds the image from the Dockerfile in the current folder + build: . + + # Gives the web container a fixed name + container_name: mongo-crud-web + + # Automatically restarts the container if it fails + restart: on-failure + + # Tells Docker to start the mongo service before the web service + # Note: this does not guarantee MongoDB is fully ready, only that it starts first + depends_on: + - mongo + + # Environment variables passed into the Flask application + environment: + # MongoDB connection string used by the Flask app + # The hostname "mongo" works because it is the service name on the Docker network + MONGO_URI: mongodb://admin:admin123@mongo:27017/ + + # MongoDB database name the app should use + MONGO_DB_NAME: appdb + + # MongoDB collection name the app should use + MONGO_COLLECTION_NAME: products + + # Flask secret key used for sessions and flash messages + SECRET_KEY: mongo-crud-secret + + # Port the Flask app listens on inside the container + PORT: 8080 + + # Maps ports between the host machine and the container + # Left side = host port, right side = container port + ports: + - "8080:8080" + + # Connects the web container to the same Docker network as MongoDB + networks: + - mongo-crud-net + +# Defines custom Docker networks used by the application +networks: + mongo-crud-net: + # Explicitly sets the network name instead of letting Docker generate one + name: mongo-crud-net + +# Defines named Docker volumes used by the application +volumes: + mongo_crud_data: + # Explicitly sets the volume name + # This volume stores MongoDB files persistently + name: mongo_crud_data \ No newline at end of file diff --git a/edit.html b/edit.html new file mode 100644 index 0000000..12871bf --- /dev/null +++ b/edit.html @@ -0,0 +1,189 @@ + + + + + + + + + + + + Edit Product + + + + + +
+ + +

Edit Product

+ + +
+ + + +
+ + +
+ + + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + + +
+ + +
+ + + + + + Back +
+
+
+
+ + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..5c15f6d --- /dev/null +++ b/index.html @@ -0,0 +1,316 @@ + + + + + + + + + + + + MongoDB Products Manager + + + + + +
+ +

MongoDB Products Manager

+ + +
+ + + {% with messages = get_flashed_messages(with_categories=true) %} + + + {% if messages %} +
+ + + {% for category, message in messages %} + + +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + +

Add Product

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + +
+ +
+
+
+ + +
+

Products

+ + + {% if products %} +
+ + + + + + + + + + + + + + + + + + + + + {% for product in products %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
product_idnamecategorypricein_stockratingtagssupplier_namesupplier_countrycreated_atactions
{{ product.product_id }}{{ product.name }}{{ product.category or '' }}{{ product.price if product.price != '' else '' }}{{ product.in_stock }}{{ product.rating if product.rating != '' else '' }}{{ product.tags_text }}{{ product.supplier_name or '' }}{{ product.supplier_country or '' }}{{ product.created_at }} +
+ + Edit + + +
+ + +
+
+
+
+ + + {% else %} +
No products found yet.
+ {% endif %} +
+
+ + \ No newline at end of file diff --git a/prepare-app.sh b/prepare-app.sh new file mode 100644 index 0000000..ef0c608 --- /dev/null +++ b/prepare-app.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Uses the user's Bash interpreter to run this script + +set -e +# Stops the script immediately if any command fails + +echo "Preparing application..." +# Prints a message so the user knows the setup process has started + +echo "Creating Docker network..." +# Prints a message before checking or creating the Docker network + +docker network inspect mongo-crud-net >/dev/null 2>&1 || docker network create mongo-crud-net +# Tries to inspect the Docker network named "mongo-crud-net" +# >/dev/null hides normal output +# 2>&1 hides error output +# If the network does not exist, the command after || runs and creates it + +echo "Creating Docker volume..." +# Prints a message before checking or creating the Docker volume + +docker volume inspect mongo_crud_data >/dev/null 2>&1 || docker volume create mongo_crud_data +# Tries to inspect the Docker volume named "mongo_crud_data" +# If the volume does not exist, it creates it +# This volume will be used to store MongoDB data persistently + +echo "Building application images..." +# Prints a message before building the Docker images + +docker compose build +# Builds the Docker images defined in docker-compose.yml +# This usually builds the web app image and prepares everything needed to run the app + +echo "" +# Prints an empty line for cleaner terminal output + +echo "Preparation complete." +# Prints a message showing the setup finished successfully + +echo "Created or verified:" +# Prints a heading for the summary of what now exists + +echo " - network: mongo-crud-net" +# Shows the Docker network that was checked or created + +echo " - volume: mongo_crud_data" +# Shows the Docker volume that was checked or created + +echo " - images: built via docker compose" +# Shows that the Docker images were built from the Compose configuration \ No newline at end of file diff --git a/remove-app.sh b/remove-app.sh new file mode 100644 index 0000000..4b393d7 --- /dev/null +++ b/remove-app.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -e + +echo "Removing application containers, network, and volume..." + +docker compose down -v --remove-orphans || true +docker network rm mongo-crud-net >/dev/null 2>&1 || true +docker volume rm mongo_crud_data >/dev/null 2>&1 || true + +echo "Application removed completely." \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4eb487a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.3 +pymongo==4.8.0 \ No newline at end of file diff --git a/start-app.sh b/start-app.sh new file mode 100644 index 0000000..78d8937 --- /dev/null +++ b/start-app.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Uses the user's Bash interpreter to run this script + +set -e +# Stops the script immediately if any command fails + +echo "Starting application..." +# Prints a message so the user knows the startup process has begun + +docker compose up -d +# Starts the services defined in docker-compose.yml in detached mode +# "detached mode" means the containers run in the background + +echo "" +# Prints an empty line for cleaner terminal output + +echo "Application started." +# Confirms that the application startup command completed successfully + +echo "Open the website at:" +# Prints a label for the website address + +echo " http://localhost:8080" +# Shows the browser address where the user can open the web application + +echo "" +# Prints another empty line for readability + +echo "To follow logs, run:" +# Prints a label for the log command + +echo " docker compose logs -f" +# Shows the command that displays live logs from the running containers \ No newline at end of file diff --git a/stop-app.sh b/stop-app.sh new file mode 100644 index 0000000..26530c0 --- /dev/null +++ b/stop-app.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Uses the user's Bash interpreter to run this script + +set -e +# Stops the script immediately if any command fails + +echo "Stopping application services..." +# Prints a message so the user knows the shutdown process has started + +docker compose stop +# Stops the running containers defined in docker-compose.yml +# The containers are stopped but not deleted + +echo "Application stopped." +# Confirms that the application services were stopped successfully + +echo "State is preserved because containers were stopped, not removed." +# Explains that data and container state are still kept +# because stop does not remove containers or volumes \ No newline at end of file diff --git a/~$cker flask mongo website documentation.docx b/~$cker flask mongo website documentation.docx new file mode 100644 index 0000000..fc25aab Binary files /dev/null and b/~$cker flask mongo website documentation.docx differ