Netdeploy2 Babyyyy

This commit is contained in:
Ben Mosley
2026-02-03 17:41:29 -06:00
commit c77674b86d
23 changed files with 659 additions and 0 deletions

0
.env 2 Normal file
View File

55
.gitignore vendored Normal file
View File

@@ -0,0 +1,55 @@
# Python bytecode
__pycache__/
*.py[cod]
*$py.class
# Virtual environments
.venv/
venv/
env/
netdeploy/
ENV/
# Flask / Python cache
instance/
.cache/
.pytest_cache/
# Environment variables
.env
.env.local
.env.*.local
# Database files
*.db
*.sqlite3
# Flask-Migrate
migrations/
!migrations/README
# Logs
*.log
# OS files
.DS_Store
Thumbs.db
# Editor files
.vscode/
.idea/
*.swp
*.swo
# Compiled assets (for later if you add frontend tooling)
node_modules/
dist/
build/
# PDF outputs (when you add them later)
generated_pdfs/
*.pdf
# Temporary files
tmp/
temp/

28
app.py Normal file
View File

@@ -0,0 +1,28 @@
from flask import Flask, redirect, url_for
from extensions import db, migrate
from routes.clients import bp as clients_bp
from routes.quotes import bp as quotes_bp
from routes.invoices import bp as invoices_bp
from models import *
def create_app():
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///quotes.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.secret_key = "dev-secret"
db.init_app(app)
migrate.init_app(app, db)
app.register_blueprint(clients_bp)
app.register_blueprint(quotes_bp)
app.register_blueprint(invoices_bp)
@app.route("/")
def index():
return redirect(url_for("quotes.dashboard"))
return app
if __name__ == "__main__":
create_app().run(debug=True, use_reloader=False)

5
extensions.py Normal file
View File

@@ -0,0 +1,5 @@
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()

15
models/client.py Normal file
View File

@@ -0,0 +1,15 @@
from datetime import datetime
from extensions import db
class Client(db.Model):
id = db.Column(db.Integer, primary_key=True)
kind = db.Column(db.String(30), default="small_business")
name = db.Column(db.String(120), nullable=False)
company = db.Column(db.String(120))
email = db.Column(db.String(255))
phone = db.Column(db.String(50))
notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)

4
models/init.py Normal file
View File

@@ -0,0 +1,4 @@
from .client import Client
from .quote import Quote, QuoteItem
from .invoice import Invoice, InvoiceLine
from .payment import Payment

28
models/invoice.py Normal file
View File

@@ -0,0 +1,28 @@
from datetime import datetime
from extensions import db
class Invoice(db.Model):
id = db.Column(db.Integer, primary_key=True)
client_id = db.Column(db.Integer, db.ForeignKey("client.id"))
client = db.relationship("Client", backref="invoices")
quote_id = db.Column(db.Integer, db.ForeignKey("quote.id"))
quote = db.relationship("Quote", backref="invoices")
status = db.Column(db.String(20), default="unpaid")
total = db.Column(db.Numeric(10,2), default=0)
amount_paid = db.Column(db.Numeric(10,2), default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class InvoiceLine(db.Model):
id = db.Column(db.Integer, primary_key=True)
invoice_id = db.Column(db.Integer, db.ForeignKey("invoice.id"))
invoice = db.relationship("Invoice", backref="lines")
description = db.Column(db.String(255))
qty = db.Column(db.Numeric(10,2))
unit_price = db.Column(db.Numeric(10,2))

13
models/payment.py Normal file
View File

@@ -0,0 +1,13 @@
from datetime import datetime
from extensions import db
class Payment(db.Model):
id = db.Column(db.Integer, primary_key=True)
invoice_id = db.Column(db.Integer, db.ForeignKey("invoice.id"))
invoice = db.relationship("Invoice", backref="payments")
amount = db.Column(db.Numeric(10,2))
method = db.Column(db.String(40))
created_at = db.Column(db.DateTime, default=datetime.utcnow)

36
models/quote.py Normal file
View File

@@ -0,0 +1,36 @@
from datetime import datetime
from extensions import db
class Quote(db.Model):
id = db.Column(db.Integer, primary_key=True)
category = db.Column(db.String(30))
status = db.Column(db.String(20), default="draft")
client_id = db.Column(db.Integer, db.ForeignKey("client.id"))
client = db.relationship("Client", backref="quotes")
title = db.Column(db.String(200))
public_notes = db.Column(db.Text)
internal_notes = db.Column(db.Text)
subtotal = db.Column(db.Numeric(10,2), default=0)
total = db.Column(db.Numeric(10,2), default=0)
milestones_json = db.Column(db.JSON)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class QuoteItem(db.Model):
id = db.Column(db.Integer, primary_key=True)
quote_id = db.Column(db.Integer, db.ForeignKey("quote.id"))
quote = db.relationship("Quote", backref="items")
description = db.Column(db.String(255))
qty = db.Column(db.Numeric(10,2), default=1)
unit_price = db.Column(db.Numeric(10,2), default=0)
@property
def line_total(self):
return (self.qty or 0) * (self.unit_price or 0)

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
Flask
Flask-SQLAlchemy
Flask-Migrate
python-dotenv

22
routes/clients.py Normal file
View File

@@ -0,0 +1,22 @@
from flask import Blueprint, render_template, request, redirect, url_for
from extensions import db
from models.client import Client
bp = Blueprint("clients", __name__, url_prefix="/clients")
@bp.route("/")
def list_clients():
return render_template("clients/list.html", clients=Client.query.all())
@bp.route("/new", methods=["GET","POST"])
def new_client():
if request.method == "POST":
c = Client(
name=request.form["name"],
email=request.form["email"]
)
db.session.add(c)
db.session.commit()
return redirect(url_for("clients.list_clients"))
return render_template("clients/new.html")

27
routes/invoices.py Normal file
View File

@@ -0,0 +1,27 @@
from flask import Blueprint, render_template, redirect, url_for
from extensions import db
from models.quote import Quote
from models.invoice import Invoice
from models.payment import Payment
from services.invoicing import invoice_from_quote
bp = Blueprint("invoices", __name__, url_prefix="/invoices")
@bp.route("/")
def list_invoices():
return render_template("invoices/list.html", invoices=Invoice.query.all())
@bp.route("/from-quote/<int:quote_id>", methods=["POST"])
def create_from_quote(quote_id):
q = Quote.query.get_or_404(quote_id)
inv = invoice_from_quote(q)
db.session.add(inv)
db.session.commit()
return redirect(url_for("invoices.view_invoice", invoice_id=inv.id))
@bp.route("/<int:invoice_id>")
def view_invoice(invoice_id):
inv = Invoice.query.get_or_404(invoice_id)
return render_template("invoices/view.html", inv=inv)

67
routes/quotes.py Normal file
View File

@@ -0,0 +1,67 @@
from flask import Blueprint, render_template, request, redirect, url_for
from extensions import db
from models.quote import Quote, QuoteItem
from services.totals import compute_quote_totals
bp = Blueprint("quotes", __name__, url_prefix="/quotes")
@bp.route("/")
def dashboard():
quotes = Quote.query.all()
return render_template("quotes/dashboard.html", quotes=quotes)
@bp.route("/new", methods=["GET","POST"])
@bp.route("/new", methods=["GET","POST"])
def new_quote():
from models.client import Client
if request.method == "POST":
q = Quote(
title=request.form["title"],
category=request.form["category"],
client_id=request.form["client_id"]
)
db.session.add(q)
db.session.commit()
return redirect(url_for("quotes.edit_quote", quote_id=q.id))
clients = Client.query.all()
return render_template("quotes/new.html", clients=clients)
@bp.route("/<int:quote_id>", methods=["GET", "POST"])
def edit_quote(quote_id):
q = Quote.query.get_or_404(quote_id)
if request.method == "POST":
q.title = request.form.get("title", q.title)
item_ids = request.form.getlist("item_id")
descriptions = request.form.getlist("description")
qtys = request.form.getlist("qty")
prices = request.form.getlist("unit_price")
from decimal import Decimal
for idx, item_id in enumerate(item_ids):
item = QuoteItem.query.get(int(item_id))
if not item or item.quote_id != q.id:
continue
item.description = descriptions[idx]
item.qty = Decimal(qtys[idx] or "0")
item.unit_price = Decimal(prices[idx] or "0")
compute_quote_totals(q)
db.session.commit()
return redirect(url_for("quotes.edit_quote", quote_id=q.id))
return render_template("quotes/edit.html", q=q)
@bp.route("/<int:quote_id>/add-item", methods=["POST"])
def add_item(quote_id):
item = QuoteItem(quote_id=quote_id, description="New Item", qty=1, unit_price=0)
db.session.add(item)
db.session.commit()
return redirect(url_for("quotes.edit_quote", quote_id=quote_id))

20
services/invoicing.py Normal file
View File

@@ -0,0 +1,20 @@
from models.invoice import Invoice, InvoiceLine
from decimal import Decimal
def invoice_from_quote(quote):
inv = Invoice(
client_id=quote.client_id,
quote_id=quote.id
)
for item in quote.items:
inv.lines.append(
InvoiceLine(
description=item.description,
qty=item.qty,
unit_price=item.unit_price
)
)
inv.total = sum([Decimal(str(l.qty)) * Decimal(str(l.unit_price)) for l in inv.lines])
return inv

6
services/totals.py Normal file
View File

@@ -0,0 +1,6 @@
from decimal import Decimal
def compute_quote_totals(quote):
subtotal = sum([Decimal(str(i.line_total)) for i in quote.items])
quote.subtotal = subtotal
quote.total = subtotal

31
templates/base.html Normal file
View File

@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>QuoteApp</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 20px; color:#111; }
nav a { margin-right: 10px; }
table { border-collapse: collapse; width: 100%; max-width: 900px; }
th, td { border: 1px solid #ddd; padding: 8px; }
th { background: #f5f5f5; text-align: left; }
input, select, button { padding: 6px; }
button { cursor: pointer; }
</style>
</head>
<body>
<nav>
<a href="{{ url_for('quotes.dashboard') }}">Dashboard</a> |
<a href="{{ url_for('clients.list_clients') }}">Clients</a> |
<a href="{{ url_for('quotes.dashboard') }}">Quotes</a> |
<a href="{{ url_for('invoices.list_invoices') }}">Invoices</a>
</nav>
<hr>
{% block content %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block content %}
<h2>Clients</h2>
<p>
<a href="{{ url_for('clients.new_client') }}">+ New Client</a>
</p>
<table border="1" cellpadding="6">
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Kind</th>
</tr>
{% for c in clients %}
<tr>
<td>{{ c.id }}</td>
<td>{{ c.name }}</td>
<td>{{ c.email or "" }}</td>
<td>{{ c.kind }}</td>
</tr>
{% else %}
<tr>
<td colspan="4">No clients yet.</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<h2>New Client</h2>
<form method="POST">
<p>
<label>Name<br>
<input name="name" required style="width: 360px;">
</label>
</p>
<p>
<label>Email<br>
<input name="email" style="width: 360px;">
</label>
</p>
<button type="submit">Create Client</button>
<a href="{{ url_for('clients.list_clients') }}" style="margin-left: 10px;">Cancel</a>
</form>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block content %}
<h2>Invoices</h2>
<table border="1" cellpadding="6">
<tr>
<th>ID</th>
<th>Client</th>
<th>Status</th>
<th>Total</th>
<th>Paid</th>
<th></th>
</tr>
{% for inv in invoices %}
<tr>
<td>{{ inv.id }}</td>
<td>{{ inv.client.name if inv.client else "" }}</td>
<td>{{ inv.status }}</td>
<td>${{ inv.total }}</td>
<td>${{ inv.amount_paid }}</td>
<td>
<a href="{{ url_for('invoices.view_invoice', invoice_id=inv.id) }}">View</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">No invoices yet.</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block content %}
<h2>Invoice #{{ inv.id }}</h2>
<p>
<strong>Client:</strong> {{ inv.client.name if inv.client else "" }}<br>
<strong>Status:</strong> {{ inv.status }}<br>
<strong>Total:</strong> ${{ inv.total }}<br>
<strong>Paid:</strong> ${{ inv.amount_paid }}<br>
</p>
<h3>Lines</h3>
<table border="1" cellpadding="6">
<tr>
<th>Description</th>
<th>Qty</th>
<th>Unit Price</th>
<th>Line Total</th>
</tr>
{% for l in inv.lines %}
<tr>
<td>{{ l.description }}</td>
<td>{{ l.qty }}</td>
<td>${{ l.unit_price }}</td>
<td>${{ (l.qty or 0) * (l.unit_price or 0) }}</td>
</tr>
{% else %}
<tr>
<td colspan="4">No lines found.</td>
</tr>
{% endfor %}
</table>
<p style="margin-top: 16px;">
<a href="{{ url_for('invoices.list_invoices') }}">Back to invoices</a>
</p>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block content %}
<h2>Dashboard</h2>
<p>
<a href="{{ url_for('quotes.new_quote') }}">+ New Quote</a>
</p>
<h3>Draft / Sent Quotes</h3>
<table border="1" cellpadding="6">
<tr>
<th>ID</th>
<th>Title</th>
<th>Status</th>
<th>Total</th>
<th></th>
</tr>
{% for q in quotes if q.status in ['draft','sent'] %}
<tr>
<td>{{ q.id }}</td>
<td>{{ q.title }}</td>
<td>{{ q.status }}</td>
<td>${{ q.total }}</td>
<td><a href="{{ url_for('quotes.edit_quote', quote_id=q.id) }}">Open</a></td>
</tr>
{% else %}
<tr><td colspan="5">None</td></tr>
{% endfor %}
</table>
<hr>
<h3>Approved Quotes (Ready for Invoice)</h3>
<table border="1" cellpadding="6">
<tr>
<th>ID</th>
<th>Title</th>
<th>Total</th>
<th></th>
</tr>
{% for q in quotes if q.status == 'approved' %}
<tr>
<td>{{ q.id }}</td>
<td>{{ q.title }}</td>
<td>${{ q.total }}</td>
<td><a href="{{ url_for('quotes.edit_quote', quote_id=q.id) }}">View</a></td>
</tr>
{% else %}
<tr><td colspan="4">None</td></tr>
{% endfor %}
</table>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block content %}
<h2>Edit Quote #{{ q.id }}</h2>
<form method="POST">
<p>
<label>
Title<br>
<input name="title" value="{{ q.title or '' }}" style="width: 420px;">
</label>
</p>
<p>
<strong>Category:</strong> {{ q.category }}<br>
<strong>Status:</strong> {{ q.status }}
</p>
<h3>Line Items</h3>
<table>
<tr>
<th>Description</th>
<th style="width:110px;">Qty</th>
<th style="width:140px;">Unit Price</th>
<th style="width:140px;">Line Total</th>
</tr>
{% for item in q.items %}
<tr>
<td>
<!-- THIS is what makes saving work -->
<input type="hidden" name="item_id" value="{{ item.id }}">
<input name="description" value="{{ item.description or '' }}" style="width: 100%;">
</td>
<td>
<input name="qty" type="number" step="0.01" value="{{ item.qty or 0 }}" style="width: 100px;">
</td>
<td>
<input name="unit_price" type="number" step="0.01" value="{{ item.unit_price or 0 }}" style="width: 120px;">
</td>
<td>
${{ ((item.qty or 0) * (item.unit_price or 0)) }}
</td>
</tr>
{% else %}
<tr>
<td colspan="4">No items yet. Click “Add Item”.</td>
</tr>
{% endfor %}
</table>
<p style="margin-top: 10px;">
<button type="submit">Save Quote</button>
<a href="{{ url_for('quotes.dashboard') }}" style="margin-left:10px;">Back</a>
</p>
</form>
<form method="POST" action="{{ url_for('quotes.add_item', quote_id=q.id) }}">
<button type="submit">Add Item</button>
</form>
<hr>
<h3>Total: ${{ q.total }}</h3>
<form method="POST" action="{{ url_for('invoices.create_from_quote', quote_id=q.id) }}">
<button type="submit">Create Invoice from Quote</button>
</form>
{% endblock %}

38
templates/quotes/new.html Normal file
View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block content %}
<h2>New Quote</h2>
<form method="POST">
<p>
<label>Title<br>
<input name="title" required style="width: 360px;">
</label>
</p>
<p>
<label>Category<br>
<select name="category" required>
<option value="web_design">Web Design</option>
<option value="it_services">IT Services</option>
<option value="pc_repair">PC Repair</option>
</select>
</label>
</p>
<p>
<label>Client<br>
<select name="client_id" required>
{% for c in clients %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</label>
</p>
<button type="submit">Create Quote</button>
<a href="{{ url_for('quotes.dashboard') }}" style="margin-left: 10px;">Cancel</a>
</form>
{% endblock %}