{"slug": "ending-product-sales-without-breaking-sales-history-implementing-soft-deletes-in", "title": "📝 Ending Product Sales Without Breaking Sales History — Implementing Soft Deletes and Reducing Gemini API Usage in Flask", "summary": "A developer building a bakery sales management application with Flask, PostgreSQL, and the Gemini API implemented soft deletes to preserve sales history when editing product masters, and reduced Gemini API usage. The developer, a self-taught programmer with about 135 hours of study, encountered a NotNullViolation error when deleting products referenced by sales records, and solved it by updating existing products by primary key and inserting only new ones. The update also cut Gemini API calls by optimizing the flow.", "body_md": "Hello from Japan! 🇯🇵\n\nThis article was originally published in Japanese on Qiita and has been translated and adapted for DEV Community.\n\nI am a professional truck driver teaching myself Python while developing a bakery sales management application with Flask, PostgreSQL, and the Gemini API.\n\nAt the time of this work, I had completed approximately **135 hours of programming study**.\n\nI also made a major update to the application and its README.\n\n[https://github.com/tosane932/sales_data_app](https://github.com/tosane932/sales_data_app)\n\nWhen I added product-master editing, I encountered several connected problems:\n\nI spent approximately six hours reviewing the entire flow:\n\n```\nProduct updates\n→ Product discontinuation\n→ Database migration\n→ Production startup\n→ Gemini API execution\n```\n\nThis article records the failed implementation and how I changed it into a safer structure.\n\nThis is a long development record, so thank you for your patience.\n\nWhen I updated the product master, the following error occurred:\n\n```\npsycopg2.errors.NotNullViolation:\nnull value in column \"product_id\" of relation \"daily_sales\"\nviolates not-null constraint\n```\n\nSQLAlchemy attempted to execute an update similar to:\n\n```\nUPDATE daily_sales\nSET product_id = NULL\nWHERE daily_sales.id = ...\n```\n\nThe `DailySales.product_id`\n\ncolumn was defined with `nullable=False`\n\n.\n\n```\nproduct_id = db.Column(\n    db.Integer,\n    db.ForeignKey(\"products.id\"),\n    nullable=False,\n)\n```\n\nTherefore, when I tried to delete a product connected to existing sales history, SQLAlchemy could not change the related `product_id`\n\nto `NULL`\n\n.\n\nThe original product-master update logic deleted all products for the selected year and month, then recreated them from the submitted form.\n\n```\nProduct.query.filter_by(\n    year=year,\n    month=month,\n).delete()\n\nfor product in products_data:\n    db.session.add(\n        Product(\n            year=year,\n            month=month,\n            name=product[\"name\"],\n            price=product[\"price\"],\n        )\n    )\n\ndb.session.commit()\n```\n\nThis looks simple when considering only the product-master table.\n\nHowever, the daily-sales table references `Product.id`\n\n.\n\n```\nproducts\n└── id = 1\n\ndaily_sales\n└── product_id = 1\n```\n\nDeleting and recreating the product generates a different primary key even when the visible product name is unchanged.\n\n```\nBefore deletion:\nWhite Bread, id = 1\n\nAfter re-registration:\nWhite Bread, id = 15\n```\n\nTo a human, both records represent the same product.\n\nTo the database, they are completely different rows.\n\nI added each existing product's `Product.id`\n\nto the form as a hidden field.\n\n```\n<input\n    type=\"hidden\"\n    name=\"product_id\"\n    value=\"{{ product.id }}\"\n>\n```\n\nNew products added through JavaScript receive an empty ID.\n\n```\n<input\n    type=\"hidden\"\n    name=\"product_id\"\n    value=\"\"\n>\n```\n\nThe Flask route receives product IDs, names, and prices in matching order.\n\n```\nproduct_ids = request.form.getlist(\"product_id\")\nproduct_names = request.form.getlist(\"prod_name\")\nproduct_prices = request.form.getlist(\"prod_price\")\n\nproducts_data = []\n\nfor product_id, name, price in zip(\n    product_ids,\n    product_names,\n    product_prices,\n):\n    if name.strip():\n        products_data.append(\n            {\n                \"id\": (\n                    int(product_id)\n                    if product_id.isdigit()\n                    else None\n                ),\n                \"name\": name.strip(),\n                \"price\": (\n                    int(price)\n                    if price.isdigit()\n                    else 0\n                ),\n            }\n        )\n```\n\nExisting products are updated by primary key.\n\nOnly products without an ID are inserted as new rows.\n\n```\nexisting_products = Product.query.filter_by(\n    year=year,\n    month=month,\n).all()\n\nexisting_dict = {\n    product.id: product\n    for product in existing_products\n}\n\nfor product_data in products_data:\n    product_id = product_data[\"id\"]\n\n    if (\n        product_id is not None\n        and product_id in existing_dict\n    ):\n        existing_product = existing_dict[product_id]\n\n        existing_product.name = product_data[\"name\"]\n        existing_product.price = product_data[\"price\"]\n        existing_product.is_active = True\n\n    else:\n        db.session.add(\n            Product(\n                year=year,\n                month=month,\n                name=product_data[\"name\"],\n                price=product_data[\"price\"],\n            )\n        )\n```\n\nNow a product name can be changed while preserving the same `Product.id`\n\n.\n\nThe existing sales history remains connected to the same database record.\n\nFor discontinued products, I decided not to delete the database row.\n\nInstead, I added a sales-status flag.\n\n```\nis_active = db.Column(\n    db.Boolean,\n    nullable=False,\n    default=True,\n    server_default=db.true(),\n)\n```\n\nI collect the IDs submitted by the form.\n\n```\nsubmitted_ids = {\n    product_data[\"id\"]\n    for product_data in products_data\n    if product_data[\"id\"] is not None\n}\n```\n\nAny existing product that is no longer included in the form is marked as inactive.\n\n```\nfor product in existing_products:\n    if product.id not in submitted_ids:\n        product.is_active = False\n```\n\nOnly active products are shown on the sales-entry screen.\n\n```\nproducts = Product.query.filter_by(\n    year=year,\n    month=month,\n    is_active=True,\n).all()\n```\n\nThis provides both behaviors:\n\n```\nDiscontinued product\n→ Hidden from future sales entry\nPast sales history\n→ Still connected to the original Product.id\n```\n\nThe product disappears from normal operation without deleting the historical record.\n\nPhysical deletion means removing the row itself.\n\n```\nDELETE FROM products ...\n```\n\nSoft deletion means keeping the row and changing its state.\n\n```\nis_active = False\n```\n\nFor a system that stores sales history, the product record is part of the historical evidence.\n\nEven when the product is no longer sold, past records still need to answer questions such as:\n\nDeleting the row would make that history difficult or impossible to interpret safely.\n\nI generated a migration locally.\n\n```\nflask db migrate -m \"add is_active to products\"\n```\n\nThe generated SQLite-oriented migration used:\n\n```\nserver_default=sa.text(\"1\")\n```\n\nI changed it to a form that is also clear for PostgreSQL.\n\n```\nserver_default=sa.true()\n```\n\nI then applied and checked the migration locally.\n\n```\nflask db upgrade\nflask db current\n```\n\nOn Render, I confirmed the following migration log:\n\n``` php\nRunning upgrade -> 043c481b4069, add is_active to products\n```\n\nThis showed that the production database had applied the new `is_active`\n\ncolumn.\n\n`stamp`\n\nand `upgrade`\n\nHave Different Roles\nThese two commands are not interchangeable.\n\n```\nflask db upgrade\n→ Executes migrations and changes the database schema\nflask db stamp head\n→ Marks the database as current without executing migrations\n```\n\nIf `stamp`\n\nruns automatically during every startup, an unapplied migration could be marked as already applied.\n\nThat would create a dangerous mismatch:\n\n```\nAlembic history\n→ Says the migration is applied\nActual database schema\n→ Has not changed\n```\n\nI therefore removed automatic `stamp`\n\nlogic from the application and used `upgrade`\n\nin the startup command instead.\n\nI replaced Flask's built-in development server with Gunicorn.\n\nIn `requirements.txt`\n\n:\n\n```\ngunicorn==26.0.0\n```\n\nThe production startup command became:\n\n```\nCMD [\n    \"sh\",\n    \"-c\",\n    \"flask db upgrade && gunicorn --bind 0.0.0.0:${PORT:-5000} app:app\"\n]\n```\n\nThe startup flow is now:\n\n```\nApply pending migrations\n        ↓\nIf successful, start Gunicorn\n        ↓\nServe the Flask application\n```\n\nAfter deployment, the logs showed:\n\n```\nStarting gunicorn 26.0.0\nListening at: http://0.0.0.0:10000\nBooting worker\nYour service is live\n```\n\nDuring testing, I encountered the following error:\n\n```\n429 RESOURCE_EXHAUSTED\n\nQuota exceeded for metric:\ngenerate_content_free_tier_requests\n\nlimit: 20\nmodel: gemini-2.5-flash\n```\n\nOriginally, the application called the Gemini API simply by opening certain pages.\n\n```\nOpen the dashboard\n→ Generate AI business advice\nOpen the daily sales-entry page\n→ Generate an AI greeting\nChange the selected period\n→ Generate AI business advice again\n```\n\nFor a free API tier, this design consumed requests unnecessarily.\n\nA user could spend the daily allowance without intentionally asking for AI analysis.\n\nI changed the default page behavior to display fixed text.\n\nThe application now calls Gemini only when the user presses an AI button.\n\nI separated the AI analysis into its own API endpoint.\n\n``` python\n@app.route(\"/api/ai-advice\")\ndef api_ai_advice():\n    year_param = request.args.get(\"year\")\n    month_param = request.args.get(\"month\")\n\n    target_year = (\n        int(year_param)\n        if year_param\n        else None\n    )\n\n    target_month = (\n        int(month_param)\n        if month_param\n        else None\n    )\n\n    sales_data = _get_sales_from_db(\n        target_year,\n        target_month,\n    )\n\n    ranked_sales = sorted(\n        sales_data.items(),\n        key=lambda item: item[1],\n        reverse=True,\n    )\n\n    return jsonify(\n        {\n            \"ai_advice\": _generate_ai_advice(\n                ranked_sales\n            )\n        }\n    )\n```\n\nThe browser calls the endpoint only when the button is pressed.\n\n``` js\nfunction loadAiAdvice() {\n    const year =\n        document.getElementById(\"selectYear\").value;\n\n    const month =\n        document.getElementById(\"selectMonth\").value;\n\n    fetch(`/api/ai-advice?year=${year}&month=${month}`)\n        .then((response) => response.json())\n        .then((data) => {\n            document.getElementById(\n                \"aiAdviceText\"\n            ).innerHTML = data.ai_advice.replace(\n                /\\n/g,\n                \"<br>\"\n            );\n        });\n}\n```\n\nI used the same principle for the daily greeting.\n\n```\nOpen the page\n→ Gemini requests: 0\nPress “Today's Message”\n→ Gemini requests: 1\n```\n\nThis makes API usage intentional instead of automatic.\n\nWhile the AI request is running, I disable the button.\n\n```\naiButton.disabled = true;\naiButton.textContent = \"Analyzing...\";\n```\n\nAfter the request finishes, the button is restored.\n\n``` js\n.finally(() => {\n    aiButton.disabled = false;\n\n    aiButton.textContent =\n        \"🤖 Ask for detailed advice again\";\n});\n```\n\nThis prevents:\n\nThe user-facing meaning of these errors is different.\n\n```\n429\n→ Quota exceeded or rate limited\n503\n→ Service temporarily unavailable or overloaded\n```\n\nI separated the messages.\n\n```\nerror_text = str(error)\n\nif (\n    \"GenerateRequestsPerDayPerProjectPerModel-FreeTier\"\n    in error_text\n):\n    return (\n        \"☕【Today's AI analysis limit has been reached】\\n\"\n        \"Your sales data was saved and aggregated normally.\"\n    )\n\nif (\n    \"429\" in error_text\n    or \"RESOURCE_EXHAUSTED\" in error_text\n):\n    return \"☕【The AI is taking a short break】\"\n\nif (\n    \"503\" in error_text\n    or \"UNAVAILABLE\" in error_text\n):\n    return \"🥐【The AI assistant is currently busy】\"\n```\n\nDetailed exception information is written to the logs instead of being displayed directly to the user.\n\nThis separates:\n\n```\nUser message\n→ Clear and understandable\nApplication log\n→ Detailed information for troubleshooting\n```\n\nThe final deployment also failed with:\n\n```\nIndentationError:\nexpected an indented block after 'if' statement\n```\n\nThis type of problem can be detected before deployment with:\n\n```\npython -m py_compile app.py\n```\n\nI added it to my pre-deployment inspection routine.\n\n```\ngit status\ngit diff\npython -m py_compile app.py\npytest\n```\n\nOne incorrect indentation level can prevent the entire production application from starting.\n\nA quick syntax check is therefore a useful final gate.\n\n```\nNew product\n→ INSERT\nExisting product\n→ UPDATE using Product.id\nProduct removed from the form\n→ is_active = False\nPast sales history\n→ Preserved\n```\n\nThe product name can change without breaking its relationship with existing sales records.\n\n```\nOpen dashboard\n→ 0 requests\nChange period and load sales data\n→ 0 requests\nPress “Ask for detailed advice”\n→ 1 request\nOpen daily sales-entry page\n→ 0 requests\nPress “Today's Message”\n→ 1 request\n```\n\nThe user now controls when the limited AI resource is consumed.\n\nWhen primary keys are different, the database treats the rows as different products.\n\nUpdates should be based on stable IDs, not only visible names.\n\nFor products connected to sales history, soft deletion was more appropriate than physical deletion.\n\n```\nGenerate\n→ Review\n→ Apply\n```\n\nAutomatically generated migrations still need human review, especially when both SQLite and PostgreSQL are involved.\n\nCalling the AI only when requested improved:\n\n```\npython -m py_compile app.py\n```\n\nA one-line indentation error can prevent the production application from starting.\n\nThis work was much more than adding a delete button.\n\nThe actual process was:\n\n```\nProduct update\n        ↓\nForeign-key error\n        ↓\nSwitch to ID-based updates\n        ↓\nIntroduce soft deletion\n        ↓\nGenerate and review a migration\n        ↓\nApply it to production\n        ↓\nSwitch production startup to Gunicorn\n        ↓\nReduce unnecessary Gemini API requests\n        ↓\nImprove 429 and 503 error messages\n```\n\nThe goal was not simply:\n\nMake the product disappear from the screen.\n\nI needed to consider:\n\nOnly after checking all of those areas did the update feel complete.\n\nMy next step is to expand pytest coverage for:\n\nThank you for reading.\n\n[https://github.com/tosane932/sales_data_app](https://github.com/tosane932/sales_data_app)\n\n[https://bakery-salesdata.onrender.com/](https://bakery-salesdata.onrender.com/)", "url": "https://wpnews.pro/news/ending-product-sales-without-breaking-sales-history-implementing-soft-deletes-in", "canonical_source": "https://dev.to/tosane932/ending-product-sales-without-breaking-sales-history-implementing-soft-deletes-and-reducing-11n5", "published_at": "2026-08-02 13:05:26+00:00", "updated_at": "2026-08-02 13:44:56.595369+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Flask", "PostgreSQL", "Gemini API", "SQLAlchemy", "Qiita", "DEV Community", "tosane932"], "alternates": {"html": "https://wpnews.pro/news/ending-product-sales-without-breaking-sales-history-implementing-soft-deletes-in", "markdown": "https://wpnews.pro/news/ending-product-sales-without-breaking-sales-history-implementing-soft-deletes-in.md", "text": "https://wpnews.pro/news/ending-product-sales-without-breaking-sales-history-implementing-soft-deletes-in.txt", "jsonld": "https://wpnews.pro/news/ending-product-sales-without-breaking-sales-history-implementing-soft-deletes-in.jsonld"}}