File: D:/web/hyperflight/apps/inxpress/prev/2025-11-04b/app - Copy (3).py
from urllib.parse import urlparse, urljoin
from flask import Flask, session, redirect, url_for, request, abort, render_template, send_from_directory
from decimal import Decimal, InvalidOperation
from .db import get_db
from .filters import register_filters
from .filters import country_name
from datetime import datetime, timedelta
from functools import wraps
import os, logging
from pathlib import Path
app = Flask(__name__)
PUBLIC_PATHS = {"/login", "/logout", "/health"} # add any public routes you truly need
app.permanent_session_lifetime = timedelta(hours=12)
@app.route("/")
def root():
return redirect(url_for("shipments_parsed"))
@app.before_request
def require_login_everywhere():
p = request.path
if p.startswith("/static/"): # let static assets through
return
if p in PUBLIC_PATHS:
return
if session.get("user"):
return
# remember and bounce to login
session["next"] = request.full_path
return redirect(url_for("login"))
# put startup config right after this:
app.secret_key = os.getenv("FLASK_SECRET", "replace-me")
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
# Optional simple creds from env/web.config
USER = os.getenv("APP_USER", "admin")
PASS = os.getenv("APP_PASS", "hyperbank") # set in web.config/appSettings or .env
def _safe_next(default_ep="shipments_parsed"):
# prefer querystring ?next=..., then session, else None
nxt = request.args.get("next") or session.pop("next", None)
if not nxt:
return url_for(default_ep)
# Build absolute URL and verify it stays on this host
test = urlparse(urljoin(request.host_url, nxt))
if test.netloc and test.netloc != urlparse(request.host_url).netloc:
return url_for(default_ep)
# Must live under our mounted prefix
prefix = (request.script_root or "/apps/inxpress").rstrip("/")
if not test.path.startswith(prefix + "/") and test.path != prefix:
return url_for(default_ep)
return test.geturl() if test.scheme else nxt # keep relative OK
def login_required(fn):
@wraps(fn)
def wrap(*args, **kwargs):
if session.get("user") == USER:
return fn(*args, **kwargs)
# remember where to go back
session["next"] = request.full_path
return redirect(url_for("login"))
return wrap
@app.route("/login", methods=["GET","POST"])
def login():
if request.method == "POST":
if request.form.get("u")==USER and request.form.get("p")==PASS:
session["user"] = USER
session.permanent = True # use app.permanent_session_lifetime (e.g., 12h)
# return redirect(session.pop("next", url_for("shipments_parsed")))
return redirect(_safe_next())
return render_template("login.html", error="Invalid credentials")
nxt = request.args.get("next")
if nxt:
session["next"] = nxt
return render_template("login.html")
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
if os.getenv("APP_DEBUG", "0") == "1":
app.debug = True
app.config["PROPAGATE_EXCEPTIONS"] = True
# log exceptions to wfastcgi.log as well
logging.basicConfig(level=logging.DEBUG)
# ---- secured utility routes (decorator is now defined) ----
PDF_ROOT = Path(r"D:\data\inxpress_pdfs") # <-- adjust if needed
@app.route("/pdf/<path:fname>")
def pdf_download(fname):
"""
Stream a PDF from D:\data\inxpress_pdfs by file name.
Prevent directory traversal and 404 if missing.
"""
# Only allow the final filename (drop any directories in DB value)
name_only = Path(fname).name
if not name_only.lower().endswith(".pdf"):
abort(404)
fpath = PDF_ROOT / name_only
if not fpath.exists():
abort(404)
# send_from_directory handles correct headers + range requests
return send_from_directory(
PDF_ROOT, name_only,
mimetype="application/pdf",
as_attachment=False,
download_name=name_only,
max_age=0 # don't cache if you prefer; remove to allow caching
)
# register Jinja filters so templates stay tidy and consistent with your ASP helpers
register_filters(app)
@app.route("/")
def home():
return "inxpress app alive"
@app.route("/health")
def health():
return {"status": "ok"}
@app.route("/inxpress")
def inxpress():
"""
Mirrors your ASP page but rendered via Jinja.
- Numeric rules: blank if zero, 2dp for totals
- Styling: use your existing CSS (copy into app/static/css/site.css)
"""
date_from = request.args.get("from") # e.g. 2024-01-01
date_to = request.args.get("to") # e.g. 2024-12-31
sql = """
SELECT InvoiceNo,
Base, Fuel, Other, Net, VAT, Total,
Charged, Cost, Calculated,
InvoiceDate
FROM inxpress_invoices
WHERE (%s IS NULL OR InvoiceDate >= %s)
AND (%s IS NULL OR InvoiceDate < %s)
ORDER BY InvoiceDate DESC, InvoiceNo DESC
LIMIT 500
"""
with get_db().cursor() as cur:
cur.execute(sql, (date_from, date_from, date_to, date_to))
rows = cur.fetchall()
return render_template("inxpress.html", rows=rows, date_from=date_from, date_to=date_to)
from flask import render_template, request
from .db import get_db
def _get_table_columns(conn, table):
"""Return list of column names in display order."""
with conn.cursor() as cur:
cur.execute("""
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s
ORDER BY ORDINAL_POSITION
""", (table,))
return [r["COLUMN_NAME"] for r in cur.fetchall()]
@app.route("/shipments")
def shipments_parsed():
table = "inxpress_shipments_parsed"
# ➜ CREATE THE CONNECTION BEFORE USING IT
conn = get_db()
# inputs
q = (request.args.get("q") or "").strip()
invoice = (request.args.get("invoice") or "").strip()
carrier = (request.args.get("carrier") or "").strip()
country = (request.args.get("country") or "").strip().upper()
# raw strings from query
date_from_raw = (request.args.get("from") or "").strip()
date_to_raw = (request.args.get("to") or "").strip()
def to_iso_date(s: str | None) -> str | None:
"""Return YYYY-MM-DD from UK (DD/MM/YYYY) or ISO input; else None."""
if not s:
return None
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%d.%m.%Y"):
try:
return datetime.strptime(s, fmt).date().isoformat()
except ValueError:
pass
return None
date_from_iso = to_iso_date(date_from_raw)
date_to_iso = to_iso_date(date_to_raw)
# sorting / paging (use your existing code here) …
# defaults
sort = (request.args.get("sort") or "shipment_id").strip() # default sort field
direction = (request.args.get("dir") or "asc").lower() # default ASC
# pagesize default -> 200
pagesize_raw = (request.args.get("pagesize") or "200").strip().lower()
pagesize = None if pagesize_raw in {"all","0","-1"} else max(min(int(pagesize_raw), 1_000_000), 10)
page = 1 if pagesize is None else max(int(request.args.get("page", 1) or 1), 1)
offset = 0 if pagesize is None else (page - 1) * pagesize
duponly = (request.args.get("duponly") == "1")
# WHERE
where, params = [], []
# Get all reference strings and find those that occur > 1 (normalized)
dup_sql = """
SELECT ref, COUNT(*) AS cnt FROM (
SELECT TRIM(LOWER(Reference)) AS ref FROM inxpress_shipments_parsed
WHERE Reference IS NOT NULL AND Reference <> ''
UNION ALL
SELECT TRIM(LOWER(Reference2)) AS ref FROM inxpress_shipments_parsed
WHERE Reference2 IS NOT NULL AND Reference2 <> ''
UNION ALL
SELECT TRIM(LOWER(Reference3)) AS ref FROM inxpress_shipments_parsed
WHERE Reference3 IS NOT NULL AND Reference3 <> ''
) t
GROUP BY ref
HAVING cnt > 1
"""
dup_map = {}
with conn.cursor() as cur:
cur.execute(dup_sql)
for row in cur.fetchall():
dup_map[row["ref"]] = int(row["cnt"])
dup_keys = list(dup_map.keys())
if q:
like = f"%{q}%"
where.append("("
"invoice_number LIKE %s OR AirbillNumber LIKE %s OR "
"Reference LIKE %s OR Reference2 LIKE %s OR Reference3 LIKE %s OR "
"ReceiverName LIKE %s)"
); params += [like]*6
if invoice:
where.append("invoice_number = %s"); params.append(invoice)
if carrier:
where.append("Carrier = %s"); params.append(carrier)
if country:
where.append("(DestCountry = %s OR OriginCountry = %s)"); params += [country, country]
# SQL WHERE (keep your existing list building)
if date_from_iso:
where.append("ShipDate >= %s"); params.append(date_from_iso)
# inclusive 'To' day (optional but recommended):
if date_to_iso:
# make the end exclusive by adding 1 day: ShipDate < (to + 1)
next_day = (datetime.strptime(date_to_iso, "%Y-%m-%d").date() + timedelta(days=1)).isoformat()
where.append("ShipDate < %s"); params.append(next_day)
# --- SQL-side duplicates-only filter ---
if duponly:
if not dup_keys:
where.append("1=0") # no dups: force empty result
else:
ph = ", ".join(["%s"] * len(dup_keys))
where.append(f"""(
TRIM(LOWER(Reference)) IN ({ph})
OR TRIM(LOWER(Reference2)) IN ({ph})
OR TRIM(LOWER(Reference3)) IN ({ph})
)""")
params.extend(dup_keys * 3)
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
# SQLs
count_sql = f"SELECT COUNT(*) AS n FROM {table} {where_sql}"
data_sql = f"""
SELECT
shipment_id, invoice_number, Carrier, AirbillNumber, ShipDate,
OriginCountry, DestCountry, Reference, Reference2, Reference3,
ReceiverName, Pieces, WeightKg, Length, Width, Height, Zone, ServiceType,
BaseCharge, FuelSurcharge, Handling, Insurance, AreaSurcharge,
ProcessingFee, ResidentialFee, OtherCharges,
NetAmount, VATAmount, TotalParsed, Total
FROM {table}
{where_sql}
ORDER BY `{sort}` {direction.upper()}, shipment_id DESC
""" + ("" if pagesize is None else " LIMIT %s OFFSET %s")
# dropdowns + counts + data
with conn.cursor() as cur:
cur.execute(f"SELECT DISTINCT invoice_number FROM {table} WHERE invoice_number<>'' ORDER BY invoice_number DESC")
invoices = [r["invoice_number"] for r in cur.fetchall()]
cur.execute(f"SELECT DISTINCT Carrier FROM {table} WHERE Carrier<>'' ORDER BY Carrier")
carriers = [r["Carrier"] for r in cur.fetchall()]
cur.execute(f"""
SELECT DISTINCT UPPER(COALESCE(DestCountry, OriginCountry)) AS cc
FROM {table}
WHERE COALESCE(DestCountry, OriginCountry) <> ''
""")
ccodes = [r["cc"] for r in cur.fetchall()]
cur.execute(count_sql, params)
total_rows = cur.fetchone()["n"]
if pagesize is None:
cur.execute(data_sql, params)
else:
cur.execute(data_sql, params + [pagesize, offset])
rows = cur.fetchall()
countries = sorted(
[{"cc": c, "name": country_name(c) or c} for c in ccodes],
key=lambda x: x["name"]
)
# compute Other + page totals (use your existing sums code) …
def d(x): return Decimal(str(x)) if x is not None else Decimal("0.00")
sums = {k: Decimal("0.00") for k in ["BaseCharge","FuelSurcharge","NetAmount","VATAmount","Total"]}
sums["Other"] = Decimal("0.00")
for r in rows:
other = d(r.get("Handling")) + d(r.get("Insurance")) + d(r.get("AreaSurcharge")) + \
d(r.get("ProcessingFee")) + d(r.get("ResidentialFee")) + d(r.get("OtherCharges"))
r["Other"] = other
sums["BaseCharge"] += d(r.get("BaseCharge"))
sums["FuelSurcharge"] += d(r.get("FuelSurcharge"))
sums["NetAmount"] += d(r.get("NetAmount"))
sums["VATAmount"] += d(r.get("VATAmount"))
sums["Total"] += d(r.get("Total"))
sums["Other"] += other
last_page = 1 if pagesize is None else max((total_rows + pagesize - 1) // pagesize, 1)
pagesize_effective = total_rows if pagesize is None else pagesize
duponly = (request.args.get("duponly") == "1")
return render_template(
"shipments_parsed.html",
rows=rows,
duplicates=dup_map,
duponly=duponly,
q=q, invoice=invoice, invoices=invoices,
carrier=carrier, carriers=carriers,
country=country, countries=countries,
date_from_iso=date_from_iso, # used by the <input type="date">
date_to_iso=date_to_iso,
page=page, pagesize=pagesize_effective, total=total_rows, last_page=last_page,
sums=sums, sort=sort, direction=direction, pagesize_raw=pagesize_raw
)
@app.route("/shipment/<int:shipment_id>")
def shipment_detail(shipment_id: int):
sql = """
SELECT shipment_id, ShipmentDetails, SenderAddress, ReceiverAddress,
PiecesWeightDimensionsZone, Charges, Total, source_pdf, invoice_number
FROM inxpress_shipments
WHERE shipment_id = %s
LIMIT 1
"""
conn = get_db()
with conn.cursor() as cur:
cur.execute(sql, (shipment_id,))
row = cur.fetchone()
if not row:
abort(404)
# Render just the inner body for the modal
return render_template("partials/shipment_detail.html", r=row)
@app.route("/invoices")
def invoices():
table = "inxpress_invoices"
# inputs
q = (request.args.get("q") or "").strip() # search invoice_number
date_from_raw = (request.args.get("from") or "").strip()
date_to_raw = (request.args.get("to") or "").strip()
# parse dates: accept DD/MM/YYYY or YYYY-MM-DD, return ISO
def to_iso(s):
if not s: return None
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%d.%m.%Y"):
try: return datetime.strptime(s, fmt).date().isoformat()
except ValueError: pass
return None
date_from_iso = to_iso(date_from_raw)
date_to_iso = to_iso(date_to_raw)
# sorting / paging
sort = (request.args.get("sort") or "invoice_date").strip()
direction = (request.args.get("dir") or "desc").lower()
allowed_sorts = {
"invoice_id","invoice_number","invoice_date","customer_number",
"airbills_count","amount_due","vat_shipments_amount",
"vat_shipments_vat","vat_shipments_total",
"non_vat_shipments_amount","non_vat_shipments_total"
}
if sort not in allowed_sorts: sort = "invoice_date"
if direction not in {"asc","desc"}: direction = "desc"
pagesize_raw = (request.args.get("pagesize") or "200").strip().lower()
pagesize = None if pagesize_raw in {"all","0","-1"} else max(min(int(pagesize_raw), 1_000_000), 10)
page = 1 if pagesize is None else max(int(request.args.get("page", 1) or 1), 1)
offset = 0 if pagesize is None else (page - 1) * pagesize
# WHERE
where, params = [], []
if q:
where.append("invoice_number LIKE %s")
params.append(f"%{q}%")
if date_from_iso:
where.append("invoice_date >= %s"); params.append(date_from_iso)
if date_to_iso:
# make end exclusive by adding 1 day
nd = (datetime.strptime(date_to_iso, "%Y-%m-%d").date() + timedelta(days=1)).isoformat()
where.append("invoice_date < %s"); params.append(nd)
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
count_sql = f"SELECT COUNT(*) AS n FROM {table} {where_sql}"
data_sql = f"""
SELECT invoice_id, invoice_number, invoice_date, customer_number,
airbills_count, amount_due,
vat_shipments_amount, vat_shipments_vat, vat_shipments_total,
non_vat_shipments_amount, non_vat_shipments_total,
pdf_file
FROM {table}
{where_sql}
ORDER BY `{sort}` {direction.upper()}, invoice_id DESC
""" + ("" if pagesize is None else " LIMIT %s OFFSET %s")
conn = get_db()
with conn.cursor() as cur:
cur.execute(count_sql, params); total_rows = cur.fetchone()["n"]
if pagesize is None:
cur.execute(data_sql, params)
else:
cur.execute(data_sql, params + [pagesize, offset])
rows = cur.fetchall()
# page totals (optional)
def d(x): return Decimal(str(x)) if x is not None else Decimal("0.00")
sums = {k: Decimal("0.00") for k in [
"amount_due","vat_shipments_amount","vat_shipments_vat","vat_shipments_total",
"non_vat_shipments_amount","non_vat_shipments_total"
]}
for r in rows:
sums["amount_due"] += d(r.get("amount_due"))
sums["vat_shipments_amount"] += d(r.get("vat_shipments_amount"))
sums["vat_shipments_vat"] += d(r.get("vat_shipments_vat"))
sums["vat_shipments_total"] += d(r.get("vat_shipments_total"))
sums["non_vat_shipments_amount"]+= d(r.get("non_vat_shipments_amount"))
sums["non_vat_shipments_total"] += d(r.get("non_vat_shipments_total"))
last_page = 1 if pagesize is None else max((total_rows + pagesize - 1) // pagesize, 1)
pagesize_effective = total_rows if pagesize is None else pagesize
return render_template(
"invoices.html",
rows=rows, q=q,
date_from_iso=date_from_iso, date_to_iso=date_to_iso,
page=page, pagesize=pagesize_effective, total=total_rows, last_page=last_page,
sort=sort, direction=direction, pagesize_raw=pagesize_raw, sums=sums
)
if __name__ == "__main__":
# Dev server (NOT for IIS). Use `scripts\run_dev.bat`.
app.run(host="127.0.0.1", port=5000, debug=True)