diff --git a/.assets-revision b/.assets-revision index 5c22104e..bc9fb484 100644 --- a/.assets-revision +++ b/.assets-revision @@ -5,4 +5,4 @@ # is a git revision (branch name like `main`, a tag, or a specific commit # sha). Override at runtime with the ASSETS_REVISION env var. repo: ChilleD/WebHarbor -revision: 54882a6a66a17a3e43455057e7c9e0d103cd8b81 +revision: c147b46db6f182b9bd29d2189f93d3808a409028 diff --git a/.claude/skills/clone-website/SKILL.md b/.claude/skills/clone-website/SKILL.md index de4ba461..c1ba59c0 100644 --- a/.claude/skills/clone-website/SKILL.md +++ b/.claude/skills/clone-website/SKILL.md @@ -204,7 +204,7 @@ Then run the site once locally to produce `instance/.db`, copy it to ```bash ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev # your new site is on port 41000 + its index curl -so /dev/null -w "%{http_code}\n" http://localhost:41000NN/ @@ -228,7 +228,7 @@ After Phase 1, you should have: - `sites//static/` with real CSS/JS/icons (and images under HF assets) - `sites//instance_seed/.db` with seeded data - Site registered in `websyn_start.sh`, `control_server.py`, `Dockerfile` -- All 15 sites still return 200 on the alt-port container +- All 16 sites still return 200 on the alt-port container - Byte-identical reset passes ## Next step diff --git a/.claude/skills/evolve-env/SKILL.md b/.claude/skills/evolve-env/SKILL.md index 091465c4..da1605ba 100644 --- a/.claude/skills/evolve-env/SKILL.md +++ b/.claude/skills/evolve-env/SKILL.md @@ -65,7 +65,7 @@ through `BASE_DIR = os.path.dirname(os.path.abspath(__file__))`. After any DB-affecting change: ```bash -# 1. Stop test container (don't touch user's working container on :40000-40014) +# 1. Stop test container (don't touch user's working container on :40000-40015) docker stop wh-test 2>/dev/null || true # 2. Rebuild @@ -73,7 +73,7 @@ docker stop wh-test 2>/dev/null || true # 3. Run on alt ports docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev # 4. Reset your site and confirm byte-identity curl -X POST http://localhost:8201/reset/ @@ -149,7 +149,7 @@ After Phase 3: - Every task in `tasks.jsonl` is hand-verified to work end-to-end - The mirror has no obvious leaks, broken forms, or empty pages - Byte-identical reset passes -- All 15 sites still return 200 +- All 16 sites still return 200 ## Next step diff --git a/.claude/skills/harden-env/SKILL.md b/.claude/skills/harden-env/SKILL.md index 8d2e1b63..199fd57c 100644 --- a/.claude/skills/harden-env/SKILL.md +++ b/.claude/skills/harden-env/SKILL.md @@ -130,7 +130,7 @@ Hardening changes seed data. After any DB change: ```bash ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev curl -X POST http://localhost:8201/reset/ docker exec wh-test md5sum \ /opt/WebSyn//instance/.db \ diff --git a/.claude/skills/review-env/SKILL.md b/.claude/skills/review-env/SKILL.md index 52dc9640..136ba007 100644 --- a/.claude/skills/review-env/SKILL.md +++ b/.claude/skills/review-env/SKILL.md @@ -33,18 +33,18 @@ gh pr checkout ./scripts/fetch_assets.sh # pull the pinned HF revision ./scripts/build.sh webharbor:dev docker run -d --rm --name wh-review \ - -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev + -p 8201:8101 -p 41000-41016:40000-40016 webharbor:dev ``` -Confirm the new/changed site is on the expected port (40000 + index). Note: the image now runs 16 sites (40000-40015). +Confirm the new/changed site is on the expected port (40000 + index). Note: the image now runs 17 sites (40000-40016). ### Step 2: The mechanical checks (5 minutes) Run the same Pre-PR checks the contributor was supposed to run. ```bash -# 1. all 16 sites return 200 -for p in $(seq 41000 41015); do +# 1. all 17 sites return 200 +for p in $(seq 41000 41016); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done @@ -231,7 +231,7 @@ Leave a structured comment on the PR: ## Review: ### Mechanical checks: PASS / FAIL -- [x] All 15 sites return 200 +- [x] All 17 sites return 200 - [x] Control plane healthy - [x] Byte-identical reset (md5 match) - [x] Parallel reset <10s diff --git a/.claude/skills/seed-database/SKILL.md b/.claude/skills/seed-database/SKILL.md index 8f0b61e8..5346d320 100644 --- a/.claude/skills/seed-database/SKILL.md +++ b/.claude/skills/seed-database/SKILL.md @@ -169,9 +169,9 @@ gh pr create ./scripts/check_assets.sh # every site has instance_seed/ ./scripts/build.sh webharbor:dev # docker build succeeds docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev -# all 15 sites return 200 +# all 16 sites return 200 for p in $(seq 41000 41014); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done @@ -182,7 +182,7 @@ time curl -X POST http://localhost:8201/reset-all # byte-identity for every site for s in allrecipes amazon apple arxiv bbc_news booking github \ google_flights google_map google_search huggingface \ - wolfram_alpha cambridge_dictionary coursera espn; do + wolfram_alpha cambridge_dictionary coursera espn fedex; do docker exec wh-test md5sum \ /opt/WebSyn/$s/instance/$s.db \ /opt/WebSyn/$s/instance_seed/$s.db diff --git a/.gitignore b/.gitignore index 24ce1529..e7899232 100644 --- a/.gitignore +++ b/.gitignore @@ -94,4 +94,4 @@ secrets.json # ============================================================ # Agent demo results # ============================================================= -agent_demo/runs/ \ No newline at end of file +agent_demo/runs/ diff --git a/AGENTS.md b/AGENTS.md index afa6f8b4..9d6868df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A coding agent (Claude Code, Cursor, Aider, Codex, ...) is reading this. Read on ## What it is -15 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. +17 Flask mirror websites (Amazon, GitHub, BBC News, ...) packaged into one Docker image, plus a control plane on `:8101` for resetting per-site state. Used as a deterministic offline environment for web-agent benchmarks. ~3 GB image. Two repos: - **code** (this one) — Flask apps, control plane, scripts. @@ -48,17 +48,17 @@ Inside the image, sites live at `/opt/WebSyn//`. The path predates the ren # fresh clone ./scripts/fetch_assets.sh # pulls assets from HF ./scripts/build.sh # docker build -t webharbor:dev . -docker run -d -p 8101:8101 -p 40000-40014:40000-40014 webharbor:dev +docker run -d -p 8101:8101 -p 40000-40016:40000-40016 webharbor:dev ``` Or use the published image directly: ```bash -docker run -d -p 8101:8101 -p 40000-40014:40000-40014 \ +docker run -d -p 8101:8101 -p 40000-40016:40000-40016 \ battalion7244/webharbor:latest ``` -Sites are on `40000`-`40014` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: +Sites are on `40000`-`40016` in the order declared by `SITES=( ... )` in `websyn_start.sh`. Control plane: | Method | Path | Purpose | |--------|---------------------|-------------------------------------------| @@ -136,13 +136,13 @@ python3 -m py_compile sites//app.py # 3. run on alt ports (don't collide with anything you already have running) docker run -d --rm --name wh-test \ - -p 8201:8101 -p 41000-41014:40000-40014 webharbor:dev + -p 8201:8101 -p 41000-41016:40000-40016 webharbor:dev # 4. control plane healthy, all sites alive curl -s http://localhost:8201/health | python3 -m json.tool | head # 5. every site renders 200 -for p in $(seq 41000 41014); do +for p in $(seq 41000 41016); do curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ done diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 927ed53f..7a251c56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ git clone https://github.com//webharbor && cd webharbor ./scripts/fetch_assets.sh # pull current assets ./scripts/new_site.py mywebsite # OR edit an existing site ./scripts/build.sh && docker run -d --rm \ - -p 8101:8101 -p 40000-40014:40000-40014 webharbor:dev + -p 8101:8101 -p 40000-40016:40000-40016 webharbor:dev # iterate locally... ./scripts/extract_assets.sh ../webharbor-static-pr/ # split assets out @@ -277,4 +277,4 @@ Sites must not import from one another. The image launches each as an independen ### Don't hard-code secrets -Each site sets `SECRET_KEY` to a deterministic dev value. Acceptable for a benchmark image (resets blow away sessions anyway). If a contrib ever needs real secrets, raise it in an issue first. \ No newline at end of file +Each site sets `SECRET_KEY` to a deterministic dev value. Acceptable for a benchmark image (resets blow away sessions anyway). If a contrib ever needs real secrets, raise it in an issue first. diff --git a/Dockerfile b/Dockerfile index 1e86b1d0..70289b07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 16 Flask mirror sites + control plane on :8101. +# 17 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -31,8 +31,8 @@ COPY sites/ /opt/WebSyn/ COPY websyn_start.sh /opt/websyn_start.sh COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py -RUN chmod +x /opt/websyn_start.sh +RUN sed -i 's/\r$//' /opt/websyn_start.sh && chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40015 +EXPOSE 8101 40000-40016 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index dce3f934..dd903262 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 15 sites today, scaling to 100+ together +- **Community-driven** — 17 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40014:40000-40014 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40016:40000-40016 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40014` to explore 15 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, and ESPN`. +Then point your agent at `http://localhost:40000` through `http://localhost:40016` to explore 17 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, and FedEx`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: @@ -65,7 +65,7 @@ git clone https://github.com/aiming-lab/WebHarbor && cd WebHarbor ## 🤝 Contribute -We have built 15 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. +We have built 17 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. There are two ways to join the author list: @@ -111,4 +111,4 @@ WebHarbor is initiated by UNC-Chapel Hill and Microsoft, with contributions from url = {https://aiming-lab.github.io/webharbor.github.io}, note = {Project website.} } -``` \ No newline at end of file +``` diff --git a/control_server.py b/control_server.py index 4b6b995e..55e6318a 100644 --- a/control_server.py +++ b/control_server.py @@ -26,7 +26,7 @@ 'allrecipes', 'amazon', 'apple', 'arxiv', 'bbc_news', 'booking', 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', - 'coursera', 'espn', 'merriam_webster', + 'coursera', 'espn', 'merriam_webster', 'fedex', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/scripts/extract_assets.sh b/scripts/extract_assets.sh index b9e58724..7f453d14 100755 --- a/scripts/extract_assets.sh +++ b/scripts/extract_assets.sh @@ -42,7 +42,7 @@ for site_dir in sites/*/; do fi out="$TARGET/$site.tar.gz" - tar -czf "$out" -C sites "${members[@]}" + COPYFILE_DISABLE=1 tar -czf "$out" -C sites "${members[@]}" sz=$(du -sh "$out" 2>/dev/null | cut -f1) printf " %-22s -> %-30s %s\n" "$site" "$site.tar.gz" "$sz" count=$((count + 1)) diff --git a/sites/fedex/_health.py b/sites/fedex/_health.py new file mode 100644 index 00000000..ed14b5bb --- /dev/null +++ b/sites/fedex/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "fedex"} diff --git a/sites/fedex/app.py b/sites/fedex/app.py new file mode 100644 index 00000000..61f1548c --- /dev/null +++ b/sites/fedex/app.py @@ -0,0 +1,921 @@ +"""FedEx local demo mirror for WebHarbor.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from flask import ( + Flask, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) +from flask_login import ( + LoginManager, + UserMixin, + current_user, + login_required, + login_user, + logout_user, +) +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import or_ +from werkzeug.security import check_password_hash, generate_password_hash + +BASE_DIR = Path(__file__).resolve().parent +INSTANCE_DIR = BASE_DIR / "instance" +DB_PATH = INSTANCE_DIR / "fedex.db" +INSTANCE_DIR.mkdir(parents=True, exist_ok=True) + +app = Flask(__name__, instance_path=str(INSTANCE_DIR)) +app.config["SECRET_KEY"] = "webharbor-fedex-demo-key" +app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("FEDEX_DATABASE_URI", f"sqlite:///{DB_PATH}") +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +db = SQLAlchemy(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message = "Sign in to use this local FedEx demo." +login_manager.login_message_category = "info" + +DEMO_PASSWORD = "TestPass123!" +STATE_LABELS = [ + "CA", "WA", "TX", "FL", "NY", "GA", "IL", "PA", + "MA", "CO", "AZ", "OR", "NC", "OH", "MI", "VA", "DC", +] + + +def dumps_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=True) + + +def loads_json(value: str | None, default: Any) -> Any: + if not value: + return default + try: + return json.loads(value) + except json.JSONDecodeError: + return default + + +class User(db.Model, UserMixin): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + first_name = db.Column(db.String(80), nullable=False) + last_name = db.Column(db.String(80), nullable=False) + phone = db.Column(db.String(40), default="") + company = db.Column(db.String(120), default="") + city = db.Column(db.String(100), default="") + state = db.Column(db.String(40), default="") + zip_code = db.Column(db.String(20), default="") + account_number = db.Column(db.String(30), unique=True, nullable=False) + preferred_location_slug = db.Column(db.String(120), default="") + invoicing_email = db.Column(db.String(120), default="") + + shipments = db.relationship("Shipment", backref="user", cascade="all, delete-orphan") + tracking_records = db.relationship("TrackingRecord", backref="user", cascade="all, delete-orphan") + invoices = db.relationship("Invoice", backref="user", cascade="all, delete-orphan") + claims = db.relationship("Claim", backref="user", cascade="all, delete-orphan") + pickup_requests = db.relationship("PickupRequest", backref="user", cascade="all, delete-orphan") + + @property + def full_name(self) -> str: + return f"{self.first_name} {self.last_name}" + + def set_password(self, password: str) -> None: + self.password_hash = generate_password_hash(password) + + def check_password(self, password: str) -> bool: + return check_password_hash(self.password_hash, password) + + +class ServiceLevel(db.Model): + __tablename__ = "service_levels" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(60), unique=True, nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + summary = db.Column(db.String(180), nullable=False) + speed_label = db.Column(db.String(120), nullable=False) + base_rate = db.Column(db.Float, nullable=False) + per_lb_rate = db.Column(db.Float, nullable=False) + zone_surcharge = db.Column(db.Float, nullable=False) + weekend_delivery = db.Column(db.Boolean, default=False) + money_back_label = db.Column(db.String(120), default="") + icon_path = db.Column(db.String(255), default="") + sort_order = db.Column(db.Integer, default=0) + + +class Location(db.Model): + __tablename__ = "locations" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(140), nullable=False) + slug = db.Column(db.String(140), unique=True, nullable=False, index=True) + city = db.Column(db.String(120), nullable=False) + state = db.Column(db.String(50), nullable=False) + address = db.Column(db.String(200), nullable=False) + phone = db.Column(db.String(30), default="") + location_type = db.Column(db.String(80), nullable=False) + hours = db.Column(db.String(120), default="") + services_json = db.Column(db.Text, default="[]") + amenities_json = db.Column(db.Text, default="[]") + image_path = db.Column(db.String(255), default="") + pickup_note = db.Column(db.String(180), default="") + + pickup_slots = db.relationship("PickupSlot", backref="location", cascade="all, delete-orphan") + pickup_requests = db.relationship("PickupRequest", backref="location", cascade="all, delete-orphan") + + @property + def services(self) -> list[str]: + return loads_json(self.services_json, []) + + @property + def amenities(self) -> list[str]: + return loads_json(self.amenities_json, []) + + +class TrackingRecord(db.Model): + __tablename__ = "tracking_records" + + id = db.Column(db.Integer, primary_key=True) + tracking_number = db.Column(db.String(30), unique=True, nullable=False, index=True) + shipment_id = db.Column(db.Integer, db.ForeignKey("shipments.id")) + user_id = db.Column(db.Integer, db.ForeignKey("users.id")) + recipient_name = db.Column(db.String(120), nullable=False) + sender_name = db.Column(db.String(120), nullable=False) + origin_city = db.Column(db.String(100), nullable=False) + origin_state = db.Column(db.String(40), nullable=False) + destination_city = db.Column(db.String(100), nullable=False) + destination_state = db.Column(db.String(40), nullable=False) + service_slug = db.Column(db.String(60), nullable=False) + package_type = db.Column(db.String(80), nullable=False) + weight_lb = db.Column(db.Float, nullable=False) + status_stage = db.Column(db.String(80), nullable=False) + status_summary = db.Column(db.String(180), nullable=False) + ship_date = db.Column(db.String(20), nullable=False) + estimated_delivery = db.Column(db.String(40), nullable=False) + latest_scan = db.Column(db.String(180), default="") + package_count = db.Column(db.Integer, default=1) + signature_required = db.Column(db.Boolean, default=False) + dropoff_location_slug = db.Column(db.String(140), default="") + + shipment = db.relationship("Shipment", backref="tracking_record", uselist=False) + events = db.relationship("TrackingEvent", backref="tracking_record", cascade="all, delete-orphan") + + +class TrackingEvent(db.Model): + __tablename__ = "tracking_events" + + id = db.Column(db.Integer, primary_key=True) + tracking_record_id = db.Column(db.Integer, db.ForeignKey("tracking_records.id"), nullable=False) + sequence = db.Column(db.Integer, nullable=False) + event_time = db.Column(db.String(30), nullable=False) + location_label = db.Column(db.String(160), nullable=False) + status_label = db.Column(db.String(120), nullable=False) + details = db.Column(db.String(220), nullable=False) + + +class Shipment(db.Model): + __tablename__ = "shipments" + + id = db.Column(db.Integer, primary_key=True) + shipment_code = db.Column(db.String(30), unique=True, nullable=False, index=True) + tracking_number = db.Column(db.String(30), unique=True, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + service_slug = db.Column(db.String(60), nullable=False) + package_type = db.Column(db.String(80), nullable=False) + package_weight = db.Column(db.Float, nullable=False) + origin_city = db.Column(db.String(100), nullable=False) + origin_state = db.Column(db.String(40), nullable=False) + destination_city = db.Column(db.String(100), nullable=False) + destination_state = db.Column(db.String(40), nullable=False) + recipient_name = db.Column(db.String(120), nullable=False) + declared_value = db.Column(db.Float, default=0.0) + total_cost = db.Column(db.Float, nullable=False) + fulfillment_mode = db.Column(db.String(60), nullable=False) + pickup_location_slug = db.Column(db.String(140), default="") + pickup_window = db.Column(db.String(120), default="") + status = db.Column(db.String(80), nullable=False) + created_on = db.Column(db.String(20), nullable=False) + invoice_number = db.Column(db.String(30), default="") + reference_label = db.Column(db.String(160), default="") + + +class PickupSlot(db.Model): + __tablename__ = "pickup_slots" + + id = db.Column(db.Integer, primary_key=True) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), nullable=False) + slot_date = db.Column(db.String(20), nullable=False) + time_window = db.Column(db.String(80), nullable=False) + remaining_capacity = db.Column(db.Integer, default=0) + cutoff_note = db.Column(db.String(120), default="") + + +class PickupRequest(db.Model): + __tablename__ = "pickup_requests" + + id = db.Column(db.Integer, primary_key=True) + confirmation_code = db.Column(db.String(30), unique=True, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), nullable=False) + slot_date = db.Column(db.String(20), nullable=False) + time_window = db.Column(db.String(80), nullable=False) + package_count = db.Column(db.Integer, nullable=False) + status = db.Column(db.String(80), nullable=False) + created_on = db.Column(db.String(20), nullable=False) + + +class Invoice(db.Model): + __tablename__ = "invoices" + + id = db.Column(db.Integer, primary_key=True) + invoice_number = db.Column(db.String(30), unique=True, nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + shipment_id = db.Column(db.Integer, db.ForeignKey("shipments.id"), nullable=False) + billed_on = db.Column(db.String(20), nullable=False) + due_date = db.Column(db.String(20), nullable=False) + amount = db.Column(db.Float, nullable=False) + status = db.Column(db.String(80), nullable=False) + + shipment = db.relationship("Shipment") + + +class Claim(db.Model): + __tablename__ = "claims" + + id = db.Column(db.Integer, primary_key=True) + claim_number = db.Column(db.String(30), unique=True, nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + tracking_number = db.Column(db.String(30), nullable=False) + claim_type = db.Column(db.String(80), nullable=False) + amount = db.Column(db.Float, nullable=False) + status = db.Column(db.String(80), nullable=False) + opened_on = db.Column(db.String(20), nullable=False) + note = db.Column(db.Text, default="") + + +class SupportArticle(db.Model): + __tablename__ = "support_articles" + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(180), nullable=False) + slug = db.Column(db.String(180), unique=True, nullable=False, index=True) + category = db.Column(db.String(120), nullable=False) + summary = db.Column(db.String(240), nullable=False) + body = db.Column(db.Text, nullable=False) + related_topics_json = db.Column(db.Text, default="[]") + + @property + def related_topics(self) -> list[str]: + return loads_json(self.related_topics_json, []) + + +class SearchLog(db.Model): + __tablename__ = "search_logs" + + id = db.Column(db.Integer, primary_key=True) + query = db.Column(db.String(180), nullable=False) + search_type = db.Column(db.String(80), nullable=False) + created_on = db.Column(db.String(20), nullable=False) + + +@login_manager.user_loader +def load_user(user_id: str) -> User | None: + return db.session.get(User, int(user_id)) + + +@app.template_filter("money") +def money(value: float) -> str: + return f"${value:,.2f}" + + +def ship_state() -> dict[str, Any]: + return session.setdefault("ship_state", {}) + + +def clear_ship_state() -> None: + session.pop("ship_state", None) + + +def support_query(query_text: str): + token_like = f"%{query_text}%" + return SupportArticle.query.filter( + or_( + SupportArticle.title.ilike(token_like), + SupportArticle.summary.ilike(token_like), + SupportArticle.body.ilike(token_like), + SupportArticle.category.ilike(token_like), + ) + ) + + +def normalize_tracking_inputs(raw: str) -> list[str]: + tokens = [piece.strip().upper() for piece in raw.replace("\n", ",").split(",")] + return [token for token in tokens if token] + + +def shipment_zone(origin_state: str, destination_state: str) -> int: + if origin_state == destination_state: + return 1 + west = {"CA", "WA", "OR", "AZ", "CO"} + east = {"NY", "MA", "PA", "FL", "GA", "NC", "VA"} + central = {"TX", "IL", "OH", "MI"} + if origin_state in west and destination_state in west: + return 2 + if origin_state in east and destination_state in east: + return 2 + if origin_state in central and destination_state in central: + return 2 + return 4 + + +def build_rate_quotes(origin_state: str, destination_state: str, weight_lb: float, package_type: str) -> list[dict[str, Any]]: + zone = shipment_zone(origin_state, destination_state) + package_fee = {"Envelope": 0, "Box": 8, "Tube": 10, "Freight pallet": 48}.get(package_type, 6) + quotes = [] + for service in ServiceLevel.query.order_by(ServiceLevel.sort_order.asc()).all(): + price = round(service.base_rate + service.per_lb_rate * weight_lb + service.zone_surcharge * zone + package_fee, 2) + quotes.append( + { + "service": service, + "price": price, + "zone": zone, + "commitment": service.speed_label, + } + ) + return quotes + + +def current_pickups() -> list[PickupRequest]: + if not current_user.is_authenticated: + return [] + return ( + PickupRequest.query.filter_by(user_id=current_user.id) + .order_by(PickupRequest.slot_date.asc(), PickupRequest.time_window.asc()) + .all() + ) + + +@app.context_processor +def inject_globals() -> dict[str, Any]: + locations = Location.query.order_by(Location.city.asc()).all() + services = ServiceLevel.query.order_by(ServiceLevel.sort_order.asc()).all() + account_links = [] + if current_user.is_authenticated: + account_links = current_pickups()[:2] + return { + "demo_password": DEMO_PASSWORD, + "state_labels": STATE_LABELS, + "nav_locations": locations[:5], + "nav_services": services, + "nav_pickups": account_links, + } + + +@app.route("/") +@app.route("/home") +def index(): + featured_tracking = TrackingRecord.query.order_by(TrackingRecord.ship_date.desc()).limit(4).all() + locations = Location.query.order_by(Location.city.asc()).limit(4).all() + services = ServiceLevel.query.order_by(ServiceLevel.sort_order.asc()).all() + articles = SupportArticle.query.order_by(SupportArticle.category.asc(), SupportArticle.title.asc()).limit(6).all() + return render_template( + "index.html", + featured_tracking=featured_tracking, + locations=locations, + services=services, + articles=articles, + ) + + +@app.route("/track", methods=["GET", "POST"]) +def track(): + if request.method == "POST": + numbers = request.form.get("tracking_numbers", "").strip() + return redirect(url_for("track_results", numbers=numbers)) + return render_template("track.html") + + +@app.route("/track/results") +def track_results(): + numbers = normalize_tracking_inputs(request.args.get("numbers", "")) + records = [] + if numbers: + records = TrackingRecord.query.filter(TrackingRecord.tracking_number.in_(numbers)).all() + records.sort(key=lambda record: numbers.index(record.tracking_number)) + return render_template("track_results.html", numbers=numbers, records=records) + + +@app.route("/tracking/") +def tracking_detail(tracking_number: str): + record = TrackingRecord.query.filter_by(tracking_number=tracking_number.upper()).first_or_404() + service = ServiceLevel.query.filter_by(slug=record.service_slug).first() + events = TrackingEvent.query.filter_by(tracking_record_id=record.id).order_by(TrackingEvent.sequence.asc()).all() + return render_template("tracking_detail.html", record=record, service=service, events=events) + + +@app.route("/rate-estimate", methods=["GET", "POST"]) +def rate_estimate(): + quotes = None + form_state = { + "origin_state": request.values.get("origin_state", "CA"), + "destination_state": request.values.get("destination_state", "TX"), + "weight_lb": request.values.get("weight_lb", "8"), + "package_type": request.values.get("package_type", "Box"), + } + if request.method == "POST": + try: + weight = float(form_state["weight_lb"]) + if weight <= 0: + raise ValueError + except (TypeError, ValueError): + flash("Enter a weight greater than 0.", "danger") + else: + quotes = build_rate_quotes( + form_state["origin_state"], + form_state["destination_state"], + weight, + form_state["package_type"], + ) + return render_template("rate_estimate.html", quotes=quotes, form_state=form_state) + + +@app.route("/ship", methods=["GET", "POST"]) +@login_required +def ship(): + state = ship_state() + if request.method == "POST": + state["recipient_name"] = request.form.get("recipient_name", "").strip() + state["origin_city"] = request.form.get("origin_city", current_user.city).strip() + state["origin_state"] = request.form.get("origin_state", current_user.state).strip() + state["destination_city"] = request.form.get("destination_city", "").strip() + state["destination_state"] = request.form.get("destination_state", "").strip() + state["package_type"] = request.form.get("package_type", "Box") + state["weight_lb"] = request.form.get("weight_lb", "8").strip() + state["declared_value"] = request.form.get("declared_value", "150").strip() + state["pickup_mode"] = request.form.get("pickup_mode", "dropoff") + session.modified = True + valid_numbers = True + try: + if float(state["weight_lb"]) <= 0: + raise ValueError + except (TypeError, ValueError): + flash("Enter a weight greater than 0.", "danger") + valid_numbers = False + try: + if float(state["declared_value"]) < 0: + raise ValueError + except (TypeError, ValueError): + flash("Enter a declared value of at least 0.", "danger") + valid_numbers = False + if not valid_numbers: + return render_template("ship.html", state=state) + return redirect(url_for("ship_service")) + return render_template("ship.html", state=state) + + +@app.route("/ship/service", methods=["GET", "POST"]) +@login_required +def ship_service(): + state = ship_state() + if not state.get("recipient_name"): + flash("Start with shipment details first.", "warning") + return redirect(url_for("ship")) + quotes = build_rate_quotes( + state.get("origin_state", current_user.state or "CA"), + state.get("destination_state", "TX"), + float(state.get("weight_lb", "8")), + state.get("package_type", "Box"), + ) + if request.method == "POST": + state["service_slug"] = request.form.get("service_slug", "priority-overnight") + session.modified = True + return redirect(url_for("ship_review")) + return render_template("ship_service.html", state=state, quotes=quotes) + + +@app.route("/ship/review", methods=["GET", "POST"]) +@login_required +def ship_review(): + state = ship_state() + if not state.get("service_slug"): + flash("Choose a service level first.", "warning") + return redirect(url_for("ship_service")) + service = ServiceLevel.query.filter_by(slug=state["service_slug"]).first_or_404() + quotes = build_rate_quotes( + state.get("origin_state", current_user.state or "CA"), + state.get("destination_state", "TX"), + float(state.get("weight_lb", "8")), + state.get("package_type", "Box"), + ) + selected_quote = next((quote for quote in quotes if quote["service"].slug == service.slug), None) + if request.method == "POST": + next_index = (db.session.query(db.func.count(Shipment.id)).scalar() or 0) + 1 + shipment_code = f"SH-{260000 + next_index}" + tracking_number = f"FDX{260000000 + next_index:09d}" + invoice_number = f"INV-{260000 + next_index}" + total_cost = selected_quote["price"] if selected_quote else 0.0 + shipment = Shipment( + shipment_code=shipment_code, + tracking_number=tracking_number, + user_id=current_user.id, + service_slug=service.slug, + package_type=state.get("package_type", "Box"), + package_weight=float(state.get("weight_lb", "8")), + origin_city=state.get("origin_city", current_user.city), + origin_state=state.get("origin_state", current_user.state), + destination_city=state.get("destination_city", "Dallas"), + destination_state=state.get("destination_state", "TX"), + recipient_name=state.get("recipient_name", "Demo Recipient"), + declared_value=float(state.get("declared_value", "150")), + total_cost=total_cost, + fulfillment_mode=state.get("pickup_mode", "dropoff"), + pickup_location_slug=state.get("pickup_location_slug", current_user.preferred_location_slug), + pickup_window=state.get("pickup_window", ""), + status="Label created", + created_on="2026-06-04", + invoice_number=invoice_number, + reference_label="Local demo shipment", + ) + db.session.add(shipment) + db.session.flush() + tracking = TrackingRecord( + tracking_number=tracking_number, + shipment_id=shipment.id, + user_id=current_user.id, + recipient_name=shipment.recipient_name, + sender_name=current_user.full_name, + origin_city=shipment.origin_city, + origin_state=shipment.origin_state, + destination_city=shipment.destination_city, + destination_state=shipment.destination_state, + service_slug=shipment.service_slug, + package_type=shipment.package_type, + weight_lb=shipment.package_weight, + status_stage="Label created", + status_summary="Shipment information sent to local FedEx demo systems.", + ship_date="2026-06-04", + estimated_delivery="2026-06-06 by 8:00 PM", + latest_scan="Label created", + package_count=1, + signature_required=False, + dropoff_location_slug=shipment.pickup_location_slug, + ) + db.session.add(tracking) + db.session.flush() + db.session.add_all( + [ + TrackingEvent( + tracking_record_id=tracking.id, + sequence=1, + event_time="2026-06-04 09:00", + location_label=f"{shipment.origin_city}, {shipment.origin_state}", + status_label="Label created", + details="Shipment information sent to local demo systems.", + ), + TrackingEvent( + tracking_record_id=tracking.id, + sequence=2, + event_time="2026-06-04 11:30", + location_label=f"{shipment.origin_city}, {shipment.origin_state}", + status_label="Picked up", + details="Package picked up in the demo handoff flow.", + ), + ] + ) + invoice = Invoice( + invoice_number=invoice_number, + user_id=current_user.id, + shipment_id=shipment.id, + billed_on="2026-06-04", + due_date="2026-06-18", + amount=total_cost, + status="Open", + ) + db.session.add(invoice) + db.session.commit() + session["shipment_confirmation_code"] = shipment_code + clear_ship_state() + session["shipment_confirmation_code"] = shipment_code + return redirect(url_for("ship_confirmation")) + return render_template( + "ship_review.html", + state=state, + service=service, + selected_quote=selected_quote, + ) + + +@app.route("/ship/confirmation") +@login_required +def ship_confirmation(): + shipment_code = session.get("shipment_confirmation_code") + if not shipment_code: + flash("No recent mock shipment is available.", "warning") + return redirect(url_for("account_shipments")) + shipment = Shipment.query.filter_by(shipment_code=shipment_code, user_id=current_user.id).first_or_404() + return render_template("ship_confirmation.html", shipment=shipment) + + +@app.route("/pickup", methods=["GET", "POST"]) +@login_required +def pickup(): + locations = Location.query.order_by(Location.city.asc()).all() + selected_slug = request.values.get("location_slug", current_user.preferred_location_slug or locations[0].slug) + location = Location.query.filter_by(slug=selected_slug).first() + slots = [] + if location: + slots = PickupSlot.query.filter_by(location_id=location.id).order_by(PickupSlot.slot_date.asc()).all() + if request.method == "POST": + try: + package_count = int(request.form.get("package_count", "1")) + if package_count < 1: + raise ValueError + except (TypeError, ValueError): + flash("Enter a package count of at least 1.", "danger") + return render_template("pickup.html", locations=locations, selected_slug=selected_slug, slots=slots) + + request_id = (db.session.query(db.func.count(PickupRequest.id)).scalar() or 0) + 1 + slot = PickupSlot.query.filter_by(id=int(request.form.get("pickup_slot_id", "0"))).first_or_404() + pickup_request = PickupRequest( + confirmation_code=f"PU-{2600 + request_id:04d}", + user_id=current_user.id, + location_id=slot.location_id, + slot_date=slot.slot_date, + time_window=slot.time_window, + package_count=package_count, + status="Scheduled", + created_on="2026-06-04", + ) + db.session.add(pickup_request) + db.session.commit() + flash(f"Pickup {pickup_request.confirmation_code} scheduled in this local demo.", "success") + return redirect(url_for("account")) + return render_template("pickup.html", locations=locations, selected_slug=selected_slug, slots=slots) + + +@app.route("/locations") +def locations(): + query = request.args.get("q", "").strip() + locations_query = Location.query + if query: + token_like = f"%{query}%" + locations_query = locations_query.filter( + or_( + Location.name.ilike(token_like), + Location.city.ilike(token_like), + Location.state.ilike(token_like), + Location.location_type.ilike(token_like), + ) + ) + locations_list = locations_query.order_by(Location.state.asc(), Location.city.asc()).all() + return render_template("locations.html", locations=locations_list, query=query) + + +@app.route("/locations/") +def location_detail(location_slug: str): + location = Location.query.filter_by(slug=location_slug).first_or_404() + slots = PickupSlot.query.filter_by(location_id=location.id).order_by(PickupSlot.slot_date.asc()).all() + return render_template("location_detail.html", location=location, slots=slots) + + +@app.route("/support") +def support(): + query = request.args.get("q", "").strip() + articles_query = SupportArticle.query + if query: + articles_query = support_query(query) + articles = articles_query.order_by(SupportArticle.category.asc(), SupportArticle.title.asc()).all() + return render_template("support.html", articles=articles, query=query) + + +@app.route("/support/") +def support_article(article_slug: str): + article = SupportArticle.query.filter_by(slug=article_slug).first_or_404() + related = ( + SupportArticle.query.filter( + SupportArticle.category == article.category, + SupportArticle.slug != article.slug, + ) + .order_by(SupportArticle.title.asc()) + .limit(4) + .all() + ) + return render_template("support_article.html", article=article, related=related) + + +@app.route("/search") +def search(): + query = request.args.get("q", "").strip() + articles = [] + locations = [] + tracking_matches = [] + if query: + db.session.add(SearchLog(query=query, search_type="global", created_on="2026-06-04")) + db.session.commit() + token_like = f"%{query}%" + articles = support_query(query).limit(8).all() + locations = Location.query.filter( + or_(Location.city.ilike(token_like), Location.state.ilike(token_like), Location.name.ilike(token_like)) + ).limit(8).all() + tracking_matches = TrackingRecord.query.filter( + or_( + TrackingRecord.tracking_number.ilike(token_like), + TrackingRecord.recipient_name.ilike(token_like), + TrackingRecord.status_summary.ilike(token_like), + ) + ).limit(8).all() + return render_template( + "search.html", + query=query, + articles=articles, + locations=locations, + tracking_matches=tracking_matches, + ) + + +@app.route("/claims") +@login_required +def claims(): + user_claims = Claim.query.filter_by(user_id=current_user.id).order_by(Claim.opened_on.desc()).all() + return render_template("claims.html", claims=user_claims) + + +@app.route("/invoices") +@login_required +def invoices(): + user_invoices = Invoice.query.filter_by(user_id=current_user.id).order_by(Invoice.billed_on.desc()).all() + return render_template("invoices.html", invoices=user_invoices) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("account")) + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + password = request.form.get("password", "") + user = User.query.filter_by(email=email).first() + if user and user.check_password(password): + login_user(user) + flash("Signed in to the local FedEx demo.", "success") + return redirect(url_for("account")) + flash("That demo sign-in did not match any seeded account.", "danger") + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if current_user.is_authenticated: + return redirect(url_for("account")) + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + if User.query.filter_by(email=email).first(): + flash("That email already exists in the local demo.", "warning") + return redirect(url_for("login")) + next_index = (db.session.query(db.func.count(User.id)).scalar() or 0) + 1 + user = User( + email=email, + first_name=request.form.get("first_name", "Demo").strip() or "Demo", + last_name=request.form.get("last_name", "User").strip() or "User", + phone=request.form.get("phone", "").strip(), + company=request.form.get("company", "").strip(), + city=request.form.get("city", "").strip(), + state=request.form.get("state", "").strip(), + zip_code=request.form.get("zip_code", "").strip(), + account_number=f"5100{next_index:05d}", + preferred_location_slug=request.form.get("preferred_location_slug", "").strip(), + invoicing_email=email, + ) + user.set_password(request.form.get("password", DEMO_PASSWORD)) + db.session.add(user) + db.session.commit() + login_user(user) + flash("Created a new local FedEx demo account.", "success") + return redirect(url_for("account")) + return render_template("register.html") + + +@app.route("/logout") +@login_required +def logout(): + logout_user() + flash("Signed out of the FedEx demo.", "info") + return redirect(url_for("index")) + + +@app.route("/account") +@login_required +def account(): + shipments = Shipment.query.filter_by(user_id=current_user.id).order_by(Shipment.created_on.desc()).limit(4).all() + invoices = Invoice.query.filter_by(user_id=current_user.id).order_by(Invoice.billed_on.desc()).limit(4).all() + claims = Claim.query.filter_by(user_id=current_user.id).order_by(Claim.opened_on.desc()).limit(4).all() + pickups = current_pickups()[:3] + return render_template( + "account.html", + shipments=shipments, + invoices=invoices, + claims=claims, + pickups=pickups, + ) + + +@app.route("/account/edit", methods=["GET", "POST"]) +@login_required +def account_edit(): + if request.method == "POST": + current_user.first_name = request.form.get("first_name", current_user.first_name).strip() + current_user.last_name = request.form.get("last_name", current_user.last_name).strip() + current_user.phone = request.form.get("phone", current_user.phone).strip() + current_user.company = request.form.get("company", current_user.company).strip() + current_user.city = request.form.get("city", current_user.city).strip() + current_user.state = request.form.get("state", current_user.state).strip() + current_user.zip_code = request.form.get("zip_code", current_user.zip_code).strip() + current_user.preferred_location_slug = request.form.get( + "preferred_location_slug", current_user.preferred_location_slug + ).strip() + current_user.invoicing_email = request.form.get("invoicing_email", current_user.invoicing_email).strip() + db.session.commit() + flash("Saved your local FedEx profile updates.", "success") + return redirect(url_for("account")) + return render_template("account_edit.html") + + +@app.route("/account/shipments") +@login_required +def account_shipments(): + shipments = Shipment.query.filter_by(user_id=current_user.id).order_by(Shipment.created_on.desc()).all() + return render_template("account_shipments.html", shipments=shipments) + + +@app.post("/account/shipments//remove") +@login_required +def account_shipment_remove(shipment_code: str): + shipment = Shipment.query.filter_by( + shipment_code=shipment_code, + user_id=current_user.id, + reference_label="Local demo shipment", + ).first_or_404() + tracking = TrackingRecord.query.filter_by(shipment_id=shipment.id).first() + Invoice.query.filter_by(shipment_id=shipment.id).delete(synchronize_session=False) + if tracking: + TrackingEvent.query.filter_by(tracking_record_id=tracking.id).delete(synchronize_session=False) + db.session.delete(tracking) + db.session.delete(shipment) + db.session.commit() + flash(f"Removed local demo shipment {shipment_code}.", "success") + return redirect(url_for("account_shipments")) + + +@app.route("/account/invoices") +@login_required +def account_invoices(): + return redirect(url_for("invoices")) + + +@app.route("/account/claims") +@login_required +def account_claims(): + return redirect(url_for("claims")) + + +@app.route("/_health") +def health(): + return jsonify( + { + "ok": True, + "site": "fedex", + "tracking_records": TrackingRecord.query.count(), + "shipments": Shipment.query.count(), + "locations": Location.query.count(), + } + ) + + +def bootstrap_site() -> None: + from seed_data import seed_benchmark_users, seed_database + + with app.app_context(): + db.create_all() + seed_database() + seed_benchmark_users() + + +if os.environ.get("WEBSYN_SKIP_BOOTSTRAP") != "1": + bootstrap_site() + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/sites/fedex/requirements.txt b/sites/fedex/requirements.txt new file mode 100644 index 00000000..f197e20d --- /dev/null +++ b/sites/fedex/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.1.0 +Flask-Login==0.6.3 +Flask-SQLAlchemy==3.1.1 +SQLAlchemy==2.0.36 +Werkzeug==3.1.3 diff --git a/sites/fedex/seed_data.py b/sites/fedex/seed_data.py new file mode 100644 index 00000000..ebd533aa --- /dev/null +++ b/sites/fedex/seed_data.py @@ -0,0 +1,689 @@ +#!/usr/bin/env python3 +"""Deterministic seed data and lightweight local assets for the FedEx mirror.""" +from __future__ import annotations + +import hashlib +import os +import shutil +from pathlib import Path +from xml.sax.saxutils import escape, quoteattr + +os.environ.setdefault("WEBSYN_SKIP_BOOTSTRAP", "1") + +from app import ( # noqa: E402 + BASE_DIR, + DB_PATH, + DEMO_PASSWORD, + INSTANCE_DIR, + Claim, + Invoice, + Location, + PickupRequest, + PickupSlot, + SearchLog, + ServiceLevel, + Shipment, + SupportArticle, + TrackingEvent, + TrackingRecord, + User, + app, + db, + dumps_json, +) + +INSTANCE_SEED_DIR = BASE_DIR / "instance_seed" +INSTANCE_SEED_DB = INSTANCE_SEED_DIR / "fedex.db" +STATIC_DIR = BASE_DIR / "static" +IMAGE_DIR = STATIC_DIR / "images" +EXTERNAL_CACHE_DIR = STATIC_DIR / "external_cache" + +BENCHMARK_USERS = [ + { + "email": "alice.j@test.com", + "first_name": "Alice", + "last_name": "Johnson", + "phone": "206-555-0140", + "company": "North Ridge Studio", + "city": "Seattle", + "state": "WA", + "zip_code": "98101", + "account_number": "510000001", + "preferred_location_slug": "seattle-downtown-wa", + "invoicing_email": "billing+alice@test.com", + }, + { + "email": "bob.c@test.com", + "first_name": "Bob", + "last_name": "Carter", + "phone": "404-555-0148", + "company": "Peachtree Parts Co.", + "city": "Atlanta", + "state": "GA", + "zip_code": "30303", + "account_number": "510000002", + "preferred_location_slug": "atlanta-midtown-ga", + "invoicing_email": "billing+bob@test.com", + }, + { + "email": "carol.d@test.com", + "first_name": "Carol", + "last_name": "Diaz", + "phone": "305-555-0116", + "company": "South Bay Labs", + "city": "Miami", + "state": "FL", + "zip_code": "33131", + "account_number": "510000003", + "preferred_location_slug": "miami-brickell-fl", + "invoicing_email": "billing+carol@test.com", + }, + { + "email": "david.k@test.com", + "first_name": "David", + "last_name": "Kim", + "phone": "312-555-0157", + "company": "Lakefront Supply", + "city": "Chicago", + "state": "IL", + "zip_code": "60601", + "account_number": "510000004", + "preferred_location_slug": "chicago-loop-il", + "invoicing_email": "billing+david@test.com", + }, +] + +SERVICE_LEVELS = [ + { + "slug": "priority-overnight", + "name": "FedEx Priority Overnight", + "summary": "Early next-business-day delivery for urgent demo parcels.", + "speed_label": "Next business day by 10:30 AM", + "base_rate": 48.0, + "per_lb_rate": 1.95, + "zone_surcharge": 3.4, + "weekend_delivery": True, + "money_back_label": "Money-back demo guarantee", + "sort_order": 1, + }, + { + "slug": "standard-overnight", + "name": "FedEx Standard Overnight", + "summary": "Reliable overnight delivery with evening commitment.", + "speed_label": "Next business day by 8:00 PM", + "base_rate": 34.0, + "per_lb_rate": 1.65, + "zone_surcharge": 2.8, + "weekend_delivery": False, + "money_back_label": "Priority hold-at-location available", + "sort_order": 2, + }, + { + "slug": "fedex-2day", + "name": "FedEx 2Day", + "summary": "Two-day shipping for mid-priority demo deliveries.", + "speed_label": "2 business days by 4:30 PM", + "base_rate": 22.0, + "per_lb_rate": 1.3, + "zone_surcharge": 2.2, + "weekend_delivery": False, + "money_back_label": "Saturday delivery on select lanes", + "sort_order": 3, + }, + { + "slug": "ground-home", + "name": "FedEx Ground Home Delivery", + "summary": "Residential ground shipping with delivery manager style updates.", + "speed_label": "1 to 5 business days", + "base_rate": 15.0, + "per_lb_rate": 0.95, + "zone_surcharge": 1.7, + "weekend_delivery": True, + "money_back_label": "Pickup and drop-off routing supported", + "sort_order": 4, + }, + { + "slug": "freight-economy", + "name": "FedEx Freight Economy", + "summary": "Less-than-truckload demo freight with pallet support.", + "speed_label": "3 to 6 business days", + "base_rate": 96.0, + "per_lb_rate": 2.45, + "zone_surcharge": 4.5, + "weekend_delivery": False, + "money_back_label": "Liftgate service optional", + "sort_order": 5, + }, +] + +LOCATION_DATA = [ + ("Seattle Downtown Ship Center", "seattle-downtown-wa", "Seattle", "WA", "1401 4th Ave", "206-555-0140", "Ship Center", "7:00 AM - 9:00 PM", ["Drop off", "Hold at location", "Packing help"], ["Parking garage", "Print station"], "Late pickup until 7:30 PM"), + ("Bellevue Office Print & Ship", "bellevue-office-wa", "Bellevue", "WA", "500 Bellevue Way NE", "425-555-0132", "Office Print & Ship", "8:00 AM - 8:00 PM", ["Drop off", "Passport photo", "Returns"], ["Copy center", "Metered street parking"], "Small parcel cutoff 6:45 PM"), + ("Portland River District Ship Center", "portland-river-or", "Portland", "OR", "412 NW Glisan St", "503-555-0150", "Ship Center", "7:30 AM - 8:30 PM", ["Drop off", "Pack and ship", "Ground pickup"], ["Bike racks", "Self-service kiosk"], "Ground trailer closes 6:15 PM"), + ("San Francisco Market Hub", "san-francisco-market-ca", "San Francisco", "CA", "210 Market St", "415-555-0161", "Ship Center", "7:00 AM - 8:00 PM", ["Express drop off", "Hold at location", "Dangerous goods desk"], ["Lobby lockers", "Wheelchair access"], "Priority Overnight cutoff 6:00 PM"), + ("Los Angeles Arts District Office", "los-angeles-arts-ca", "Los Angeles", "CA", "777 Alameda St", "213-555-0172", "Office Print & Ship", "8:00 AM - 9:00 PM", ["Print & ship", "Returns", "Package hold"], ["On-site parking", "Photo services"], "Same-day courier handoff 5:30 PM"), + ("Phoenix Camelback Ground Center", "phoenix-camelback-az", "Phoenix", "AZ", "1900 E Camelback Rd", "602-555-0144", "Ship Center", "7:00 AM - 8:00 PM", ["Ground drop off", "Packaging", "Dry ice acceptance"], ["Drive-up bays", "Truck access"], "Freight dock opens at 9:00 AM"), + ("Denver Union Station Ship Center", "denver-union-co", "Denver", "CO", "1701 Wynkoop St", "303-555-0180", "Ship Center", "7:00 AM - 8:30 PM", ["Express drop off", "Hold at location", "Saturday pickup"], ["Transit access", "Bike storage"], "Weekend handoff by noon"), + ("Dallas Arts District Hub", "dallas-arts-tx", "Dallas", "TX", "2200 Ross Ave", "214-555-0192", "Ship Center", "7:00 AM - 9:00 PM", ["Drop off", "Ground pickup", "Freight consult"], ["Loading zone", "Label printer"], "Freight cutoff 4:45 PM"), + ("Houston Midtown Ship Center", "houston-midtown-tx", "Houston", "TX", "3040 Main St", "713-555-0184", "Ship Center", "7:30 AM - 8:30 PM", ["Express drop off", "Hold at location", "Returns"], ["Covered parking", "Lobby lockers"], "Medical cold-pack prep until 5:00 PM"), + ("Chicago Loop Ship Center", "chicago-loop-il", "Chicago", "IL", "120 W Jackson Blvd", "312-555-0157", "Ship Center", "7:00 AM - 9:00 PM", ["Drop off", "Pack and ship", "Passport photo"], ["Elevator access", "Copy center"], "Priority cutoff 6:30 PM"), + ("Atlanta Midtown Ship Center", "atlanta-midtown-ga", "Atlanta", "GA", "880 Peachtree St NE", "404-555-0148", "Ship Center", "7:00 AM - 8:30 PM", ["Express drop off", "Pickup counter", "Ground pickup"], ["Garage parking", "Self-service kiosk"], "Saturday pickup until 11:30 AM"), + ("Miami Brickell Print & Ship", "miami-brickell-fl", "Miami", "FL", "1200 Brickell Ave", "305-555-0116", "Office Print & Ship", "8:00 AM - 8:00 PM", ["Print & ship", "Returns", "Hold at location"], ["Lobby seating", "Photo services"], "International docs accepted until 5:45 PM"), + ("Charlotte South End Ship Center", "charlotte-southend-nc", "Charlotte", "NC", "1425 S Tryon St", "704-555-0168", "Ship Center", "7:00 AM - 8:30 PM", ["Drop off", "Packing help", "Ground pickup"], ["Free parking", "Large parcel scale"], "Ground dispatch at 6:00 PM"), + ("Washington Navy Yard Office", "washington-navy-yard-dc", "Washington", "DC", "50 M St SE", "202-555-0145", "Office Print & Ship", "8:00 AM - 8:00 PM", ["Express drop off", "Returns", "Shipping supplies"], ["Metro access", "Elevator access"], "No freight service"), + ("Boston Back Bay Ship Center", "boston-back-bay-ma", "Boston", "MA", "699 Boylston St", "617-555-0136", "Ship Center", "7:00 AM - 8:00 PM", ["Drop off", "Hold at location", "Saturday pickup"], ["Bike racks", "Copy center"], "Express cutoff 6:15 PM"), +] + +SUPPORT_ARTICLES = [ + ("Track by multiple numbers", "track-multiple-numbers", "Tracking", "Paste several tracking numbers separated by commas or line breaks to monitor multi-piece demo shipments in one view."), + ("What does shipment exception mean?", "shipment-exception-status", "Tracking", "Review common synthetic exception states such as weather delay, address review, or consignee unavailable."), + ("How local pickup scheduling works", "demo-pickup-scheduling", "Pickup", "See how this local mirror books a pickup window without creating a real courier request."), + ("Rate estimate zones explained", "rate-estimate-zones", "Shipping rates", "Understand how the demo calculates zone surcharges between origin and destination states."), + ("Hold at location workflow", "hold-at-location", "Locations", "Learn when a demo package can stay at a staffed hold location for later collection."), + ("Freight pallet requirements", "freight-pallet-guidance", "Freight", "Box dimensions, pallet notes, and liftgate reminders for local freight quotes."), + ("Invoice due dates in the demo", "invoice-due-dates", "Billing", "How synthetic invoice due dates and open balances appear in seeded account history."), + ("File a missing package claim", "missing-package-claim", "Claims", "What claim stages mean inside this deterministic claims dashboard."), + ("Proof of delivery and signatures", "proof-of-delivery", "Tracking", "Understand signature-required tracking events and delivery handoff notes."), + ("Drop-off locations and amenities", "dropoff-location-amenities", "Locations", "Search nearby locations by city, services, and lobby amenities."), + ("Ground vs overnight services", "ground-vs-overnight", "Shipping rates", "Compare the speed, weekend handling, and rate structure for seeded service levels."), + ("International paperwork in the demo", "international-paperwork-demo", "Shipping", "Synthetic documentation reminders for customs-style paperwork flows."), + ("Packaging supplies guide", "packaging-supplies-guide", "Packaging", "Recommended envelope, box, tube, and pallet choices for different product categories."), + ("Weekend delivery commitments", "weekend-delivery-commitments", "Shipping rates", "See which service levels expose weekend delivery copy in the local mirror."), + ("Claims status timeline", "claims-status-timeline", "Claims", "Interpret submitted, review, info requested, and closed claim milestones."), + ("Account invoices export", "account-invoices-export", "Billing", "Where invoicing email and historical billing records appear for seeded users."), + ("Address correction hold", "address-correction-hold", "Tracking", "Why a package may pause for address clarification in the demo timeline."), + ("Weather delay guidance", "weather-delay-guidance", "Tracking", "Suggested next steps when the seeded timeline includes weather disruptions."), +] + +PALETTE = [ + ("#4d148c", "#ff6600", "#f4f0fb"), + ("#5b1aa3", "#ff8f1f", "#f7f2ff"), + ("#472f92", "#f8c471", "#f5f4fb"), + ("#3c1053", "#ff7f32", "#fff4eb"), +] + + +def ensure_dirs() -> None: + for path in [INSTANCE_DIR, INSTANCE_SEED_DIR, IMAGE_DIR, EXTERNAL_CACHE_DIR]: + path.mkdir(parents=True, exist_ok=True) + + +def write_svg(path: Path, title: str, accent: str, secondary: str, background: str, lines: list[str]) -> None: + bars = "".join( + f'' + for i in range(len(lines)) + ) + labels = "".join( + f'{escape(line)}' + for i, line in enumerate(lines) + ) + path.write_text( + f""" + + + + + + + + + + + + {bars} + {labels} + {escape(title)} + +""", + encoding="utf-8", + ) + + +def ensure_visual_assets() -> None: + write_svg( + IMAGE_DIR / "hero-tracking.svg", + "FedEx demo tracker", + "#4d148c", + "#ff6600", + "#f8f4ff", + ["Track package", "Estimate rates", "Schedule pickup"], + ) + for index, service in enumerate(SERVICE_LEVELS): + accent, secondary, background = PALETTE[index % len(PALETTE)] + write_svg( + IMAGE_DIR / f"service-{service['slug']}.svg", + service["name"].replace("FedEx ", ""), + accent, + secondary, + background, + [service["speed_label"], service["summary"], service["money_back_label"]], + ) + for index, location in enumerate(LOCATION_DATA): + accent, secondary, background = PALETTE[index % len(PALETTE)] + write_svg( + IMAGE_DIR / f"location-{location[1]}.svg", + location[2], + accent, + secondary, + background, + [location[0], location[5], location[6]], + ) + + +def service_price(service_slug: str, weight_lb: float, lane_index: int) -> float: + service = next(item for item in SERVICE_LEVELS if item["slug"] == service_slug) + zone = 1 + (lane_index % 4) + package_fee = [0.0, 6.0, 9.5, 18.0, 52.0][lane_index % 5] + return round(service["base_rate"] + service["per_lb_rate"] * weight_lb + service["zone_surcharge"] * zone + package_fee, 2) + + +def tracking_timeline(record_id: int, origin_city: str, origin_state: str, destination_city: str, destination_state: str, stage: str) -> list[dict[str, str]]: + stages = [ + { + "event_time": "2026-06-01 08:15", + "location_label": f"{origin_city}, {origin_state}", + "status_label": "Label created", + "details": "Shipment information sent to the local demo network.", + }, + { + "event_time": "2026-06-01 13:40", + "location_label": f"{origin_city}, {origin_state}", + "status_label": "Picked up", + "details": "Driver completed the scheduled pickup in this local benchmark flow.", + }, + { + "event_time": "2026-06-02 05:20", + "location_label": "Memphis, TN", + "status_label": "In transit", + "details": "Package reached the national sort hub used by the demo timeline.", + }, + { + "event_time": "2026-06-02 18:05", + "location_label": f"{destination_city}, {destination_state}", + "status_label": "At local facility", + "details": "Shipment arrived at the destination station for final handling.", + }, + ] + if stage == "Delivered": + stages.append( + { + "event_time": "2026-06-03 11:02", + "location_label": f"{destination_city}, {destination_state}", + "status_label": "Delivered", + "details": "Delivered to front desk in the local demo environment.", + } + ) + elif stage == "Out for delivery": + stages.append( + { + "event_time": "2026-06-03 08:10", + "location_label": f"{destination_city}, {destination_state}", + "status_label": "Out for delivery", + "details": "Courier is en route for final delivery today.", + } + ) + elif stage == "Weather delay": + stages.append( + { + "event_time": "2026-06-03 07:35", + "location_label": f"{destination_city}, {destination_state}", + "status_label": "Shipment exception", + "details": "Weather conditions paused the last-mile handoff in this seeded timeline.", + } + ) + elif stage == "Address review": + stages.append( + { + "event_time": "2026-06-03 09:25", + "location_label": f"{destination_city}, {destination_state}", + "status_label": "Operational delay", + "details": "Address details are under review before a new delivery attempt.", + } + ) + else: + stages.append( + { + "event_time": "2026-06-03 09:00", + "location_label": f"{destination_city}, {destination_state}", + "status_label": "In transit", + "details": "Package is on the final transfer leg toward destination.", + } + ) + return [ + {"tracking_record_id": record_id, "sequence": index + 1, **event} + for index, event in enumerate(stages) + ] + + +def stage_copy(stage: str) -> tuple[str, str, str]: + mapping = { + "Delivered": ( + "Delivered", + "Delivered to the recipient in this local demo mirror.", + "Delivered on 2026-06-03 by 11:02 AM", + ), + "Out for delivery": ( + "Out for delivery", + "Courier is en route for the final handoff.", + "Expected by 8:00 PM today", + ), + "Weather delay": ( + "Shipment exception", + "Weather conditions affected the final leg of the trip.", + "Updated delivery date pending weather clearance", + ), + "Address review": ( + "Operational delay", + "Address details are being reviewed before another attempt.", + "Customer action may be required", + ), + "In transit": ( + "In transit", + "Package is moving through the local network toward destination.", + "Expected delivery by end of next business day", + ), + } + return mapping[stage] + + +def seed_database() -> None: + if ServiceLevel.query.count() > 0: + return + + ensure_dirs() + ensure_visual_assets() + + services: dict[str, ServiceLevel] = {} + for service in SERVICE_LEVELS: + row = ServiceLevel( + slug=service["slug"], + name=service["name"], + summary=service["summary"], + speed_label=service["speed_label"], + base_rate=service["base_rate"], + per_lb_rate=service["per_lb_rate"], + zone_surcharge=service["zone_surcharge"], + weekend_delivery=service["weekend_delivery"], + money_back_label=service["money_back_label"], + icon_path=f"/static/images/service-{service['slug']}.svg", + sort_order=service["sort_order"], + ) + db.session.add(row) + services[row.slug] = row + + locations: list[Location] = [] + for entry in LOCATION_DATA: + name, slug, city, state, address, phone, location_type, hours, services_list, amenities_list, pickup_note = entry + row = Location( + name=name, + slug=slug, + city=city, + state=state, + address=address, + phone=phone, + location_type=location_type, + hours=hours, + services_json=dumps_json(services_list), + amenities_json=dumps_json(amenities_list), + image_path=f"/static/images/location-{slug}.svg", + pickup_note=pickup_note, + ) + db.session.add(row) + locations.append(row) + + for title, slug, category, summary in SUPPORT_ARTICLES: + db.session.add( + SupportArticle( + title=title, + slug=slug, + category=category, + summary=summary, + body=( + f"{summary} This page is part of a deterministic local FedEx-style demo. " + "It uses synthetic shipping records, seeded route milestones, and fixed support guidance so benchmark agents can practice tracking, billing, and pickup workflows without contacting any live carrier service." + ), + related_topics_json=dumps_json([category, "demo workflow", "tracking help"]), + ) + ) + + db.session.flush() + + for index, location in enumerate(locations): + for slot_index, slot_date in enumerate(["2026-06-05", "2026-06-06", "2026-06-07"]): + db.session.add( + PickupSlot( + location_id=location.id, + slot_date=slot_date, + time_window=["9:00 AM - 11:00 AM", "12:30 PM - 2:30 PM", "4:00 PM - 6:00 PM"][slot_index], + remaining_capacity=max(3, 12 - ((index + slot_index) % 7)), + cutoff_note=[ + "Book 90 minutes ahead", + "Same-day requests close at noon", + "Weekend pickup requires staffed counter", + ][slot_index], + ) + ) + + db.session.commit() + + +def seed_benchmark_users() -> None: + if User.query.filter_by(email="alice.j@test.com").first(): + return + + locations = {location.slug: location for location in Location.query.order_by(Location.slug.asc()).all()} + users: list[User] = [] + for entry in BENCHMARK_USERS: + user = User(**entry) + user.set_password(DEMO_PASSWORD) + db.session.add(user) + users.append(user) + db.session.flush() + + slots = PickupSlot.query.order_by(PickupSlot.slot_date.asc(), PickupSlot.id.asc()).all() + stages = ["Delivered", "In transit", "Out for delivery", "Weather delay", "Address review"] + location_list = list(locations.values()) + + shipment_counter = 0 + tracking_counter = 0 + for user_index, user in enumerate(users): + for shipment_index in range(15): + shipment_counter += 1 + origin = location_list[(user_index * 3 + shipment_index) % len(location_list)] + destination = location_list[(user_index * 3 + shipment_index + 5) % len(location_list)] + service = SERVICE_LEVELS[(shipment_index + user_index) % len(SERVICE_LEVELS)] + package_type = ["Box", "Envelope", "Tube", "Pak", "Freight pallet"][shipment_index % 5] + weight_lb = round(1.5 + (shipment_index % 6) * 2.75 + user_index * 0.4, 1) + total_cost = service_price(service["slug"], weight_lb, shipment_counter) + stage = stages[(shipment_index + user_index) % len(stages)] + shipment_code = f"SH-{260000 + shipment_counter}" + tracking_number = f"FDX{260000000 + shipment_counter:09d}" + invoice_number = f"INV-{260000 + shipment_counter}" + + shipment = Shipment( + shipment_code=shipment_code, + tracking_number=tracking_number, + user_id=user.id, + service_slug=service["slug"], + package_type=package_type, + package_weight=weight_lb, + origin_city=origin.city, + origin_state=origin.state, + destination_city=destination.city, + destination_state=destination.state, + recipient_name=["Maya Harper", "Noah Bennett", "Priya Shah", "Leo Kim", "Jules Chen"][shipment_index % 5], + declared_value=round(80 + shipment_index * 22 + user_index * 18, 2), + total_cost=total_cost, + fulfillment_mode=["dropoff", "pickup", "dropbox"][shipment_index % 3], + pickup_location_slug=origin.slug, + pickup_window=["", "12:30 PM - 2:30 PM", "4:00 PM - 6:00 PM"][shipment_index % 3], + status=stage_copy(stage)[0], + created_on=f"2026-05-{10 + ((shipment_index + user_index) % 18):02d}", + invoice_number=invoice_number, + reference_label=[ + "Demo replacement parts", + "Client presentation materials", + "Prototype samples", + "Signed documents", + "Warehouse replenishment", + ][shipment_index % 5], + ) + db.session.add(shipment) + db.session.flush() + + status_stage, status_summary, estimated_delivery = stage_copy(stage) + tracking = TrackingRecord( + tracking_number=tracking_number, + shipment_id=shipment.id, + user_id=user.id, + recipient_name=shipment.recipient_name, + sender_name=user.full_name, + origin_city=shipment.origin_city, + origin_state=shipment.origin_state, + destination_city=shipment.destination_city, + destination_state=shipment.destination_state, + service_slug=shipment.service_slug, + package_type=shipment.package_type, + weight_lb=shipment.package_weight, + status_stage=status_stage, + status_summary=status_summary, + ship_date=shipment.created_on, + estimated_delivery=estimated_delivery, + latest_scan=status_stage, + package_count=1 + (shipment_index % 3 == 0), + signature_required=shipment_index % 4 == 0, + dropoff_location_slug=origin.slug, + ) + db.session.add(tracking) + db.session.flush() + + for event in tracking_timeline( + tracking.id, + shipment.origin_city, + shipment.origin_state, + shipment.destination_city, + shipment.destination_state, + stage, + ): + db.session.add(TrackingEvent(**event)) + + db.session.add( + Invoice( + invoice_number=invoice_number, + user_id=user.id, + shipment_id=shipment.id, + billed_on=shipment.created_on, + due_date=f"2026-06-{8 + ((shipment_index + user_index) % 16):02d}", + amount=shipment.total_cost, + status=["Paid", "Open", "Open", "Processing"][shipment_index % 4], + ) + ) + + if shipment_index in {2, 7, 11}: + db.session.add( + Claim( + claim_number=f"CLM-{2600 + shipment_counter}", + user_id=user.id, + tracking_number=tracking_number, + claim_type=["Delay reimbursement", "Damage review", "Missing package"][shipment_index % 3], + amount=round(shipment.total_cost * [0.35, 0.55, 0.8][shipment_index % 3], 2), + status=["Under review", "Info requested", "Closed"][shipment_index % 3], + opened_on=f"2026-06-{2 + ((shipment_index + user_index) % 9):02d}", + note="Synthetic claim created for benchmark review flows.", + ) + ) + + for pickup_index in range(2): + slot = slots[(user_index * 4 + pickup_index * 3) % len(slots)] + db.session.add( + PickupRequest( + confirmation_code=f"PU-{2600 + user_index * 10 + pickup_index:04d}", + user_id=user.id, + location_id=slot.location_id, + slot_date=slot.slot_date, + time_window=slot.time_window, + package_count=pickup_index + 1, + status=["Scheduled", "Ready for driver"][pickup_index], + created_on=f"2026-06-0{pickup_index + 2}", + ) + ) + + guest_statuses = ["Delivered", "In transit", "Weather delay", "Address review"] + for extra_index in range(12): + tracking_counter += 1 + origin = location_list[(extra_index + 2) % len(location_list)] + destination = location_list[(extra_index + 8) % len(location_list)] + service = SERVICE_LEVELS[extra_index % len(SERVICE_LEVELS)] + stage = guest_statuses[extra_index % len(guest_statuses)] + status_stage, status_summary, estimated_delivery = stage_copy(stage) + tracking = TrackingRecord( + tracking_number=f"FDX{260000500 + extra_index:09d}", + recipient_name=["Avery Stone", "Harper Lee", "Jordan Park", "Taylor Moss"][extra_index % 4], + sender_name=["Retail Returns", "Warehouse Dock", "Studio Supply", "Medical Lab"][extra_index % 4], + origin_city=origin.city, + origin_state=origin.state, + destination_city=destination.city, + destination_state=destination.state, + service_slug=service["slug"], + package_type=["Box", "Pak", "Envelope", "Tube"][extra_index % 4], + weight_lb=round(2.2 + extra_index * 0.6, 1), + status_stage=status_stage, + status_summary=status_summary, + ship_date=f"2026-05-{20 + (extra_index % 8):02d}", + estimated_delivery=estimated_delivery, + latest_scan=status_stage, + package_count=1 + (extra_index % 3 == 0), + signature_required=extra_index % 4 == 0, + dropoff_location_slug=origin.slug, + ) + db.session.add(tracking) + db.session.flush() + for event in tracking_timeline( + tracking.id, + tracking.origin_city, + tracking.origin_state, + tracking.destination_city, + tracking.destination_state, + stage, + ): + db.session.add(TrackingEvent(**event)) + + db.session.add_all( + [ + SearchLog(query="weather delay", search_type="support", created_on="2026-06-04"), + SearchLog(query="FDX260000001", search_type="tracking", created_on="2026-06-04"), + SearchLog(query="Seattle location", search_type="global", created_on="2026-06-04"), + ] + ) + + db.session.commit() + + +def rebuild_seed_database() -> None: + ensure_dirs() + db.session.remove() + db.engine.dispose() + for db_file in [DB_PATH, INSTANCE_SEED_DB]: + if db_file.exists(): + db_file.unlink() + db.drop_all() + db.create_all() + seed_database() + seed_benchmark_users() + db.session.remove() + shutil.copy2(DB_PATH, INSTANCE_SEED_DB) + + +def current_md5(path: Path) -> str: + digest = hashlib.md5() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(8192), b""): + digest.update(chunk) + return digest.hexdigest() + + +if __name__ == "__main__": + with app.app_context(): + rebuild_seed_database() + print(f"seed db -> {INSTANCE_SEED_DB}") + print(f"md5 -> {current_md5(INSTANCE_SEED_DB)}") diff --git a/sites/fedex/static/css/.gitkeep b/sites/fedex/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/fedex/static/css/main.css b/sites/fedex/static/css/main.css new file mode 100644 index 00000000..db4c035f --- /dev/null +++ b/sites/fedex/static/css/main.css @@ -0,0 +1,537 @@ +:root { + --fedex-purple: #4d148c; + --fedex-purple-deep: #38106a; + --fedex-orange: #ff6600; + --fedex-orange-soft: #ffeddc; + --ink: #1f2937; + --muted: #6b7280; + --line: #d7d8df; + --surface: #ffffff; + --page: #f6f4fb; + --success: #157347; + --warning: #ad6700; + --danger: #b42318; + --shadow: 0 18px 36px rgba(33, 20, 61, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; + color: var(--ink); + background: linear-gradient(180deg, #efe8fb 0%, var(--page) 240px); +} + +a { + color: inherit; + text-decoration: none; +} + +img { + max-width: 100%; + display: block; +} + +.site-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + background: var(--fedex-purple); + color: #fff; + padding: 0.85rem 0; + box-shadow: 0 2px 0 rgba(0, 0, 0, 0.12); +} + +.inner { + width: min(1180px, calc(100% - 2rem)); + margin: 0 auto; +} + +.topbar .inner, +.main-nav .inner { + display: flex; + align-items: center; + gap: 1rem; +} + +.brand { + font-size: 1.35rem; + font-weight: 800; + letter-spacing: 0.02em; +} + +.brand span { + color: var(--fedex-orange); +} + +.eyebrow { + margin-left: auto; + font-size: 0.92rem; + opacity: 0.88; +} + +.main-nav { + background: rgba(255, 255, 255, 0.9); + border-bottom: 1px solid rgba(77, 20, 140, 0.08); + backdrop-filter: blur(12px); +} + +.main-nav .inner { + flex-wrap: wrap; + padding: 0.9rem 0; +} + +.nav-links { + display: flex; + gap: 0.85rem; + flex-wrap: wrap; + align-items: center; +} + +.nav-links a { + padding: 0.45rem 0.7rem; + border-radius: 999px; + font-size: 0.95rem; + color: var(--fedex-purple-deep); +} + +.nav-links a:hover, +.nav-links a.active { + background: rgba(77, 20, 140, 0.08); +} + +.nav-utility { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.nav-utility .pill { + background: var(--fedex-orange-soft); + color: var(--fedex-purple-deep); + font-size: 0.88rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; +} + +.page { + padding: 2rem 0 3rem; +} + +.hero { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 1.5rem; + align-items: stretch; + margin-bottom: 1.5rem; +} + +.hero-copy, +.hero-side, +.card, +.flash-list li, +.surface { + background: rgba(255, 255, 255, 0.96); + border: 1px solid rgba(77, 20, 140, 0.08); + border-radius: 24px; + box-shadow: var(--shadow); +} + +.hero-copy { + padding: 2rem; +} + +.hero-copy h1 { + margin: 0 0 0.8rem; + font-size: clamp(2rem, 3vw, 3rem); + line-height: 1.05; +} + +.hero-copy p { + color: var(--muted); + font-size: 1rem; + line-height: 1.6; +} + +.hero-actions, +.stack-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 1.15rem; +} + +.hero-side { + padding: 1rem; + display: grid; + gap: 0.9rem; +} + +.hero-stat { + padding: 1rem; + background: linear-gradient(135deg, rgba(77, 20, 140, 0.05), rgba(255, 102, 0, 0.08)); + border-radius: 18px; +} + +.hero-stat strong { + display: block; + font-size: 1.55rem; + color: var(--fedex-purple); +} + +.hero-stat span { + display: block; + color: var(--muted); + margin-top: 0.25rem; +} + +.button, +button, +input[type="submit"] { + border: 0; + border-radius: 999px; + padding: 0.8rem 1.15rem; + font-size: 0.96rem; + font-weight: 700; + cursor: pointer; + transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease; +} + +.button:hover, +button:hover, +input[type="submit"]:hover { + transform: translateY(-1px); +} + +.button-primary, +button, +input[type="submit"] { + background: linear-gradient(135deg, var(--fedex-purple), #6525b7); + color: #fff; + box-shadow: 0 12px 24px rgba(77, 20, 140, 0.22); +} + +.button-secondary { + background: #fff; + color: var(--fedex-purple); + border: 1px solid rgba(77, 20, 140, 0.18); +} + +.button-accent { + background: linear-gradient(135deg, var(--fedex-orange), #ff8a23); + color: #fff; + box-shadow: 0 12px 24px rgba(255, 102, 0, 0.2); +} + +.grid { + display: grid; + gap: 1rem; +} + +.grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.grid.four { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.card, +.surface { + padding: 1.2rem; +} + +.card h2, +.surface h2, +.surface h3 { + margin-top: 0; +} + +.kicker { + color: var(--fedex-orange); + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.75rem; +} + +.muted { + color: var(--muted); +} + +.badge-row, +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.badge, +.chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.35rem 0.6rem; + border-radius: 999px; + font-size: 0.82rem; +} + +.badge { + background: rgba(77, 20, 140, 0.08); + color: var(--fedex-purple); +} + +.badge.orange { + background: rgba(255, 102, 0, 0.12); + color: #8f4600; +} + +.status-good { color: var(--success); } +.status-warn { color: var(--warning); } +.status-risk { color: var(--danger); } + +.form-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.form-grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field { + display: grid; + gap: 0.4rem; +} + +label { + font-weight: 700; + color: var(--fedex-purple-deep); +} + +input, +select, +textarea { + width: 100%; + padding: 0.85rem 0.95rem; + border-radius: 14px; + border: 1px solid rgba(77, 20, 140, 0.16); + background: #fff; + font: inherit; + color: var(--ink); +} + +textarea { + min-height: 120px; + resize: vertical; +} + +.section-title { + display: flex; + justify-content: space-between; + align-items: end; + gap: 1rem; + margin: 1.8rem 0 1rem; +} + +.section-title h2 { + margin: 0; +} + +.flash-list { + list-style: none; + padding: 0; + margin: 0 0 1rem; + display: grid; + gap: 0.7rem; +} + +.flash-list li { + padding: 0.9rem 1rem; +} + +.flash-success { border-left: 6px solid var(--success); } +.flash-warning { border-left: 6px solid var(--warning); } +.flash-danger { border-left: 6px solid var(--danger); } +.flash-info { border-left: 6px solid var(--fedex-purple); } + +.split { + display: grid; + gap: 1rem; + grid-template-columns: 1.25fr 0.75fr; +} + +.timeline { + display: grid; + gap: 0.8rem; +} + +.timeline-item { + position: relative; + padding-left: 1.5rem; + border-left: 2px solid rgba(77, 20, 140, 0.14); +} + +.timeline-item::before { + content: ""; + position: absolute; + left: -0.45rem; + top: 0.2rem; + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + background: var(--fedex-orange); +} + +.timeline-item strong { + display: block; + color: var(--fedex-purple); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; +} + +.stat { + padding: 1rem; + border-radius: 18px; + background: linear-gradient(180deg, rgba(77, 20, 140, 0.05), rgba(255, 255, 255, 0.95)); +} + +.stat strong { + display: block; + font-size: 1.35rem; + color: var(--fedex-purple); +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: 0.85rem 0.7rem; + border-bottom: 1px solid rgba(77, 20, 140, 0.09); + vertical-align: top; +} + +th { + color: var(--fedex-purple); + font-size: 0.86rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.media { + display: grid; + grid-template-columns: 140px 1fr; + gap: 1rem; + align-items: start; +} + +.media img { + border-radius: 18px; + border: 1px solid rgba(77, 20, 140, 0.1); +} + +.location-list, +.article-list, +.record-list { + display: grid; + gap: 0.9rem; +} + +.stack { + display: grid; + gap: 0.9rem; +} + +.footer { + margin-top: auto; + padding: 1.2rem 0 2rem; + color: var(--muted); + font-size: 0.92rem; +} + +.footer .inner { + display: flex; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.empty-state { + padding: 1.2rem; + border-radius: 18px; + background: rgba(77, 20, 140, 0.04); + color: var(--muted); +} + +@media (max-width: 980px) { + .hero, + .split, + .grid.three, + .grid.four, + .stats-grid, + .media { + grid-template-columns: 1fr; + } + + .form-grid, + .form-grid.three, + .grid.two { + grid-template-columns: 1fr; + } + + .nav-utility { + margin-left: 0; + } +} + +@media (max-width: 640px) { + .inner { + width: min(100% - 1rem, 1180px); + } + + .topbar .inner, + .main-nav .inner { + align-items: flex-start; + } + + .hero-copy { + padding: 1.35rem; + } + + .card, + .surface { + padding: 1rem; + } + + .button, + button, + input[type="submit"] { + width: 100%; + justify-content: center; + } +} diff --git a/sites/fedex/static/icons/.gitkeep b/sites/fedex/static/icons/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/fedex/static/js/.gitkeep b/sites/fedex/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/fedex/static/js/main.js b/sites/fedex/static/js/main.js new file mode 100644 index 00000000..c55b9d07 --- /dev/null +++ b/sites/fedex/static/js/main.js @@ -0,0 +1,11 @@ +document.addEventListener("DOMContentLoaded", () => { + const sampleButtons = document.querySelectorAll("[data-fill-tracking]"); + sampleButtons.forEach((button) => { + button.addEventListener("click", () => { + const target = document.querySelector(button.dataset.fillTracking); + if (!target) return; + target.value = button.dataset.value || ""; + target.focus(); + }); + }); +}); diff --git a/sites/fedex/tasks.jsonl b/sites/fedex/tasks.jsonl new file mode 100644 index 00000000..2c9d3751 --- /dev/null +++ b/sites/fedex/tasks.jsonl @@ -0,0 +1,18 @@ +{"web_name":"FedEx","id":"FedEx--0","ques":"Track package FDX260000004, open its detail page, and report the location and cause shown in the latest exception timeline entry.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/tracking.html","verifier_path":"sites/fedex/verify/verify_0.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST use tracking results and open the requested package's tracking-detail page. (2) The final answer MUST report both the location and the cause from the latest exception timeline entry, not merely repeat the summary card. (3) The answer must be non-empty. FAIL if: no detail-page visit; either requested fact is missing; or the answer relies only on the results card."} +{"web_name":"FedEx","id":"FedEx--1","ques":"Track FDX260000001, FDX260000002, and FDX260000003 together. Which one is already delivered, and does its detail page say a signature is required?","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/tracking.html","verifier_path":"sites/fedex/verify/verify_1.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST submit the multi-package tracking request and open the delivered record's detail page. (2) The final answer MUST identify the delivered tracking number and state the signature requirement shown on its detail page. (3) The answer must be non-empty. FAIL if: the records are not compared; the delivered detail is not opened; or either requested fact is absent."} +{"web_name":"FedEx","id":"FedEx--2","ques":"Use support search for tracking, find the article that explains what a shipment exception means, and open it. Besides its Tracking category, report the two related-topic chips shown on the detail page.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/customer-support.html","verifier_path":"sites/fedex/verify/verify_2.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST run the broad support search specified by the task and open the matching article detail among its distractors. (2) The final answer MUST report both related-topic chips requested by the task, excluding the category chip. (3) The answer must be non-empty. FAIL if: only a search-result card is used; the article is not opened; or either chip is omitted."} +{"web_name":"FedEx","id":"FedEx--3","ques":"Estimate a shipment from California to Texas for an 8 lb box. Which seeded service level is the cheapest, and what price is shown?","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/online/rating.html","verifier_path":"sites/fedex/verify/verify_3.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST submit the stated origin, destination, weight, and package type on the rate-estimate page. (2) The final answer MUST name the cheapest displayed service and its displayed price after comparing the quote cards. (3) The answer must be non-empty. FAIL if: the requested quote is not generated; the service or price is wrong or missing; or the answer is guessed without site navigation."} +{"web_name":"FedEx","id":"FedEx--4","ques":"Estimate a shipment from Washington to Florida for a 4 lb envelope. Compare the fastest and cheapest displayed options: name both services and report their price difference.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/online/rating.html","verifier_path":"sites/fedex/verify/verify_4.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST generate the requested quote. (2) The final answer MUST identify both the fastest and cheapest displayed services and give the correct difference between their displayed prices. (3) The answer must be non-empty. FAIL if: it merely names the first card; either service is missing; the arithmetic is wrong; or no quote-page navigation occurred."} +{"web_name":"FedEx","id":"FedEx--5","ques":"Sign in as alice.j@test.com with password TestPass123! and find the invoice number tied to the shipment delivered to Dallas, TX.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/account/","verifier_path":"sites/fedex/verify/verify_5.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST sign in and inspect both shipment history and invoice history. (2) The final answer MUST report the invoice that maps to the shipment whose destination is specified in the task. (3) The answer must be non-empty. FAIL if: authentication or either history page is skipped; the cross-page mapping is not performed; or the invoice is wrong or missing."} +{"web_name":"FedEx","id":"FedEx--6","ques":"Sign in as bob.c@test.com with password TestPass123! and inspect his claims. For the claim waiting for more information, report both its claim number and tracking number.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/customer-support/claims.html","verifier_path":"sites/fedex/verify/verify_6.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST sign in as the requested account and open its claims page. (2) The final answer MUST identify the claim with the requested status and report both identifiers shown on its card. (3) The answer must be non-empty. FAIL if: the wrong account or status is used; either identifier is omitted; or the claims page is not opened."} +{"web_name":"FedEx","id":"FedEx--7","ques":"Sign in as carol.d@test.com with password TestPass123! and inspect her scheduled pickups. For the pickup marked Ready for driver, report its confirmation code and time window.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/shipping/schedule-manage-pickups.html","verifier_path":"sites/fedex/verify/verify_7.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST sign in as the requested account and inspect the scheduled pickups on its account page. (2) The final answer MUST report the confirmation code and time window for the pickup with the specified status. (3) The answer must be non-empty. FAIL if: the wrong account or pickup is used; either requested field is omitted; or no authenticated pickup view is shown."} +{"web_name":"FedEx","id":"FedEx--8","ques":"Sign in as david.k@test.com with password TestPass123! and open his shipment history. List the shipment code and destination city for every shipment marked Out for delivery.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/manage-delivery.html","verifier_path":"sites/fedex/verify/verify_8.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST sign in as the requested account and open the full shipment-history page. (2) The final answer MUST include every row with the requested status, pairing each shipment code with its destination city. (3) The answer must be non-empty. FAIL if: any qualifying row is omitted; an extra non-qualifying row is presented as a match; a code-city pairing is wrong; or shipment history is not opened."} +{"web_name":"FedEx","id":"FedEx--9","ques":"On the locations page, search for Ship Center, open the Dallas Arts District Hub, and report its pickup cutoff note.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/shipping/drop-off-package.html","verifier_path":"sites/fedex/verify/verify_9.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST use the locations search and open the requested location's detail page among the matching locations. (2) The final answer MUST report the pickup cutoff note from that detail page. (3) The answer must be non-empty. FAIL if: only listing-card content is used; the requested detail page is not opened; or the cutoff note is wrong or missing."} +{"web_name":"FedEx","id":"FedEx--10","ques":"Open the Miami Brickell Print & Ship location detail page and identify the note about international documents.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/shipping/drop-off-package.html","verifier_path":"sites/fedex/verify/verify_10.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST navigate through the locations view to the requested location's detail page. (2) The final answer MUST reproduce the international-document note shown there, including its cutoff time. (3) The answer must be non-empty. FAIL if: the detail page is not opened; the note or time is wrong or missing; or the answer is inferred from a listing card."} +{"web_name":"FedEx","id":"FedEx--11","ques":"Use global search for the word delay and open the article specifically about weather-related disruptions. According to the article body, which three workflows can benchmark agents practice?","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/customer-support.html","verifier_path":"sites/fedex/verify/verify_11.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST use global search and open the weather-disruption article's detail page. (2) The final answer MUST list all three workflow areas named in the article body. (3) The answer must be non-empty. FAIL if: only the result title or summary is used; the detail page is not opened; or any workflow area is wrong or omitted."} +{"web_name":"FedEx","id":"FedEx--12","ques":"Sign in as alice.j@test.com with password TestPass123!, create a mock shipment for Alex Brown from Seattle, WA to Boston, MA using a 6 lb box with declared value 240, choose FedEx 2Day, and report the generated tracking number.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/shipping/create-shipment.html","verifier_path":"sites/fedex/verify/verify_12.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST authenticate and complete shipment details, service selection, review, and confirmation. (2) The after-state MUST contain one new shipment for the requested account with the exact recipient, lane, package, weight, declared value, and service. (3) The final answer MUST report the tracking number generated for that row. FAIL if: the workflow is incomplete; the database state does not match every requested field; or the reported tracking number does not match the created shipment."} +{"web_name":"FedEx","id":"FedEx--13","ques":"Sign in as alice.j@test.com with password TestPass123!, schedule a demo pickup for one package at the Seattle Downtown Ship Center using the earliest available slot, and report the confirmation code.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/shipping/schedule-manage-pickups.html","verifier_path":"sites/fedex/verify/verify_13.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST authenticate, open pickup scheduling, select the requested location and earliest available slot, submit one package, and reach the updated account view. (2) The after-state MUST contain exactly the requested new pickup. (3) The final answer MUST report its confirmation code. FAIL if: the selected location, slot, or package count is wrong; no matching row exists; or the reported code does not match it."} +{"web_name":"FedEx","id":"FedEx--14","ques":"Use global search for Ship Center and open the Seattle Downtown Ship Center detail page. What hours are posted?","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/shipping/drop-off-package.html","verifier_path":"sites/fedex/verify/verify_14.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST use global search and open the requested location detail from the multiple location matches. (2) The final answer MUST report the complete posted opening and closing hours shown on the detail page. (3) The answer must be non-empty. FAIL if: only search-card content is used; the detail page is not opened; or either endpoint of the hours is wrong or missing."} +{"web_name":"FedEx","id":"FedEx--15","ques":"Sign in as david.k@test.com with password TestPass123! and find the damage review claim in his account. Report both its claim number and tracking number.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/customer-support/claims.html","verifier_path":"sites/fedex/verify/verify_15.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST sign in as the requested account and open its claims page. (2) The final answer MUST select the claim type named in the task and report both identifiers shown on its card. (3) The answer must be non-empty. FAIL if: the wrong account or claim type is used; either identifier is omitted; or the claims page is not opened."} +{"web_name":"FedEx","id":"FedEx--16","ques":"Track FDX260000500, open its detail page, and report whether it is delivered or still moving plus the city and state of its final timeline handoff.","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/tracking.html","verifier_path":"sites/fedex/verify/verify_16.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST track the requested number and open its detail page. (2) The final answer MUST state the delivery state and the city and state from the final timeline event. (3) The answer must be non-empty. FAIL if: only the tracking-results card is used; the detail page is not opened; or either the state or final handoff location is wrong or missing."} +{"web_name":"FedEx","id":"FedEx--17","ques":"Estimate a freight pallet shipment from Texas to Florida weighing 12 lb. Which service is the most expensive in the seeded quote list, and what price is shown?","web":"http://localhost:40016/","upstream_url":"https://www.fedex.com/en-us/online/rating.html","verifier_path":"sites/fedex/verify/verify_17.py","judge_rubric":"FACT CHECKPOINTS: (1) The trajectory MUST generate the requested freight-pallet quote with the stated lane and weight. (2) The final answer MUST identify the most expensive displayed service after comparing the options and report its displayed price. (3) The answer must be non-empty. FAIL if: the requested quote is not generated; the service or price is wrong or missing; or the answer is guessed without site navigation."} diff --git a/sites/fedex/templates/.gitkeep b/sites/fedex/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/fedex/templates/account.html b/sites/fedex/templates/account.html new file mode 100644 index 00000000..2ba6dabf --- /dev/null +++ b/sites/fedex/templates/account.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Account{% endblock %} +{% block content %} +
+
+
Account dashboard
+

{{ current_user.full_name }}

+
+ Edit profile +
+
+
{{ shipments|length }}Recent shipments
+
{{ invoices|length }}Recent invoices
+
{{ claims|length }}Active claims
+
+ +
+
+
+

Recent shipments

+ View all +
+ {% for shipment in shipments %} +
+ {{ shipment.shipment_code }} +

{{ shipment.destination_city }}, {{ shipment.destination_state }} · {{ shipment.status }}

+
+ {% else %} +
No shipments yet.
+ {% endfor %} +
+
+
+

Scheduled pickups

+ Schedule another +
+ {% for pickup in pickups %} +
+ {{ pickup.confirmation_code }} +

{{ pickup.slot_date }} · {{ pickup.time_window }} · {{ pickup.status }}

+
+ {% else %} +
No pickups scheduled.
+ {% endfor %} +
+
+ +
+
+
+

Invoices

+ Open billing +
+ {% for invoice in invoices %} +
+ {{ invoice.invoice_number }} +

{{ invoice.amount|money }} · {{ invoice.status }} · due {{ invoice.due_date }}

+
+ {% else %} +
No invoices available.
+ {% endfor %} +
+
+
+

Claims

+ Open claims +
+ {% for claim in claims %} +
+ {{ claim.claim_number }} +

{{ claim.claim_type }} · {{ claim.status }}

+
+ {% else %} +
No active claims.
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/fedex/templates/account_edit.html b/sites/fedex/templates/account_edit.html new file mode 100644 index 00000000..4f9d1677 --- /dev/null +++ b/sites/fedex/templates/account_edit.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Edit Account{% endblock %} +{% block content %} +
+
Profile
+

Edit local account details

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+{% endblock %} diff --git a/sites/fedex/templates/account_shipments.html b/sites/fedex/templates/account_shipments.html new file mode 100644 index 00000000..3d7e3fae --- /dev/null +++ b/sites/fedex/templates/account_shipments.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Account Shipments{% endblock %} +{% block content %} +
+
+
Shipment history
+

All seeded shipments for {{ current_user.first_name }}

+
+
+
+ + + + + + + + + + + + + {% for shipment in shipments %} + + + + + + + + + {% endfor %} + +
ShipmentTrackingRouteStatusTotalActions
{{ shipment.shipment_code }}{{ shipment.tracking_number }}{{ shipment.origin_city }}, {{ shipment.origin_state }} → {{ shipment.destination_city }}, {{ shipment.destination_state }}{{ shipment.status }}{{ shipment.total_cost|money }} + {% if shipment.reference_label == "Local demo shipment" %} +
+ +
+ {% else %} + Seeded record + {% endif %} +
+
+{% endblock %} diff --git a/sites/fedex/templates/base.html b/sites/fedex/templates/base.html new file mode 100644 index 00000000..71f11ab9 --- /dev/null +++ b/sites/fedex/templates/base.html @@ -0,0 +1,67 @@ + + + + + + {% block title %}FedEx Demo{% endblock %} + + + +
+
+
+
+ FedEx Demo +
Local benchmark mirror with deterministic synthetic shipment data only
+
+
+ +
+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for category, message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+
+ +
+
+
FedEx Demo mirror for WebHarbor. No live carrier APIs, payments, pickups, or address validation.
+
Control plane reset target: /reset/fedex
+
+
+
+ + + diff --git a/sites/fedex/templates/claims.html b/sites/fedex/templates/claims.html new file mode 100644 index 00000000..29ae68b4 --- /dev/null +++ b/sites/fedex/templates/claims.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Claims{% endblock %} +{% block content %} +
+
+
Claims
+

Shipment claim statuses

+
+
+
+ {% for claim in claims %} +
+
+ {{ claim.claim_number }} + {{ claim.status }} +
+

{{ claim.claim_type }}

+

Tracking number {{ claim.tracking_number }}

+

{{ claim.note }}

+ {{ claim.amount|money }} +
+ {% else %} +
No claims found for this account.
+ {% endfor %} +
+{% endblock %} diff --git a/sites/fedex/templates/index.html b/sites/fedex/templates/index.html new file mode 100644 index 00000000..fee1a245 --- /dev/null +++ b/sites/fedex/templates/index.html @@ -0,0 +1,165 @@ +{% extends "base.html" %} +{% block title %}FedEx Demo Home{% endblock %} +{% block content %} +
+
+
Tracking, shipping, pickup, and support
+

Practice end-to-end parcel workflows without touching a live carrier.

+

+ This mirror recreates the feel of a FedEx-style logistics dashboard using synthetic tracking numbers, + fixed route milestones, seeded account history, and deterministic pickup schedules. It never creates + a real shipment or charges a payment method. +

+ +
+
+ FedEx demo tracker art +
+ {{ featured_tracking|length }} + Featured seeded tracking records ready for smoke tests +
+
+ {{ services|length }} + Deterministic service levels with rate estimates and delivery commitments +
+
+
+ +
+
+
+
+
Quick track
+

Track one or several demo packages

+
+
+
+
+ + +
+
+ + +
+
+
+
+
Seeded account sign-in
+

Benchmark users

+

Use these deterministic demo accounts for shipment, invoice, claim, and pickup flows.

+
+ + + + + + + + + + +
EmailPreferred location
alice.j@test.comSeattle Downtown Ship Center
bob.c@test.comAtlanta Midtown Ship Center
carol.d@test.comMiami Brickell Print & Ship
david.k@test.comChicago Loop Ship Center
+
+

Password for all seeded users: {{ demo_password }}

+
+
+ +
+
+
Service menu
+

Compare delivery commitments

+
+ Open rate estimate +
+
+ {% for service in services %} +
+ {{ service.name }} +
+ {{ service.speed_label }} + {% if service.weekend_delivery %}Weekend eligible{% endif %} +
+

{{ service.name }}

+

{{ service.summary }}

+

{{ service.money_back_label }}

+
+ {% endfor %} +
+ +
+
+
Active seeded records
+

Recent package statuses

+
+ Track more +
+
+ {% for record in featured_tracking %} +
+
+
+
+ {{ record.tracking_number }} + {{ record.service_slug|replace('-', ' ')|title }} +
+

{{ record.status_stage }}

+

{{ record.origin_city }}, {{ record.origin_state }} → {{ record.destination_city }}, {{ record.destination_state }}

+

{{ record.status_summary }}

+
+
+ ETA +

{{ record.estimated_delivery }}

+ Latest scan +

{{ record.latest_scan }}

+
+
+
+ {% endfor %} +
+ +
+
+
Nearby staffed counters
+

Pickup and hold-at-location options

+
+ Browse all locations +
+
+ {% for location in locations %} +
+ {{ location.name }} +
+
+ {{ location.location_type }} + {{ location.city }}, {{ location.state }} +
+

{{ location.name }}

+

{{ location.address }}

+

{{ location.pickup_note }}

+
+
+ {% endfor %} +
+ +
+
+
Support articles
+

Benchmark-friendly help content

+
+ See all support topics +
+
+ {% for article in articles %} + + {% endfor %} +
+{% endblock %} diff --git a/sites/fedex/templates/invoices.html b/sites/fedex/templates/invoices.html new file mode 100644 index 00000000..7274ce38 --- /dev/null +++ b/sites/fedex/templates/invoices.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}Invoices{% endblock %} +{% block content %} +
+
+
Billing
+

Invoice history

+
+
+
+ + + + + + + + + + + + + {% for invoice in invoices %} + + + + + + + + + {% endfor %} + +
InvoiceShipmentBilledDueStatusAmount
{{ invoice.invoice_number }}{{ invoice.shipment.shipment_code if invoice.shipment else "—" }}{{ invoice.billed_on }}{{ invoice.due_date }}{{ invoice.status }}{{ invoice.amount|money }}
+
+{% endblock %} diff --git a/sites/fedex/templates/location_detail.html b/sites/fedex/templates/location_detail.html new file mode 100644 index 00000000..e1f4e535 --- /dev/null +++ b/sites/fedex/templates/location_detail.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}{{ location.name }}{% endblock %} +{% block content %} +
+ {{ location.name }} +
+
+ {{ location.location_type }} + {{ location.city }}, {{ location.state }} + {{ location.hours }} +
+

{{ location.name }}

+

{{ location.address }} · {{ location.phone }}

+

{{ location.pickup_note }}

+
+ {% for amenity in location.amenities %} + {{ amenity }} + {% endfor %} +
+
+
+
+
+

Services

+
+ {% for service in location.services %} + {{ service }} + {% endfor %} +
+
+
+

Pickup windows

+
+ {% for slot in slots %} +
+ {{ slot.slot_date }} · {{ slot.time_window }} + {{ slot.remaining_capacity }} capacity left +

{{ slot.cutoff_note }}

+
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/sites/fedex/templates/locations.html b/sites/fedex/templates/locations.html new file mode 100644 index 00000000..c2ce061e --- /dev/null +++ b/sites/fedex/templates/locations.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Locations{% endblock %} +{% block content %} +
+
+
Drop-off locations
+

Find nearby staffed counters and pickup points

+
+
+
+
+
+ + +
+
+ +
+
+
+
+ {% for location in locations %} +
+ {{ location.name }} +
+
+ {{ location.location_type }} + {{ location.hours }} +
+

{{ location.name }}

+

{{ location.address }} · {{ location.city }}, {{ location.state }}

+
+ {% for service in location.services %} + {{ service }} + {% endfor %} +
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/sites/fedex/templates/login.html b/sites/fedex/templates/login.html new file mode 100644 index 00000000..d787e377 --- /dev/null +++ b/sites/fedex/templates/login.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}Sign In{% endblock %} +{% block content %} +
+
+
Sign in
+

Access seeded shipments, invoices, and claims

+
+
+ + +
+
+ + +
+ +
+
+
+
Benchmark accounts
+

Every seeded user shares the same deterministic password: {{ demo_password }}.

+
    +
  • alice.j@test.com
  • +
  • bob.c@test.com
  • +
  • carol.d@test.com
  • +
  • david.k@test.com
  • +
+ Create another local account +
+
+{% endblock %} diff --git a/sites/fedex/templates/pickup.html b/sites/fedex/templates/pickup.html new file mode 100644 index 00000000..2a82f9cb --- /dev/null +++ b/sites/fedex/templates/pickup.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Schedule Pickup{% endblock %} +{% block content %} +
+
+
Pickup scheduling
+

Book a local demo pickup window

+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+{% endblock %} diff --git a/sites/fedex/templates/rate_estimate.html b/sites/fedex/templates/rate_estimate.html new file mode 100644 index 00000000..c113ee39 --- /dev/null +++ b/sites/fedex/templates/rate_estimate.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Rate Estimate{% endblock %} +{% block content %} +
+
+
Rate estimate
+

Compare seeded service levels by lane and package profile

+
+
+
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + {% if quotes %} +
+

Estimated options

+
+
+ {% for quote in quotes %} +
+ {{ quote.service.name }} +
+ Zone {{ quote.zone }} + {{ quote.commitment }} +
+

{{ quote.service.name }}

+

{{ quote.service.summary }}

+

{{ quote.price|money }}

+
+ {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/sites/fedex/templates/register.html b/sites/fedex/templates/register.html new file mode 100644 index 00000000..29b1a069 --- /dev/null +++ b/sites/fedex/templates/register.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Create Account{% endblock %} +{% block content %} +
+
Register
+

Create a local FedEx demo account

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ + +
+ +
+
+{% endblock %} diff --git a/sites/fedex/templates/search.html b/sites/fedex/templates/search.html new file mode 100644 index 00000000..f4fb1066 --- /dev/null +++ b/sites/fedex/templates/search.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Search{% endblock %} +{% block content %} +
+
+
Global search
+

Search tracking, locations, and support

+
+
+
+
+
+ + +
+
+ +
+
+
+ {% if query %} +
+
+

Tracking matches

+
+ {% for record in tracking_matches %} +
+ {{ record.tracking_number }} +

{{ record.status_summary }}

+
+ {% else %} +
No tracking results matched.
+ {% endfor %} +
+
+
+

Locations

+
+ {% for location in locations %} +
+ {{ location.name }} +

{{ location.city }}, {{ location.state }}

+
+ {% else %} +
No location results matched.
+ {% endfor %} +
+
+
+

Support articles

+
+ {% for article in articles %} +
+ {{ article.title }} +

{{ article.summary }}

+
+ {% else %} +
No support articles matched.
+ {% endfor %} +
+
+
+ {% endif %} +{% endblock %} diff --git a/sites/fedex/templates/ship.html b/sites/fedex/templates/ship.html new file mode 100644 index 00000000..974e3702 --- /dev/null +++ b/sites/fedex/templates/ship.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% block title %}Create Shipment{% endblock %} +{% block content %} +
+
+
Mock shipment
+

Start a local shipping flow

+
+
+
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+{% endblock %} diff --git a/sites/fedex/templates/ship_confirmation.html b/sites/fedex/templates/ship_confirmation.html new file mode 100644 index 00000000..2bdc2089 --- /dev/null +++ b/sites/fedex/templates/ship_confirmation.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Shipment Confirmation{% endblock %} +{% block content %} +
+
Shipment created
+

{{ shipment.shipment_code }}

+
+ {{ shipment.tracking_number }} + {{ shipment.status }} +
+

This confirmation exists only in the local FedEx demo mirror.

+
+
{{ shipment.total_cost|money }}Mock billed total
+
{{ shipment.invoice_number }}Invoice reference
+
{{ shipment.destination_city }}Destination city
+
+ +
+{% endblock %} diff --git a/sites/fedex/templates/ship_review.html b/sites/fedex/templates/ship_review.html new file mode 100644 index 00000000..d00642a1 --- /dev/null +++ b/sites/fedex/templates/ship_review.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Review Shipment{% endblock %} +{% block content %} +
+
+
Step 3
+

Review and create your local shipment

+
+
+
+
+
+ + + + + + + + +
Recipient{{ state.get('recipient_name') }}
Route{{ state.get('origin_city') }}, {{ state.get('origin_state') }} → {{ state.get('destination_city') }}, {{ state.get('destination_state') }}
Package{{ state.get('package_type') }} · {{ state.get('weight_lb') }} lb
Declared value{{ state.get('declared_value')|float|money }}
Handoff mode{{ state.get('pickup_mode', 'dropoff')|replace('-', ' ')|title }}
+
+
+ +
+{% endblock %} diff --git a/sites/fedex/templates/ship_service.html b/sites/fedex/templates/ship_service.html new file mode 100644 index 00000000..b4eb5608 --- /dev/null +++ b/sites/fedex/templates/ship_service.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Choose Service{% endblock %} +{% block content %} +
+
+
Step 2
+

Select a service level

+
+
+
+ {% for quote in quotes %} + + {% endfor %} +
+
Shipment summary
+

{{ state.get('recipient_name') }}

+

{{ state.get('origin_city') }}, {{ state.get('origin_state') }} → {{ state.get('destination_city') }}, {{ state.get('destination_state') }}

+

{{ state.get('package_type') }} · {{ state.get('weight_lb') }} lb · Declared {{ state.get('declared_value')|float|money }}

+ +
+
+{% endblock %} diff --git a/sites/fedex/templates/support.html b/sites/fedex/templates/support.html new file mode 100644 index 00000000..fb109dfe --- /dev/null +++ b/sites/fedex/templates/support.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Support{% endblock %} +{% block content %} +
+
+
Support center
+

Search synthetic help content

+
+
+
+
+
+ + +
+
+ +
+
+
+
+ {% for article in articles %} + + {% endfor %} +
+{% endblock %} diff --git a/sites/fedex/templates/support_article.html b/sites/fedex/templates/support_article.html new file mode 100644 index 00000000..6b10a88d --- /dev/null +++ b/sites/fedex/templates/support_article.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}{{ article.title }}{% endblock %} +{% block content %} +
+
+
{{ article.category }}
+

{{ article.title }}

+

{{ article.summary }}

+

{{ article.body }}

+
+ {% for topic in article.related_topics %} + {{ topic }} + {% endfor %} +
+
+ +
+{% endblock %} diff --git a/sites/fedex/templates/track.html b/sites/fedex/templates/track.html new file mode 100644 index 00000000..f570801b --- /dev/null +++ b/sites/fedex/templates/track.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Track Packages{% endblock %} +{% block content %} +
+
+
Tracking
+

Track one or several synthetic packages

+
+
+
+
+
+
+ + +
+

Separate multiple numbers with commas or line breaks. This mirror never calls a real carrier system.

+
+ + +
+
+
+ +
+{% endblock %} diff --git a/sites/fedex/templates/track_results.html b/sites/fedex/templates/track_results.html new file mode 100644 index 00000000..fbd5b91a --- /dev/null +++ b/sites/fedex/templates/track_results.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Tracking Results{% endblock %} +{% block content %} +
+
+
Tracking results
+

{{ records|length }} of {{ numbers|length }} packages found

+
+ Track again +
+ {% if numbers and not records %} +
No seeded demo tracking numbers matched your request.
+ {% endif %} + {% if records %} +
+ {% for record in records %} +
+
+
+
+ {{ record.tracking_number }} + {{ record.service_slug|replace('-', ' ')|title }} +
+

{{ record.status_stage }}

+

{{ record.origin_city }}, {{ record.origin_state }} → {{ record.destination_city }}, {{ record.destination_state }}

+

{{ record.status_summary }}

+
+
+ Estimated delivery +

{{ record.estimated_delivery }}

+ Recipient +

{{ record.recipient_name }}

+
+
+
+ {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/sites/fedex/templates/tracking_detail.html b/sites/fedex/templates/tracking_detail.html new file mode 100644 index 00000000..77ef2c78 --- /dev/null +++ b/sites/fedex/templates/tracking_detail.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}{{ record.tracking_number }}{% endblock %} +{% block content %} +
+
+
Tracking detail
+

{{ record.tracking_number }}

+
+ Back to results +
+ +
+
+
+ {{ record.status_stage }} + {% if service %}{{ service.name }}{% endif %} +
+

{{ record.status_summary }}

+
+
{{ record.origin_city }}{{ record.origin_state }} origin
+
{{ record.destination_city }}{{ record.destination_state }} destination
+
{{ record.estimated_delivery }}Current ETA
+
+
+ + + + + + + + + +
Sender{{ record.sender_name }}
Recipient{{ record.recipient_name }}
Package type{{ record.package_type }}
Weight{{ record.weight_lb }} lb
Package count{{ record.package_count }}
Signature{{ "Required" if record.signature_required else "Not required" }}
+
+
+ + +
+{% endblock %} diff --git a/sites/fedex/tests/test_fedex_routes.py b/sites/fedex/tests/test_fedex_routes.py new file mode 100644 index 00000000..4f66c39d --- /dev/null +++ b/sites/fedex/tests/test_fedex_routes.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import os +import re +import subprocess +import sys +import tarfile +import tempfile +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + + +SITE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SITE_ROOT)) +TEST_TEMP_DIR = tempfile.TemporaryDirectory() +TEST_DB_PATH = Path(TEST_TEMP_DIR.name) / "fedex-test.db" +os.environ["WEBSYN_SKIP_BOOTSTRAP"] = "1" +os.environ["FEDEX_DATABASE_URI"] = f"sqlite:///{TEST_DB_PATH}" + +from app import PickupRequest, app, db # noqa: E402 +from seed_data import seed_benchmark_users, seed_database # noqa: E402 + + +class FedExRouteTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + app.config.update(TESTING=True) + + @classmethod + def tearDownClass(cls) -> None: + TEST_TEMP_DIR.cleanup() + + def setUp(self) -> None: + self.app_context = app.app_context() + self.app_context.push() + db.drop_all() + db.create_all() + seed_database() + seed_benchmark_users() + self.client = app.test_client() + + def tearDown(self) -> None: + db.session.remove() + db.drop_all() + self.app_context.pop() + + def test_rate_estimate_rejects_non_numeric_weight_without_server_error(self) -> None: + response = self.client.post( + "/rate-estimate", + data={ + "origin_state": "CA", + "destination_state": "TX", + "weight_lb": "not-a-number", + "package_type": "Box", + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertIn(b"Enter a weight greater than 0", response.data) + + def test_ship_rejects_invalid_numeric_values_without_server_error(self) -> None: + self.client.post( + "/login", + data={"email": "alice.j@test.com", "password": "TestPass123!"}, + ) + valid_form = { + "recipient_name": "Regression Recipient", + "origin_city": "Seattle", + "origin_state": "WA", + "destination_city": "Austin", + "destination_state": "TX", + "package_type": "Box", + "weight_lb": "5", + "declared_value": "100", + "pickup_mode": "dropoff", + } + + for field, invalid_value, message in ( + ("weight_lb", "not-a-number", b"Enter a weight greater than 0"), + ("declared_value", "not-a-number", b"Enter a declared value of at least 0"), + ): + with self.subTest(field=field): + form = {**valid_form, field: invalid_value} + response = self.client.post("/ship", data=form, follow_redirects=True) + self.assertEqual(response.status_code, 200) + self.assertIn(message, response.data) + + def test_pickup_rejects_non_numeric_package_count_without_server_error(self) -> None: + self.client.post( + "/login", + data={"email": "alice.j@test.com", "password": "TestPass123!"}, + ) + pickup_page = self.client.get("/pickup") + slot_match = re.search( + rb'name="pickup_slot_id".*?