HEX
Server: Microsoft-IIS/10.0
System: Windows NT ITPWINWEBSVR22 10.0 build 20348 (Windows Server 2022) AMD64
User: www.conferencesearch.co.uk (0)
PHP: 8.3.30
Disabled: NONE
Upload Files
File: D:/web/hyperflight/apps/inxpress/app/app - Copy (6).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()
    
    noorder = (request.args.get("noorder") == "1")

    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)    

    if noorder:
      where.append("o.OrderNo IS NULL")

        
    from_parts = """
    FROM inxpress_shipments_parsed sp
    LEFT JOIN orders o ON o.OrderNo = sp.Reference
    """

    where_sql = ("WHERE " + " AND ".join(where)) if where else ""

    count_sql = f"SELECT COUNT(*) AS n {from_parts} {where_sql}"

    data_sql = f"""
        SELECT
          sp.shipment_id, sp.invoice_number, sp.Carrier, sp.AirbillNumber, sp.ShipDate,
          sp.OriginCountry, sp.DestCountry, sp.Reference, sp.Reference2, sp.Reference3,
          sp.ReceiverName, sp.Pieces, sp.WeightKg, sp.Length, sp.Width, sp.Height, sp.Zone,
          sp.ServiceType, sp.BaseCharge, sp.FuelSurcharge, sp.Handling, sp.Insurance,
          sp.AreaSurcharge, sp.ProcessingFee, sp.ResidentialFee, sp.OtherCharges,
          sp.NetAmount, sp.VATAmount, sp.TotalParsed, sp.Total,

          o.Delivery               AS Charge,
          o.DeliveryAgentCost      AS Cost,
          o.DeliveryCalculatedCost AS Calculated,

          -- Value deltas vs NetAmount
          CASE
            WHEN o.DeliveryAgentCost IS NULL OR sp.NetAmount IS NULL THEN NULL
            ELSE o.DeliveryAgentCost - sp.NetAmount
          END AS DiffCost,
          CASE
            WHEN o.DeliveryCalculatedCost IS NULL OR sp.NetAmount IS NULL THEN NULL
            ELSE o.DeliveryCalculatedCost - sp.NetAmount
          END AS DiffCalc,

          -- Percent deltas vs NetAmount
          CASE
            WHEN o.DeliveryAgentCost IS NULL OR sp.NetAmount IS NULL OR sp.NetAmount = 0 THEN NULL
            ELSE 100.0 * (o.DeliveryAgentCost - sp.NetAmount) / sp.NetAmount
          END AS DiffCostPct,
          CASE
            WHEN o.DeliveryCalculatedCost IS NULL OR sp.NetAmount IS NULL OR sp.NetAmount = 0 THEN NULL
            ELSE 100.0 * (o.DeliveryCalculatedCost - sp.NetAmount) / sp.NetAmount
          END AS DiffCalcPct


          {from_parts}
          {where_sql}
          ORDER BY {sort} {direction.upper()}, sp.shipment_id ASC

    """ + ("" 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")
    
    D = lambda x: Decimal(str(x)) if x not in (None, "") else Decimal("0")
    # add keys to your existing sums dict
    for k in ["Charge", "Cost", "Calculated", "NetAmount"]:
        sums[k] = sums.get(k, Decimal("0"))
    
    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
        
        sums["Charge"]     += D(r.get("Charge"))
        sums["Cost"]       += D(r.get("Cost"))
        sums["Calculated"] += D(r.get("Calculated"))
        sums["NetAmount"]  += D(r.get("NetAmount"))

    # page-level deltas vs Net
    sums["DiffCostTotal"] = sums["Cost"] - sums["NetAmount"]
    sums["DiffCalcTotal"] = sums["Calculated"] - sums["NetAmount"]

    # page-level % (safe when Net > 0)
    sums["DiffCostPct"] = (sums["DiffCostTotal"] * 100 / sums["NetAmount"]) if sums["NetAmount"] else None
    sums["DiffCalcPct"] = (sums["DiffCalcTotal"] * 100 / sums["NetAmount"]) if sums["NetAmount"] else None        

    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,
        noorder=noorder,
        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
    )

@app.get("/order/by-ref/<ref>")
def order_by_ref(ref):
    conn = get_db()
    with conn.cursor() as cur:
        cur.execute("""
            SELECT
              o.OrderNo,
              CONCAT_WS(' ', o.FirstName, o.Surname) AS Name,
              o.CompanyName,
              o.AddressLine1, o.AddressLine2, o.Town, o.County, o.Postcode, o.Country,
              o.EmailAddress,
              o.Message, o.Notes,
              o.DateTimeOrdered, o.DateTimePaid, o.DateTimeCompleted,
              o.TotalWeight, o.Subtotal, o.Discount, o.Delivery, o.VATIncluded, o.VATDeducted, o.GrandTotal,
              o.DeliveryWillCollect, o.DeliveryAddressSameAsInvoice,
              o.DeliveryName, o.DeliveryCompanyName, o.DeliveryAddressLine1, o.DeliveryAddressLine2,
              o.DeliveryTown, o.DeliveryCounty, o.DeliveryPostcode, o.DeliveryCountry, o.DeliveryInfo,
              o.DeliveryOption, o.DeliveryService, o.DeliveryCalculatedCost,
              o.EstimatedGrossWeight, o.EstimatedVolumetricWeight,
              o.PackageSize, o.PackageName, o.PaymentMethod, o.PaymentReference,
              o.Status, o.Courier, o.TrackingNo, o.MessageToCustomer,
              o.DeliveryAgentName, o.DeliveryAgentDate, o.DeliveryAgentCost,
              o.PackedBy, o.GrossWeight, o.CN22TariffCode, o.CommercialInvoiceInfo
            FROM orders o
            WHERE o.OrderNo = %s
            LIMIT 1
        """, [ref])
        row = cur.fetchone()
    return render_template("partials/order_detail.html", o=row, ref=ref)




if __name__ == "__main__":
    # Dev server (NOT for IIS). Use `scripts\run_dev.bat`.
    app.run(host="127.0.0.1", port=5000, debug=True)