diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..ccf5c684 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Force LF line endings for shell scripts so they execute in Linux containers +# even when checked out on Windows. +*.sh text eol=lf +*.bash text eol=lf +Dockerfile text eol=lf diff --git a/Dockerfile b/Dockerfile index 991e5ab6..1e86b1d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 15 Flask mirror sites + control plane on :8101. +# 16 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -33,6 +33,6 @@ COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py RUN chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40014 +EXPOSE 8101 40000-40015 CMD ["/opt/websyn_start.sh"] diff --git a/control_server.py b/control_server.py index c255253c..f86e4262 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', + 'coursera', 'espn', 'carmax', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/scripts/verify_carmax.sh b/scripts/verify_carmax.sh new file mode 100644 index 00000000..a3973e04 --- /dev/null +++ b/scripts/verify_carmax.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Phase 1.9 — Docker container verification for the carmax mirror. +# Runs build + run + reset + md5sum + cleanup. +# +# Usage: bash scripts/verify_carmax.sh +set -uo pipefail + +cd "$(dirname "$0")/.." + +CONTAINER=wh-test +PORT_CONTROL=8201 +PORT_RANGE_LOW=41000 +PORT_RANGE_HIGH=41015 +SITE=carmax +SITE_PORT=41015 + +cleanup() { + docker stop "$CONTAINER" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "" +echo "============================================================" +echo " Phase 1.9 — Docker verification for site: $SITE" +echo "============================================================" + +# ---- Step 1: clean stale container ---- +echo "" +echo "[1/6] Stop any stale test container..." +docker stop "$CONTAINER" >/dev/null 2>&1 || true + +# ---- Step 2: build image ---- +echo "" +echo "[2/6] Build webharbor:dev (first time ~3 min, warm cache ~30 s)..." +bash scripts/build.sh webharbor:dev || { echo "BUILD FAILED"; exit 1; } + +# ---- Step 3: run container on alt ports ---- +echo "" +echo "[3/6] Run container on alt ports ($PORT_CONTROL + $PORT_RANGE_LOW-$PORT_RANGE_HIGH)..." +docker run -d --rm --name "$CONTAINER" \ + -p "$PORT_CONTROL:8101" \ + -p "$PORT_RANGE_LOW-$PORT_RANGE_HIGH:40000-40015" \ + webharbor:dev >/dev/null \ + || { echo "DOCKER RUN FAILED"; exit 1; } + +echo " container started. waiting 35s for all 16 sites to boot..." +sleep 35 + +# ---- Step 4: health check ---- +echo "" +echo "[4/6] Control plane health + HTTP 200 sweep:" +curl -s "http://localhost:$PORT_CONTROL/health" \ + | python -m json.tool 2>/dev/null \ + | head -50 || echo " (control plane not responding — see docker logs $CONTAINER)" + +echo "" +echo " Per-site HTTP status:" +for p in $(seq $PORT_RANGE_LOW $PORT_RANGE_HIGH); do + code=$(curl -so /dev/null -w "%{http_code}" "http://localhost:$p/" || echo "ERR") + printf " :%d %s\n" "$p" "$code" +done + +# ---- Step 5: byte-identical reset ---- +echo "" +echo "[5/6] /reset/$SITE (the strict invariant)..." +RESET_RESP=$(curl -sX POST "http://localhost:$PORT_CONTROL/reset/$SITE") +echo " reset response: $RESET_RESP" + +echo "" +echo " md5sum (the two values MUST match):" +# MSYS_NO_PATHCONV=1 disables Git Bash's path translation on Windows +# (otherwise /opt/WebSyn becomes C:/Program Files/Git/opt/WebSyn). +MSYS_NO_PATHCONV=1 docker exec "$CONTAINER" md5sum \ + "/opt/WebSyn/$SITE/instance/$SITE.db" \ + "/opt/WebSyn/$SITE/instance_seed/$SITE.db" + +# ---- Step 6: result ---- +echo "" +echo "[6/6] Verification done." +echo "" +echo "============================================================" +echo " If both md5sums above are identical -> Phase 1 PASSED ✅" +echo " If they differ -> seed function isn't idempotent, see" +echo " seed-database skill for diagnosis." +echo "============================================================" diff --git a/sites/apple/instance/apple_store.db b/sites/apple/instance/apple_store.db new file mode 100644 index 00000000..62341417 Binary files /dev/null and b/sites/apple/instance/apple_store.db differ diff --git a/sites/carmax/PHASE_1_SUMMARY.md b/sites/carmax/PHASE_1_SUMMARY.md new file mode 100644 index 00000000..74f163f4 --- /dev/null +++ b/sites/carmax/PHASE_1_SUMMARY.md @@ -0,0 +1,267 @@ +# CarMax mirror — Phase 1 Summary + +Phase 1 of the WebHarbor contribution pipeline for `carmax.com`. Code is +complete; the remaining boot-and-freeze + Docker verification must run +on your local Windows host (sandbox limits prevent it from this side). + +## Files added / modified + +### Code (new) +| File | Lines | Purpose | +|---|---|---| +| `sites/carmax/app.py` | 1990 | Flask app: 13 models, 10 forms, 59 routes, search/research/sell/finance/checkout | +| `sites/carmax/seed_data.py` | 900 | Idempotent seed: 12 stores, ~155 vehicles, 5 users, 20 reviews, 10 articles | +| `sites/carmax/scrape_carmax.py` | 205 | Playwright recipe for harvesting real evox images from carmax.com | +| `sites/carmax/requirements.txt` | 11 | Pinned deps matching the Dockerfile | +| `sites/carmax/templates/` | 1519 (44 files) | base + macros + 42 page templates | +| `sites/carmax/static/css/main.css` | 221 | CarMax brand: navy `#1660a8` + yellow `#FFD900` | +| `sites/carmax/static/images/_pending.svg` | tiny | onerror fallback for missing vehicle photos | +| `sites/carmax/scraped_data/recon_notes.md` | 130 | URL/feature/visual recon captured via WebFetch+WebSearch | + +### Code (modified) +| File | Change | +|---|---| +| `websyn_start.sh` | Added `carmax` to `SITES`; switched the three hardcoded `15`s to `${#SITES[@]}` | +| `control_server.py` | Added `'carmax'` to `SITES` list | +| `Dockerfile` | `EXPOSE 8101 40000-40014` → `40000-40015`; comment "15 sites" → "16 sites" | + +## Seeded row counts per major model + +When `seed_database()` + `seed_benchmark_users()` run from an empty DB: + +| Model | Rows | +|---|---| +| Store | 12 (real CarMax addresses across CA/TX/FL/GA/NY/IL/VA/AZ/CO/NC/WA/MA) | +| Vehicle | ~155 (31 model templates × ~5 variants — actual count = `len(_build_vehicle_seeds())`) | +| Article | 10 | +| Review | 20 | +| User | 5 (`alice.j` `bob.k` `carol.l` `dan.m` `emma.n` @test.com) | +| FinancePreQual | 4 (Dan has no pre-qual) | +| SavedVehicle | 6 | +| Reservation | 1 | +| TestDrive | 2 | +| Appraisal | 3 | +| Order | 1 (Dan's ready-for-pickup vehicle #37) | + +All benchmark users share the same password **`CarMax!2026`** (bcrypt hash +hardcoded for deterministic md5). + +## Byte-identical reset — status + +**Not yet verified.** Requires the boot-and-freeze cycle on your Windows +host (sandbox has neither `pip install Flask` access nor Docker). See +**Verification procedure** below. + +The seed functions are coded for byte-identity: +- Both `seed_database()` and `seed_benchmark_users()` early-return on + populated DB (function-level gate, not row-level). +- All inserted rows use `SEED_NOW = datetime(2026, 1, 15, 12, 0, 0)` + instead of `datetime.utcnow()`. +- Vehicle records are produced by deterministic `_build_vehicle_seeds()` + iterating templates × trims × years; stock numbers and VINs are + derived from those indices. +- Bcrypt password hash for benchmark users is pinned (no random salt). + +## Verification procedure (run on your Windows host) + +### Step 1 — Finish extracting the HF asset tarballs + +Earlier `bash scripts/fetch_assets.sh` downloaded all 15 tarballs but +crashed on the GBK-encoded `✓` after the download. Re-run just the +extract loop: + +```bash +cd /e/GitHub/WebHarbor +for tarball in sites/.cache/tarballs/*.tar.gz; do + site=$(basename "$tarball" .tar.gz) + echo "extracting $site..." + tar --skip-old-files -xzf "$tarball" -C sites/ +done +ls sites/booking/static/images/ | head -3 # verify it actually expanded +``` + +### Step 2 — Harvest CarMax images (one-time) + +```bash +cd /e/GitHub/WebHarbor +pip install playwright httpx +python -m playwright install chromium + +# scrape ~600-900 vehicle photos into sites/carmax/static/images/vehicles/ +python sites/carmax/scrape_carmax.py +ls sites/carmax/static/images/vehicles/ | wc -l # should print >300 +``` + +Why this step: the seeded DB references local paths like +`/static/images/vehicles/-front.jpg`. The scraper grabs the real +evox stock photos from `content-images.carmax.com` (the same CDN the +live site uses) and writes them to those filenames. + +### Step 3 — Boot the app once locally to build the seed DB + +```bash +cd /e/GitHub/WebHarbor/sites/carmax +pip install -r requirements.txt +PORT=5099 python app.py & +sleep 4 +curl -s http://localhost:5099/_health | head # verify {ok: true, vehicles: 15x, ...} +kill %1 # stop the dev server + +# Promote the freshly-created DB to the seed +cp instance/carmax.db instance_seed/carmax.db + +# Re-boot, confirm seeds early-return and md5 matches +PORT=5099 python app.py & +sleep 4 +kill %1 +md5sum instance/carmax.db instance_seed/carmax.db # MUST match +``` + +If they don't match, the typical culprit is a `created_at` that fell +through to `datetime.utcnow()`; grep `seed_data.py` for any row that +omits a timestamp. + +### Step 4 — Build and verify in Docker + +```bash +cd /e/GitHub/WebHarbor +bash scripts/build.sh webharbor:dev + +docker run -d --rm --name wh-test \ + -p 8201:8101 -p 41000-41015:40000-40015 webharbor:dev +sleep 30 + +# carmax is index 15, so port 41015 +curl -so /dev/null -w "carmax:%{http_code}\n" http://localhost:41015/ +curl -s http://localhost:8201/health | python -m json.tool | head + +# all 16 sites should 200 +for p in $(seq 41000 41015); do + curl -so /dev/null -w "$p:%{http_code}\n" http://localhost:$p/ +done + +# byte-identical reset (the strict invariant) +curl -X POST http://localhost:8201/reset/carmax +docker exec wh-test md5sum \ + /opt/WebSyn/carmax/instance/carmax.db \ + /opt/WebSyn/carmax/instance_seed/carmax.db +# the two md5s MUST match + +docker stop wh-test +``` + +## Anything that needs human review + +1. **Two leftover bash test files** in `sites/carmax/`: + `_bash_test.txt` and `_bashwrite_test.py`. My sandbox mount denies + `rm`. Please delete them manually: + `rm sites/carmax/_bash_test.txt sites/carmax/_bashwrite_test.py` +2. **Images directory**: `static/images/vehicles/` will be empty until + Step 2 (scraper) runs. The app still renders — `_pending.svg` is the + onerror fallback. +3. **Article/store images** referenced in the DB (`/static/images/articles/.jpg`, + `/static/images/stores/storefront_default.jpg`) are not harvested by + the current scraper. The site still works; they just fall back to + the pending SVG. If review feedback insists, extend `scrape_carmax.py` + to grab Contentful URLs from the article hero `` selectors. +4. **`scrape_carmax.py` URL provenance**: the script assumes the + `content-images.carmax.com/stockimages////` + convention I observed in WebFetch results. If a particular + year/make/model combo doesn't have evox photos there, that vehicle + will keep showing the pending SVG. +5. **Slug normalization in scrape_carmax.py vs DB**: the script's + `TEMPLATE_INDEX` hand-writes `'silverado-1500'` etc., but + `seed_data.py` uses just `'Silverado'` → slug `silverado`. After + Step 3 boots, run `grep model_slug sites/carmax/instance/carmax.db` + via sqlite3 or a Python one-liner to confirm the joins land. Fix + `TEMPLATE_INDEX` if a model slug mismatches. + +## Phase 2-5 next steps (after Phase 1 verifies) + +| Phase | Skill | Deliverable | +|---|---|---| +| 2 | `.claude/skills/design-tasks` | `sites/carmax/tasks.jsonl` — 15-20 WebVoyager tasks across search/browse/cart/checkout/account/finance/sell. Schema: `{web_name, id, ques, web, upstream_url}` | +| 3 | `.claude/skills/evolve-env` | Walk each task manually; extend mirror to support; fix info leaks, superficial completion, insufficient distractors | +| 4 | `.claude/skills/harden-env` | Audit against the 4 hardening dimensions + 13 leak archetypes; re-verify byte-identical reset | +| 5 | `.claude/skills/seed-database` | Re-confirm seed_*() idempotency; finalize scored token-overlap search (already done in `app.py:search_vehicles`); freeze the canonical instance_seed/carmax.db | + +## Final PR submission (do these after all 5 phases pass) + +### A. Hugging Face assets PR + +```bash +# 1. Pack the carmax assets into a tarball matching the rest of the dataset +cd /e/GitHub/WebHarbor +bash scripts/extract_assets.sh carmax # produces sites/.cache/tarballs/carmax.tar.gz + +# 2. Upload to the HF dataset on a feature branch +hf auth login # only if you don't have a token cached +hf upload ChilleD/WebHarbor \ + sites/.cache/tarballs/carmax.tar.gz \ + carmax.tar.gz \ + --repo-type dataset \ + --revision add-carmax + +# 3. Open a PR on huggingface.co/datasets/ChilleD/WebHarbor merging +# 'add-carmax' -> 'main' with the description: +# "Add carmax.tar.gz (Phase 1-5 mirror, ~150 vehicles + 12 stores, byte-identical reset verified)" + +# 4. After the HF PR merges, note its revision SHA — you'll bump +# .assets-revision to that SHA on the GitHub side. +``` + +### B. GitHub code PR + +```bash +cd /e/GitHub/WebHarbor + +# 1. Make sure scraped_data/, instance/, __pycache__ are NOT staged +git status # eyeball it +git add sites/carmax/{app.py,seed_data.py,requirements.txt,scrape_carmax.py,_health.py,tasks.jsonl} +git add sites/carmax/templates/ sites/carmax/static/css/ sites/carmax/static/icons/ sites/carmax/static/js/ +git add websyn_start.sh control_server.py Dockerfile +git add sites/carmax/PHASE_1_SUMMARY.md # optional - mostly for review traceability +git status + +# 2. Sanity checks (must all pass) +python3 -m py_compile sites/carmax/app.py +bash scripts/build.sh webharbor:dev +# ...full pre-PR checklist from AGENTS.md... + +# 3. Bump the HF asset pin +# Edit .assets-revision: set 'revision:' to the merged HF PR's commit SHA +git add .assets-revision + +# 4. Commit + push +git commit -m "Add carmax mirror (16th site) + +- 13 SQLAlchemy models (User, Store, Vehicle, SavedVehicle, Comparison, + Reservation, TestDrive, Appraisal, FinancePreQual, Order, Review, + Article + ComparisonItem) +- 59 Flask routes covering search/research/comparison/saved/stores/sell/ + finance/reserve/test-drive/checkout/account/articles/FAQ/MaxCare/auth +- ~155 deterministically-seeded vehicles across 31 templates, + 12 real CarMax store locations +- Token-overlap scored search with multi-field weighting +- Idempotent seed_database + seed_benchmark_users (alice.j@test.com et al.) +- Byte-identical reset verified" +git push origin add-carmax + +# 5. Open the GitHub PR with reference to the merged HF revision. +``` + +### C. Final integration check before requesting review + +```bash +# Pull from your fork as if you were a reviewer: +git checkout main && git pull +bash scripts/fetch_assets.sh # should now pull carmax.tar.gz +bash scripts/build.sh webharbor:dev +docker run -d --rm --name wh-final \ + -p 8101:8101 -p 40000-40015:40000-40015 webharbor:dev +curl -s http://localhost:8101/health | python -m json.tool | head +docker stop wh-final +``` + +That's it — when those steps all green, ping the WebHarbor maintainers +for review. diff --git a/sites/carmax/_health.py b/sites/carmax/_health.py new file mode 100644 index 00000000..0a9ec22f --- /dev/null +++ b/sites/carmax/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "carmax"} diff --git a/sites/carmax/app.py b/sites/carmax/app.py new file mode 100644 index 00000000..17dcc017 --- /dev/null +++ b/sites/carmax/app.py @@ -0,0 +1,1997 @@ +#!/usr/bin/env python3 +"""CarMax.com mirror — Flask application. + +Full inventory search, vehicle detail, research, comparison, saved cars, +sell-my-car appraisal, financing pre-qualification, reserve & test drive +booking, checkout, MaxCare warranty, stores, articles, FAQ, customer +reviews. +""" +import json +import os +import re +from datetime import date, datetime, timedelta + +from flask import (Flask, abort, flash, jsonify, redirect, render_template, + request, session, url_for) +from flask_bcrypt import Bcrypt +from flask_login import (LoginManager, UserMixin, current_user, login_required, + login_user, logout_user) +from flask_sqlalchemy import SQLAlchemy +from flask_wtf import FlaskForm +from flask_wtf.csrf import CSRFProtect, generate_csrf +from sqlalchemy import func +from wtforms import (BooleanField, FloatField, HiddenField, IntegerField, + PasswordField, RadioField, SelectField, StringField, + TextAreaField) +from wtforms.validators import (DataRequired, Email, EqualTo, Length, + NumberRange, Optional, Regexp) + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +app = Flask(__name__) +app.config['SECRET_KEY'] = 'carmax-mirror-webharbor-key' +app.config['SQLALCHEMY_DATABASE_URI'] = ( + f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'carmax.db')}" +) +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['WTF_CSRF_TIME_LIMIT'] = None +app.config['MAX_CONTENT_LENGTH'] = 8 * 1024 * 1024 + +os.makedirs(os.path.join(BASE_DIR, 'instance'), exist_ok=True) + +db = SQLAlchemy(app) +bcrypt = Bcrypt(app) +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Please sign in to access your CarMax account.' +login_manager.login_message_category = 'info' +csrf = CSRFProtect(app) + + +# ============================================================================= +# Models +# ============================================================================= + +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), default='') + last_name = db.Column(db.String(80), default='') + phone = db.Column(db.String(30), default='') + zip_code = db.Column(db.String(10), default='') + address_line1 = db.Column(db.String(200), default='') + address_line2 = db.Column(db.String(200), default='') + city = db.Column(db.String(80), default='') + state = db.Column(db.String(2), default='') + home_store_id = db.Column(db.Integer, db.ForeignKey('stores.id'), nullable=True) + + pre_qual_active = db.Column(db.Boolean, default=False) + pre_qual_monthly_max = db.Column(db.Float, default=0.0) + pre_qual_term_months = db.Column(db.Integer, default=72) + pre_qual_apr = db.Column(db.Float, default=0.0) + pre_qual_down_payment = db.Column(db.Float, default=2000.0) + pre_qual_credit_tier = db.Column(db.String(20), default='') + pre_qual_expires_at = db.Column(db.Date, nullable=True) + + annual_income = db.Column(db.Integer, default=0) + employment_status = db.Column(db.String(40), default='') + + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + saved_vehicles = db.relationship('SavedVehicle', backref='user', lazy=True, + cascade='all, delete-orphan') + reservations = db.relationship('Reservation', backref='user', lazy=True, + cascade='all, delete-orphan') + test_drives = db.relationship('TestDrive', backref='user', lazy=True, + cascade='all, delete-orphan') + appraisals = db.relationship('Appraisal', backref='user', lazy=True, + cascade='all, delete-orphan') + orders = db.relationship('Order', backref='user', lazy=True, + cascade='all, delete-orphan', + foreign_keys='Order.user_id') + reviews = db.relationship('Review', backref='user', lazy=True, + cascade='all, delete-orphan') + comparisons = db.relationship('Comparison', backref='user', lazy=True, + cascade='all, delete-orphan') + + def set_password(self, pw): + self.password_hash = bcrypt.generate_password_hash(pw).decode('utf-8') + + def check_password(self, pw): + try: + return bcrypt.check_password_hash(self.password_hash, pw) + except Exception: + return False + + @property + def full_name(self): + n = f'{self.first_name} {self.last_name}'.strip() + return n or (self.email.split('@')[0] if self.email else 'CarMax customer') + + @property + def pre_qual_is_valid(self): + return bool(self.pre_qual_active and self.pre_qual_expires_at + and self.pre_qual_expires_at >= date(2026, 5, 14)) + + +class Store(db.Model): + __tablename__ = 'stores' + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + street = db.Column(db.String(200), default='') + city = db.Column(db.String(80), nullable=False, index=True) + state = db.Column(db.String(2), nullable=False, index=True) + zip_code = db.Column(db.String(10), default='') + phone = db.Column(db.String(30), default='') + hours_weekday = db.Column(db.String(40), default='10:00 AM - 9:00 PM') + hours_saturday = db.Column(db.String(40), default='9:00 AM - 9:00 PM') + hours_sunday = db.Column(db.String(40), default='12:00 PM - 7:00 PM') + has_appraisal = db.Column(db.Boolean, default=True) + has_express_pickup = db.Column(db.Boolean, default=True) + has_service = db.Column(db.Boolean, default=True) + has_home_delivery = db.Column(db.Boolean, default=True) + latitude = db.Column(db.Float, default=0.0) + longitude = db.Column(db.Float, default=0.0) + image = db.Column(db.String(255), default='/static/images/stores/storefront_default.jpg') + + vehicles = db.relationship('Vehicle', backref='store', lazy=True) + + @property + def location_label(self): + return f'{self.city}, {self.state}' + + +class Vehicle(db.Model): + __tablename__ = 'vehicles' + id = db.Column(db.Integer, primary_key=True) + stock_number = db.Column(db.String(20), unique=True, nullable=False, index=True) + slug = db.Column(db.String(200), unique=True, nullable=False, index=True) + vin = db.Column(db.String(20), default='') + + year = db.Column(db.Integer, nullable=False, index=True) + make = db.Column(db.String(40), nullable=False, index=True) + make_slug = db.Column(db.String(40), nullable=False, index=True) + model = db.Column(db.String(60), nullable=False, index=True) + model_slug = db.Column(db.String(60), nullable=False, index=True) + trim = db.Column(db.String(60), default='', index=True) + trim_slug = db.Column(db.String(60), default='', index=True) + body_style = db.Column(db.String(30), default='Sedan', index=True) + + exterior_color = db.Column(db.String(40), default='') + interior_color = db.Column(db.String(40), default='') + + mileage = db.Column(db.Integer, nullable=False) + price = db.Column(db.Float, nullable=False, index=True) + list_price = db.Column(db.Float, default=0.0) + + engine_text = db.Column(db.String(80), default='') + engine_displacement = db.Column(db.Float, default=0.0) + horsepower = db.Column(db.Integer, default=0) + torque = db.Column(db.Integer, default=0) + transmission = db.Column(db.String(30), default='Automatic') + drive_type = db.Column(db.String(10), default='FWD') + fuel_type = db.Column(db.String(30), default='Gasoline', index=True) + + mpg_city = db.Column(db.Integer, default=0) + mpg_highway = db.Column(db.Integer, default=0) + mpg_combined = db.Column(db.Integer, default=0) + + seating_capacity = db.Column(db.Integer, default=5) + cargo_volume = db.Column(db.Float, default=0.0) + wheelbase = db.Column(db.Float, default=0.0) + overall_length = db.Column(db.Float, default=0.0) + width = db.Column(db.Float, default=0.0) + height = db.Column(db.Float, default=0.0) + fuel_capacity = db.Column(db.Float, default=0.0) + + features = db.Column(db.Text, default='[]') + description = db.Column(db.Text, default='') + + image = db.Column(db.String(255), default='') + gallery_images = db.Column(db.Text, default='[]') + + customer_rating = db.Column(db.Float, default=4.4) + customer_rating_count = db.Column(db.Integer, default=0) + repairpal_rating = db.Column(db.Float, default=4.0) + + is_certified = db.Column(db.Boolean, default=True) + is_featured = db.Column(db.Boolean, default=False) + is_no_haggle = db.Column(db.Boolean, default=True) + is_new_arrival = db.Column(db.Boolean, default=False) + is_price_drop = db.Column(db.Boolean, default=False) + + store_id = db.Column(db.Integer, db.ForeignKey('stores.id'), nullable=False) + transfer_fee = db.Column(db.Float, default=0.0) + + days_on_lot = db.Column(db.Integer, default=14) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + + @property + def title(self): + parts = [str(self.year), self.make, self.model] + if self.trim: + parts.append(self.trim) + return ' '.join(parts) + + @property + def short_title(self): + return f'{self.year} {self.make} {self.model}' + + @property + def headline_price(self): + return f'${int(self.price):,}' + + @property + def mileage_label(self): + return f'{self.mileage:,} mi' + + def get_features(self): + try: + return json.loads(self.features or '[]') + except Exception: + return [] + + def get_gallery(self): + try: + paths = json.loads(self.gallery_images or '[]') + return [p for p in paths if p] + except Exception: + return [] + + def all_images(self): + gallery = self.get_gallery() + if self.image and self.image not in gallery: + return [self.image] + gallery + return gallery or ([self.image] if self.image else []) + + def has_feature(self, feat): + f = feat.lower() + return any(f == g.lower() for g in self.get_features()) + + def estimated_monthly_payment(self, term_months=72, apr=0.0699, down=2000): + return estimated_payment(self.price, term_months, apr, down) + + def savings(self): + if self.list_price and self.list_price > self.price: + return self.list_price - self.price + return 0.0 + + +class SavedVehicle(db.Model): + __tablename__ = 'saved_vehicles' + __table_args__ = (db.UniqueConstraint('user_id', 'vehicle_id', + name='uq_saved_user_vehicle'),) + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False) + saved_at = db.Column(db.DateTime, default=datetime.utcnow) + note = db.Column(db.String(200), default='') + + vehicle = db.relationship('Vehicle', lazy='joined') + + +class Comparison(db.Model): + __tablename__ = 'comparisons' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + session_key = db.Column(db.String(64), default='', index=True) + name = db.Column(db.String(80), default='My comparison') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + items = db.relationship('ComparisonItem', backref='comparison', lazy=True, + cascade='all, delete-orphan') + + +class ComparisonItem(db.Model): + __tablename__ = 'comparison_items' + __table_args__ = (db.UniqueConstraint('comparison_id', 'vehicle_id', + name='uq_compare_item'),) + id = db.Column(db.Integer, primary_key=True) + comparison_id = db.Column(db.Integer, db.ForeignKey('comparisons.id'), nullable=False) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + vehicle = db.relationship('Vehicle', lazy='joined') + + +class Reservation(db.Model): + __tablename__ = 'reservations' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey('stores.id'), nullable=False) + status = db.Column(db.String(20), default='active') + appointment_date = db.Column(db.Date, nullable=True) + expires_at = db.Column(db.Date, nullable=False) + transfer_required = db.Column(db.Boolean, default=False) + transfer_fee = db.Column(db.Float, default=0.0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + vehicle = db.relationship('Vehicle', lazy='joined') + store = db.relationship('Store', lazy='joined') + + +class TestDrive(db.Model): + __tablename__ = 'test_drives' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey('stores.id'), nullable=False) + location_type = db.Column(db.String(20), default='in_store') + scheduled_date = db.Column(db.Date, nullable=False) + scheduled_time = db.Column(db.String(10), default='10:00 AM') + status = db.Column(db.String(20), default='confirmed') + notes = db.Column(db.String(500), default='') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + vehicle = db.relationship('Vehicle', lazy='joined') + store = db.relationship('Store', lazy='joined') + + +class Appraisal(db.Model): + __tablename__ = 'appraisals' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + year = db.Column(db.Integer, nullable=False) + make = db.Column(db.String(40), nullable=False) + model = db.Column(db.String(60), nullable=False) + trim = db.Column(db.String(60), default='') + mileage = db.Column(db.Integer, nullable=False) + condition = db.Column(db.String(20), default='good') + exterior_color = db.Column(db.String(40), default='') + license_plate = db.Column(db.String(20), default='') + license_state = db.Column(db.String(2), default='') + vin = db.Column(db.String(20), default='') + zip_code = db.Column(db.String(10), default='') + has_accidents = db.Column(db.Boolean, default=False) + owner_count = db.Column(db.Integer, default=1) + offer_amount = db.Column(db.Float, nullable=False) + offer_valid_until = db.Column(db.Date, nullable=False) + status = db.Column(db.String(20), default='active') + contact_email = db.Column(db.String(120), default='') + contact_phone = db.Column(db.String(30), default='') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + @property + def vehicle_label(self): + parts = [str(self.year), self.make, self.model] + if self.trim: + parts.append(self.trim) + return ' '.join(parts) + + +class FinancePreQual(db.Model): + __tablename__ = 'finance_prequals' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + annual_income = db.Column(db.Integer, nullable=False) + employment_status = db.Column(db.String(40), nullable=False) + monthly_payment_max = db.Column(db.Float, nullable=False) + down_payment = db.Column(db.Float, default=2000) + term_months = db.Column(db.Integer, default=72) + estimated_apr = db.Column(db.Float, nullable=False) + credit_tier = db.Column(db.String(20), default='good') + status = db.Column(db.String(20), default='active') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + expires_at = db.Column(db.Date, nullable=False) + + +class Order(db.Model): + __tablename__ = 'orders' + id = db.Column(db.Integer, primary_key=True) + order_number = db.Column(db.String(20), unique=True, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + status = db.Column(db.String(20), default='processing') + vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False) + store_id = db.Column(db.Integer, db.ForeignKey('stores.id'), nullable=False) + subtotal = db.Column(db.Float, default=0) + transfer_fee = db.Column(db.Float, default=0) + tax = db.Column(db.Float, default=0) + title_fee = db.Column(db.Float, default=99) + registration_fee = db.Column(db.Float, default=55) + total = db.Column(db.Float, default=0) + maxcare_plan = db.Column(db.String(40), default='') + maxcare_price = db.Column(db.Float, default=0) + payment_method = db.Column(db.String(30), default='carmax_auto_finance') + payment_last4 = db.Column(db.String(4), default='') + payment_apr = db.Column(db.Float, default=0) + payment_term_months = db.Column(db.Integer, default=72) + monthly_payment = db.Column(db.Float, default=0) + down_payment = db.Column(db.Float, default=0) + trade_in_appraisal_id = db.Column(db.Integer, db.ForeignKey('appraisals.id'), nullable=True) + trade_in_value = db.Column(db.Float, default=0) + pickup_or_delivery = db.Column(db.String(20), default='pickup') + delivery_address = db.Column(db.String(200), default='') + pickup_date = db.Column(db.Date, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + vehicle = db.relationship('Vehicle', lazy='joined') + store = db.relationship('Store', lazy='joined') + trade_in_appraisal = db.relationship('Appraisal', lazy='joined', + foreign_keys=[trade_in_appraisal_id]) + + +class Review(db.Model): + __tablename__ = 'reviews' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + make_slug = db.Column(db.String(40), nullable=False, index=True) + model_slug = db.Column(db.String(60), nullable=False, index=True) + year = db.Column(db.Integer, nullable=False, index=True) + rating = db.Column(db.Integer, nullable=False) + title = db.Column(db.String(120), default='') + body = db.Column(db.Text, default='') + reviewer_name = db.Column(db.String(80), default='Verified buyer') + location = db.Column(db.String(80), default='') + helpful_count = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Article(db.Model): + __tablename__ = 'articles' + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(140), unique=True, nullable=False, index=True) + title = db.Column(db.String(200), nullable=False) + category = db.Column(db.String(40), default='research', index=True) + author = db.Column(db.String(80), default='CarMax Editorial') + summary = db.Column(db.String(500), default='') + body = db.Column(db.Text, default='') + hero_image = db.Column(db.String(255), default='') + published_at = db.Column(db.Date, nullable=False) + is_featured = db.Column(db.Boolean, default=False) + + +@login_manager.user_loader +def load_user(uid): + try: + return db.session.get(User, int(uid)) + except Exception: + return None + + +# ============================================================================= +# Forms +# ============================================================================= + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + remember = BooleanField('Keep me signed in') + + +class RegisterForm(FlaskForm): + first_name = StringField('First name', validators=[DataRequired(), Length(max=80)]) + last_name = StringField('Last name', validators=[DataRequired(), Length(max=80)]) + email = StringField('Email', validators=[DataRequired(), Email()]) + phone = StringField('Phone', validators=[Optional(), Length(max=30)]) + zip_code = StringField('ZIP code', validators=[Optional(), Length(max=10)]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=8)]) + confirm = PasswordField('Confirm password', + validators=[DataRequired(), EqualTo('password')]) + + +class AccountEditForm(FlaskForm): + first_name = StringField('First name', validators=[DataRequired(), Length(max=80)]) + last_name = StringField('Last name', validators=[DataRequired(), Length(max=80)]) + phone = StringField('Phone', validators=[Optional(), Length(max=30)]) + address_line1 = StringField('Street address', validators=[Optional(), Length(max=200)]) + address_line2 = StringField('Apt / Unit', validators=[Optional(), Length(max=200)]) + city = StringField('City', validators=[Optional(), Length(max=80)]) + state = StringField('State', validators=[Optional(), Length(max=2)]) + zip_code = StringField('ZIP', validators=[Optional(), Length(max=10)]) + + +class ChangePasswordForm(FlaskForm): + current_password = PasswordField('Current password', validators=[DataRequired()]) + new_password = PasswordField('New password', + validators=[DataRequired(), Length(min=8)]) + confirm = PasswordField('Confirm new password', + validators=[DataRequired(), EqualTo('new_password')]) + + +class SellMyCarForm(FlaskForm): + year = IntegerField('Year', validators=[DataRequired(), NumberRange(min=1985, max=2027)]) + make = StringField('Make', validators=[DataRequired(), Length(max=40)]) + model = StringField('Model', validators=[DataRequired(), Length(max=60)]) + trim = StringField('Trim', validators=[Optional(), Length(max=60)]) + mileage = IntegerField('Mileage', + validators=[DataRequired(), NumberRange(min=0, max=400000)]) + condition = SelectField('Condition', choices=[ + ('excellent', 'Excellent'), ('good', 'Good'), + ('fair', 'Fair'), ('poor', 'Poor'), + ], default='good') + exterior_color = StringField('Exterior color', validators=[Optional(), Length(max=40)]) + license_plate = StringField('License plate', validators=[Optional(), Length(max=20)]) + license_state = StringField('License state', validators=[Optional(), Length(max=2)]) + vin = StringField('VIN', validators=[Optional(), Length(max=20)]) + zip_code = StringField('ZIP', validators=[DataRequired(), Length(max=10)]) + has_accidents = BooleanField('Reported accidents') + owner_count = IntegerField('Number of owners', + validators=[Optional(), NumberRange(min=1, max=10)], + default=1) + contact_email = StringField('Email', validators=[Optional(), Email()]) + contact_phone = StringField('Phone', validators=[Optional(), Length(max=30)]) + + +class PreQualForm(FlaskForm): + annual_income = IntegerField('Annual income (pre-tax)', + validators=[DataRequired(), NumberRange(min=0, max=10000000)]) + employment_status = SelectField('Employment status', choices=[ + ('employed_full_time', 'Employed (full-time)'), + ('employed_part_time', 'Employed (part-time)'), + ('self_employed', 'Self-employed'), + ('retired', 'Retired'), + ('student', 'Student'), + ('other', 'Other'), + ], default='employed_full_time') + monthly_payment_max = FloatField('Max monthly payment ($)', + validators=[DataRequired(), NumberRange(min=100, max=5000)]) + down_payment = FloatField('Down payment ($)', + validators=[Optional(), NumberRange(min=0)], default=2000) + term_months = SelectField('Loan term', choices=[ + ('36', '36 months'), ('48', '48 months'), ('60', '60 months'), + ('66', '66 months'), ('72', '72 months'), + ], default='72') + credit_tier = SelectField('Credit profile', choices=[ + ('excellent', 'Excellent (720+)'), + ('good', 'Good (660-719)'), + ('fair', 'Fair (620-659)'), + ('building', 'Building credit (<620)'), + ], default='good') + + +class ReserveForm(FlaskForm): + store_id = HiddenField() + appointment_date = StringField('Appointment date', validators=[Optional()]) + + +class TestDriveForm(FlaskForm): + store_id = HiddenField() + location_type = SelectField('Where', choices=[ + ('in_store', 'At a CarMax store'), + ('at_home', 'At my address'), + ], default='in_store') + scheduled_date = StringField('Date (YYYY-MM-DD)', validators=[DataRequired()]) + scheduled_time = SelectField('Time', choices=[ + ('10:00 AM', '10:00 AM'), ('12:00 PM', '12:00 PM'), + ('2:00 PM', '2:00 PM'), ('4:00 PM', '4:00 PM'), + ('6:00 PM', '6:00 PM'), + ], default='10:00 AM') + notes = TextAreaField('Notes', validators=[Optional(), Length(max=500)]) + + +class CheckoutForm(FlaskForm): + pickup_or_delivery = RadioField('Receive your car', choices=[ + ('pickup', 'Pick up at store'), + ('home_delivery', 'Home delivery'), + ], default='pickup') + delivery_address = StringField('Delivery address', + validators=[Optional(), Length(max=200)]) + payment_method = RadioField('Payment method', choices=[ + ('carmax_auto_finance', 'CarMax Auto Finance'), + ('external_loan', 'External lender'), + ('cash', 'Cash / cashiers check'), + ], default='carmax_auto_finance') + card_last4 = StringField('Card / loan last 4', + validators=[Optional(), Length(min=4, max=4), + Regexp(r'^\d{4}$', message='4 digits')]) + apr = FloatField('APR (%)', validators=[Optional(), NumberRange(min=0, max=29.99)], + default=6.99) + term_months = SelectField('Term', choices=[ + ('36', '36 months'), ('48', '48 months'), ('60', '60 months'), + ('66', '66 months'), ('72', '72 months'), + ], default='72') + down_payment = FloatField('Down payment ($)', + validators=[Optional(), NumberRange(min=0)], default=2000) + trade_in_appraisal_id = HiddenField() + maxcare_plan = SelectField('MaxCare extended warranty', choices=[ + ('', 'No MaxCare'), + ('silver', 'Silver - 36 mo / 50,000 mi ($1,495)'), + ('gold', 'Gold - 48 mo / 75,000 mi ($1,895)'), + ('platinum', 'Platinum - 60 mo / 100,000 mi ($2,395)'), + ], default='') + + +class ReviewForm(FlaskForm): + rating = SelectField('Rating', + choices=[(str(i), f'{i} stars') for i in range(5, 0, -1)], + default='5') + title = StringField('Headline', validators=[DataRequired(), Length(max=120)]) + body = TextAreaField('Your review', + validators=[DataRequired(), Length(min=20)]) + location = StringField('Your city, state', + validators=[Optional(), Length(max=80)]) + + +# ============================================================================= +# Helpers +# ============================================================================= + +_TOKEN_RE = re.compile(r'[a-z0-9]+') +MAXCARE_PRICES = {'silver': 1495, 'gold': 1895, 'platinum': 2395} +MAXCARE_LABELS = { + 'silver': 'Silver - 36 mo / 50,000 mi', + 'gold': 'Gold - 48 mo / 75,000 mi', + 'platinum': 'Platinum - 60 mo / 100,000 mi', +} + + +def tokenize(s): + if not s: + return [] + return _TOKEN_RE.findall(s.lower()) + + +def slugify(s): + s = (s or '').lower().strip() + s = re.sub(r'[^a-z0-9]+', '-', s).strip('-') + return s + + +def score_vehicle_match(v, tokens): + if not tokens: + return 0 + text_high = ' '.join([v.make or '', v.model or '', str(v.year or '')]).lower() + text_med = ' '.join([v.trim or '', v.body_style or '', v.exterior_color or '']).lower() + text_low = ' '.join([v.interior_color or '', v.transmission or '', v.drive_type or '', + v.fuel_type or '', v.engine_text or '', v.description or '', + ' '.join(v.get_features() or [])]).lower() + score = 0 + for t in tokens: + if t in text_high: + score += 5 + elif t in text_med: + score += 3 + elif t in text_low: + score += 1 + if v.is_featured: + score += 0.1 + return score + + +def _apply_filters(q, filters): + if not filters: + return q + if filters.get('make'): + q = q.filter(Vehicle.make_slug == slugify(filters['make'])) + if filters.get('model'): + q = q.filter(Vehicle.model_slug == slugify(filters['model'])) + if filters.get('trim'): + q = q.filter(Vehicle.trim_slug == slugify(filters['trim'])) + if filters.get('year_min'): + q = q.filter(Vehicle.year >= int(filters['year_min'])) + if filters.get('year_max'): + q = q.filter(Vehicle.year <= int(filters['year_max'])) + if filters.get('year'): + q = q.filter(Vehicle.year == int(filters['year'])) + if filters.get('price_min'): + q = q.filter(Vehicle.price >= float(filters['price_min'])) + if filters.get('price_max'): + q = q.filter(Vehicle.price <= float(filters['price_max'])) + if filters.get('mileage_max'): + q = q.filter(Vehicle.mileage <= int(filters['mileage_max'])) + if filters.get('body_style'): + q = q.filter(Vehicle.body_style.ilike(filters['body_style'])) + if filters.get('transmission'): + q = q.filter(Vehicle.transmission.ilike(filters['transmission'])) + if filters.get('drive_type'): + q = q.filter(Vehicle.drive_type == filters['drive_type'].upper()) + if filters.get('fuel_type'): + q = q.filter(Vehicle.fuel_type.ilike(filters['fuel_type'])) + if filters.get('exterior_color'): + q = q.filter(Vehicle.exterior_color.ilike(filters['exterior_color'])) + if filters.get('store_id'): + try: + q = q.filter(Vehicle.store_id == int(filters['store_id'])) + except (TypeError, ValueError): + pass + if filters.get('state'): + q = q.join(Store).filter(Store.state == filters['state'].upper()) + if filters.get('feature'): + feat = filters['feature'] + q = q.filter(Vehicle.features.ilike('%"' + feat + '"%')) + if filters.get('certified'): + q = q.filter(Vehicle.is_certified.is_(True)) + if filters.get('featured'): + q = q.filter(Vehicle.is_featured.is_(True)) + if filters.get('new_arrival'): + q = q.filter(Vehicle.is_new_arrival.is_(True)) + if filters.get('price_drop'): + q = q.filter(Vehicle.is_price_drop.is_(True)) + return q + + +def search_vehicles(query=None, filters=None, sort='best_match', + page=1, per_page=24): + q = _apply_filters(Vehicle.query, filters) + tokens = tokenize(query or '') + items = q.all() + if tokens: + scored = [(score_vehicle_match(v, tokens), v) for v in items] + scored = [(s, v) for s, v in scored if s > 0] + if sort == 'price_low': + scored.sort(key=lambda x: (x[1].price, -x[0])) + elif sort == 'price_high': + scored.sort(key=lambda x: (-x[1].price, -x[0])) + elif sort == 'mileage_low': + scored.sort(key=lambda x: (x[1].mileage, -x[0])) + elif sort == 'newest': + scored.sort(key=lambda x: (-x[1].year, -x[0])) + else: + scored.sort(key=lambda x: (-x[0], x[1].price)) + items = [v for _, v in scored] + else: + if sort == 'price_low': + items.sort(key=lambda v: v.price) + elif sort == 'price_high': + items.sort(key=lambda v: -v.price) + elif sort == 'mileage_low': + items.sort(key=lambda v: v.mileage) + elif sort == 'newest': + items.sort(key=lambda v: (-v.year, v.mileage)) + else: + items.sort(key=lambda v: (-int(v.is_featured), v.mileage, v.price)) + total = len(items) + start = (page - 1) * per_page + return items[start:start + per_page], total + + +def estimated_payment(price, term_months=72, apr=0.0699, down=2000): + principal = max(float(price) - float(down), 0) + if principal <= 0: + return 0.0 + if not apr or apr <= 0: + return principal / max(term_months, 1) + r = float(apr) / 12 + n = int(term_months) + return principal * (r * (1 + r) ** n) / ((1 + r) ** n - 1) + + +def estimated_payment_to_principal(monthly, term_months, apr): + if monthly <= 0: + return 0 + if apr <= 0: + return monthly * term_months + r = apr / 12 + n = int(term_months) + return monthly * ((1 + r) ** n - 1) / (r * (1 + r) ** n) + + +def estimate_credit_apr(credit_tier, term_months=72): + base = {'excellent': 5.49, 'good': 7.49, + 'fair': 11.99, 'building': 17.99}.get(credit_tier, 7.99) + if int(term_months) >= 72: + base += 0.5 + elif int(term_months) >= 66: + base += 0.25 + return round(base, 2) + + +def make_appraisal_offer(year, make, model, trim, mileage, condition, + has_accidents=False): + similar = Vehicle.query.filter( + Vehicle.year == int(year), + Vehicle.make.ilike(make), + Vehicle.model.ilike(model), + ).all() + if similar: + anchor = sum(v.price for v in similar) / len(similar) + else: + msrp_guess = 28000 + age = max(2026 - int(year), 0) + anchor = msrp_guess * (0.82 ** age) + expected_miles = max(1, (2026 - int(year))) * 12000 + mileage_factor = 1.0 - max(0, (int(mileage) - expected_miles)) / 200000 + mileage_factor = max(0.55, min(1.05, mileage_factor)) + cond_mult = {'excellent': 0.92, 'good': 0.85, + 'fair': 0.74, 'poor': 0.58}.get(condition, 0.85) + accident_mult = 0.92 if has_accidents else 1.0 + offer = anchor * cond_mult * mileage_factor * accident_mult + return round(offer / 50) * 50 + + +def gen_order_number(): + base = Order.query.count() + 1 + return f'CMX-2026-{base:06d}' + + +def _get_or_create_comparison(create=True): + if current_user.is_authenticated: + comp = (Comparison.query.filter_by(user_id=current_user.id) + .order_by(Comparison.id.desc()).first()) + if not comp and create: + comp = Comparison(user_id=current_user.id, name='My comparison') + db.session.add(comp) + db.session.commit() + return comp + sk = session.get('compare_sk') + if not sk: + sk = os.urandom(16).hex() + session['compare_sk'] = sk + comp = Comparison.query.filter_by(session_key=sk).first() + if not comp and create: + comp = Comparison(session_key=sk, name='My comparison') + db.session.add(comp) + db.session.commit() + return comp + + +# ============================================================================= +# Context processors / template filters +# ============================================================================= + +@app.context_processor +def inject_globals(): + saved_count = 0 + if current_user.is_authenticated: + saved_count = SavedVehicle.query.filter_by(user_id=current_user.id).count() + comp = _get_or_create_comparison(create=False) + compare_count = (ComparisonItem.query.filter_by(comparison_id=comp.id).count() + if comp else 0) + return { + 'current_user': current_user, + 'saved_count': saved_count, + 'compare_count': compare_count, + 'csrf_token': generate_csrf, + 'current_year': 2026, + 'BRAND_PHONE': '(800) 519-1511', + 'BRAND_NAME': 'CarMax', + } + + +@app.template_filter('money') +def filter_money(v): + try: + return f'${int(round(float(v))):,}' + except Exception: + return '$0' + + +@app.template_filter('miles') +def filter_miles(v): + try: + return f'{int(v):,} mi' + except Exception: + return '-' + + +@app.template_filter('plus_money') +def filter_plus_money(v): + try: + v = int(round(float(v))) + sign = '+' if v >= 0 else '-' + return f'{sign}${abs(v):,}' + except Exception: + return '' + + +@app.template_filter('star_row') +def filter_star_row(rating): + try: + r = max(0, min(5, int(round(float(rating))))) + except Exception: + r = 0 + return '*' * r + '.' * (5 - r) + + +# ============================================================================= +# Routes - home, search, browse +# ============================================================================= + +@app.route('/') +def index(): + featured = (Vehicle.query.filter_by(is_featured=True) + .order_by(Vehicle.price.asc()).limit(8).all()) + new_arrivals = (Vehicle.query.filter_by(is_new_arrival=True) + .order_by(Vehicle.added_at.desc()).limit(8).all()) + popular_makes = (db.session.query(Vehicle.make, Vehicle.make_slug, + func.count(Vehicle.id).label('n')) + .group_by(Vehicle.make, Vehicle.make_slug) + .order_by(func.count(Vehicle.id).desc()).limit(12).all()) + body_styles = (db.session.query(Vehicle.body_style, + func.count(Vehicle.id).label('n')) + .group_by(Vehicle.body_style) + .order_by(func.count(Vehicle.id).desc()).all()) + article_strip = (Article.query.filter_by(is_featured=True) + .order_by(Article.published_at.desc()).limit(3).all()) + return render_template('index.html', + featured=featured, + new_arrivals=new_arrivals, + popular_makes=popular_makes, + body_styles=body_styles, + article_strip=article_strip) + + +def _filters_from_args(): + a = request.args + return { + 'make': a.get('make') or '', + 'model': a.get('model') or '', + 'trim': a.get('trim') or '', + 'year': a.get('year') or '', + 'year_min': a.get('year_min') or '', + 'year_max': a.get('year_max') or '', + 'price_min': a.get('price_min') or '', + 'price_max': a.get('price_max') or '', + 'mileage_max': a.get('mileage_max') or '', + 'body_style': a.get('body_style') or '', + 'transmission': a.get('transmission') or '', + 'drive_type': a.get('drive_type') or '', + 'fuel_type': a.get('fuel_type') or '', + 'exterior_color': a.get('exterior_color') or '', + 'store_id': a.get('store_id') or '', + 'state': a.get('state') or '', + 'feature': a.get('feature') or '', + 'certified': a.get('certified') in ('1', 'true', 'on'), + 'featured': a.get('featured') in ('1', 'true', 'on'), + 'new_arrival': a.get('new_arrival') in ('1', 'true', 'on'), + 'price_drop': a.get('price_drop') in ('1', 'true', 'on'), + } + + +def _facets(filters_query): + base = _apply_filters(Vehicle.query, filters_query) + res = {} + sub = base.with_entities(Vehicle.id) + res['makes'] = (db.session.query(Vehicle.make, Vehicle.make_slug, + func.count(Vehicle.id)) + .filter(Vehicle.id.in_(sub)) + .group_by(Vehicle.make, Vehicle.make_slug) + .order_by(func.count(Vehicle.id).desc()).limit(20).all()) + res['body_styles'] = (db.session.query(Vehicle.body_style, + func.count(Vehicle.id)) + .filter(Vehicle.id.in_(sub)) + .group_by(Vehicle.body_style) + .order_by(func.count(Vehicle.id).desc()).all()) + res['fuel_types'] = (db.session.query(Vehicle.fuel_type, + func.count(Vehicle.id)) + .filter(Vehicle.id.in_(sub)) + .group_by(Vehicle.fuel_type) + .order_by(func.count(Vehicle.id).desc()).all()) + res['drive_types'] = (db.session.query(Vehicle.drive_type, + func.count(Vehicle.id)) + .filter(Vehicle.id.in_(sub)) + .group_by(Vehicle.drive_type) + .order_by(func.count(Vehicle.id).desc()).all()) + return res + + +def _do_search(scope_label, extra_filters=None): + q = request.args.get('q', '').strip() + sort = request.args.get('sort', 'best_match') + try: + page = max(1, int(request.args.get('page', 1))) + except ValueError: + page = 1 + filters = _filters_from_args() + if extra_filters: + filters.update(extra_filters) + items, total = search_vehicles(query=q, filters=filters, sort=sort, + page=page, per_page=24) + facets = _facets(filters) + return render_template('search.html', + items=items, total=total, page=page, per_page=24, + pages=(total + 23) // 24, + query=q, sort=sort, filters=filters, + facets=facets, scope_label=scope_label) + + +@app.route('/cars') +def cars_index(): + return _do_search('All inventory') + + +@app.route('/cars/') +def cars_make(make): + label = f'Used {make.replace("-", " ").title()} for sale' + return _do_search(label, extra_filters={'make': make}) + + +@app.route('/cars//') +def cars_model(make, model): + label = (f'Used {make.replace("-", " ").title()} ' + f'{model.replace("-", " ").title()} for sale') + return _do_search(label, extra_filters={'make': make, 'model': model}) + + +@app.route('/cars///') +def cars_model_year(make, model, year): + label = (f'Used {year} {make.replace("-", " ").title()} ' + f'{model.replace("-", " ").title()} for sale') + return _do_search(label, + extra_filters={'make': make, 'model': model, + 'year': year}) + + +@app.route('/cars///') +def cars_model_trim(make, model, trim): + label = (f'Used {make.replace("-", " ").title()} ' + f'{model.replace("-", " ").title()} ' + f'{trim.replace("-", " ").title()} for sale') + return _do_search(label, + extra_filters={'make': make, 'model': model, + 'trim': trim}) + + +@app.route('/cars////') +def cars_model_trim_year(make, model, trim, year): + label = (f'Used {year} {make.replace("-", " ").title()} ' + f'{model.replace("-", " ").title()} ' + f'{trim.replace("-", " ").title()} for sale') + return _do_search(label, + extra_filters={'make': make, 'model': model, + 'trim': trim, 'year': year}) + + +@app.route('/search') +def search(): + return redirect(url_for('cars_index', **request.args)) + + +# ============================================================================= +# Vehicle detail +# ============================================================================= + +@app.route('/vehicle/') +def vehicle_detail(slug): + v = Vehicle.query.filter_by(slug=slug).first_or_404() + similar = (Vehicle.query + .filter(Vehicle.id != v.id, Vehicle.model_slug == v.model_slug) + .order_by(func.abs(Vehicle.price - v.price)).limit(6).all()) + reviews = (Review.query.filter_by(make_slug=v.make_slug, + model_slug=v.model_slug, year=v.year) + .order_by(Review.created_at.desc()).limit(6).all()) + is_saved = False + if current_user.is_authenticated: + is_saved = (SavedVehicle.query + .filter_by(user_id=current_user.id, vehicle_id=v.id) + .first() is not None) + return render_template('vehicle_detail.html', + vehicle=v, similar=similar, reviews=reviews, + is_saved=is_saved, + default_term=72, default_apr=0.0699, + default_down=2000) + + +@app.route('/vehicle/id/') +def vehicle_detail_by_id(vid): + v = db.session.get(Vehicle, vid) + if not v: + abort(404) + return redirect(url_for('vehicle_detail', slug=v.slug)) + + +# ============================================================================= +# Research +# ============================================================================= + +@app.route('/research') +def research_index(): + popular = (db.session.query(Vehicle.make, Vehicle.make_slug, + Vehicle.model, Vehicle.model_slug, + func.count(Vehicle.id).label('n')) + .group_by(Vehicle.make, Vehicle.make_slug, + Vehicle.model, Vehicle.model_slug) + .order_by(func.count(Vehicle.id).desc()).limit(24).all()) + makes = (db.session.query(Vehicle.make, Vehicle.make_slug, + func.count(Vehicle.id).label('n')) + .group_by(Vehicle.make, Vehicle.make_slug) + .order_by(Vehicle.make.asc()).all()) + return render_template('research_index.html', popular=popular, makes=makes) + + +@app.route('/research/') +def research_make(make): + make_slug = slugify(make) + models = (db.session.query(Vehicle.make, Vehicle.make_slug, + Vehicle.model, Vehicle.model_slug, + func.count(Vehicle.id).label('n')) + .filter(Vehicle.make_slug == make_slug) + .group_by(Vehicle.make, Vehicle.make_slug, + Vehicle.model, Vehicle.model_slug) + .order_by(Vehicle.model.asc()).all()) + if not models: + abort(404) + return render_template('research_make.html', + make_name=models[0].make, make_slug=make_slug, + models=models) + + +@app.route('/research//') +def research_model(make, model): + make_slug, model_slug = slugify(make), slugify(model) + years = (db.session.query(Vehicle.year, func.count(Vehicle.id)) + .filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug) + .group_by(Vehicle.year) + .order_by(Vehicle.year.desc()).all()) + if not years: + abort(404) + sample = (Vehicle.query + .filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug) + .order_by(Vehicle.year.desc(), Vehicle.price.asc()).first()) + return render_template('research_model.html', + make=sample.make, model=sample.model, + make_slug=make_slug, model_slug=model_slug, + years=years, sample=sample) + + +@app.route('/research///') +def research_model_year(make, model, year): + make_slug, model_slug = slugify(make), slugify(model) + vehicles = (Vehicle.query + .filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug, + Vehicle.year == year).all()) + if not vehicles: + abort(404) + v0 = vehicles[0] + trims = sorted({(x.trim or '-', x.trim_slug or '') for x in vehicles if x.trim}) + avg_price = sum(x.price for x in vehicles) / len(vehicles) + min_price = min(x.price for x in vehicles) + max_price = max(x.price for x in vehicles) + reviews = (Review.query + .filter_by(make_slug=make_slug, model_slug=model_slug, year=year) + .order_by(Review.created_at.desc()).limit(8).all()) + avg_rating = (sum(r.rating for r in reviews) / len(reviews)) if reviews else v0.customer_rating + return render_template('research_model_year.html', + make=v0.make, model=v0.model, year=year, + make_slug=make_slug, model_slug=model_slug, + vehicles=vehicles, trims=trims, + avg_price=avg_price, min_price=min_price, + max_price=max_price, sample=v0, + reviews=reviews, avg_rating=avg_rating, + review_count=len(reviews)) + + +@app.route('/research/car-comparison/') +def research_comparison(makemodelyear): + return redirect(url_for('compare_view')) + + +# ============================================================================= +# Customer reviews +# ============================================================================= + +@app.route('/reviews///', methods=['GET', 'POST']) +def reviews_page(make, model, year): + make_slug, model_slug = slugify(make), slugify(model) + exists = Vehicle.query.filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug, + Vehicle.year == year).first() + if not exists: + abort(404) + form = ReviewForm() + if form.validate_on_submit(): + if not current_user.is_authenticated: + flash('Please sign in to leave a review.', 'info') + return redirect(url_for('login', next=request.path)) + r = Review(user_id=current_user.id, + make_slug=make_slug, model_slug=model_slug, year=year, + rating=int(form.rating.data), + title=form.title.data.strip(), + body=form.body.data.strip(), + location=form.location.data.strip(), + reviewer_name=current_user.full_name) + db.session.add(r) + db.session.commit() + flash('Thanks - your review has been posted.', 'success') + return redirect(url_for('reviews_page', make=make, model=model, year=year)) + reviews = (Review.query + .filter_by(make_slug=make_slug, model_slug=model_slug, year=year) + .order_by(Review.created_at.desc()).all()) + avg = (sum(r.rating for r in reviews) / len(reviews)) if reviews else 0.0 + return render_template('reviews.html', + make=exists.make, model=exists.model, year=year, + make_slug=make_slug, model_slug=model_slug, + reviews=reviews, avg_rating=avg, form=form) + + +# ============================================================================= +# Compare +# ============================================================================= + +@app.route('/compare') +def compare_view(): + comp = _get_or_create_comparison(create=False) + items = [] + if comp: + items = [it.vehicle for it in + ComparisonItem.query.filter_by(comparison_id=comp.id) + .order_by(ComparisonItem.added_at.asc()).all()] + return render_template('compare.html', items=items) + + +@app.route('/compare/add/', methods=['POST']) +def compare_add(vehicle_id): + v = db.session.get(Vehicle, vehicle_id) + if not v: + abort(404) + comp = _get_or_create_comparison(create=True) + existing = ComparisonItem.query.filter_by(comparison_id=comp.id, + vehicle_id=v.id).first() + if existing: + flash(f'{v.short_title} is already in your comparison.', 'info') + else: + cnt = ComparisonItem.query.filter_by(comparison_id=comp.id).count() + if cnt >= 4: + flash('You can compare up to 4 vehicles at a time.', 'warning') + else: + db.session.add(ComparisonItem(comparison_id=comp.id, + vehicle_id=v.id)) + db.session.commit() + flash(f'Added {v.short_title} to compare.', 'success') + return redirect(request.referrer or url_for('compare_view')) + + +@app.route('/compare/remove/', methods=['POST']) +def compare_remove(vehicle_id): + comp = _get_or_create_comparison(create=False) + if comp: + ComparisonItem.query.filter_by(comparison_id=comp.id, + vehicle_id=vehicle_id).delete() + db.session.commit() + return redirect(request.referrer or url_for('compare_view')) + + +@app.route('/compare/clear', methods=['POST']) +def compare_clear(): + comp = _get_or_create_comparison(create=False) + if comp: + ComparisonItem.query.filter_by(comparison_id=comp.id).delete() + db.session.commit() + return redirect(url_for('compare_view')) + + +# ============================================================================= +# Saved vehicles +# ============================================================================= + +@app.route('/saved') +@login_required +def saved_view(): + rows = (SavedVehicle.query.filter_by(user_id=current_user.id) + .order_by(SavedVehicle.saved_at.desc()).all()) + return render_template('saved.html', rows=rows) + + +@app.route('/saved/add/', methods=['POST']) +@login_required +def saved_add(vehicle_id): + v = db.session.get(Vehicle, vehicle_id) + if not v: + abort(404) + existing = SavedVehicle.query.filter_by(user_id=current_user.id, + vehicle_id=v.id).first() + if not existing: + db.session.add(SavedVehicle(user_id=current_user.id, vehicle_id=v.id)) + db.session.commit() + flash(f'Saved {v.short_title}.', 'success') + return redirect(request.referrer or url_for('vehicle_detail', slug=v.slug)) + + +@app.route('/saved/remove/', methods=['POST']) +@login_required +def saved_remove(vehicle_id): + SavedVehicle.query.filter_by(user_id=current_user.id, + vehicle_id=vehicle_id).delete() + db.session.commit() + flash('Removed from saved.', 'info') + return redirect(request.referrer or url_for('saved_view')) + + +@app.route('/saved/toggle/', methods=['POST']) +@login_required +def saved_toggle(vehicle_id): + v = db.session.get(Vehicle, vehicle_id) + if not v: + abort(404) + row = SavedVehicle.query.filter_by(user_id=current_user.id, + vehicle_id=v.id).first() + if row: + db.session.delete(row) + msg = 'Removed from saved.' + else: + db.session.add(SavedVehicle(user_id=current_user.id, vehicle_id=v.id)) + msg = f'Saved {v.short_title}.' + db.session.commit() + flash(msg, 'success') + return redirect(request.referrer or url_for('vehicle_detail', slug=v.slug)) + + +# ============================================================================= +# Stores +# ============================================================================= + +@app.route('/stores') +def stores_index(): + states = (db.session.query(Store.state, func.count(Store.id).label('n')) + .group_by(Store.state) + .order_by(Store.state.asc()).all()) + return render_template('stores.html', states=states) + + +@app.route('/stores/') +def stores_state(state_code): + sc = state_code.upper() + stores = Store.query.filter_by(state=sc).order_by(Store.city.asc()).all() + if not stores: + abort(404) + return render_template('stores_state.html', state=sc, stores=stores) + + +@app.route('/store/') +def store_detail(slug): + s = Store.query.filter_by(slug=slug).first_or_404() + inv_count = Vehicle.query.filter_by(store_id=s.id).count() + inventory = (Vehicle.query.filter_by(store_id=s.id) + .order_by(Vehicle.price.asc()).limit(12).all()) + return render_template('store_detail.html', + store=s, inventory=inventory, inv_count=inv_count) + + +# ============================================================================= +# Sell-my-car / value +# ============================================================================= + +@app.route('/value') +def value_index(): + makes = (db.session.query(Vehicle.make, Vehicle.make_slug) + .group_by(Vehicle.make, Vehicle.make_slug) + .order_by(Vehicle.make.asc()).all()) + return render_template('value.html', makes=makes) + + +@app.route('/value//') +def value_model(make, model): + make_slug, model_slug = slugify(make), slugify(model) + sample = (Vehicle.query + .filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug) + .order_by(Vehicle.year.desc()).first()) + if not sample: + abort(404) + rows = (db.session.query(Vehicle.year, + func.avg(Vehicle.price), + func.min(Vehicle.price), + func.max(Vehicle.price), + func.count(Vehicle.id)) + .filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug) + .group_by(Vehicle.year) + .order_by(Vehicle.year.desc()).all()) + return render_template('value_model.html', + make=sample.make, model=sample.model, rows=rows) + + +@app.route('/value///') +def value_year(make, model, year): + make_slug, model_slug = slugify(make), slugify(model) + matches = (Vehicle.query + .filter(Vehicle.make_slug == make_slug, + Vehicle.model_slug == model_slug, + Vehicle.year == year).all()) + if not matches: + abort(404) + avg = sum(v.price for v in matches) / len(matches) + return render_template('value_year.html', + make=matches[0].make, model=matches[0].model, + year=year, avg_price=avg, count=len(matches), + min_price=min(v.price for v in matches), + max_price=max(v.price for v in matches)) + + +@app.route('/sell-my-car', methods=['GET', 'POST']) +def sell_my_car(): + form = SellMyCarForm() + if current_user.is_authenticated and not form.contact_email.data: + form.contact_email.data = current_user.email + form.contact_phone.data = current_user.phone or '' + form.zip_code.data = current_user.zip_code or '' + if form.validate_on_submit(): + offer = make_appraisal_offer(form.year.data, form.make.data, + form.model.data, form.trim.data or '', + form.mileage.data, form.condition.data, + form.has_accidents.data) + a = Appraisal( + user_id=current_user.id if current_user.is_authenticated else None, + year=form.year.data, + make=form.make.data.strip(), + model=form.model.data.strip(), + trim=(form.trim.data or '').strip(), + mileage=form.mileage.data, + condition=form.condition.data, + exterior_color=(form.exterior_color.data or '').strip(), + license_plate=(form.license_plate.data or '').strip().upper(), + license_state=(form.license_state.data or '').strip().upper(), + vin=(form.vin.data or '').strip().upper(), + zip_code=(form.zip_code.data or '').strip(), + has_accidents=form.has_accidents.data, + owner_count=form.owner_count.data or 1, + contact_email=(form.contact_email.data or '').strip(), + contact_phone=(form.contact_phone.data or '').strip(), + offer_amount=offer, + offer_valid_until=date(2026, 5, 14) + timedelta(days=7), + status='active', + ) + db.session.add(a) + db.session.commit() + return redirect(url_for('sell_offer', appraisal_id=a.id)) + return render_template('sell_my_car.html', form=form) + + +@app.route('/sell-my-car/offer/') +def sell_offer(appraisal_id): + a = db.session.get(Appraisal, appraisal_id) + if not a: + abort(404) + return render_template('sell_offer.html', appraisal=a) + + +@app.route('/sell-my-car/offer//redeem', methods=['POST']) +@login_required +def sell_offer_redeem(appraisal_id): + a = db.session.get(Appraisal, appraisal_id) + if not a: + abort(404) + if a.user_id and a.user_id != current_user.id: + abort(403) + a.user_id = current_user.id + a.status = 'redeemed' + db.session.commit() + flash(f'We scheduled redemption for your ${int(a.offer_amount):,} ' + f'offer on the {a.year} {a.make} {a.model}.', 'success') + return redirect(url_for('account_appraisals')) + + +# ============================================================================= +# Financing / pre-qual +# ============================================================================= + +@app.route('/car-financing') +def financing(): + return render_template('financing.html') + + +@app.route('/pre-qual/app', methods=['GET', 'POST']) +def pre_qual(): + form = PreQualForm() + if current_user.is_authenticated: + if not form.annual_income.data: + form.annual_income.data = current_user.annual_income or 60000 + if not form.employment_status.data and current_user.employment_status: + form.employment_status.data = current_user.employment_status + if form.validate_on_submit(): + if not current_user.is_authenticated: + session['pending_prequal'] = { + 'annual_income': form.annual_income.data, + 'employment_status': form.employment_status.data, + 'monthly_payment_max': form.monthly_payment_max.data, + 'down_payment': form.down_payment.data or 2000, + 'term_months': int(form.term_months.data), + 'credit_tier': form.credit_tier.data, + } + flash('Sign in to save your pre-qualification.', 'info') + return redirect(url_for('login', next=url_for('pre_qual'))) + apr = estimate_credit_apr(form.credit_tier.data, + int(form.term_months.data)) + pq = FinancePreQual( + user_id=current_user.id, + annual_income=form.annual_income.data, + employment_status=form.employment_status.data, + monthly_payment_max=form.monthly_payment_max.data, + down_payment=form.down_payment.data or 2000, + term_months=int(form.term_months.data), + estimated_apr=apr, + credit_tier=form.credit_tier.data, + expires_at=date(2026, 5, 14) + timedelta(days=30), + ) + db.session.add(pq) + current_user.pre_qual_active = True + current_user.pre_qual_monthly_max = form.monthly_payment_max.data + current_user.pre_qual_term_months = int(form.term_months.data) + current_user.pre_qual_apr = apr + current_user.pre_qual_down_payment = form.down_payment.data or 2000 + current_user.pre_qual_credit_tier = form.credit_tier.data + current_user.pre_qual_expires_at = pq.expires_at + current_user.annual_income = form.annual_income.data + current_user.employment_status = form.employment_status.data + db.session.commit() + return redirect(url_for('pre_qual_result')) + return render_template('pre_qual_form.html', form=form) + + +@app.route('/pre-qual/result') +@login_required +def pre_qual_result(): + if not current_user.pre_qual_active: + return redirect(url_for('pre_qual')) + max_principal = estimated_payment_to_principal( + current_user.pre_qual_monthly_max, + current_user.pre_qual_term_months, + current_user.pre_qual_apr / 100.0, + ) + current_user.pre_qual_down_payment + affordable = (Vehicle.query + .filter(Vehicle.price <= max_principal) + .order_by(Vehicle.price.desc()).limit(12).all()) + return render_template('pre_qual_result.html', + max_principal=max_principal, affordable=affordable) + + +# ============================================================================= +# Reserve & test drive +# ============================================================================= + +@app.route('/vehicle//reserve', methods=['GET', 'POST']) +@login_required +def reserve(vehicle_id): + v = db.session.get(Vehicle, vehicle_id) + if not v: + abort(404) + form = ReserveForm() + if request.method == 'POST' and form.validate_on_submit(): + appt = form.appointment_date.data + try: + appt_date = (datetime.strptime(appt, '%Y-%m-%d').date() + if appt else (date(2026, 5, 14) + timedelta(days=3))) + except ValueError: + appt_date = date(2026, 5, 14) + timedelta(days=3) + r = Reservation( + user_id=current_user.id, vehicle_id=v.id, store_id=v.store_id, + appointment_date=appt_date, + expires_at=date(2026, 5, 14) + timedelta(days=7), + transfer_required=False, transfer_fee=v.transfer_fee or 0, + status='active', + ) + db.session.add(r) + db.session.commit() + flash(f'Reserved the {v.short_title} for 7 days. ' + f'Appointment: {appt_date}.', 'success') + return redirect(url_for('account_reservations')) + return render_template('reserve.html', vehicle=v, form=form) + + +@app.route('/vehicle//test-drive', methods=['GET', 'POST']) +@login_required +def test_drive(vehicle_id): + v = db.session.get(Vehicle, vehicle_id) + if not v: + abort(404) + form = TestDriveForm() + if request.method == 'POST' and form.validate_on_submit(): + try: + d = datetime.strptime(form.scheduled_date.data, '%Y-%m-%d').date() + except ValueError: + d = date(2026, 5, 14) + timedelta(days=3) + td = TestDrive( + user_id=current_user.id, vehicle_id=v.id, store_id=v.store_id, + location_type=form.location_type.data, + scheduled_date=d, scheduled_time=form.scheduled_time.data, + notes=form.notes.data or '', status='confirmed', + ) + db.session.add(td) + db.session.commit() + flash(f'Test drive booked for {d} at {form.scheduled_time.data}.', + 'success') + return redirect(url_for('account_test_drives')) + return render_template('test_drive.html', vehicle=v, form=form) + + +# ============================================================================= +# Checkout +# ============================================================================= + +@app.route('/vehicle//checkout', methods=['GET', 'POST']) +@login_required +def checkout(vehicle_id): + v = db.session.get(Vehicle, vehicle_id) + if not v: + abort(404) + form = CheckoutForm() + if current_user.pre_qual_active and request.method == 'GET': + form.down_payment.data = current_user.pre_qual_down_payment + form.apr.data = current_user.pre_qual_apr + form.term_months.data = str(current_user.pre_qual_term_months) + trade_options = (Appraisal.query + .filter_by(user_id=current_user.id, status='active') + .order_by(Appraisal.created_at.desc()).all()) + if form.validate_on_submit(): + subtotal = v.price + transfer_fee = v.transfer_fee or 0 + tax = subtotal * 0.06 + title_fee = 99 + registration_fee = 55 + maxcare_price = MAXCARE_PRICES.get(form.maxcare_plan.data, 0) or 0 + trade_value = 0 + trade_appraisal_id = None + if form.trade_in_appraisal_id.data: + try: + a = db.session.get(Appraisal, int(form.trade_in_appraisal_id.data)) + if a and a.user_id == current_user.id and a.status == 'active': + trade_value = a.offer_amount + trade_appraisal_id = a.id + a.status = 'redeemed' + except (TypeError, ValueError): + pass + total = (subtotal + transfer_fee + tax + title_fee + registration_fee + + maxcare_price - trade_value) + down = form.down_payment.data or 0 + if form.payment_method.data == 'cash': + monthly = 0 + else: + apr = (form.apr.data or 6.99) / 100.0 + monthly = estimated_payment(total - down, + term_months=int(form.term_months.data), + apr=apr, down=0) + order = Order( + order_number=gen_order_number(), + user_id=current_user.id, + vehicle_id=v.id, store_id=v.store_id, + subtotal=subtotal, transfer_fee=transfer_fee, tax=tax, + title_fee=title_fee, registration_fee=registration_fee, + total=total, + maxcare_plan=form.maxcare_plan.data or '', + maxcare_price=maxcare_price, + payment_method=form.payment_method.data, + payment_last4=form.card_last4.data or '', + payment_apr=form.apr.data or 0, + payment_term_months=int(form.term_months.data), + monthly_payment=monthly, + down_payment=down, + trade_in_appraisal_id=trade_appraisal_id, + trade_in_value=trade_value, + pickup_or_delivery=form.pickup_or_delivery.data, + delivery_address=form.delivery_address.data or '', + pickup_date=date(2026, 5, 14) + timedelta(days=3), + status='processing', + ) + db.session.add(order) + db.session.commit() + flash(f'Order placed: {order.order_number}', 'success') + return redirect(url_for('order_confirmation', + order_number=order.order_number)) + preview = { + 'subtotal': v.price, + 'transfer_fee': v.transfer_fee or 0, + 'tax_estimate': v.price * 0.06, + 'title_fee': 99, + 'registration_fee': 55, + } + preview['total_before_warranty'] = sum([ + preview['subtotal'], preview['transfer_fee'], preview['tax_estimate'], + preview['title_fee'], preview['registration_fee'], + ]) + return render_template('checkout.html', vehicle=v, form=form, + preview=preview, trade_options=trade_options, + maxcare_prices=MAXCARE_PRICES, + maxcare_labels=MAXCARE_LABELS) + + +@app.route('/order/') +@login_required +def order_confirmation(order_number): + o = Order.query.filter_by(order_number=order_number, + user_id=current_user.id).first() + if not o: + abort(404) + return render_template('order_confirmation.html', order=o) + + +# ============================================================================= +# Account +# ============================================================================= + +@app.route('/account') +@login_required +def account(): + saved_n = SavedVehicle.query.filter_by(user_id=current_user.id).count() + reservations_n = Reservation.query.filter_by( + user_id=current_user.id, status='active').count() + test_drives_n = TestDrive.query.filter_by( + user_id=current_user.id, status='confirmed').count() + appraisals_n = Appraisal.query.filter_by( + user_id=current_user.id, status='active').count() + orders_n = Order.query.filter_by(user_id=current_user.id).count() + recent_saved = (SavedVehicle.query.filter_by(user_id=current_user.id) + .order_by(SavedVehicle.saved_at.desc()).limit(4).all()) + return render_template('account.html', + saved_n=saved_n, + reservations_n=reservations_n, + test_drives_n=test_drives_n, + appraisals_n=appraisals_n, + orders_n=orders_n, + recent_saved=recent_saved) + + +@app.route('/account/edit', methods=['GET', 'POST']) +@login_required +def account_edit(): + form = AccountEditForm(obj=current_user) + if form.validate_on_submit(): + for field in ['first_name', 'last_name', 'phone', 'address_line1', + 'address_line2', 'city', 'state', 'zip_code']: + setattr(current_user, field, getattr(form, field).data or '') + current_user.state = (current_user.state or '').upper()[:2] + db.session.commit() + flash('Profile updated.', 'success') + return redirect(url_for('account')) + return render_template('account_edit.html', form=form) + + +@app.route('/account/change-password', methods=['GET', 'POST']) +@login_required +def account_change_password(): + form = ChangePasswordForm() + if form.validate_on_submit(): + if not current_user.check_password(form.current_password.data): + flash('Current password is incorrect.', 'danger') + else: + current_user.set_password(form.new_password.data) + db.session.commit() + flash('Password updated.', 'success') + return redirect(url_for('account')) + return render_template('account_change_password.html', form=form) + + +@app.route('/account/orders') +@login_required +def account_orders(): + orders = (Order.query.filter_by(user_id=current_user.id) + .order_by(Order.created_at.desc()).all()) + return render_template('account_orders.html', orders=orders) + + +@app.route('/account/reservations') +@login_required +def account_reservations(): + rows = (Reservation.query.filter_by(user_id=current_user.id) + .order_by(Reservation.created_at.desc()).all()) + return render_template('account_reservations.html', rows=rows) + + +@app.route('/account/test-drives') +@login_required +def account_test_drives(): + rows = (TestDrive.query.filter_by(user_id=current_user.id) + .order_by(TestDrive.scheduled_date.desc()).all()) + return render_template('account_test_drives.html', rows=rows) + + +@app.route('/account/appraisals') +@login_required +def account_appraisals(): + rows = (Appraisal.query.filter_by(user_id=current_user.id) + .order_by(Appraisal.created_at.desc()).all()) + return render_template('account_appraisals.html', rows=rows) + + +@app.route('/account/reservations//cancel', methods=['POST']) +@login_required +def reservation_cancel(reservation_id): + r = db.session.get(Reservation, reservation_id) + if not r or r.user_id != current_user.id: + abort(404) + r.status = 'cancelled' + db.session.commit() + flash('Reservation cancelled.', 'info') + return redirect(url_for('account_reservations')) + + +@app.route('/account/test-drives//cancel', methods=['POST']) +@login_required +def test_drive_cancel(td_id): + r = db.session.get(TestDrive, td_id) + if not r or r.user_id != current_user.id: + abort(404) + r.status = 'cancelled' + db.session.commit() + flash('Test drive cancelled.', 'info') + return redirect(url_for('account_test_drives')) + + +# ============================================================================= +# Articles & FAQ & MaxCare +# ============================================================================= + +@app.route('/articles') +def articles_index(): + cat = request.args.get('category', '').strip() + q = Article.query + if cat: + q = q.filter(Article.category == cat) + posts = q.order_by(Article.published_at.desc()).all() + cats = (db.session.query(Article.category, func.count(Article.id)) + .group_by(Article.category).all()) + return render_template('articles_index.html', + posts=posts, cats=cats, current_cat=cat) + + +@app.route('/articles/') +def article_detail(slug): + a = Article.query.filter_by(slug=slug).first_or_404() + related = (Article.query.filter(Article.category == a.category, + Article.id != a.id) + .order_by(Article.published_at.desc()).limit(4).all()) + return render_template('article_detail.html', article=a, related=related) + + +FAQ_DATA = { + 'finding-a-car': [ + ('how-can-i-find-out-when-cars-i-like-are-added-to-carmax-inventory', + 'How can I find out when cars I like are added to inventory?', + 'Save vehicles you like to your CarMax account. We will email you ' + 'when similar matches arrive, and you can also adjust your search ' + 'to email alerts on new listings that meet your criteria.'), + ('how-many-vehicles-does-carmax-have', + 'How many vehicles does CarMax have?', + 'CarMax maintains a nationwide inventory of approximately 50,000 ' + 'used vehicles across more than 240 stores.'), + ('what-is-carmax-certified', + 'What is CarMax Certified?', + 'Every car we sell is CarMax Certified - it has been through our ' + '125+ point inspection, has no flood or frame damage, and has no ' + 'salvage history.'), + ], + 'selling-a-car': [ + ('can-i-get-both-an-online-and-in-store-appraisal', + 'Can I get both an online and in-store appraisal?', + 'Yes - you can start your appraisal online with an instant offer ' + 'in under two minutes, then bring your car to a store for in-person ' + 'verification. The price is the same whether you sell outright or ' + 'trade in.'), + ('how-long-is-my-appraisal-offer-good-for', + 'How long is my appraisal offer good for?', + 'Your written offer is valid for 7 days from the day we make it.'), + ], + 'financing': [ + ('what-is-pre-qualification', + 'What is pre-qualification?', + 'Pre-qualification is a soft credit inquiry that gives you ' + 'personalized monthly payment terms without impacting your credit ' + 'score. It takes about 5 minutes and is valid for 30 days.'), + ('does-carmax-finance-first-time-buyers', + 'Does CarMax finance first-time buyers?', + 'Yes, CarMax has finance sources to accommodate most credit ' + 'profiles, including first-time buyers.'), + ], + 'warranty-and-returns': [ + ('what-is-the-30-day-return-policy', + 'What is the 30-day return policy?', + 'You may return your vehicle within 10 days for any reason for a ' + 'full refund, and every vehicle comes with a 30-day limited ' + 'warranty (60-day in CT/MN/RI, 90-day in MA/NJ/NY).'), + ('what-does-maxcare-cover', + 'What does MaxCare cover?', + 'MaxCare extended service plans cover repairs, with deductible-per-' + 'visit pricing. Plans run up to 60 months and include 24/7 roadside ' + 'assistance, rental reimbursement up to $40/day, and nationwide ' + 'coverage in the U.S. and Canada.'), + ], +} + + +@app.route('/faq') +def faq_index(): + return render_template('faq.html', categories=FAQ_DATA) + + +@app.route('/faq/') +def faq_category(category): + items = FAQ_DATA.get(category) + if not items: + abort(404) + return render_template('faq_category.html', + category=category, items=items) + + +@app.route('/faq//') +def faq_detail(category, slug): + items = FAQ_DATA.get(category) or [] + found = next(((s, q, a) for (s, q, a) in items if s == slug), None) + if not found: + abort(404) + return render_template('faq_detail.html', + category=category, q_slug=found[0], + question=found[1], answer=found[2]) + + +@app.route('/car-buying-process/maxcare-service-plans') +def maxcare_plans(): + return render_template('maxcare.html', + prices=MAXCARE_PRICES, + labels=MAXCARE_LABELS) + + +# ============================================================================= +# Auth +# ============================================================================= + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('account')) + form = LoginForm() + next_url = request.args.get('next') or url_for('account') + if form.validate_on_submit(): + u = User.query.filter_by(email=form.email.data.strip().lower()).first() + if u and u.check_password(form.password.data): + login_user(u, remember=form.remember.data) + flash(f'Welcome back, {u.full_name}!', 'success') + return redirect(next_url) + flash('Invalid email or password.', 'danger') + return render_template('login.html', form=form, next_url=next_url) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('account')) + form = RegisterForm() + if form.validate_on_submit(): + email = form.email.data.strip().lower() + if User.query.filter_by(email=email).first(): + flash('That email is already registered. Please sign in.', 'warning') + return redirect(url_for('login')) + u = User(email=email, + first_name=form.first_name.data.strip(), + last_name=form.last_name.data.strip(), + phone=(form.phone.data or '').strip(), + zip_code=(form.zip_code.data or '').strip()) + u.set_password(form.password.data) + db.session.add(u) + db.session.commit() + login_user(u) + flash('Welcome to CarMax!', 'success') + return redirect(url_for('account')) + return render_template('register.html', form=form) + + +@app.route('/logout') +def logout(): + logout_user() + flash('You have been signed out.', 'info') + return redirect(url_for('index')) + + +# ============================================================================= +# Health & errors +# ============================================================================= + +@app.route('/_health') +def health(): + return jsonify({'ok': True, 'site': 'carmax', + 'vehicles': Vehicle.query.count(), + 'stores': Store.query.count(), + 'users': User.query.count()}) + + +@app.errorhandler(404) +def not_found(e): + return render_template('404.html'), 404 + + +@app.errorhandler(500) +def server_error(e): + return render_template('500.html'), 500 + + +# ============================================================================= +# Bootstrap +# ============================================================================= + +# Map this module under the name 'app' so seed_data's deferred +# 'from app import ...' returns this same instance (not a fresh re-import +# under __name__ == '__main__'). +import sys as _sys +_sys.modules.setdefault('app', _sys.modules[__name__]) + +from seed_data import seed_database, seed_benchmark_users # noqa: E402 + +with app.app_context(): + db.create_all() + seed_database() + seed_benchmark_users() + + +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/carmax/instance/.gitkeep b/sites/carmax/instance/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/carmax/instance/carmax.db b/sites/carmax/instance/carmax.db new file mode 100644 index 00000000..b2443263 Binary files /dev/null and b/sites/carmax/instance/carmax.db differ diff --git a/sites/carmax/requirements.txt b/sites/carmax/requirements.txt new file mode 100644 index 00000000..d10ecf9c --- /dev/null +++ b/sites/carmax/requirements.txt @@ -0,0 +1,12 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Flask-WTF==1.2.2 +Flask-Bcrypt==1.0.1 +Werkzeug==3.1.3 +Jinja2==3.1.4 +SQLAlchemy==2.0.36 +WTForms==3.2.1 +email-validator==2.2.0 +Pillow==11.0.0 +# probe diff --git a/sites/carmax/scrape_articles.py b/sites/carmax/scrape_articles.py new file mode 100644 index 00000000..5c418657 --- /dev/null +++ b/sites/carmax/scrape_articles.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Download CarMax article hero images. + +Each seeded article references /static/images/articles/.jpg. This +script fetches the real CarMax content-images.carmax.com hero image for +each article and saves it locally. + +Run from the repo root: + pip install httpx + python sites/carmax/scrape_articles.py +""" +import os +import pathlib +import sys + +# Force UTF-8 stdout so any unicode print does not crash on Windows GBK console. +try: + sys.stdout.reconfigure(encoding='utf-8', errors='replace') +except Exception: + pass + +try: + import httpx +except ImportError: + sys.exit("missing httpx. install with: pip install httpx") + +ROOT = pathlib.Path(__file__).resolve().parent +OUT = ROOT / "static" / "images" / "articles" +OUT.mkdir(parents=True, exist_ok=True) + +UA = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/124.0.0.0 Safari/537.36') + +# slug (matches seed_data.py) -> hero image URL on carmax content CDN +ARTICLE_HEROES = { + 'how-carmax-works': + 'https://content-images.carmax.com/qeontfmijmzv/3pDBIajbVpFy688qLx6XdN/' + '357c3453f4691ba733764186573ae7b0/01-240117_CarMax_Store_052_2x.jpg', + 'how-to-sell-your-car-to-carmax': + 'https://content-images.carmax.com/qeontfmijmzv/7DIdfvK5g5050BjRMti3bj/' + '71bbced50424d75704b715267d971ad4/' + '02-How_to_Sell_Your_Car_GettyImages-1291494870_HiRes_2.jpg', + 'pre-approval-vs-pre-qualified': + 'https://content-images.carmax.com/qeontfmijmzv/44LpZ9Yk7L3HFoR8sjM0Df/' + '0dbf783f7232012f6781173f87bfa743/' + 'NEW_Hero_Fullwidth_1440x780_GETTYimages.jpg', + 'best-compact-sedan-honda-civic-vs-toyota-corolla-vs-nissan-sentra': + 'https://content-images.carmax.com/qeontfmijmzv/7pHQrxB3gpMjo5bdDP01aC/' + '802b0a22a85e782ee45fe10a510d628a/' + 'Best_Compact_Sedan_Civic-Corolla-Sentra_Hero_Fullwidth_1440x625.jpg', + 'best-hatchback-cars-ranking': + 'https://content-images.carmax.com/qeontfmijmzv/60SzfaTFIamvIj9EmTKeO9/' + '149aaf80562785d7bf8763c7275b56a4/' + '474204_BestHatchbackCars_Hero_Fullwidth_1440x500_2.png', + 'how-to-buy-a-used-car': + 'https://content-images.carmax.com/qeontfmijmzv/4lXC25xUvsSh54qogDVRWq/' + '16c2c97f6ffd238f3de05032a14a7f9f/' + '11-24-25-How_to_Buy_a_Used_Car-GettyImages-2152358874_HiRes.jpg', + 'maxcare-explained': + 'https://content-images.carmax.com/qeontfmijmzv/X7vHNvN8eaeOUTblYEq4b/' + '5fec55afd57715736eb2a7516787e628/' + 'maxcare-explained_Hero_Fullwidth_1440x625.jpg', + 'first-time-car-buyer': + 'https://content-images.carmax.com/qeontfmijmzv/31NdNXfNgoLwSy9uEd6cYP/' + '94b8c26ca9c4b19ac3f3ccf7ba53d6af/' + '601201_Edmunds_5-Steps-to-Financing-Your-First-Car_Hero_Fullwidth_1440x625.jpg', + 'best-high-mpg-cars': + 'https://content-images.carmax.com/qeontfmijmzv/2H3DQYX05wSeyyS2OFRMKL/' + '9e06e606f995eca5aad0246e6556e289/' + '617103_Best-High-MPG-Cars_Hero_Fullwidth_1440x625.jpg', + 'attainable-dream-cars-under-50000': + 'https://content-images.carmax.com/qeontfmijmzv/605BZbR1bg8CoqsBYwB3L/' + 'a687fe4756f7dae4df831449f2620f1d/' + 'Dream_Cars_on_a_Budge_Hero_Fullwidth_1440x625.jpg', +} + + +def main(): + print(f"[articles] downloading {len(ARTICLE_HEROES)} article hero images") + ok = skipped = failed = 0 + with httpx.Client(headers={'User-Agent': UA}, + follow_redirects=True, timeout=30) as cx: + for slug, url in ARTICLE_HEROES.items(): + dest = OUT / f"{slug}.jpg" + if dest.exists() and dest.stat().st_size > 2000: + print(f" -- skipping {slug} (already present)") + skipped += 1 + continue + try: + r = cx.get(url) + if r.status_code == 200 and len(r.content) > 2000: + dest.write_bytes(r.content) + print(f" [OK] {slug} ({len(r.content) // 1024} KB)") + ok += 1 + else: + print(f" [!!] {slug}: HTTP {r.status_code}") + failed += 1 + except Exception as e: + print(f" [!!] {slug}: {e}") + failed += 1 + print(f"\n[articles] done: downloaded={ok}, skipped={skipped}, failed={failed}") + print(f"[articles] images saved under {OUT}") + + +if __name__ == '__main__': + main() diff --git a/sites/carmax/scrape_carmax.py b/sites/carmax/scrape_carmax.py new file mode 100644 index 00000000..203da26d --- /dev/null +++ b/sites/carmax/scrape_carmax.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Harvest real CarMax product images for the WebHarbor mirror. + +Direct httpx fetch from content-images.carmax.com (open CDN, no anti-bot). +Every model uses the same `st2400` evox prefix, so URLs are fully +deterministic — no scraping required: + + https://content-images.carmax.com/stockimages////st2400--evoxwebmedium.png + +The seeded DB references local paths like: + /static/images/vehicles/-front.jpg + /static/images/vehicles/-side.jpg etc. + +This script fetches one set of evox photos per (year, make_slug, model_slug) +tuple, then writes a copy under each matching vehicle's stock_number. + +Run from the repo root: + pip install httpx + python sites/carmax/scrape_carmax.py +""" +import os +import pathlib +import sys + +try: + import httpx +except ImportError: + sys.exit("missing httpx. install with: pip install httpx") + +ROOT = pathlib.Path(__file__).resolve().parent +IMG_DIR = ROOT / "static" / "images" / "vehicles" +IMG_DIR.mkdir(parents=True, exist_ok=True) + +# evox view code → our local filename suffix +VIEW_MAP = { + 'front': '089', # angled front (used as main thumbnail) + 'dashboard': '174', + 'side': '037', + 'rear': '119', + 'cargo': '122', + 'interior': '118', # front (alt angle, used for interior shot in our gallery) +} + +# Our seed's model_slug → carmax CDN URL model slug. +# Carmax sometimes uses a different convention from our slugify(). +SLUG_REMAP = { + 'f-150': 'f150', + 'silverado': 'silverado-1500', + # 'cr-v' stays 'cr-v', 'model-3' stays 'model-3', etc. + # 'c-class' is the carmax URL slug for Mercedes C-Class. + # If your seed uses something else, add a mapping here. +} + +UA = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/124.0.0.0 Safari/537.36') + + +def build_url(year, make_slug, model_slug, view_code): + msk = SLUG_REMAP.get(model_slug, model_slug) + return (f"https://content-images.carmax.com/stockimages/" + f"{year}/{make_slug}/{msk}/st2400-{view_code}-evoxwebmedium.png") + + +def main(): + # Load the seeded vehicles + sys.path.insert(0, str(ROOT)) + os.environ.setdefault('FLASK_RUN_FROM_CLI', '1') + from app import app, Vehicle + with app.app_context(): + vehicles = Vehicle.query.order_by(Vehicle.id).all() + print(f"[scrape] {len(vehicles)} vehicles need images") + + # Cache: (year, make_slug, model_slug) -> {view_name: bytes or None} + # Each unique (year, make, model) gets one CDN fetch per view; we then + # write the bytes under each vehicle's stock_number that matches. + cache = {} + downloaded = 0 + skipped = 0 + missing = 0 + + with httpx.Client(headers={'User-Agent': UA}, + follow_redirects=True, timeout=30) as cx: + for v in vehicles: + key = (v.year, v.make_slug, v.model_slug) + if key not in cache: + cache[key] = {} + for view_name, view_code in VIEW_MAP.items(): + url = build_url(v.year, v.make_slug, v.model_slug, view_code) + try: + r = cx.get(url) + if r.status_code == 200 and len(r.content) > 2000: + cache[key][view_name] = r.content + else: + cache[key][view_name] = None + except Exception as e: + print(f" ! {url}: {e}") + cache[key][view_name] = None + ok_views = sum(1 for b in cache[key].values() if b) + print(f" fetched {ok_views}/6 views for " + f"{v.year} {v.make} {v.model}") + + for view_name, view_code in VIEW_MAP.items(): + dest = IMG_DIR / f"{v.stock_number}-{view_name}.jpg" + if dest.exists() and dest.stat().st_size > 2000: + skipped += 1 + continue + data = cache[key].get(view_name) + if data is None: + missing += 1 + continue + dest.write_bytes(data) + downloaded += 1 + + print(f"\n[scrape] done: downloaded={downloaded}, " + f"skipped (already present)={skipped}, " + f"missing (no CDN match)={missing}") + print(f"[scrape] {len(cache)} unique (year, make, model) tuples") + no_image = [k for k, v in cache.items() + if not any(v.values())] + if no_image: + print(f"[scrape] {len(no_image)} tuples had ZERO images " + f"(check slug mapping):") + for k in no_image[:10]: + print(f" - {k}") + + +if __name__ == '__main__': + main() diff --git a/sites/carmax/scraped_data/.gitkeep b/sites/carmax/scraped_data/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/carmax/scraped_data/image_urls.json b/sites/carmax/scraped_data/image_urls.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/sites/carmax/scraped_data/image_urls.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/sites/carmax/scraped_data/recon_notes.md b/sites/carmax/scraped_data/recon_notes.md new file mode 100644 index 00000000..39d7439e --- /dev/null +++ b/sites/carmax/scraped_data/recon_notes.md @@ -0,0 +1,140 @@ +# carmax.com — recon notes (Phase 1.3) + +Captured via WebFetch + WebSearch on 2026-05-14. No browser/image harvesting from +the sandbox; that step happens locally via `scrape_carmax.py`. + +## URL patterns + +| Pattern | Purpose | +|---|---| +| `/` | Homepage | +| `/cars` | Master inventory listing (supports `?location=`, `?make=`, etc.) | +| `/cars/` | Make landing | +| `/cars//` | Model listings | +| `/cars///` | Year-filtered model listings | +| `/cars////` | Trim+year filter | +| `/cars/?year=YYYY-YYYY&mileage=N` | Filter via query params | +| `/research` | Research landing | +| `/research///` | Vehicle research page (specs, trims, FAQ, reviews) | +| `/research/car-comparison/--` | Comparison tool | +| `/reviews///` | Customer reviews | +| `/value` | Used-car value entry | +| `/value///` | Year-specific value lookup | +| `/sell-my-car` | Sell-my-car / instant offer flow | +| `/stores` | National store locator | +| `/stores/` | State stores list | +| `/pre-qual/app` | Pre-qualification application | +| `/car-financing` | Financing landing | +| `/articles` | Articles index | +| `/articles/` | Article detail | +| `/faq` | FAQ root | +| `/car-buying-process/maxcare-service-plans` | MaxCare extended warranty | + +## Functional modules (mirror scope: ALL of these) + +1. **Inventory search** — filters: ZIP/location, make, model, year range, trim, body style, mileage max, price range, color, transmission, drive type, fuel type, features +2. **Vehicle detail** — photos, price, mileage, specs, features list, store assignment, transfer fee, reserve/test-drive/financing CTAs +3. **Research pages** — model overview with specs, trims, pros/cons, ratings, reliability score, FAQ +4. **Customer reviews** — 1-5 star ratings, written review text +5. **Vehicle comparison** — side-by-side +6. **Saved vehicles** — heart icon, per-user list +7. **Store locator** — 42 states, ~250 stores +8. **Pre-qualification** — soft credit check → personalized monthly payment terms +9. **Sell my car** — appraisal form, instant offer valid 7 days +10. **Reserve a vehicle** — hold up to 7 days +11. **Test drive scheduling** — in-store or at-home +12. **Order/Checkout** — buy reserved vehicle online +13. **MaxCare** — extended warranty info +14. **Articles** — content marketing posts +15. **My account** — saved cars, offers, orders, reservations, test drives, pre-qual status + +## Vehicle data shape + +Identity: year, make, model, trim, body_style, stock_number, vin, slug + +Specs: engine_text, horsepower, torque, transmission, drive_type, fuel_type, +fuel_economy_{city,highway,combined}, seating_capacity, cargo_volume_cuft, +wheelbase, length, width, height + +Commercial: price, list_price (MSRP), mileage, days_on_lot, exterior_color, +interior_color, store_id, transfer_fee, is_certified (always True), is_featured, +is_no_haggle (always True), customer_rating, customer_rating_count, repairpal_rating + +Features (json list, sample tokens from real carmax research pages): +- Apple CarPlay, Android Auto, Bluetooth, Navigation System, BOSE Sound System, + AM/FM Stereo, Satellite Radio Ready, Auxiliary Audio Input +- Backup/Rear View Camera, Blind Spot Monitor, Lane Departure Warning, + Automated Cruise Control, Parking Sensors +- Heated Seats, Leather Seats, Power Seats, Power Windows, Power Locks, + Power Mirrors, Heated Mirrors, Remote Start, Smart Key +- Sunroof, Alloy Wheels, Rear Spoiler, Turbo Charged Engine, Manual Transmission +- ABS Brakes, Traction Control, Side Airbags, Overhead Airbags, Air Conditioning, + Rear Defroster + +## Brand visual identity + +- **Primary**: deep navy blue (carmax-blue ≈ `#1660a8` / `#0d3a72`) +- **Accent**: bright yellow (carmax-yellow ≈ `#FFD900` / `#FFC600`) +- **Background**: white + light gray (`#f7f7f7`) +- **Text**: near-black (`#202020`) on white; white on dark blue +- **Logo**: black "CarMax" wordmark, often paired with a yellow box icon +- **Vehicle cards**: white card on light gray, image top, price + mileage prominent, + CarMax Certified badge, transfer fee badge, "shipping available" pill +- **CTAs**: yellow buttons with dark text (primary), navy outline buttons (secondary) + +## Real image URL patterns (for the scrape script) + +``` +https://content-images.carmax.com/stockimages////--evoxwebmedium.png + views: 089 (angled-front), 174 (dashboard), 118 (front), 037 (side), 119 (back), 122 (cargo) + +https://content-images.carmax.com/qeontfmijmzv///.jpg + Contentful CDN for article hero images & lifestyle photos + +/stores/images/CarMax-Icon-Yellow-BOX-HEX.png — logo icon +``` + +## Sales pitch / copy elements + +- "CarMax Certified" (125+ point inspection, no flood/frame damage, no salvage) +- "10-day Money Back Guarantee" +- "30-day limited warranty" (60-day in CT/MN/RI, 90-day in MA/NJ/NY) +- "MaxCare extended service plan" +- "Free vehicle history report" +- "Upfront, no-haggle prices — same price for everyone" +- "Pre-qualified in 5 minutes, no impact to credit" +- "Real offer in under 2 minutes, valid 7 days" +- "Largest used car inventory in the nation, ~50,000 vehicles" +- "We'll buy your car even if you don't buy ours®" +- Shipping fee disclosure: "non-refundable transfer fee may apply" + +## Store distribution (informs Store seed) + +Sample from /stores page: AL 6, AZ 5, AR 1, CA 34, CO 6, CT 3, DE 1, FL 24, +GA 13, ID 1, IL 11, IN 4, IA 1, KS 2, KY 3, LA 5, ME 1, MD 9, MA 4, MI 1, +MN 2, MS 3, MO 4, NE 1, NV 4, NH 1, NJ 6, NM 2, NY 5, NC 13, OH 6, OK 3, +OR 3, PA 5, RI 1, SC 4, TN 10, TX 29, UT 1, VA 12, WA 7, WI 4. Total ≈250. + +For seed we'll sample ~12 stores across diverse states (CA, TX, FL, GA, NY, +IL, VA, AZ, CO, NC, WA, MA). + +## Trim/feature taxonomy (real, from research pages) + +Honda Civic 2022 trims observed: LX, Sport, Sport Touring, EX, EX-L, Touring, SI +Body styles in catalog: Sedan, Hatchback, Coupe, SUV, Truck, Minivan, Convertible, Wagon +Engine sizes observed: 1.5L Turbo, 2.0L NA, 2.4L, 3.5L V6, 5.0L V8, 5.3L V8, 2.5L hybrid + +## Sources + +- https://www.carmax.com/ +- https://www.carmax.com/cars +- https://www.carmax.com/cars/honda/civic/2022 +- https://www.carmax.com/research/honda/civic/2022 +- https://www.carmax.com/stores +- https://www.carmax.com/articles/carmax-questions-answered +- https://www.carmax.com/articles/pre-approval-vs-pre-qualified +- https://www.carmax.com/sell-my-car (referenced) +- https://www.carmax.com/value (referenced) +- https://www.carmax.com/pre-qual/app (referenced) + +WebVoyager upstream_url for tasks: `https://www.carmax.com/` diff --git a/sites/carmax/seed_data.py b/sites/carmax/seed_data.py new file mode 100644 index 00000000..f03dd5fd --- /dev/null +++ b/sites/carmax/seed_data.py @@ -0,0 +1,904 @@ +"""CarMax mirror seed data. + +Both seed_database() and seed_benchmark_users() are idempotent at the +function level (early-return on populated DB). All inserted rows use +frozen timestamps so the resulting SQLite file is byte-stable across +boots, which is critical for the WebHarbor reset invariant. + +Image paths point into static/images/vehicles/-.jpg. Those +files are populated by scripts/scrape_carmax.py (Playwright). Until the +scraper has run, templates fall back to _pending.svg via onerror. +""" +import json +from datetime import date, datetime, timedelta + + +# Frozen wall-clock so seeded rows are byte-stable across reset cycles. +SEED_NOW = datetime(2026, 1, 15, 12, 0, 0) +TODAY = date(2026, 5, 14) + + +def _slug(s): + import re + s = (s or '').lower().strip() + return re.sub(r'[^a-z0-9]+', '-', s).strip('-') + + +# ============================================================================= +# Stores: 12 real CarMax locations (public-info addresses) +# ============================================================================= + +STORES = [ + # slug, name, street, city, state, zip, phone, lat, lon + ('atlanta-southlake', 'CarMax Atlanta Southlake', + '6889 Mount Zion Blvd', 'Morrow', 'GA', '30260', + '(770) 477-1480', 33.580, -84.336), + ('houston-katy', 'CarMax Houston Katy', + '21015 Katy Fwy', 'Katy', 'TX', '77449', + '(281) 599-9890', 29.789, -95.741), + ('miami-kendall', 'CarMax Miami Kendall', + '11400 SW 88th St', 'Miami', 'FL', '33176', + '(305) 271-7000', 25.685, -80.371), + ('los-angeles-buena-park', 'CarMax Los Angeles Buena Park', + '6101 Auto Center Dr', 'Buena Park', 'CA', '90621', + '(714) 522-3690', 33.866, -118.011), + ('chicago-tinley-park', 'CarMax Chicago Tinley Park', + '8800 W 159th St', 'Tinley Park', 'IL', '60477', + '(708) 532-1480', 41.595, -87.795), + ('new-york-white-plains', 'CarMax White Plains', + '120 Westchester Ave', 'White Plains', 'NY', '10601', + '(914) 824-7100', 41.030, -73.770), + ('washington-laurel', 'CarMax Laurel', + '8800 Freestate Dr', 'Laurel', 'MD', '20723', + '(301) 776-0070', 39.099, -76.880), + ('boston-norwood', 'CarMax Boston Norwood', + '500 Providence Hwy', 'Norwood', 'MA', '02062', + '(781) 762-7600', 42.193, -71.198), + ('seattle-lynnwood', 'CarMax Seattle Lynnwood', + '17900 Highway 99', 'Lynnwood', 'WA', '98037', + '(425) 670-0091', 47.834, -122.293), + ('phoenix-tempe', 'CarMax Phoenix Tempe', + '8000 S Autoplex Loop', 'Tempe', 'AZ', '85284', + '(480) 753-0200', 33.346, -111.962), + ('denver-thornton', 'CarMax Denver Thornton', + '14150 Lincoln St', 'Thornton', 'CO', '80023', + '(303) 252-7800', 39.954, -104.987), + ('raleigh-cary', 'CarMax Raleigh Cary', + '601 Davis Dr', 'Cary', 'NC', '27513', + '(919) 467-1000', 35.795, -78.795), +] + + +# ============================================================================= +# Vehicle template catalog. Each template generates several vehicles. +# Fields: (make, model, body_style, [trim1,trim2,...], [yr,yr,...], +# engine_text, hp, torque, transmission, drive_type, fuel_type, +# mpg_city, mpg_hwy, seats, msrp_new, base_features, [colors]) +# ============================================================================= + +TEMPLATES = [ + ('Honda', 'Civic', 'Sedan', + ['LX', 'Sport', 'EX', 'Touring'], + [2020, 2021, 2022, 2023], + '2.0L I-4', 158, 138, 'CVT Automatic', 'FWD', 'Gasoline', + 31, 40, 5, 24500, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Bluetooth Technology', + 'Lane Departure Warning', 'Automated Cruise Control'], + ['Modern Steel Metallic', 'Aegean Blue Metallic', 'Crystal Black Pearl', + 'Lunar Silver Metallic', 'Sonic Gray Pearl']), + ('Honda', 'Accord', 'Sedan', + ['LX', 'Sport', 'EX-L', 'Touring'], + [2019, 2020, 2021, 2022], + '1.5L Turbo I-4', 192, 192, 'CVT Automatic', 'FWD', 'Gasoline', + 30, 38, 5, 28500, + ['Apple CarPlay', 'Android Auto', 'Heated Seats', 'Leather Seats', + 'Lane Departure Warning', 'Automated Cruise Control', 'Sunroof', + 'BOSE Sound System'], + ['Modern Steel Metallic', 'Platinum White Pearl', 'Crystal Black Pearl', + 'Radiant Red Metallic', 'Lunar Silver Metallic']), + ('Honda', 'CR-V', 'SUV', + ['LX', 'EX', 'EX-L', 'Touring'], + [2019, 2020, 2021, 2022, 2023], + '1.5L Turbo I-4', 190, 179, 'CVT Automatic', 'AWD', 'Gasoline', + 27, 32, 5, 31200, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Blind Spot Monitor', + 'Heated Seats', 'Power Seats', 'Sunroof', 'Automated Cruise Control'], + ['Crystal Black Pearl', 'Modern Steel Metallic', 'Platinum White Pearl', + 'Radiant Red Metallic', 'Sonic Gray Pearl']), + ('Honda', 'Pilot', 'SUV', + ['LX', 'EX-L', 'Touring'], + [2020, 2021, 2022], + '3.5L V-6', 280, 262, '9-Speed Automatic', 'AWD', 'Gasoline', + 19, 26, 8, 38500, + ['Apple CarPlay', 'Android Auto', 'Leather Seats', 'Power Seats', + 'Heated Seats', 'Blind Spot Monitor', 'Navigation System', 'Sunroof', + 'Third Row Seating'], + ['Modern Steel Metallic', 'Platinum White Pearl', 'Crystal Black Pearl', + 'Forest Mist Metallic']), + ('Toyota', 'Camry', 'Sedan', + ['LE', 'SE', 'XLE', 'XSE'], + [2019, 2020, 2021, 2022, 2023], + '2.5L I-4', 203, 184, '8-Speed Automatic', 'FWD', 'Gasoline', + 28, 39, 5, 27000, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Bluetooth Technology', + 'Lane Departure Warning', 'Automated Cruise Control'], + ['Midnight Black Metallic', 'Celestial Silver Metallic', 'Predawn Gray Mica', + 'Wind Chill Pearl', 'Supersonic Red']), + ('Toyota', 'Corolla', 'Sedan', + ['L', 'LE', 'SE', 'XSE'], + [2020, 2021, 2022, 2023], + '2.0L I-4', 169, 151, 'CVT Automatic', 'FWD', 'Gasoline', + 31, 40, 5, 22500, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Bluetooth Technology', + 'Lane Departure Warning'], + ['Classic Silver Metallic', 'Midnight Black Metallic', 'Blizzard Pearl', + 'Blueprint', 'Barcelona Red Metallic']), + ('Toyota', 'RAV4', 'SUV', + ['LE', 'XLE', 'XLE Premium', 'Limited'], + [2019, 2020, 2021, 2022, 2023], + '2.5L I-4', 203, 184, '8-Speed Automatic', 'AWD', 'Gasoline', + 27, 35, 5, 30200, + ['Apple CarPlay', 'Android Auto', 'Blind Spot Monitor', 'Sunroof', + 'Power Seats', 'Heated Seats', 'Automated Cruise Control'], + ['Magnetic Gray Metallic', 'Midnight Black Metallic', 'Blueprint', + 'Lunar Rock', 'Ruby Flare Pearl']), + ('Toyota', 'Tacoma', 'Truck', + ['SR', 'SR5', 'TRD Sport', 'TRD Off-Road'], + [2019, 2020, 2021, 2022, 2023], + '3.5L V-6', 278, 265, '6-Speed Automatic', '4WD', 'Gasoline', + 18, 22, 5, 32800, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Bluetooth Technology', + 'Skid Plates', 'Bed Liner', 'Tow Hitch'], + ['Magnetic Gray Metallic', 'Midnight Black Metallic', 'Silver Sky Metallic', + 'Cement', 'Army Green']), + ('Toyota', 'Highlander', 'SUV', + ['LE', 'XLE', 'Limited'], + [2020, 2021, 2022], + '3.5L V-6', 295, 263, '8-Speed Automatic', 'AWD', 'Gasoline', + 20, 27, 8, 36500, + ['Apple CarPlay', 'Android Auto', 'Third Row Seating', 'Power Seats', + 'Heated Seats', 'Leather Seats', 'Navigation System', 'Blind Spot Monitor'], + ['Midnight Black Metallic', 'Magnetic Gray Metallic', 'Celestial Silver', + 'Blueprint', 'Wind Chill Pearl']), + ('Ford', 'F-150', 'Truck', + ['XL', 'XLT', 'Lariat', 'King Ranch'], + [2019, 2020, 2021, 2022, 2023], + '5.0L V-8', 400, 410, '10-Speed Automatic', '4WD', 'Gasoline', + 16, 22, 5, 42000, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Tow Package', + 'Power Seats', 'Heated Seats', 'Bluetooth Technology', 'Bed Liner'], + ['Oxford White', 'Agate Black Metallic', 'Iconic Silver Metallic', + 'Velocity Blue Metallic', 'Race Red']), + ('Ford', 'Explorer', 'SUV', + ['Base', 'XLT', 'Limited', 'Platinum'], + [2019, 2020, 2021, 2022], + '2.3L Turbo I-4', 300, 310, '10-Speed Automatic', 'AWD', 'Gasoline', + 20, 28, 7, 35700, + ['Apple CarPlay', 'Android Auto', 'Third Row Seating', 'Power Seats', + 'Heated Seats', 'Sunroof', 'Navigation System'], + ['Agate Black Metallic', 'Iconic Silver Metallic', 'Oxford White', + 'Atlas Blue Metallic', 'Rapid Red Metallic']), + ('Ford', 'Mustang', 'Coupe', + ['EcoBoost', 'GT Premium', 'Mach 1'], + [2019, 2020, 2021, 2022], + '5.0L V-8', 460, 420, '10-Speed Automatic', 'RWD', 'Gasoline', + 15, 24, 4, 37000, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Leather Seats', + 'Heated Seats', 'Bluetooth Technology', 'Sunroof'], + ['Oxford White', 'Race Red', 'Shadow Black', 'Velocity Blue Metallic', + 'Twister Orange Metallic']), + ('Ford', 'Escape', 'SUV', + ['S', 'SE', 'Titanium'], + [2019, 2020, 2021, 2022], + '1.5L Turbo I-3', 181, 190, '8-Speed Automatic', 'AWD', 'Gasoline', + 27, 33, 5, 27800, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Lane Departure Warning', + 'Automated Cruise Control'], + ['Agate Black Metallic', 'Oxford White', 'Iconic Silver Metallic', + 'Atlas Blue Metallic']), + ('Chevrolet', 'Silverado', 'Truck', + ['WT', 'Custom', 'LT', 'RST', 'High Country'], + [2019, 2020, 2021, 2022, 2023], + '5.3L V-8', 355, 383, '8-Speed Automatic', '4WD', 'Gasoline', + 15, 21, 6, 44000, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Tow Package', + 'Bed Liner', 'Bluetooth Technology', 'Power Seats'], + ['Summit White', 'Silver Ice Metallic', 'Black', 'Northsky Blue Metallic', + 'Cherry Red Tintcoat']), + ('Chevrolet', 'Equinox', 'SUV', + ['L', 'LS', 'LT', 'Premier'], + [2019, 2020, 2021, 2022], + '1.5L Turbo I-4', 170, 203, '6-Speed Automatic', 'AWD', 'Gasoline', + 26, 31, 5, 27600, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Blind Spot Monitor', + 'Bluetooth Technology'], + ['Summit White', 'Silver Ice Metallic', 'Mosaic Black Metallic', + 'Pacific Blue Metallic', 'Cayenne Orange Metallic']), + ('Chevrolet', 'Tahoe', 'SUV', + ['LS', 'LT', 'Premier'], + [2020, 2021, 2022, 2023], + '5.3L V-8', 355, 383, '10-Speed Automatic', '4WD', 'Gasoline', + 14, 19, 8, 56000, + ['Apple CarPlay', 'Android Auto', 'Third Row Seating', 'Leather Seats', + 'Heated Seats', 'Power Seats', 'Navigation System', 'Sunroof'], + ['Summit White', 'Black', 'Empire Beige Metallic', 'Northsky Blue Metallic', + 'Silver Ice Metallic']), + ('Nissan', 'Altima', 'Sedan', + ['S', 'SV', 'SR', 'SL'], + [2019, 2020, 2021, 2022, 2023], + '2.5L I-4', 188, 180, 'CVT Automatic', 'FWD', 'Gasoline', + 28, 39, 5, 25300, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Bluetooth Technology', + 'Lane Departure Warning'], + ['Super Black', 'Brilliant Silver Metallic', 'Pearl White Tricoat', + 'Storm Blue Metallic', 'Scarlet Ember Tintcoat']), + ('Nissan', 'Rogue', 'SUV', + ['S', 'SV', 'SL', 'Platinum'], + [2019, 2020, 2021, 2022, 2023], + '2.5L I-4', 181, 181, 'CVT Automatic', 'AWD', 'Gasoline', + 27, 34, 5, 27800, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Blind Spot Monitor', + 'Power Seats', 'Heated Seats'], + ['Super Black', 'Brilliant Silver Metallic', 'Pearl White Tricoat', + 'Caspian Blue Metallic', 'Scarlet Ember Tintcoat']), + ('Hyundai', 'Elantra', 'Sedan', + ['SE', 'SEL', 'Limited'], + [2020, 2021, 2022, 2023], + '2.0L I-4', 147, 132, 'CVT Automatic', 'FWD', 'Gasoline', + 33, 42, 5, 21300, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Lane Departure Warning', + 'Bluetooth Technology', 'Automated Cruise Control'], + ['Phantom Black', 'Phantom Black Pearl', 'Quartz White Pearl', + 'Cyber Gray', 'Lava Orange', 'Intense Blue']), + ('Hyundai', 'Tucson', 'SUV', + ['SE', 'SEL', 'Limited'], + [2020, 2021, 2022, 2023], + '2.5L I-4', 187, 178, '8-Speed Automatic', 'AWD', 'Gasoline', + 24, 29, 5, 28400, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Blind Spot Monitor', + 'Heated Seats', 'Power Seats'], + ['Phantom Black', 'Shimmering Silver', 'Magnetic Force Metallic', + 'Intense Blue', 'Quartz White Pearl']), + ('Hyundai', 'Santa Fe', 'SUV', + ['SE', 'SEL', 'Limited'], + [2019, 2020, 2021, 2022], + '2.5L Turbo I-4', 277, 311, '8-Speed Automatic', 'AWD', 'Gasoline', + 22, 28, 5, 31800, + ['Apple CarPlay', 'Android Auto', 'Leather Seats', 'Heated Seats', + 'Power Seats', 'Blind Spot Monitor', 'Navigation System'], + ['Phantom Black', 'Quartz White Pearl', 'Magnetic Force', + 'Calypso Red', 'Lagoon Blue']), + ('Kia', 'Sportage', 'SUV', + ['LX', 'EX', 'SX Turbo'], + [2019, 2020, 2021, 2022], + '2.4L I-4', 181, 175, '6-Speed Automatic', 'AWD', 'Gasoline', + 22, 28, 5, 25800, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Bluetooth Technology', + 'Blind Spot Monitor'], + ['Snow White Pearl', 'Steel Gray', 'Pacific Blue', 'Hyper Red', + 'Black Cherry']), + ('Kia', 'Sorento', 'SUV', + ['LX', 'EX', 'SX Prestige'], + [2019, 2020, 2021, 2022, 2023], + '2.5L Turbo I-4', 281, 311, '8-Speed Automatic', 'AWD', 'Gasoline', + 21, 28, 7, 31900, + ['Apple CarPlay', 'Android Auto', 'Third Row Seating', 'Leather Seats', + 'Heated Seats', 'Power Seats', 'Sunroof'], + ['Snow White Pearl', 'Ebony Black', 'Steel Gray', 'Sapphire Blue', + 'Runway Red']), + ('Jeep', 'Grand Cherokee', 'SUV', + ['Laredo', 'Limited', 'Overland', 'Summit'], + [2019, 2020, 2021, 2022, 2023], + '3.6L V-6', 293, 260, '8-Speed Automatic', '4WD', 'Gasoline', + 19, 26, 5, 41000, + ['Apple CarPlay', 'Android Auto', 'Leather Seats', 'Heated Seats', + 'Power Seats', 'Navigation System', 'Sunroof', 'Blind Spot Monitor'], + ['Diamond Black Crystal', 'Bright White', 'Velvet Red Pearl', + 'Granite Crystal Metallic', 'Hydro Blue Pearl']), + ('Jeep', 'Wrangler', 'SUV', + ['Sport', 'Sport S', 'Rubicon', 'Sahara'], + [2019, 2020, 2021, 2022, 2023], + '3.6L V-6', 285, 260, '8-Speed Automatic', '4WD', 'Gasoline', + 17, 23, 4, 33500, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Skid Plates', + 'Tow Hooks', 'Bluetooth Technology'], + ['Bright White', 'Black', 'Firecracker Red', 'Sting-Gray', + 'Hellayella', 'Sarge Green']), + ('Subaru', 'Outback', 'Wagon', + ['Base', 'Premium', 'Limited', 'Touring'], + [2019, 2020, 2021, 2022, 2023], + '2.5L I-4', 182, 176, 'CVT Automatic', 'AWD', 'Gasoline', + 26, 33, 5, 28800, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Blind Spot Monitor', + 'Heated Seats', 'Power Seats', 'Automated Cruise Control'], + ['Crystal White Pearl', 'Crystal Black Silica', 'Ice Silver Metallic', + 'Abyss Blue Pearl', 'Autumn Green Metallic']), + ('Subaru', 'Forester', 'SUV', + ['Base', 'Premium', 'Sport', 'Limited'], + [2019, 2020, 2021, 2022], + '2.5L I-4', 182, 176, 'CVT Automatic', 'AWD', 'Gasoline', + 26, 33, 5, 27400, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Lane Departure Warning', + 'Blind Spot Monitor', 'Heated Seats'], + ['Crystal White Pearl', 'Crystal Black Silica', 'Magnetite Gray', + 'Horizon Blue Pearl', 'Jasper Green Metallic']), + ('Mazda', 'CX-5', 'SUV', + ['Sport', 'Touring', 'Grand Touring'], + [2019, 2020, 2021, 2022, 2023], + '2.5L I-4', 187, 186, '6-Speed Automatic', 'AWD', 'Gasoline', + 24, 30, 5, 28250, + ['Apple CarPlay', 'Android Auto', 'Backup Camera', 'Leather Seats', + 'Heated Seats', 'Power Seats', 'Blind Spot Monitor', 'Sunroof'], + ['Jet Black Mica', 'Snowflake White Pearl Mica', 'Sonic Silver Metallic', + 'Soul Red Crystal Metallic', 'Polymetal Gray Metallic']), + ('BMW', '3 Series', 'Sedan', + ['330i', '330i xDrive', 'M340i'], + [2019, 2020, 2021, 2022], + '2.0L Turbo I-4', 255, 295, '8-Speed Automatic', 'AWD', 'Gasoline', + 26, 36, 5, 44000, + ['Apple CarPlay', 'Android Auto', 'Leather Seats', 'Heated Seats', + 'Power Seats', 'Navigation System', 'Sunroof', 'BOSE Sound System'], + ['Alpine White', 'Black Sapphire Metallic', 'Mineral Gray Metallic', + 'Portimao Blue Metallic', 'Skyscraper Gray Metallic']), + ('Mercedes-Benz', 'C-Class', 'Sedan', + ['C300', 'C300 4MATIC', 'AMG C43'], + [2019, 2020, 2021, 2022], + '2.0L Turbo I-4', 255, 273, '9-Speed Automatic', 'AWD', 'Gasoline', + 25, 35, 5, 45000, + ['Apple CarPlay', 'Android Auto', 'Leather Seats', 'Heated Seats', + 'Navigation System', 'Sunroof', 'Blind Spot Monitor'], + ['Polar White', 'Obsidian Black Metallic', 'Iridium Silver Metallic', + 'Mojave Silver Metallic', 'Selenite Gray Metallic']), + ('Tesla', 'Model 3', 'Sedan', + ['Standard Range Plus', 'Long Range', 'Performance'], + [2019, 2020, 2021, 2022, 2023], + 'Dual Electric Motors', 346, 389, 'Single-Speed', 'AWD', 'Electric', + 132, 126, 5, 49000, + ['Backup Camera', 'Automated Cruise Control', 'Lane Departure Warning', + 'Heated Seats', 'Power Seats', 'Navigation System', 'Sunroof'], + ['Pearl White Multi-Coat', 'Solid Black', 'Midnight Silver Metallic', + 'Deep Blue Metallic', 'Red Multi-Coat']), +] + + +# Interior colors keyed by exterior — short, deterministic mapping +INTERIOR_COLORS = ['Black', 'Gray', 'Beige', 'Brown'] + +# Trim feature accruals: each successive trim inherits prior + adds +TRIM_FEATURE_ADDONS = [ + [], # trim 0 (base): no extras + ['Power Seats', 'Power Windows', 'Power Locks', 'Cruise Control'], # trim 1 + ['Heated Seats', 'Sunroof', 'Blind Spot Monitor', 'Smart Key', + 'Power Mirrors', 'Heated Mirrors'], # trim 2 + ['Leather Seats', 'Navigation System', 'BOSE Sound System', + 'Remote Start', 'Parking Sensors'], # trim 3 + ['Premium Sound', 'Wireless Charging', 'Premium Wheels'], # trim 4+ +] + + +def _vehicle_for(template_idx, trim_idx, year_idx, store_idx, color_idx, + mileage_seed): + """Deterministically build one vehicle from a template + indexed choices.""" + t = TEMPLATES[template_idx] + (make, model, body_style, trims, years, engine, hp, torque, + trans, drive, fuel, mpgc, mpgh, seats, msrp, base_feats, colors) = t + trim = trims[trim_idx] + year = years[year_idx] + color = colors[color_idx % len(colors)] + interior = INTERIOR_COLORS[(trim_idx + year_idx) % len(INTERIOR_COLORS)] + + # Features accrue by trim level + feats = list(base_feats) + for i in range(trim_idx + 1): + if i < len(TRIM_FEATURE_ADDONS): + for f in TRIM_FEATURE_ADDONS[i]: + if f not in feats: + feats.append(f) + # Sport/Performance/SI/Type/AMG/Performance/Touring extras + sporty = any(w in trim for w in ('Sport', 'SI', 'AMG', 'Performance', + 'Mach', 'TRD', 'Rubicon', 'M340')) + if sporty: + for f in ('Rear Spoiler', 'Alloy Wheels'): + if f not in feats: + feats.append(f) + # Cross-field consistency: only claim Turbo feature when the engine actually is turbo + if 'Turbo' in engine and 'Turbo Charged Engine' not in feats: + feats.append('Turbo Charged Engine') + if 'Electric' in fuel: + feats = [f for f in feats if 'Turbo' not in f] + feats.append('All-Electric Drivetrain') + feats.append('CarMax Certified') + + # Depreciation: 18% first year + 11% subsequent (so 5-yr ~ 50%) + age = max(2026 - year, 0) + if age == 0: + depr = 1.0 + else: + depr = 0.82 * (0.89 ** (age - 1)) + trim_premium = 1.0 + 0.04 * trim_idx + price = msrp * depr * trim_premium + price = round(price / 100) * 100 # round to $100 + list_price = round(msrp * (1.0 + 0.02 * trim_idx) / 100) * 100 + + # Mileage: deterministic from age + seed + expected_per_year = 11500 + (mileage_seed % 3) * 1500 + mileage = age * expected_per_year + (mileage_seed % 1500) + mileage = max(2400, mileage) + + # Stock + VIN: deterministic + stock = f"{template_idx:02d}{trim_idx}{year_idx}{store_idx:02d}{color_idx % 6}{mileage_seed % 10}".ljust(8, '0')[:8] + vin = f"1HG{template_idx:02d}{trim_idx}{year_idx % 10}{store_idx:02d}{(template_idx*97 + trim_idx*53 + year_idx*7) % 1000:03d}{color_idx:03d}".replace(' ', '0')[:17].upper() + + slug = _slug(f"{year}-{make}-{model}-{trim}-{stock}") + + image = f"/static/images/vehicles/{stock}-front.jpg" + gallery = [ + f"/static/images/vehicles/{stock}-front.jpg", + f"/static/images/vehicles/{stock}-side.jpg", + f"/static/images/vehicles/{stock}-rear.jpg", + f"/static/images/vehicles/{stock}-dashboard.jpg", + f"/static/images/vehicles/{stock}-cargo.jpg", + f"/static/images/vehicles/{stock}-interior.jpg", + ] + + description = (f"This {year} {make} {model} {trim} comes equipped with a " + f"{engine.lower()} engine, {trans.lower()}, " + f"and {drive} drivetrain. It has been CarMax Certified through our " + f"125+ point inspection — no flood or frame damage, no salvage " + f"history. Eligible for our 30-day limited warranty and " + f"10-day money back guarantee.") + + transfer_fee = 0 if mileage_seed % 4 == 0 else ( + 99 if mileage_seed % 4 == 1 else (199 if mileage_seed % 4 == 2 else 399)) + + days_on_lot = 4 + ((template_idx * 7 + trim_idx * 13 + year_idx * 19 + + store_idx * 23 + mileage_seed) % 40) + + customer_rating = round(3.8 + ((template_idx + year_idx) % 13) / 10.0, 1) + customer_rating_count = 6 + (template_idx + trim_idx * 3) % 24 + repairpal = round(3.5 + (template_idx % 16) / 10.0, 1) + + # Featured/new-arrival/price-drop flags (deterministic) + is_featured = (template_idx + trim_idx) % 7 == 0 + is_new_arrival = days_on_lot <= 9 + is_price_drop = (mileage_seed % 5) == 0 and (list_price > price) + + return { + 'stock_number': stock, + 'slug': slug, + 'vin': vin, + 'year': year, + 'make': make, + 'make_slug': _slug(make), + 'model': model, + 'model_slug': _slug(model), + 'trim': trim, + 'trim_slug': _slug(trim), + 'body_style': body_style, + 'exterior_color': color, + 'interior_color': interior, + 'mileage': mileage, + 'price': price, + 'list_price': list_price, + 'engine_text': engine, + 'engine_displacement': float(engine.split('L')[0].split(' ')[-1]) if 'L' in engine else 0.0, + 'horsepower': hp, + 'torque': torque, + 'transmission': trans, + 'drive_type': drive, + 'fuel_type': fuel, + 'mpg_city': mpgc, + 'mpg_highway': mpgh, + 'mpg_combined': (mpgc + mpgh) // 2 if fuel != 'Electric' else (mpgc + mpgh) // 2, + 'seating_capacity': seats, + 'cargo_volume': 14.0 + (template_idx % 30), + 'wheelbase': 105.0 + (template_idx % 25), + 'overall_length': 178.0 + (template_idx % 40), + 'width': 70.0 + (template_idx % 8), + 'height': 55.0 + (template_idx % 18), + 'fuel_capacity': 13.0 + (template_idx % 12), + 'features': json.dumps(feats), + 'description': description, + 'image': image, + 'gallery_images': json.dumps(gallery), + 'customer_rating': customer_rating, + 'customer_rating_count': customer_rating_count, + 'repairpal_rating': repairpal, + 'is_certified': True, + 'is_featured': is_featured, + 'is_no_haggle': True, + 'is_new_arrival': is_new_arrival, + 'is_price_drop': is_price_drop, + 'store_id': store_idx + 1, # 1-indexed + 'transfer_fee': transfer_fee, + 'days_on_lot': days_on_lot, + 'added_at': SEED_NOW - timedelta(days=days_on_lot), + } + + +def _build_vehicle_seeds(): + """Deterministically iterate templates/trims/years/stores to ~150 vehicles.""" + rows = [] + counter = 0 + for ti, t in enumerate(TEMPLATES): + # Pick 5 vehicle variants per template (roughly): one per trim, varied year + trims = t[3] + years = t[4] + colors_count = len(t[16]) + n_variants = min(5, max(4, len(trims) + 1)) + for variant in range(n_variants): + trim_idx = variant % len(trims) + year_idx = (variant + ti) % len(years) + store_idx = (counter + ti) % len(STORES) + color_idx = (variant * 3 + ti) % colors_count + mileage_seed = (counter * 313 + ti * 97) % 9973 + rows.append(_vehicle_for(ti, trim_idx, year_idx, store_idx, + color_idx, mileage_seed)) + counter += 1 + return rows + + +# ============================================================================= +# Articles +# ============================================================================= + +ARTICLES = [ + ('how-carmax-works', + 'How CarMax Works — Buy, Sell, Finance', + 'research', True, + 'Shop online or in store, get pre-qualified, and enjoy our 10-day money-back guarantee and 30-day limited warranty.', + 'Shopping with CarMax is straightforward. We are customer-focused and want you to have a great car-buying experience. You can shop online, in-store, or a mix of both — whatever works best for you.\n\nEvery car we sell is CarMax Certified, which means no flood or frame damage and no salvage history. Each car undergoes a detailed 125+ point checklist by our trained technicians, and we will repair, replace, or detail anything necessary to meet our standards.\n\nWe are known for being upfront on our pricing — we do not haggle. For a stress-free and transparent customer experience, it is the same price for everyone.\n\nWe understand that buying a vehicle is a big decision, and you want to feel confident about getting it right the first time.'), + ('how-to-sell-your-car-to-carmax', + 'How to Sell Your Car to CarMax', + 'selling', True, + 'Get a real, upfront written offer in under 2 minutes. Good for 7 days. Sell or trade — your choice.', + 'Selling your car to CarMax is fast and transparent. You can start your offer online with just your license plate, mileage, and ZIP, then bring the car to any CarMax store for a brief verification.\n\nThe offer is good for 7 days. The price is the same whether you trade in or sell outright.\n\nOnline appraisals take about 2 minutes; in-store appraisals run 30-45 minutes including the test drive and inspection.'), + ('pre-approval-vs-pre-qualified', + 'Getting Pre-Qualified: Shop with Personalized Financing Terms', + 'financing', True, + 'Pre-qualification uses a soft credit check and gives you personalized monthly payments — no impact to your credit score.', + 'A pre-qualification reviews your current financial situation and credit history with a soft credit inquiry. It does not impact your credit score.\n\nPre-qualifying lets you shop with personalized terms — see actual monthly payments on every car. Final terms require a credit application, which results in a hard inquiry.\n\nAt CarMax, pre-qualifications are valid for 30 days.'), + ('best-compact-sedan-honda-civic-vs-toyota-corolla-vs-nissan-sentra', + 'Best Compact Sedan: Honda Civic vs. Toyota Corolla vs. Nissan Sentra', + 'research', False, + 'Three popular compact sedans compared on price, fuel economy, and features.', + 'The compact sedan segment remains one of the most popular for value-minded buyers. Here is how three of the best-sellers compare.\n\nThe Honda Civic offers refined driving dynamics and a sophisticated cabin. The Toyota Corolla brings legendary reliability and the lowest cost of ownership. The Nissan Sentra punches above its weight with standard advanced driver-assistance features.'), + ('best-hatchback-cars-ranking', + 'Best Used Hatchback Cars for 2024: Ranked', + 'research', False, + 'Practical, versatile, and surprisingly fun — our take on the best used hatchbacks.', + 'Hatchbacks deliver sedan-like fuel economy with SUV-like cargo flexibility. We ranked the most popular used hatchbacks based on sales data, reliability ratings, and cargo capacity.'), + ('how-to-buy-a-used-car', + 'How to Buy a Used Car: From Online to the Lot', + 'how-to', False, + 'A simple step-by-step guide to buying a used car the smart way.', + 'Step 1: Set your budget — or get pre-qualified to make that easy. Step 2: Narrow down body style, then make and model, using research pages. Step 3: Shop the nationwide inventory online; reserve any car for 7 days while you finish paperwork. Step 4: Test drive at the store or at home. Step 5: Finalize financing. Step 6: Take delivery.'), + ('maxcare-explained', + 'MaxCare Service Plans — Coverage Explained', + 'how-to', False, + 'Optional extended-warranty coverage that picks up where the limited warranty leaves off.', + 'MaxCare extended service plans run up to 60 months / 100,000 miles. Each plan includes hassle-free repairs at any licensed shop, 24/7 roadside assistance, and rental reimbursement up to $40/day. You can cancel any time.'), + ('first-time-car-buyer', + 'First-Time Car Buyer? Your Step-by-Step Guide', + 'how-to', False, + 'Building credit, picking a car, and financing your first vehicle without overpaying.', + 'Buying your first car is a big step. The two best things you can do up front: (1) understand your monthly budget, including insurance and fuel; (2) get pre-qualified so you know exactly what you can afford. CarMax has finance sources for first-time buyers.'), + ('best-high-mpg-cars', + 'Best High-MPG Used Cars', + 'research', False, + 'Looking for the best gas mileage? These used cars consistently top fuel economy lists.', + 'Hybrids dominate this list, but even non-hybrid compact sedans can clear 40 mpg highway. Top picks include the Toyota Corolla Hybrid, Honda Civic, Hyundai Elantra, and Toyota Camry.'), + ('attainable-dream-cars-under-50000', + 'Attainable Dream Cars Under $50,000', + 'research', False, + 'For the price of an average new car, you can drive something special.', + 'New cars now average around $50,000 — but the used market opens the door to driving something more exciting. Convertibles, sports coupes, and luxury SUVs at this price point are well within reach.'), +] + + +# ============================================================================= +# Customer reviews (one or two per popular model/year combo) +# ============================================================================= + +REVIEW_TEMPLATES = [ + # (make_slug, model_slug, year, rating, title, body, name, location) + ('honda', 'civic', 2022, 5, 'Best small sedan period', + 'Great gas mileage, refined cabin, and just enough power for everything I need to do. Highway road noise is the only knock.', + 'Marcus T.', 'Chicago, IL'), + ('honda', 'civic', 2022, 4, 'Solid choice for commuters', + 'Comfortable seats, easy to use Apple CarPlay, and reliable so far. The base 2.0L feels a bit slow on hills.', + 'Priya R.', 'Atlanta, GA'), + ('honda', 'accord', 2021, 5, 'Quiet, spacious, and well-built', + 'Roomy interior, smooth ride, and the Touring trim has every feature I wanted. Best sedan I have owned.', + 'David K.', 'Houston, TX'), + ('honda', 'cr-v', 2022, 5, 'Perfect family SUV', + 'Plenty of cargo space, comfortable for road trips, and AWD handles winter just fine. Fuel economy is impressive too.', + 'Sarah B.', 'Denver, CO'), + ('toyota', 'camry', 2022, 5, 'Reliable as ever', + 'Toyota nailed the redesign. Comfortable, gets great mileage, and the tech finally feels modern.', + 'Jennifer L.', 'Los Angeles, CA'), + ('toyota', 'rav4', 2021, 4, 'Great value crossover', + 'Honest, reliable SUV. Not the most fun to drive, but it does everything well and has been bulletproof.', + 'Mike P.', 'Seattle, WA'), + ('toyota', 'tacoma', 2021, 5, 'Best off-road truck for the money', + 'TRD Off-Road has handled everything I have thrown at it. Resale value is unreal.', + 'James W.', 'Phoenix, AZ'), + ('ford', 'f-150', 2022, 5, 'Workhorse and comfortable cruiser', + 'Towed my boat across three states without issue. Cabin is quiet and the SYNC system is finally good.', + 'Robert M.', 'Raleigh, NC'), + ('ford', 'mustang', 2021, 5, 'GT is a riot', + 'V8 sounds incredible, and the chassis is much better than people give it credit for. 10-speed automatic is buttery.', + 'Carlos D.', 'Miami, FL'), + ('chevrolet', 'silverado', 2022, 4, 'Capable and quiet', + 'Tows great, comfortable on long drives, but fuel economy is what you expect from a V8 truck.', + 'Linda H.', 'Houston, TX'), + ('chevrolet', 'tahoe', 2022, 5, 'Family hauler king', + 'Plenty of room for everyone and everything. The new independent rear suspension is a huge upgrade.', + 'Anthony S.', 'Atlanta, GA'), + ('nissan', 'altima', 2021, 4, 'Underrated sedan', + 'Quiet ride, good fuel economy, and the 2.0 turbo is plenty fast. Interior could be a bit nicer.', + 'Ashley N.', 'Tinley Park, IL'), + ('hyundai', 'tucson', 2022, 5, 'Bold redesign, great value', + 'The new styling is polarizing but I love it. Tech features rival cars twice the price.', + 'Wei C.', 'White Plains, NY'), + ('kia', 'sorento', 2022, 5, 'Three-row SUV that feels premium', + 'Comfortable interior, smooth turbo engine, and the third row is actually usable for short trips.', + 'Maria G.', 'Laurel, MD'), + ('jeep', 'wrangler', 2021, 5, 'Lives up to the legend', + 'Off-road capability is unmatched. On-road manners are quirky but that is part of the charm.', + 'Tyler J.', 'Buena Park, CA'), + ('subaru', 'outback', 2022, 5, 'Best all-weather wagon', + 'Standard AWD, generous cargo, and EyeSight has saved me from at least one rear-end. Highly recommend.', + 'Hannah O.', 'Norwood, MA'), + ('mazda', 'cx-5', 2021, 5, 'Most fun crossover in its class', + 'Steering feel and chassis tuning are a cut above. Beautiful cabin too.', + 'Daniel F.', 'Cary, NC'), + ('bmw', '3-series', 2021, 4, 'Still the sport sedan benchmark', + 'Engine and transmission are fantastic. iDrive takes some learning. Ride is firm.', + 'Elena V.', 'Thornton, CO'), + ('tesla', 'model-3', 2021, 5, 'No going back to gas', + 'Instant torque, never visit a gas station, and Supercharging on road trips is easy. Build quality has improved a lot.', + 'Naveen P.', 'Lynnwood, WA'), + ('toyota', 'corolla', 2022, 4, 'Just works', + 'Boring in the best way. Sips fuel, never breaks, easy to park. What more do you want from a commuter?', + 'Beth E.', 'Tempe, AZ'), +] + + +# ============================================================================= +# Seeding functions — IDEMPOTENT at the function level +# ============================================================================= + +def seed_database(): + """Create stores, vehicles, articles, reviews. Early-return if populated.""" + from app import (Article, Review, Store, Vehicle, db) + if Vehicle.query.count() > 0: + return + + # Stores + for slug, name, street, city, state, zip_code, phone, lat, lon in STORES: + s = Store(slug=slug, name=name, street=street, city=city, state=state, + zip_code=zip_code, phone=phone, latitude=lat, longitude=lon, + has_appraisal=True, has_express_pickup=True, + has_service=True, has_home_delivery=True, + hours_weekday='10:00 AM - 9:00 PM', + hours_saturday='9:00 AM - 9:00 PM', + hours_sunday='12:00 PM - 7:00 PM', + image='/static/images/stores/storefront_default.jpg') + db.session.add(s) + db.session.flush() + + # Vehicles — built deterministically from templates + seeds = _build_vehicle_seeds() + for s in seeds: + v = Vehicle(**s) + db.session.add(v) + + # Articles + for slug, title, category, featured, summary, body in ARTICLES: + pub = date(2025, 11, 1) + timedelta(days=(hash(slug) % 180)) + a = Article(slug=slug, title=title, category=category, + summary=summary, body=body, + hero_image=f"/static/images/articles/{slug}.jpg", + published_at=pub, is_featured=featured) + db.session.add(a) + + # Reviews — written by the system (no user_id, anonymized name) + for make_slug, model_slug, year, rating, title, body, name, loc in REVIEW_TEMPLATES: + r = Review(make_slug=make_slug, model_slug=model_slug, year=year, + rating=rating, title=title, body=body, + reviewer_name=name, location=loc, + created_at=SEED_NOW) + db.session.add(r) + + db.session.commit() + + +def seed_benchmark_users(): + """Five benchmark users used by WebVoyager tasks. Idempotent.""" + from app import (Appraisal, FinancePreQual, Order, Reservation, + Review, SavedVehicle, Store, TestDrive, User, Vehicle, + bcrypt, db) + if User.query.filter_by(email='alice.j@test.com').first(): + return + + # Look up store IDs (slug -> id) deterministically + store_id_by_slug = {s.slug: s.id for s in Store.query.all()} + + users = [ + # (email, first, last, phone, zip, addr1, city, state, home_store_slug, + # prequal: (monthly_max, term, apr, down, tier, expires_offset), + # annual_income, employment_status) + ('alice.j@test.com', 'Alice', 'Johnson', '(404) 555-0118', '30303', + '410 Peachtree St NE', 'Atlanta', 'GA', 'atlanta-southlake', + (550.0, 72, 7.49, 2500.0, 'good', 30), 78000, 'employed_full_time'), + ('bob.k@test.com', 'Bob', 'Kim', '(713) 555-0119', '77002', + '1500 Louisiana St', 'Houston', 'TX', 'houston-katy', + (700.0, 60, 5.49, 5000.0, 'excellent', 30), 142000, 'employed_full_time'), + ('carol.l@test.com', 'Carol', 'Lopez', '(305) 555-0120', '33176', + '11400 SW 92nd Ct', 'Miami', 'FL', 'miami-kendall', + (425.0, 72, 11.99, 1500.0, 'fair', 30), 54000, 'self_employed'), + ('dan.m@test.com', 'Dan', 'Murphy', '(617) 555-0121', '02062', + '88 School St', 'Norwood', 'MA', 'boston-norwood', + None, 65000, 'employed_full_time'), + ('emma.n@test.com', 'Emma', 'Nguyen', '(206) 555-0122', '98037', + '17800 Highway 99', 'Lynnwood', 'WA', 'seattle-lynnwood', + (320.0, 66, 17.99, 1000.0, 'building', 30), 38000, 'student'), + ] + + user_objs = {} + for (email, first, last, phone, zip_code, addr1, city, state, + home_slug, prequal, income, emp) in users: + u = User( + email=email, + first_name=first, last_name=last, + phone=phone, zip_code=zip_code, + address_line1=addr1, city=city, state=state, + home_store_id=store_id_by_slug.get(home_slug), + annual_income=income, + employment_status=emp, + created_at=SEED_NOW, + ) + # Set deterministic password (bcrypt is randomized by salt; we need + # to set a stable password_hash by using a fixed pre-computed hash + # OR by setting one via set_password but accepting the salt churn. + # Since the salt randomness would break md5 stability, we use a + # pre-baked bcrypt hash for 'CarMax!2026' generated once. + # NOTE: bcrypt verification still works with this fixed hash. + u.password_hash = '$2b$12$abcdefghijklmnopqrstuuj6phTDGC0QgZUgJBeZsSqG7EdTlBv7K' + if prequal: + mmax, term, apr, down, tier, exp_off = prequal + u.pre_qual_active = True + u.pre_qual_monthly_max = mmax + u.pre_qual_term_months = term + u.pre_qual_apr = apr + u.pre_qual_down_payment = down + u.pre_qual_credit_tier = tier + u.pre_qual_expires_at = TODAY + timedelta(days=exp_off) + db.session.add(u) + user_objs[email] = u + db.session.flush() + + # FinancePreQual rows mirroring the pre-qual snapshots on users + for (email, first, last, phone, zip_code, addr1, city, state, + home_slug, prequal, income, emp) in users: + if not prequal: + continue + u = user_objs[email] + mmax, term, apr, down, tier, exp_off = prequal + pq = FinancePreQual( + user_id=u.id, annual_income=income, employment_status=emp, + monthly_payment_max=mmax, down_payment=down, term_months=term, + estimated_apr=apr, credit_tier=tier, status='active', + created_at=SEED_NOW, + expires_at=TODAY + timedelta(days=exp_off), + ) + db.session.add(pq) + + # Seed a handful of saved vehicles, reservations, test drives, appraisals, + # and an order so the benchmark accounts feel lived-in. + alice = user_objs['alice.j@test.com'] + bob = user_objs['bob.k@test.com'] + carol = user_objs['carol.l@test.com'] + dan = user_objs['dan.m@test.com'] + + # Pick deterministic vehicles by id range + v1 = db.session.get(Vehicle, 1) + v3 = db.session.get(Vehicle, 3) + v5 = db.session.get(Vehicle, 5) + v7 = db.session.get(Vehicle, 7) + v11 = db.session.get(Vehicle, 11) + v15 = db.session.get(Vehicle, 15) + v23 = db.session.get(Vehicle, 23) + v37 = db.session.get(Vehicle, 37) + + if v1: db.session.add(SavedVehicle(user_id=alice.id, vehicle_id=v1.id, saved_at=SEED_NOW)) + if v11: db.session.add(SavedVehicle(user_id=alice.id, vehicle_id=v11.id, saved_at=SEED_NOW)) # 2021 Honda CR-V LX — disambig from v1 + if v7: db.session.add(SavedVehicle(user_id=bob.id, vehicle_id=v7.id, saved_at=SEED_NOW)) + if v11: db.session.add(SavedVehicle(user_id=bob.id, vehicle_id=v11.id, saved_at=SEED_NOW)) + if v23: db.session.add(SavedVehicle(user_id=carol.id, vehicle_id=v23.id, saved_at=SEED_NOW)) + if v37: db.session.add(SavedVehicle(user_id=dan.id, vehicle_id=v37.id, saved_at=SEED_NOW)) + + if v3: + db.session.add(Reservation( + user_id=alice.id, vehicle_id=v3.id, store_id=v3.store_id, + status='active', + appointment_date=TODAY + timedelta(days=2), + expires_at=TODAY + timedelta(days=7), + transfer_required=False, transfer_fee=v3.transfer_fee or 0, + created_at=SEED_NOW)) + + if v11: + db.session.add(TestDrive( + user_id=bob.id, vehicle_id=v11.id, store_id=v11.store_id, + location_type='in_store', + scheduled_date=TODAY + timedelta(days=3), + scheduled_time='2:00 PM', + status='confirmed', notes='', + created_at=SEED_NOW)) + + if v15: + db.session.add(TestDrive( + user_id=alice.id, vehicle_id=v15.id, store_id=v15.store_id, + location_type='at_home', + scheduled_date=TODAY + timedelta(days=5), + scheduled_time='4:00 PM', + status='confirmed', notes='Please call when you arrive.', + created_at=SEED_NOW)) + + # Appraisals for benchmark users — deterministic offers + db.session.add(Appraisal( + user_id=alice.id, year=2017, make='Toyota', model='Camry', trim='LE', + mileage=78500, condition='good', + exterior_color='Celestial Silver Metallic', + license_plate='AJC2017', license_state='GA', vin='4T1B11HK1HU000118', + zip_code='30303', has_accidents=False, owner_count=1, + contact_email='alice.j@test.com', contact_phone='(404) 555-0118', + offer_amount=11650.0, offer_valid_until=TODAY + timedelta(days=7), + status='active', created_at=SEED_NOW)) + + db.session.add(Appraisal( + user_id=bob.id, year=2015, make='Honda', model='Accord', trim='Sport', + mileage=125400, condition='fair', + exterior_color='Modern Steel Metallic', + license_plate='BKHX2015', license_state='TX', vin='1HGCR2F33FA000119', + zip_code='77002', has_accidents=True, owner_count=2, + contact_email='bob.k@test.com', contact_phone='(713) 555-0119', + offer_amount=6850.0, offer_valid_until=TODAY + timedelta(days=4), + status='active', created_at=SEED_NOW)) + + db.session.add(Appraisal( + user_id=carol.id, year=2019, make='Nissan', model='Altima', trim='SV', + mileage=42800, condition='excellent', + exterior_color='Pearl White Tricoat', + license_plate='CL2019N', license_state='FL', vin='1N4BL4DV3KC000120', + zip_code='33176', has_accidents=False, owner_count=1, + contact_email='carol.l@test.com', contact_phone='(305) 555-0120', + offer_amount=14750.0, offer_valid_until=TODAY + timedelta(days=6), + status='active', created_at=SEED_NOW)) + + # One completed order for Dan + if v37: + db.session.add(Order( + order_number='CMX-2026-000001', + user_id=dan.id, + status='ready_for_pickup', + vehicle_id=v37.id, store_id=v37.store_id, + subtotal=v37.price, + transfer_fee=v37.transfer_fee or 0, + tax=v37.price * 0.0625, + title_fee=99, registration_fee=55, + total=(v37.price + (v37.transfer_fee or 0) + v37.price * 0.0625 + + 99 + 55 + 1895), # includes MaxCare gold + maxcare_plan='gold', maxcare_price=1895, + payment_method='carmax_auto_finance', + payment_last4='1234', payment_apr=6.49, + payment_term_months=60, + monthly_payment=520.0, down_payment=3000.0, + trade_in_value=0, + pickup_or_delivery='pickup', + delivery_address='', + pickup_date=TODAY + timedelta(days=3), + created_at=SEED_NOW)) + + db.session.commit() diff --git a/sites/carmax/static/css/.gitkeep b/sites/carmax/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/carmax/static/css/main.css b/sites/carmax/static/css/main.css new file mode 100644 index 00000000..29ede660 --- /dev/null +++ b/sites/carmax/static/css/main.css @@ -0,0 +1,221 @@ +/* CarMax mirror — brand styles. Deep blue + yellow accent. */ +:root { + --cmx-blue: #1660a8; + --cmx-blue-dark: #0d3a72; + --cmx-blue-darker: #06223f; + --cmx-yellow: #FFD900; + --cmx-yellow-dark: #ffc600; + --cmx-text: #1a1a1a; + --cmx-text-light: #585858; + --cmx-bg: #ffffff; + --cmx-bg-soft: #f4f6f8; + --cmx-border: #e0e3e8; + --cmx-success: #008060; + --cmx-danger: #c8302c; + --cmx-warning: #b8770e; + --cmx-radius: 6px; + --cmx-shadow-sm: 0 1px 2px rgba(0,0,0,0.06); + --cmx-shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; font-family: 'Helvetica Neue', Arial, sans-serif; + color: var(--cmx-text); background: var(--cmx-bg); + -webkit-font-smoothing: antialiased; } +a { color: var(--cmx-blue); text-decoration: none; } +a:hover { text-decoration: underline; } +img { max-width: 100%; height: auto; } + +.container { max-width: 1280px; margin: 0 auto; padding: 0 16px; } +.container-narrow { max-width: 960px; margin: 0 auto; padding: 0 16px; } +.container-tight { max-width: 560px; margin: 0 auto; padding: 0 16px; } + +/* ===== Header ===== */ +.site-header { background: var(--cmx-blue-darker); color: #fff; } +.site-header .container { display: flex; align-items: center; gap: 24px; padding: 12px 16px; } +.site-header .logo { font-size: 28px; font-weight: 900; color: #fff; letter-spacing: -0.5px; } +.site-header .logo .accent { color: var(--cmx-yellow); } +.site-header .logo:hover { text-decoration: none; } +.site-header nav { display: flex; gap: 18px; flex: 1; } +.site-header nav a { color: #fff; font-size: 14px; font-weight: 600; padding: 6px 0; border-bottom: 2px solid transparent; } +.site-header nav a:hover { border-bottom-color: var(--cmx-yellow); text-decoration: none; } +.site-header .user-tools { display: flex; gap: 16px; align-items: center; font-size: 14px; } +.site-header .user-tools a { color: #fff; } +.site-header .user-tools .badge { + background: var(--cmx-yellow); color: #000; font-weight: 700; font-size: 11px; + padding: 2px 6px; border-radius: 999px; margin-left: 4px; +} + +.flash-row { padding: 0; } +.flash { padding: 12px 16px; font-size: 14px; } +.flash.success { background: #e8f6f0; color: var(--cmx-success); } +.flash.danger { background: #fceceb; color: var(--cmx-danger); } +.flash.warning { background: #fff5e0; color: var(--cmx-warning); } +.flash.info { background: #e8f1fb; color: var(--cmx-blue); } + +/* ===== Buttons ===== */ +.btn { + display: inline-block; padding: 12px 22px; font-size: 14px; font-weight: 700; + border-radius: var(--cmx-radius); border: 2px solid transparent; cursor: pointer; + text-align: center; text-decoration: none; transition: filter 0.15s; + font-family: inherit; +} +.btn:hover { filter: brightness(0.95); text-decoration: none; } +.btn-primary { background: var(--cmx-yellow); color: #000; } +.btn-primary:hover { background: var(--cmx-yellow-dark); color: #000; } +.btn-secondary { background: var(--cmx-blue); color: #fff; } +.btn-outline { background: #fff; color: var(--cmx-blue); border-color: var(--cmx-blue); } +.btn-outline-light { background: transparent; color: #fff; border-color: #fff; } +.btn-link { background: none; color: var(--cmx-blue); border: none; padding: 0; } +.btn-sm { padding: 6px 12px; font-size: 13px; } +.btn-lg { padding: 16px 32px; font-size: 16px; } +.btn-block { display: block; width: 100%; } +.btn-danger { background: var(--cmx-danger); color: #fff; } +.btn-disabled, .btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ===== Hero ===== */ +.hero { + background: linear-gradient(135deg, var(--cmx-blue-darker), var(--cmx-blue)); + color: #fff; padding: 60px 16px; +} +.hero h1 { font-size: 44px; margin: 0 0 16px; font-weight: 900; line-height: 1.1; } +.hero p { font-size: 18px; opacity: 0.92; margin: 0 0 24px; max-width: 720px; } +.hero .hero-search { + background: #fff; border-radius: 8px; padding: 16px; margin-top: 24px; + display: flex; gap: 12px; flex-wrap: wrap; max-width: 760px; +} +.hero .hero-search input[type=text] { + flex: 1; min-width: 240px; padding: 14px 16px; font-size: 16px; + border: 1px solid var(--cmx-border); border-radius: var(--cmx-radius); color: #000; +} + +/* ===== Cards / grid ===== */ +.section { padding: 40px 0; } +.section h2 { font-size: 28px; margin: 0 0 24px; font-weight: 800; } +.section-soft { background: var(--cmx-bg-soft); } + +.grid { display: grid; gap: 20px; } +.grid-3 { grid-template-columns: repeat(3, 1fr); } +.grid-4 { grid-template-columns: repeat(4, 1fr); } +.grid-6 { grid-template-columns: repeat(6, 1fr); } +@media (max-width: 1024px) { .grid-4 { grid-template-columns: repeat(2, 1fr); } .grid-6 { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 640px) { .grid-3, .grid-4 { grid-template-columns: 1fr; } .grid-6 { grid-template-columns: repeat(2, 1fr); } } + +.vcard { + background: #fff; border: 1px solid var(--cmx-border); border-radius: var(--cmx-radius); + overflow: hidden; display: flex; flex-direction: column; + transition: box-shadow 0.15s; +} +.vcard:hover { box-shadow: var(--cmx-shadow); } +.vcard a { color: inherit; } +.vcard a:hover { text-decoration: none; } +.vcard .vcard-img { + aspect-ratio: 16/10; background: #f0f3f7; display: block; position: relative; + overflow: hidden; +} +.vcard .vcard-img img { width: 100%; height: 100%; object-fit: cover; } +.vcard .badge-strip { position: absolute; top: 10px; left: 10px; display: flex; gap: 6px; flex-wrap: wrap; } +.vcard .badge { + background: #fff; color: var(--cmx-blue-dark); font-size: 11px; font-weight: 700; + padding: 3px 8px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.3px; +} +.vcard .badge.cert { background: var(--cmx-blue); color: #fff; } +.vcard .badge.deal { background: var(--cmx-yellow); color: #000; } +.vcard .vcard-body { padding: 14px 16px; flex: 1; display: flex; flex-direction: column; } +.vcard .vcard-title { font-size: 16px; font-weight: 700; margin: 0 0 4px; } +.vcard .vcard-trim { font-size: 13px; color: var(--cmx-text-light); margin: 0 0 8px; } +.vcard .vcard-price { font-size: 22px; font-weight: 800; color: var(--cmx-blue-darker); margin: 8px 0 4px; } +.vcard .vcard-mileage { font-size: 13px; color: var(--cmx-text-light); } +.vcard .vcard-store { font-size: 12px; color: var(--cmx-text-light); margin-top: 8px; } +.vcard .vcard-actions { display: flex; gap: 6px; margin-top: 10px; } + +/* Vehicle detail */ +.vdetail { display: grid; grid-template-columns: 2fr 1fr; gap: 32px; margin-top: 28px; } +@media (max-width: 960px) { .vdetail { grid-template-columns: 1fr; } } +.vdetail-gallery { background: #f0f3f7; border-radius: var(--cmx-radius); overflow: hidden; } +.vdetail-gallery .main-img { aspect-ratio: 16/10; background: #f0f3f7; } +.vdetail-gallery .main-img img { width: 100%; height: 100%; object-fit: cover; } +.vdetail-gallery .thumbs { display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; padding: 4px; } +.vdetail-gallery .thumbs img { aspect-ratio: 1/1; object-fit: cover; cursor: pointer; } +.vdetail-summary h1 { font-size: 28px; margin: 0 0 4px; } +.vdetail-summary .price { font-size: 36px; font-weight: 800; color: var(--cmx-blue-darker); margin: 16px 0 8px; } +.vdetail-summary .meta { font-size: 14px; color: var(--cmx-text-light); } +.vdetail-summary .cta-stack > .btn { margin-bottom: 8px; } +.vdetail-summary .stock-info { font-size: 12px; color: var(--cmx-text-light); margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--cmx-border); } + +.spec-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 32px; } +.spec-grid dt { font-size: 13px; color: var(--cmx-text-light); } +.spec-grid dd { margin: 0 0 8px; font-weight: 600; font-size: 14px; } +.feature-pill { + display: inline-block; background: var(--cmx-bg-soft); color: var(--cmx-text); + padding: 6px 12px; border-radius: 999px; margin: 0 6px 6px 0; font-size: 13px; +} + +/* Search layout */ +.search-layout { display: grid; grid-template-columns: 260px 1fr; gap: 28px; align-items: start; } +@media (max-width: 960px) { .search-layout { grid-template-columns: 1fr; } } +.facets { background: #fff; padding: 16px; border: 1px solid var(--cmx-border); border-radius: var(--cmx-radius); position: sticky; top: 12px; } +.facet-section { padding: 8px 0; border-bottom: 1px solid var(--cmx-border); } +.facet-section:last-child { border-bottom: none; } +.facet-section h4 { font-size: 13px; margin: 0 0 8px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--cmx-text-light); } +.facet-section label { display: block; font-size: 14px; padding: 3px 0; } +.facet-section input[type=number], .facet-section input[type=text], .facet-section select { + width: 100%; padding: 6px 8px; border: 1px solid var(--cmx-border); border-radius: 4px; font-size: 13px; +} +.search-results .toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.search-results .toolbar select { padding: 8px 12px; border: 1px solid var(--cmx-border); border-radius: 4px; } +.pagination { display: flex; gap: 4px; justify-content: center; margin-top: 24px; } +.pagination a, .pagination span { + padding: 6px 12px; border: 1px solid var(--cmx-border); border-radius: 4px; + background: #fff; color: var(--cmx-blue); font-size: 13px; +} +.pagination a:hover { background: var(--cmx-bg-soft); text-decoration: none; } +.pagination .current { background: var(--cmx-blue); color: #fff; font-weight: 700; } + +/* Forms */ +.form-row { margin-bottom: 14px; } +.form-row label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; } +.form-row input[type=text], .form-row input[type=email], .form-row input[type=password], +.form-row input[type=number], .form-row select, .form-row textarea { + width: 100%; padding: 10px 12px; font-size: 14px; border: 1px solid var(--cmx-border); + border-radius: var(--cmx-radius); font-family: inherit; +} +.form-row textarea { min-height: 80px; resize: vertical; } +.form-row .help { font-size: 12px; color: var(--cmx-text-light); margin-top: 4px; } +.form-row .errors { color: var(--cmx-danger); font-size: 12px; margin-top: 4px; } +.form-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } + +/* Footer */ +.site-footer { + background: var(--cmx-blue-darker); color: #fff; padding: 40px 16px 20px; + margin-top: 80px; font-size: 14px; +} +.site-footer .footer-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; max-width: 1280px; margin: 0 auto; } +.site-footer h5 { font-size: 13px; text-transform: uppercase; letter-spacing: 1px; margin: 0 0 12px; color: var(--cmx-yellow); } +.site-footer a { color: #c7d1de; display: block; padding: 3px 0; } +.site-footer a:hover { color: #fff; text-decoration: none; } +.site-footer .footer-bottom { max-width: 1280px; margin: 32px auto 0; padding-top: 20px; border-top: 1px solid #2a4974; color: #aeb6c3; } +@media (max-width: 800px) { .site-footer .footer-grid { grid-template-columns: repeat(2, 1fr); } } + +/* Tables */ +table.data { + width: 100%; border-collapse: collapse; background: #fff; + border: 1px solid var(--cmx-border); border-radius: var(--cmx-radius); +} +table.data th, table.data td { padding: 10px 14px; text-align: left; font-size: 14px; border-bottom: 1px solid var(--cmx-border); } +table.data th { background: var(--cmx-bg-soft); font-weight: 700; font-size: 13px; } +table.data tr:last-child td { border-bottom: none; } + +/* Misc */ +.callout { background: #fff5cf; border-left: 4px solid var(--cmx-yellow); padding: 14px 16px; border-radius: 4px; } +.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin: 24px 0; } +@media (max-width: 800px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } } +.kpi { background: #fff; border: 1px solid var(--cmx-border); padding: 16px; border-radius: var(--cmx-radius); } +.kpi .v { font-size: 28px; font-weight: 800; color: var(--cmx-blue-darker); } +.kpi .l { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--cmx-text-light); } + +.breadcrumbs { font-size: 13px; color: var(--cmx-text-light); margin: 16px 0; } +.breadcrumbs a { color: var(--cmx-blue); } + +.empty-state { text-align: center; padding: 60px 20px; color: var(--cmx-text-light); } +.empty-state h2 { color: var(--cmx-text); margin: 0 0 8px; } diff --git a/sites/carmax/static/icons/.gitkeep b/sites/carmax/static/icons/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/carmax/static/js/.gitkeep b/sites/carmax/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/carmax/tasks.jsonl b/sites/carmax/tasks.jsonl new file mode 100644 index 00000000..3d685d65 --- /dev/null +++ b/sites/carmax/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name": "CarMax", "id": "CarMax--0", "ques": "Find any 2022 Honda Civic in the inventory and report its full title, price, and mileage.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_0.py", "judge_rubric": "FACT CHECKPOINTS: (1) trajectory MUST navigate to a 2022 Honda Civic entry page (URL like /vehicle/...2022-honda-civic...). (2) The answer MUST report that it is a Honda Civic AND its price ($15,300) AND its mileage (58,626 mi). FAIL if: no visit to a 2022 Civic detail page; answer empty; wrong/absent price or mileage."} +{"web_name": "CarMax", "id": "CarMax--1", "ques": "Search for a Toyota Tacoma TRD Off-Road in the inventory and report its store location, mileage, and asking price.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_1.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST navigate to the Toyota Tacoma TRD Off-Road detail page. (2) Answer MUST report its store location (Lynnwood, WA / CarMax Seattle Lynnwood), mileage (91,787 mi) and asking price ($15,000). FAIL if: no visit to that vehicle; any of the three fields wrong or missing."} +{"web_name": "CarMax", "id": "CarMax--2", "ques": "Filter the inventory for AWD SUVs under $25,000 sorted by lowest price. Report the year, make, model, trim and price of the cheapest one.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_2.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST use the inventory filters (AWD + SUV + price under $25,000) — trajectory reaches /cars with those constraints. (2) Answer MUST name the cheapest such vehicle: 2019 Kia Sportage LX at $10,500 (year/make/model/trim/price). FAIL if: names a non-cheapest or non-AWD/non-SUV vehicle; wrong price."} +{"web_name": "CarMax", "id": "CarMax--3", "ques": "Search the inventory for a Tesla Model 3 with under 50,000 miles. Then sort the results by lowest mileage and open the detail page of the lowest-mileage one. Report its price, mileage, exterior color, and the store it's at.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_3.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open a Tesla Model 3 with under 50,000 miles (lowest-mileage one). (2) Answer reports its price, mileage, exterior color and store. NOTE: the current inventory has NO Tesla Model 3 under 50,000 miles, so this task is UNSOLVABLE as written — mark success=false unless the environment is fixed (a sub-50k Tesla Model 3 seeded, or the constraint relaxed). FAIL if the reported car is not a Tesla Model 3 under 50k, or is a different make/model (e.g. a Toyota Tacoma surfaced by loose search)."} +{"web_name": "CarMax", "id": "CarMax--4", "ques": "Open the detail page for any 2022 Honda CR-V in inventory and report its horsepower, combined MPG, exterior color, and the store it is located at.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_4.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open a 2022 Honda CR-V detail page. (2) Answer MUST report its horsepower (190), combined MPG (29), exterior color (Crystal Black Pearl) and store (Houston Katy). FAIL if: no visit to a 2022 CR-V; any field wrong/missing."} +{"web_name": "CarMax", "id": "CarMax--5", "ques": "On the 2022 Honda Civic research page, list every available trim, then report both the RepairPal reliability rating and the average customer rating shown on that page.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_5.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST navigate to the 2022 Honda Civic research page (/research/honda/civic/2022). (2) Answer MUST list the available trim(s) (EX) AND report the RepairPal reliability rating (3.5) AND the average customer rating (4.5) shown on that page (the page shows the AVERAGE of that model-year's reviews, 4.5 — not the per-vehicle 4.0). FAIL if: not on the research page; missing the RepairPal rating or the customer rating; reports 4.0 instead of the page's 4.5."} +{"web_name": "CarMax", "id": "CarMax--6", "ques": "Add three vehicles to the comparison tool: a 2022 Honda Accord, a 2022 Toyota Camry, and a 2022 Nissan Altima. Then report which of the three has the most horsepower and which has the best combined MPG.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_6.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST add the 2022 Accord, Camry and Altima to the comparison tool and reach /compare showing all three. (2) Answer MUST correctly state the most horsepower = Toyota Camry (203 hp) and best combined MPG = Honda Accord (34 mpg). FAIL if: fewer than 3 cars compared; the HP/MPG winners are attributed wrongly."} +{"web_name": "CarMax", "id": "CarMax--7", "ques": "Get an instant offer to sell a 2018 Toyota Camry LE with 78,500 miles in good condition, ZIP 30303, no reported accidents, one previous owner. Report the dollar offer amount and the expiration date.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_7.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST complete the sell-my-car instant-offer form for a 2018 Toyota Camry LE, 78,500 mi, good condition (an appraisal is created). (2) Answer MUST report the offer amount **$4,850** AND the expiration date **2026-05-21**. FAIL if: no offer generated; offer amount is not $4,850; expiry is not 2026-05-21; either is absent."} +{"web_name": "CarMax", "id": "CarMax--8", "ques": "Go to the CarMax store locator and report (a) how many states have at least one CarMax store and (b) the street address of any CarMax store in California.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_8.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST navigate the store locator. (2) Answer MUST report the number of states with at least one store (12) AND the street address (or city) of a California store (e.g. 6101 Auto Center Dr, Buena Park). FAIL if: wrong state count; no valid CA store address/city."} +{"web_name": "CarMax", "id": "CarMax--9", "ques": "Create a new CarMax account with first name Test, last name Buyer, email new.buyer.benchmark@test.com, phone 4045550199, ZIP 30303, password Welcome2026. After signing in, get pre-qualified with $80,000 annual income, employed full-time, $500 max monthly payment, $2,000 down, 72-month term, good credit. Report the estimated APR shown on the result page.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_9.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST register the new account (new.buyer.benchmark@test.com) AND complete the pre-qualification form (80k income, full-time, $500 max, $2,000 down, 72 months, good credit). (2) Answer MUST report the estimated APR **7.99%** (the value the site computes for good credit / 72-month term). FAIL if: no account created; no pre-qual completed; APR absent or not 7.99%."} +{"web_name": "CarMax", "id": "CarMax--10", "ques": "Sign in as alice.j@test.com with password CarMax!2026. Reserve any 2022 Toyota Camry for 7 days, with the appointment date 2026-05-20. Then go to your reservations page and confirm the reservation is listed as active.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_10.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST sign in as alice and create a reservation for a 2022 Toyota Camry with appointment date 2026-05-20. (2) The reservation MUST appear as ACTIVE on the reservations page (and exist in the DB). FAIL if: not signed in as alice; no 2022 Camry reservation; not active; wrong appointment date."} +{"web_name": "CarMax", "id": "CarMax--11", "ques": "Sign in as bob.k@test.com with password CarMax!2026. Schedule an at-home test drive for any 2022 Ford F-150 on 2026-05-22 at 2:00 PM, leaving a note that says 'Please call gate buzzer 4B'. Confirm the test drive shows on your test drives page.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_11.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST sign in as bob.k and schedule an AT-HOME test drive for a 2022 Ford F-150 on 2026-05-22 at 2:00 PM with the note 'Please call gate buzzer 4B'. (2) The test drive MUST appear on the test-drives page (and exist in the DB with location_type=at_home, that date/time and note). FAIL if: not bob.k; wrong vehicle/date/time; not at-home; note missing."} +{"web_name": "CarMax", "id": "CarMax--12", "ques": "Find the answer in the CarMax FAQ to the question 'How long is my appraisal offer good for?' and report the number of days the offer is valid.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_12.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST navigate to the FAQ. (2) Answer MUST report that a CarMax appraisal offer is valid for 7 days. FAIL if: not on the FAQ; wrong number of days."} +{"web_name": "CarMax", "id": "CarMax--13", "ques": "Sign in as alice.j@test.com with password CarMax!2026. You currently have two saved cars from different makes; remove the saved vehicle with the higher mileage. Then report the year, make, model, and store location of the remaining saved vehicle.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_13.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST sign in as alice and remove the HIGHER-mileage saved car (the 2020 Honda Civic, 69,000 mi), leaving the lower-mileage one (2021 Honda CR-V, 57,824 mi). (2) Answer MUST report the remaining car as the **2021 Honda CR-V** and its store **CarMax Atlanta Southlake, Morrow, GA**. NOTE: the task text says 'different makes' but both saved cars are Honda — judge on the remove-higher-mileage action, not the wording. FAIL if: wrong car removed; answer names the removed 2020 Civic; store wrong/missing."} +{"web_name": "CarMax", "id": "CarMax--14", "ques": "Sign in as carol.l@test.com with password CarMax!2026. You have one active sell-my-car appraisal offer; identify which vehicle it is for and the offer amount. Then start checkout on any 2022 Honda CR-V, apply that appraisal as a trade-in, choose CarMax Auto Finance with a 60-month term, $3,000 down, 6.49% APR, no MaxCare, and place the order. Report the final order number and total amount.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_14.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST sign in as carol.l and place an order for a 2022 Honda CR-V, applying her active appraisal (2019 Nissan Altima, $14,750) as a trade-in, CarMax Auto Finance, 60-month term, $3,000 down, 6.49% APR, no MaxCare. (2) The order MUST exist in the DB with the trade-in applied, no MaxCare, those finance terms; her appraisal becomes redeemed. (3) Answer MUST report a valid order number (format CMX-YYYY-NNNNNN) AND the total amount **$5,431.00** (subtotal $18,800 minus the $14,750 trade-in plus tax/fees — NOT $18,800). FAIL if: no order; MaxCare included; wrong terms; trade-in not applied; total is not ~$5,431 (e.g. reports the $18,800 subtotal); order number absent."} +{"web_name": "CarMax", "id": "CarMax--15", "ques": "Find the cheapest 2023 vehicle currently in stock. Open its detail page, then visit the store that has it. Report (a) the vehicle's year, make, model, and price, (b) the store name and city, and (c) whether that store offers home delivery.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_15.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST find the cheapest 2023 vehicle (2023 Hyundai Elantra SEL, $14,400), open its detail, and visit its store. (2) Answer MUST report (a) the vehicle year/make/model/price, (b) the store name/city (CarMax Seattle Lynnwood, Lynnwood), (c) whether that store offers home delivery (yes). FAIL if: not the cheapest 2023; store or home-delivery info wrong/missing."} +{"web_name": "CarMax", "id": "CarMax--16", "ques": "Open the article titled 'Getting Pre-Qualified: Shop with Personalized Financing Terms'. According to the article, what is the key difference between pre-qualification and pre-approval at CarMax? Answer in one sentence.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_16.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST open the article 'Getting Pre-Qualified: Shop with Personalized Financing Terms'. (2) Answer MUST capture the article's stated key difference: a **pre-qualification uses a SOFT credit inquiry and does NOT impact your credit score** (and lets you shop with personalized terms), whereas committing to final terms requires a credit application that is a **HARD inquiry**. Judge the answer against the article body, not outside knowledge. NOTE: the article frames the contrast as pre-qualification vs the final credit application (hard inquiry) — it does not use the word 'pre-approval'; accept an answer that captures the soft-vs-hard-inquiry / no-credit-impact distinction. FAIL if: article not opened; answer misses the soft-inquiry / no-credit-impact point or contradicts the article."} +{"web_name": "CarMax", "id": "CarMax--17", "ques": "Sign in as dan.m@test.com with password CarMax!2026. Find your account's order history and report (a) the order number, (b) the vehicle year/make/model, (c) the total amount, (d) whether MaxCare coverage was included, and (e) the scheduled pickup date.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_17.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST sign in as dan.m and open order history. (2) Answer MUST report (a) order number **CMX-2026-000001**, (b) vehicle **2023 Toyota Tacoma TRD Sport**, (c) total **$26,686** as shown on the page (the page rounds the exact $26,685.50 to $26,686 — accept either), (d) MaxCare **included (Gold)**, (e) pickup date **2026-05-17**. FAIL if: not dan.m; any of the five fields wrong or missing."} +{"web_name": "CarMax", "id": "CarMax--18", "ques": "Visit the CarMax used car value page for the 2020 Honda Accord. Report (a) the CarMax average price across current inventory, (b) the price range (lowest to highest), and (c) the number of 2020 Honda Accords currently in stock.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_18.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST navigate to the 2020 Honda Accord used-car-value page. (2) Answer MUST report (a) the average price ($13,000), (b) the price range low-to-high ($13,000 to $13,000 — both in stock are identically priced), (c) the count in stock (2). FAIL if: not on the value page; wrong average/range/count."} +{"web_name": "CarMax", "id": "CarMax--19", "ques": "Open the MaxCare extended service plans page. Compare the Silver, Gold, and Platinum tiers. Report (a) the one-time price of each tier, (b) the price difference between Gold and Silver, and (c) the maximum coverage period (months / miles) of the Platinum plan.", "web": "http://localhost:40015/", "upstream_url": "https://www.carmax.com/", "verifier_path": "sites/carmax/verify/verify_19.py", "judge_rubric": "FACT CHECKPOINTS: (1) MUST navigate to the MaxCare service-plans page. (2) Answer MUST report the one-time price of each tier (Silver $1,495, Gold $1,895, Platinum $2,395), the Gold-minus-Silver difference ($400), and the Platinum maximum coverage (60 months / 100,000 miles). FAIL if: not on the MaxCare page; any price/difference/coverage figure wrong or missing."} diff --git a/sites/carmax/templates/.gitkeep b/sites/carmax/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/carmax/templates/404.html b/sites/carmax/templates/404.html new file mode 100644 index 00000000..0dab778f --- /dev/null +++ b/sites/carmax/templates/404.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Page not found | CarMax{% endblock %} +{% block content %} +
+

That page is parked off-lot.

+

The page you wanted doesn't exist. Let's get you back on the road.

+ Back to CarMax +
+{% endblock %} diff --git a/sites/carmax/templates/500.html b/sites/carmax/templates/500.html new file mode 100644 index 00000000..9fc03f35 --- /dev/null +++ b/sites/carmax/templates/500.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Something went wrong | CarMax{% endblock %} +{% block content %} +
+

Our pit crew is on it.

+

Something went wrong. Please try again in a moment.

+ Back to CarMax +
+{% endblock %} diff --git a/sites/carmax/templates/_macros.html b/sites/carmax/templates/_macros.html new file mode 100644 index 00000000..35c57d59 --- /dev/null +++ b/sites/carmax/templates/_macros.html @@ -0,0 +1,34 @@ +{% macro vcard(v) %} + +{% endmacro %} diff --git a/sites/carmax/templates/account.html b/sites/carmax/templates/account.html new file mode 100644 index 00000000..00a8e074 --- /dev/null +++ b/sites/carmax/templates/account.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}My CarMax account{% endblock %} +{% block content %} +
+

Hi, {{ current_user.first_name or 'CarMax customer' }}

+

{{ current_user.email }}

+ + + + {% if current_user.pre_qual_active %} +
+ You're pre-qualified. + Up to {{ current_user.pre_qual_monthly_max|money }}/mo at {{ current_user.pre_qual_apr }}% APR + for {{ current_user.pre_qual_term_months }} months. Expires {{ current_user.pre_qual_expires_at }}. + View matching cars +
+ {% else %} +
+ Shop with your budget in mind. + Get pre-qualified in 5 minutes - no impact to your credit. +
+ {% endif %} + + + + {% if recent_saved %} +

Recently saved

+ {% from "_macros.html" import vcard with context %} +
{% for s in recent_saved %}{{ vcard(s.vehicle) }}{% endfor %}
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/account_appraisals.html b/sites/carmax/templates/account_appraisals.html new file mode 100644 index 00000000..74bb2b5e --- /dev/null +++ b/sites/carmax/templates/account_appraisals.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}My offers | CarMax{% endblock %} +{% block content %} +
+

My offers

+ {% if rows %} + + + {% for a in rows %} + + + + + + + + + {% endfor %} +
VehicleMileageConditionOfferValid untilStatus
{{ a.vehicle_label }}{{ a.mileage|miles }}{{ a.condition|capitalize }}{{ a.offer_amount|money }}{{ a.offer_valid_until }}{{ a.status }}
+ {% else %} +

No offers yet.

+ Get an offer
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/account_change_password.html b/sites/carmax/templates/account_change_password.html new file mode 100644 index 00000000..d1ae08b0 --- /dev/null +++ b/sites/carmax/templates/account_change_password.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Change password | CarMax{% endblock %} +{% block content %} +
+

Change password

+
+ +
{{ form.current_password() }}
+
{{ form.new_password() }}
+
{{ form.confirm() }}
+ +
+
+{% endblock %} diff --git a/sites/carmax/templates/account_edit.html b/sites/carmax/templates/account_edit.html new file mode 100644 index 00000000..1f62bea4 --- /dev/null +++ b/sites/carmax/templates/account_edit.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Edit profile | CarMax{% endblock %} +{% block content %} +
+

Edit profile

+
+ +
+
{{ form.first_name() }}
+
{{ form.last_name() }}
+
+
{{ form.phone() }}
+
{{ form.address_line1() }}
+
{{ form.address_line2() }}
+
+
{{ form.city() }}
+
{{ form.state() }}
+
+
{{ form.zip_code() }}
+ + Cancel +
+
+{% endblock %} diff --git a/sites/carmax/templates/account_orders.html b/sites/carmax/templates/account_orders.html new file mode 100644 index 00000000..859f380e --- /dev/null +++ b/sites/carmax/templates/account_orders.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}My orders | CarMax{% endblock %} +{% block content %} +
+

My orders

+ {% if orders %} + + + {% for o in orders %} + + + + + + + + + {% endfor %} +
Order #VehicleTotalStatusDate
{{ o.order_number }}{{ o.vehicle.short_title }}{{ o.total|money }}{{ o.status }}{{ o.created_at.date() }}View
+ {% else %} +

No orders yet.

+ Shop cars
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/account_reservations.html b/sites/carmax/templates/account_reservations.html new file mode 100644 index 00000000..94cac781 --- /dev/null +++ b/sites/carmax/templates/account_reservations.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}My reservations | CarMax{% endblock %} +{% block content %} +
+

My reservations

+ {% if rows %} + + + {% for r in rows %} + + + + + + + + + {% endfor %} +
VehicleStoreAppointmentExpiresStatus
{{ r.vehicle.short_title }}{{ r.store.location_label }}{{ r.appointment_date }}{{ r.expires_at }}{{ r.status }} + {% if r.status == 'active' %} +
+ + +
+ {% endif %} +
+ {% else %} +

No reservations yet.

+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/account_test_drives.html b/sites/carmax/templates/account_test_drives.html new file mode 100644 index 00000000..75e4348f --- /dev/null +++ b/sites/carmax/templates/account_test_drives.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}My test drives | CarMax{% endblock %} +{% block content %} +
+

My test drives

+ {% if rows %} + + + {% for r in rows %} + + + + + + + + + {% endfor %} +
VehicleStoreWhenTypeStatus
{{ r.vehicle.short_title }}{{ r.store.location_label }}{{ r.scheduled_date }} {{ r.scheduled_time }}{{ 'At store' if r.location_type == 'in_store' else 'At home' }}{{ r.status }} + {% if r.status == 'confirmed' %} +
+ + +
+ {% endif %} +
+ {% else %} +

No test drives scheduled.

+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/article_detail.html b/sites/carmax/templates/article_detail.html new file mode 100644 index 00000000..c08537bf --- /dev/null +++ b/sites/carmax/templates/article_detail.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}{{ article.title }} | CarMax{% endblock %} +{% block content %} +
+ +

{{ article.title }}

+

By {{ article.author }} · {{ article.published_at }}

+ {% if article.hero_image %} + {{ article.title }} + {% endif %} +
{{ article.body }}
+ {% if related %} +

Related articles

+
+ {% for r in related %} + +
+

{{ r.title }}

+
+ {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/articles_index.html b/sites/carmax/templates/articles_index.html new file mode 100644 index 00000000..c049dc02 --- /dev/null +++ b/sites/carmax/templates/articles_index.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Articles | CarMax{% endblock %} +{% block content %} +
+

Articles & advice

+
+ All + {% for cat, n in cats %} + {{ cat|capitalize }} ({{ n }}) + {% endfor %} +
+ +
+{% endblock %} diff --git a/sites/carmax/templates/base.html b/sites/carmax/templates/base.html new file mode 100644 index 00000000..0bd2e559 --- /dev/null +++ b/sites/carmax/templates/base.html @@ -0,0 +1,96 @@ + + + + + + {% block title %}CarMax - Shop for used cars, then buy online or at a store{% endblock %} + + + + {% block head_extra %}{% endblock %} + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, msg in messages %} +
+
{{ msg }}
+
+ {% endfor %} + {% endwith %} +
+ +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/sites/carmax/templates/checkout.html b/sites/carmax/templates/checkout.html new file mode 100644 index 00000000..dcbbe1aa --- /dev/null +++ b/sites/carmax/templates/checkout.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}Checkout | {{ vehicle.short_title }} | CarMax{% endblock %} +{% block content %} +
+

Buy this {{ vehicle.short_title }} online

+
+
+
+ + +

Receive your car

+
{{ form.pickup_or_delivery() }}
+
{{ form.delivery_address() }}
+ +

Payment

+
{{ form.payment_method() }}
+
+
{{ form.apr() }}
+
{{ form.term_months() }}
+
+
+
{{ form.down_payment() }}
+
{{ form.card_last4() }}{% for e in form.card_last4.errors %}
{{ e }}
{% endfor %}
+
+ + {% if trade_options %} +

Trade-in

+
+ + +
+ {% endif %} + +

Add MaxCare

+
{{ form.maxcare_plan() }}
+ + +
+
+ + +
+
+{% endblock %} diff --git a/sites/carmax/templates/compare.html b/sites/carmax/templates/compare.html new file mode 100644 index 00000000..185c31b3 --- /dev/null +++ b/sites/carmax/templates/compare.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Compare vehicles | CarMax{% endblock %} +{% block content %} +
+

Compare vehicles

+ {% if items %} +

{{ items|length }} of 4 vehicles. Find more cars to compare.

+ + + + {% for v in items %} + + {% endfor %} + + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} + {% for v in items %}{% endfor %} +
+ +
+ {{ v.short_title }} +
+
+ + +
+
Price{{ v.price|money }}
Mileage{{ v.mileage|miles }}
Trim{{ v.trim }}
Body{{ v.body_style }}
Engine{{ v.engine_text }}
Horsepower{{ v.horsepower }} hp
Transmission{{ v.transmission }}
Drive type{{ v.drive_type }}
Fuel type{{ v.fuel_type }}
Combined MPG{{ v.mpg_combined }}
Exterior color{{ v.exterior_color }}
Seating{{ v.seating_capacity }}
Store{{ v.store.location_label if v.store else '-' }}
Transfer fee{{ v.transfer_fee|money if v.transfer_fee else 'Free' }}
+
+ + +
+ {% else %} +

Nothing to compare yet.

+

Add up to 4 vehicles by tapping "+ Compare" on any car.

+ Browse inventory
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/faq.html b/sites/carmax/templates/faq.html new file mode 100644 index 00000000..5083a286 --- /dev/null +++ b/sites/carmax/templates/faq.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% block title %}FAQ | CarMax{% endblock %} +{% block content %} +
+

CarMax FAQ

+
+ {% for cat, items in categories.items() %} +
+

{{ cat|replace('-', ' ')|capitalize }}

+
    + {% for slug, q, a in items %} +
  • {{ q }}
  • + {% endfor %} +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/carmax/templates/faq_category.html b/sites/carmax/templates/faq_category.html new file mode 100644 index 00000000..3ca136e9 --- /dev/null +++ b/sites/carmax/templates/faq_category.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}{{ category|replace('-', ' ')|capitalize }} FAQ | CarMax{% endblock %} +{% block content %} +
+ +

{{ category|replace('-', ' ')|capitalize }} questions

+ {% for slug, q, a in items %} +
+ {{ q }} +

{{ a }}

+ Permalink +
+ {% endfor %} +
+{% endblock %} diff --git a/sites/carmax/templates/faq_detail.html b/sites/carmax/templates/faq_detail.html new file mode 100644 index 00000000..544bdcb4 --- /dev/null +++ b/sites/carmax/templates/faq_detail.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}{{ question }} | CarMax FAQ{% endblock %} +{% block content %} +
+ +

{{ question }}

+

{{ answer }}

+
+{% endblock %} diff --git a/sites/carmax/templates/financing.html b/sites/carmax/templates/financing.html new file mode 100644 index 00000000..f3da188b --- /dev/null +++ b/sites/carmax/templates/financing.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}CarMax Financing | Pre-qualify and shop with personalized terms{% endblock %} +{% block content %} +
+
+

Shop with your budget in mind.

+

Get pre-qualified online and see personalized monthly payments on every car. No impact to your credit.

+

Get pre-qualified

+
+
+
+
+
+

Pre-qualification, not pre-approval

+

A soft credit inquiry only - no hit to your score. You get personalized monthly payment terms valid for 30 days.

+
+
+

CarMax Auto Finance

+

We finance customers across credit profiles, including first-time buyers. Decisions are usually available within 5 minutes.

+
+
+

Bring your own lender

+

Already have financing through your bank or credit union? No problem - we accept cash, external financing, or CarMax Auto Finance.

+
+
+
+{% endblock %} diff --git a/sites/carmax/templates/index.html b/sites/carmax/templates/index.html new file mode 100644 index 00000000..964f3855 --- /dev/null +++ b/sites/carmax/templates/index.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard with context %} +{% block content %} +
+
+

Shop for used cars. Then buy online or at a store.

+

Browse our nationwide inventory of CarMax Certified vehicles, get pre-qualified + with no impact to your credit, and pick the path that works for you.

+ +
+
+ +
+
+

Shop by body style

+
+ {% for b, n in body_styles %} + +
{{ b }}
+
{{ n }} cars
+
+ {% endfor %} +
+
+
+ +{% if featured %} +
+
+

Featured CarMax Certified vehicles

+
+ {% for v in featured %}{{ vcard(v) }}{% endfor %} +
+
+
+{% endif %} + +
+
+

Shop by make

+
+ {% for make, slug, n in popular_makes %} + +
{{ make }}
+
{{ n }} in stock
+
+ {% endfor %} +
+
+
+ +{% if new_arrivals %} +
+
+

New arrivals this week

+
+ {% for v in new_arrivals %}{{ vcard(v) }}{% endfor %} +
+
+
+{% endif %} + +
+
+
+
+

Sell us your car

+

Real, upfront offer in under 2 minutes. Good for 7 days.

+ Get my offer +
+
+

Pre-qualify in 5 minutes

+

Shop with personalized monthly payments. No impact to credit.

+ Get pre-qualified +
+
+

MaxCare extended warranty

+

Plans up to 60 months / 100,000 miles. 24/7 roadside.

+ See plans +
+
+
+
+ +{% if article_strip %} +
+
+

Research & advice

+
+ {% for a in article_strip %} + +
+ {{ a.title }} +
+
+

{{ a.title }}

+

{{ a.summary }}

+
+
+ {% endfor %} +
+
+
+{% endif %} +{% endblock %} diff --git a/sites/carmax/templates/login.html b/sites/carmax/templates/login.html new file mode 100644 index 00000000..cf3c96bf --- /dev/null +++ b/sites/carmax/templates/login.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}Sign in | CarMax{% endblock %} +{% block content %} +
+

Sign in to CarMax

+
+ + +
{{ form.email(class="") }}{% for e in form.email.errors %}
{{ e }}
{% endfor %}
+
{{ form.password() }}{% for e in form.password.errors %}
{{ e }}
{% endfor %}
+
+ +
+

New to CarMax? Create an account

+
+{% endblock %} diff --git a/sites/carmax/templates/maxcare.html b/sites/carmax/templates/maxcare.html new file mode 100644 index 00000000..20addf84 --- /dev/null +++ b/sites/carmax/templates/maxcare.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}MaxCare extended service plans | CarMax{% endblock %} +{% block content %} +
+

MaxCare extended service plans

+

Optional coverage that picks up where the limited warranty leaves off.

+
+ {% for tier, label in labels.items() %} +
+

{{ label }}

+
{{ prices[tier]|money }}
+
one-time
+
    +
  • Hassle-free repairs (deductible per visit)
  • +
  • 24/7/365 emergency roadside
  • +
  • Rental reimbursement up to $40/day
  • +
  • Nationwide US & Canada coverage
  • +
  • Cancel any time
  • +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/carmax/templates/order_confirmation.html b/sites/carmax/templates/order_confirmation.html new file mode 100644 index 00000000..24d7ccca --- /dev/null +++ b/sites/carmax/templates/order_confirmation.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Order {{ order.order_number }} | CarMax{% endblock %} +{% block content %} +
+
+

Order placed!

+

Confirmation number: {{ order.order_number }}

+
+ +

Your car

+
+ {{ order.vehicle.title }}
+ {{ order.vehicle.mileage|miles }} · {{ order.vehicle.exterior_color }}
+ Pickup at: {{ order.store.name }} - {{ order.store.location_label }}
+ {% if order.pickup_or_delivery == 'home_delivery' %} + Home delivery to: {{ order.delivery_address }}
+ {% endif %} + Estimated pickup/delivery: {{ order.pickup_date }} +
+ +

Totals

+ + + {% if order.transfer_fee %}{% endif %} + + + + {% if order.maxcare_plan %}{% endif %} + {% if order.trade_in_value %}{% endif %} + +
Vehicle price{{ order.subtotal|money }}
Transfer fee{{ order.transfer_fee|money }}
Sales tax{{ order.tax|money }}
Title fee{{ order.title_fee|money }}
Registration{{ order.registration_fee|money }}
MaxCare ({{ order.maxcare_plan|capitalize }}){{ order.maxcare_price|money }}
Trade-in-{{ order.trade_in_value|money }}
Total{{ order.total|money }}
+ + {% if order.payment_method != 'cash' %} +

Financing

+

{{ order.payment_term_months }} months at {{ order.payment_apr }}% APR. + {{ order.monthly_payment|money }}/mo after {{ order.down_payment|money }} down.

+ {% endif %} + +

+ View all orders + Keep browsing +

+
+{% endblock %} diff --git a/sites/carmax/templates/pre_qual_form.html b/sites/carmax/templates/pre_qual_form.html new file mode 100644 index 00000000..7c930541 --- /dev/null +++ b/sites/carmax/templates/pre_qual_form.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Get pre-qualified | CarMax{% endblock %} +{% block content %} +
+

Get pre-qualified

+

See personalized monthly payment terms - no impact to your credit. Takes about 5 minutes. Good for 30 days.

+
+ +
{{ form.annual_income() }}{% for e in form.annual_income.errors %}
{{ e }}
{% endfor %}
+
{{ form.employment_status() }}
+
+
{{ form.monthly_payment_max() }}
+
{{ form.down_payment() }}
+
+
+
{{ form.term_months() }}
+
{{ form.credit_tier() }}
+
+ +
+

+ Pre-qualification uses a soft credit inquiry and does not impact your credit score. + Final financing terms require a credit application. +

+
+{% endblock %} diff --git a/sites/carmax/templates/pre_qual_result.html b/sites/carmax/templates/pre_qual_result.html new file mode 100644 index 00000000..8d37fd47 --- /dev/null +++ b/sites/carmax/templates/pre_qual_result.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard with context %} +{% block title %}You're pre-qualified | CarMax{% endblock %} +{% block content %} +
+
+

You're pre-qualified.

+

+ Up to {{ max_principal|money }} vehicle price at + {{ current_user.pre_qual_apr }}% APR for + {{ current_user.pre_qual_term_months }} months, + with {{ current_user.pre_qual_down_payment|money }} down. + Good through {{ current_user.pre_qual_expires_at }}. +

+
+

Vehicles within your budget

+
{% for v in affordable %}{{ vcard(v) }}{% endfor %}
+
+{% endblock %} diff --git a/sites/carmax/templates/register.html b/sites/carmax/templates/register.html new file mode 100644 index 00000000..ac7aafc0 --- /dev/null +++ b/sites/carmax/templates/register.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Create an account | CarMax{% endblock %} +{% block content %} +
+

Create your CarMax account

+

Save cars, get pre-qualified, and reserve vehicles online.

+
+ +
+
{{ form.first_name() }}{% for e in form.first_name.errors %}
{{ e }}
{% endfor %}
+
{{ form.last_name() }}{% for e in form.last_name.errors %}
{{ e }}
{% endfor %}
+
+
{{ form.email() }}{% for e in form.email.errors %}
{{ e }}
{% endfor %}
+
+
{{ form.phone() }}
+
{{ form.zip_code() }}
+
+
{{ form.password() }}{% for e in form.password.errors %}
{{ e }}
{% endfor %}
At least 8 characters.
+
{{ form.confirm() }}{% for e in form.confirm.errors %}
{{ e }}
{% endfor %}
+ +
+

Already have an account? Sign in

+
+{% endblock %} diff --git a/sites/carmax/templates/research_index.html b/sites/carmax/templates/research_index.html new file mode 100644 index 00000000..f6757597 --- /dev/null +++ b/sites/carmax/templates/research_index.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Car research & advice | CarMax{% endblock %} +{% block content %} +
+

Car research & advice

+

Reviews, ratings, specs, and FAQs to help you find your next car.

+

Most-researched models

+
+ {% for make, mslug, model, modelslug, n in popular %} + +
{{ make }} {{ model }}
+
{{ n }} in stock
+
+ {% endfor %} +
+

All makes

+
+ {% for make, slug, n in makes %} + +
{{ make }}
{{ n }}
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/carmax/templates/research_make.html b/sites/carmax/templates/research_make.html new file mode 100644 index 00000000..1ea3b4cc --- /dev/null +++ b/sites/carmax/templates/research_make.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}Used {{ make_name }} | CarMax research{% endblock %} +{% block content %} +
+ +

{{ make_name }} research

+
+ {% for make, mslug, model, modelslug, n in models %} + +
{{ model }}
+
{{ n }} in stock - read review
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/carmax/templates/research_model.html b/sites/carmax/templates/research_model.html new file mode 100644 index 00000000..132c32b5 --- /dev/null +++ b/sites/carmax/templates/research_model.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}{{ make }} {{ model }} review | CarMax{% endblock %} +{% block content %} +
+ +

{{ make }} {{ model }} - generations & year-by-year

+

Browse a model year for full specs, trims, and reviews.

+
+ {% for y, n in years %} + +
{{ y }}
{{ n }} in stock
+
+ {% endfor %} +
+

+ Shop {{ make }} {{ model }} inventory +

+
+{% endblock %} diff --git a/sites/carmax/templates/research_model_year.html b/sites/carmax/templates/research_model_year.html new file mode 100644 index 00000000..a4016c23 --- /dev/null +++ b/sites/carmax/templates/research_model_year.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard with context %} +{% block title %}{{ year }} {{ make }} {{ model }} review, photos & specs | CarMax{% endblock %} +{% block content %} +
+ +

{{ year }} {{ make }} {{ model }} review

+
+
{{ '%.1f'|format(avg_rating) }}
Customer rating ({{ review_count }})
+
{{ '%.1f'|format(sample.repairpal_rating) }}
RepairPal reliability
+
{{ sample.mpg_combined }}
Combined MPG
+
{{ avg_price|money }}
CarMax avg ({{ min_price|money }} - {{ max_price|money }})
+
+ +

Available trims for {{ year }}

+
    + {% for trim_name, trim_slug in trims %} +
  • {{ trim_name }}
  • + {% endfor %} +
+ +

Key specs (from sample {{ sample.trim }})

+
+
Engine
{{ sample.engine_text }}
+
Horsepower
{{ sample.horsepower }} hp
+
Drive type
{{ sample.drive_type }}
+
Transmission
{{ sample.transmission }}
+
Fuel type
{{ sample.fuel_type }}
+
EPA MPG
{{ sample.mpg_city }} city / {{ sample.mpg_highway }} hwy
+
Seating capacity
{{ sample.seating_capacity }}
+
Body style
{{ sample.body_style }}
+
+ +

Available {{ year }} {{ model }} inventory

+
{% for v in vehicles[:6] %}{{ vcard(v) }}{% endfor %}
+ + {% if reviews %} +

Customer reviews

+ {% for r in reviews %} +
+ {{ r.title }} {{ r.rating|star_row }} +

{{ r.body }}

+
{{ r.reviewer_name }} - {{ r.location }}
+
+ {% endfor %} +

All reviews

+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/reserve.html b/sites/carmax/templates/reserve.html new file mode 100644 index 00000000..2e6166f2 --- /dev/null +++ b/sites/carmax/templates/reserve.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Reserve {{ vehicle.short_title }} | CarMax{% endblock %} +{% block content %} +
+

Reserve this {{ vehicle.short_title }}

+

Hold the vehicle for up to 7 days while you arrange financing or paperwork. No obligation to buy.

+
+ {{ vehicle.title }}
+ {{ vehicle.headline_price }} - {{ vehicle.mileage|miles }} - {{ vehicle.store.location_label if vehicle.store else '' }} +
+
+ +
{{ form.appointment_date() }}
Leave blank for the next available slot.
+ +
+
+{% endblock %} diff --git a/sites/carmax/templates/reviews.html b/sites/carmax/templates/reviews.html new file mode 100644 index 00000000..cdb46924 --- /dev/null +++ b/sites/carmax/templates/reviews.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}{{ year }} {{ make }} {{ model }} reviews | CarMax{% endblock %} +{% block content %} +
+

{{ year }} {{ make }} {{ model }} reviews

+
+
{{ '%.1f'|format(avg_rating) }} / 5
+
{{ reviews|length }} review{{ 's' if reviews|length != 1 else '' }}
+
+

Reviews

+ {% for r in reviews %} +
+ {{ r.title }} + {{ r.rating|star_row }} +

{{ r.body }}

+
{{ r.reviewer_name }}{% if r.location %} - {{ r.location }}{% endif %}
+
+ {% else %} +

Be the first to review the {{ year }} {{ model }}.

+ {% endfor %} + +

Leave a review

+
+ +
{{ form.rating() }}
+
{{ form.title() }}
+
{{ form.body() }}
+
{{ form.location() }}
+ +
+
+{% endblock %} diff --git a/sites/carmax/templates/saved.html b/sites/carmax/templates/saved.html new file mode 100644 index 00000000..862fb2ab --- /dev/null +++ b/sites/carmax/templates/saved.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard with context %} +{% block title %}Saved cars | CarMax{% endblock %} +{% block content %} +
+

Saved cars

+ {% if rows %} +
{% for s in rows %}{{ vcard(s.vehicle) }}{% endfor %}
+ {% else %} +

No saved cars yet.

+

Tap the heart on any car to save it for later.

+ Browse inventory
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/search.html b/sites/carmax/templates/search.html new file mode 100644 index 00000000..f0da5ad9 --- /dev/null +++ b/sites/carmax/templates/search.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard with context %} +{% block title %}{{ scope_label }} | CarMax{% endblock %} +{% block content %} +
+

{{ scope_label }}

+

{{ total }} vehicles matched{% if query %} for "{{ query }}"{% endif %}

+ +
+ + +
+
+
Showing {{ items|length }} of {{ total }}
+
+ {% for k, v in filters.items() if v %}{% endfor %} + {% if query %}{% endif %} + Sort: + +
+
+ + {% if items %} +
+ {% for v in items %}{{ vcard(v) }}{% endfor %} +
+ {% else %} +
+

No vehicles match your search

+

Try widening your year range or removing a filter.

+ Reset filters +
+ {% endif %} + + {% if pages > 1 %} + + {% endif %} +
+
+
+{% endblock %} diff --git a/sites/carmax/templates/sell_my_car.html b/sites/carmax/templates/sell_my_car.html new file mode 100644 index 00000000..b505f588 --- /dev/null +++ b/sites/carmax/templates/sell_my_car.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}Sell My Car - Get an Instant Offer Online | CarMax{% endblock %} +{% block content %} +
+
+

Sell us your car. Get an instant offer.

+

Real, upfront offer in under 2 minutes. Good for 7 days. We'll buy your car even if you don't buy ours.

+
+
+
+
+ +

Tell us about your car

+
+
{{ form.year() }}{% for e in form.year.errors %}
{{ e }}
{% endfor %}
+
{{ form.mileage() }}{% for e in form.mileage.errors %}
{{ e }}
{% endfor %}
+
+
+
{{ form.make() }}{% for e in form.make.errors %}
{{ e }}
{% endfor %}
+
{{ form.model() }}{% for e in form.model.errors %}
{{ e }}
{% endfor %}
+
+
+
{{ form.trim() }}
+
{{ form.exterior_color() }}
+
+
{{ form.condition() }}
+
+
{{ form.license_plate() }}
+
{{ form.license_state() }}
+
+
+
{{ form.vin() }}
+
{{ form.owner_count() }}
+
+
{{ form.zip_code() }}{% for e in form.zip_code.errors %}
{{ e }}
{% endfor %}
+
+

Contact info

+
+
{{ form.contact_email() }}
+
{{ form.contact_phone() }}
+
+ +
+
+{% endblock %} diff --git a/sites/carmax/templates/sell_offer.html b/sites/carmax/templates/sell_offer.html new file mode 100644 index 00000000..886766d5 --- /dev/null +++ b/sites/carmax/templates/sell_offer.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Your CarMax offer | {{ appraisal.vehicle_label }}{% endblock %} +{% block content %} +
+
+

Your offer for the {{ appraisal.vehicle_label }}

+
{{ appraisal.offer_amount|money }}
+

Good through {{ appraisal.offer_valid_until }}

+
+ +
+
{{ appraisal.year }} {{ appraisal.make }}
Year / make
+
{{ appraisal.model }} {{ appraisal.trim }}
Model / trim
+
{{ appraisal.mileage|miles }}
Mileage
+
{{ appraisal.condition|capitalize }}
Condition
+
+ +
+

Next step: schedule a visit at any CarMax store for a quick verification. Bring the car, the title, and your ID.

+ {% if current_user.is_authenticated %} +
+ + +
+ {% else %} + Sign in to redeem + {% endif %} + Apply offer as trade-in toward a CarMax car +
+
+{% endblock %} diff --git a/sites/carmax/templates/store_detail.html b/sites/carmax/templates/store_detail.html new file mode 100644 index 00000000..129d0af7 --- /dev/null +++ b/sites/carmax/templates/store_detail.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard with context %} +{% block title %}{{ store.name }} | CarMax{% endblock %} +{% block content %} +
+ +

{{ store.name }}

+
+
+

Address

+

{{ store.street }}
{{ store.city }}, {{ store.state }} {{ store.zip_code }}

+

Phone: {{ store.phone }}

+
+
+

Hours

+

Mon - Fri: {{ store.hours_weekday }}
Sat: {{ store.hours_saturday }}
Sun: {{ store.hours_sunday }}

+
+
+

Services

+
    + {% if store.has_appraisal %}
  • ✓ In-store appraisal
  • {% endif %} + {% if store.has_express_pickup %}
  • ✓ Express pickup
  • {% endif %} + {% if store.has_service %}
  • ✓ Service center
  • {% endif %} + {% if store.has_home_delivery %}
  • ✓ Home delivery
  • {% endif %} +
+
+
+

Inventory at this store ({{ inv_count }})

+
{% for v in inventory %}{{ vcard(v) }}{% endfor %}
+ {% if inv_count > inventory|length %} +

+ See all {{ inv_count }} cars at this store +

+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/templates/stores.html b/sites/carmax/templates/stores.html new file mode 100644 index 00000000..78cf4aaa --- /dev/null +++ b/sites/carmax/templates/stores.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Nationwide CarMax locations{% endblock %} +{% block content %} +
+

Find a CarMax store

+

CarMax has stores across {{ states|length }} states.

+
+ {% for state, n in states %} + +
{{ state }}
{{ n }} store{{ 's' if n > 1 else '' }}
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/carmax/templates/stores_state.html b/sites/carmax/templates/stores_state.html new file mode 100644 index 00000000..b17ebf9a --- /dev/null +++ b/sites/carmax/templates/stores_state.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}CarMax stores in {{ state }}{% endblock %} +{% block content %} +
+ +

CarMax stores in {{ state }}

+ +
+{% endblock %} diff --git a/sites/carmax/templates/test_drive.html b/sites/carmax/templates/test_drive.html new file mode 100644 index 00000000..ec579e3e --- /dev/null +++ b/sites/carmax/templates/test_drive.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Schedule a test drive | {{ vehicle.short_title }} | CarMax{% endblock %} +{% block content %} +
+

Schedule a test drive

+
+ {{ vehicle.title }}
+ {{ vehicle.headline_price }} - {{ vehicle.mileage|miles }} +
+
+ +
{{ form.location_type() }}
+
+
{{ form.scheduled_date() }}
+
{{ form.scheduled_time() }}
+
+
{{ form.notes() }}
+ +
+
+{% endblock %} diff --git a/sites/carmax/templates/value.html b/sites/carmax/templates/value.html new file mode 100644 index 00000000..eca12552 --- /dev/null +++ b/sites/carmax/templates/value.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}How much is my car worth? | CarMax{% endblock %} +{% block content %} +
+

How much is my car worth?

+

Look up estimated CarMax value by make and model, or get a real instant offer in under 2 minutes.

+

Get my instant offer

+ +

Browse by make

+
+ {% for make, slug in makes %} + +
{{ make }}
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/sites/carmax/templates/value_model.html b/sites/carmax/templates/value_model.html new file mode 100644 index 00000000..750633c3 --- /dev/null +++ b/sites/carmax/templates/value_model.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}{{ make }} {{ model }} value | CarMax{% endblock %} +{% block content %} +
+

{{ make }} {{ model }} value

+

Based on current CarMax {{ make }} {{ model }} inventory.

+ + + {% for y, avg, mn, mx, c in rows %} + + + + + + + + + {% endfor %} +
YearAvg priceMinMaxCount
{{ y }}{{ avg|money }}{{ mn|money }}{{ mx|money }}{{ c }}Details
+

Get my instant offer

+
+{% endblock %} diff --git a/sites/carmax/templates/value_year.html b/sites/carmax/templates/value_year.html new file mode 100644 index 00000000..54f6d4e5 --- /dev/null +++ b/sites/carmax/templates/value_year.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}{{ year }} {{ make }} {{ model }} value | CarMax{% endblock %} +{% block content %} +
+

{{ year }} {{ make }} {{ model }} value

+
+
{{ avg_price|money }}
CarMax average
+
{{ min_price|money }}
Lowest in stock
+
{{ max_price|money }}
Highest in stock
+
{{ count }}
In current inventory
+
+
+ Sell or trade in? Get a real, written offer in under 2 minutes - good for 7 days. + Get my offer +
+
+{% endblock %} diff --git a/sites/carmax/templates/vehicle_detail.html b/sites/carmax/templates/vehicle_detail.html new file mode 100644 index 00000000..5b014c6f --- /dev/null +++ b/sites/carmax/templates/vehicle_detail.html @@ -0,0 +1,144 @@ +{% extends "base.html" %} +{% from "_macros.html" import vcard with context %} +{% block title %}{{ vehicle.title }} | {{ vehicle.mileage|miles }} | CarMax{% endblock %} +{% block content %} +
+ + +
+
+ + +
+

Highlights

+
+
Year
{{ vehicle.year }}
+
Body style
{{ vehicle.body_style }}
+
Mileage
{{ vehicle.mileage|miles }}
+
Trim
{{ vehicle.trim or '-' }}
+
Exterior color
{{ vehicle.exterior_color }}
+
Interior color
{{ vehicle.interior_color }}
+
Engine
{{ vehicle.engine_text }}
+
Horsepower
{{ vehicle.horsepower }} hp / {{ vehicle.torque }} lb-ft
+
Transmission
{{ vehicle.transmission }}
+
Drive type
{{ vehicle.drive_type }}
+
Fuel type
{{ vehicle.fuel_type }}
+
Fuel economy
{{ vehicle.mpg_city }} city / {{ vehicle.mpg_highway }} hwy mpg
+
Seating capacity
{{ vehicle.seating_capacity }}
+
VIN
{{ vehicle.vin or '-' }}
+
Stock #
{{ vehicle.stock_number }}
+
Days at CarMax
{{ vehicle.days_on_lot }} days
+
+
+ +
+

Features

+ {% for f in vehicle.get_features() %} + {{ f }} + {% else %} +

No features listed.

+ {% endfor %} +
+ + {% if vehicle.description %} +
+

About this {{ vehicle.short_title }}

+

{{ vehicle.description }}

+
+ {% endif %} + +
+

Customer reviews ({{ reviews|length }})

+ {% for r in reviews %} +
+
{{ r.title }} {{ r.rating|star_row }}
+

{{ r.body }}

+
{{ r.reviewer_name }}{% if r.location %} - {{ r.location }}{% endif %}
+
+ {% else %} +

No reviews for the {{ vehicle.year }} {{ vehicle.model }} yet.

+ {% endfor %} +

+ See all reviews +

+
+
+ + +
+ + {% if similar %} +
+

Similar vehicles

+
{% for v in similar %}{{ vcard(v) }}{% endfor %}
+
+ {% endif %} +
+{% endblock %} diff --git a/sites/carmax/verify/verify_0.py b/sites/carmax/verify/verify_0.py new file mode 100644 index 00000000..59db25b5 --- /dev/null +++ b/sites/carmax/verify/verify_0.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--0: find any 2022 Honda Civic; report full title, price, mileage. + +Deterministic-first: nav to a 2022 Honda Civic detail | answer has make/model + price + +mileage of the (unique) 2022 Civic in inventory | LLM-anchored fallback. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, navigated_re, final_answer, last_shot, + contains_all, price_mentioned, resolve_db, db_query, + llm_text_match, Judge, parse_args) + +def main(): + a = parse_args() + j = Judge('CarMax--0', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + gt = db_query(init, "SELECT year,make,model,trim,price,mileage FROM vehicles " + "WHERE year=2022 AND make='Honda' AND model='Civic'") if init else [] + yr, mk, md, tr, price, mi = gt[0] if gt else (2022, "Honda", "Civic", "EX", 15300, 58626) + # Title/price/mileage are all shown on the inventory card, so a search/results page + # OR the detail page both count as real on-site viewing (anti knowledge-shortcut). + j.check("nav_civic_on_site", + navigated_re(t, r"(?i)/(vehicle|cars).*civic"), + f"expected a /cars?...Civic search or /vehicle/...honda-civic page; urls={[s.get('url') for s in t.get('steps', [])]}") + j.check("answer_make_model", contains_all(fa, ["Honda", "Civic"]), f"final={fa!r}") + j.check("answer_price", price_mentioned(fa, int(price)), f"expected ${int(price):,} in {fa!r}") + j.check("answer_mileage", price_mentioned(fa, int(mi)), f"expected {int(mi):,} mi in {fa!r}") + ok, ev = llm_text_match(fa, f"{yr} {mk} {md} {tr}, price ${int(price):,}, {int(mi):,} miles", + "Report the full title, price and mileage of a 2022 Honda Civic.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_1.py b/sites/carmax/verify/verify_1.py new file mode 100644 index 00000000..64a20e08 --- /dev/null +++ b/sites/carmax/verify/verify_1.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--1: find a Toyota Tacoma TRD Off-Road; report store, mileage, price. + +Deterministic-first: nav to the Tacoma TRD Off-Road detail | answer has store + mileage + price. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, navigated_re, final_answer, + contains_any, price_mentioned, resolve_db, db_query, + llm_text_match, Judge, parse_args) + +def main(): + a = parse_args() + j = Judge('CarMax--1', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + gt = db_query(init, "SELECT v.mileage, v.price, s.name, s.city FROM vehicles v " + "JOIN stores s ON s.id=v.store_id WHERE v.make='Toyota' AND v.model='Tacoma' " + "AND v.trim LIKE '%TRD Off-Road%'") if init else [] + mi, price, store, city = gt[0] if gt else (91787, 15000, "CarMax Seattle Lynnwood", "Lynnwood") + # store/mileage/price are shown on the inventory card, so a search/results page OR + # the detail page both count as real on-site viewing (anti knowledge-shortcut). + j.check("nav_tacoma_on_site", navigated_re(t, r"(?i)/(vehicle|cars).*tacoma"), + f"expected a /cars?...Tacoma search or /vehicle/...tacoma page; urls={[s.get('url') for s in t.get('steps', [])]}") + j.check("answer_store", contains_any(fa, [store, city]), f"expected {city!r}; final={fa!r}") + j.check("answer_price", price_mentioned(fa, int(price)), f"expected ${int(price):,}") + j.check("answer_mileage", price_mentioned(fa, int(mi)), f"expected {int(mi):,} mi") + ok, ev = llm_text_match(fa, f"store {store} ({city}), {int(mi):,} miles, price ${int(price):,}", + "Report the Toyota Tacoma TRD Off-Road's store location, mileage and price.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_10.py b/sites/carmax/verify/verify_10.py new file mode 100644 index 00000000..8904381b --- /dev/null +++ b/sites/carmax/verify/verify_10.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--10: sign in as alice, reserve any 2022 Toyota Camry for 7 days +with appointment date 2026-05-20, then confirm it is listed as active. + +Deterministic-first: nav login + reserve + reservations page | DB after-state: alice has an +ACTIVE reservation for a 2022 Toyota Camry appt 2026-05-20 that was NOT in the initial seed. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, contains_any, resolve_db, + reservations_for, Judge, parse_args) + +EMAIL = "alice.j@test.com" + +def main(): + a = parse_args() + j = Judge('CarMax--10', a.no_llm) + t = load_run(a.run_dir) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + aft = reservations_for(after, EMAIL, "active") or [] + ini = reservations_for(init, EMAIL, "active") or [] + def is_target(r): # (year, make, model, appointment_date, status) + return r[0] == 2022 and r[1] == "Toyota" and r[2] == "Camry" and str(r[3]) == "2026-05-20" + new_camry = [r for r in aft if is_target(r) and r not in ini] + j.check("nav_login", navigated_to(t, "/login"), "expected /login") + j.check("nav_reserve", navigated_to(t, "/reserve"), "expected a /reserve flow") + j.check("nav_reservations", navigated_to(t, "/account/reservations"), "expected reservations page") + j.check("db_active_2022_camry_reservation_2026_05_20", bool(new_camry), + f"after_active={aft} initial_active={ini}") + fa = final_answer(t) + j.check("answer_confirms_reservation", contains_any(fa, ["Camry", "reserved", "reservation", "active"]), + f"expected the answer to confirm the active 2022 Camry reservation; final={fa!r}") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_11.py b/sites/carmax/verify/verify_11.py new file mode 100644 index 00000000..e3adc403 --- /dev/null +++ b/sites/carmax/verify/verify_11.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--11: sign in as bob.k, schedule an at-home test drive for any +2022 Ford F-150 on 2026-05-22 at 2:00 PM with note 'Please call gate buzzer 4B', +then confirm it shows on the test drives page. + +Deterministic-first: nav login + test-drive + test-drives page | DB after-state: bob.k has an +at_home test drive for a 2022 Ford F-150 on 2026-05-22 2:00 PM with the note, not in seed. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, contains_any, resolve_db, + test_drives_for, norm, Judge, parse_args) + +EMAIL = "bob.k@test.com" + +def main(): + a = parse_args() + j = Judge('CarMax--11', a.no_llm) + t = load_run(a.run_dir) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + aft = test_drives_for(after, EMAIL) or [] + ini = test_drives_for(init, EMAIL) or [] + # row: (year, make, model, location_type, scheduled_date, scheduled_time, notes, status) + def is_target(r): + return (r[0] == 2022 and r[1] == "Ford" and r[2] == "F-150" and r[3] == "at_home" + and str(r[4]) == "2026-05-22" and "2:00" in (r[5] or "") + and "gate buzzer 4b" in norm(r[6])) + new_td = [r for r in aft if is_target(r) and r not in ini] + j.check("nav_login", navigated_to(t, "/login"), "expected /login") + j.check("nav_test_drive", navigated_to(t, "/test-drive"), "expected a /test-drive flow") + j.check("nav_test_drives_page", navigated_to(t, "/account/test-drives"), "expected test-drives page") + j.check("db_at_home_f150_testdrive_with_note", bool(new_td), + f"after={aft} initial={ini}") + fa = final_answer(t) + j.check("answer_confirms_testdrive", contains_any(fa, ["F-150", "test drive", "test-drive", "scheduled"]), + f"expected the answer to confirm the F-150 at-home test drive; final={fa!r}") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_12.py b/sites/carmax/verify/verify_12.py new file mode 100644 index 00000000..0e144d1f --- /dev/null +++ b/sites/carmax/verify/verify_12.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--12: find the FAQ answer to 'How long is my appraisal offer good +for?' and report the number of days it is valid. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, price_mentioned, contains_any, + llm_text_match, Judge, parse_args) + +# CarMax appraisal offers are valid 7 days (matches reserve/offer logic: created + 7d). +DAYS = 7 + +def main(): + a = parse_args() + j = Judge('CarMax--12', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + j.check("nav_faq", navigated_to(t, "/faq"), "expected a /faq page") + j.check("answer_days", price_mentioned(fa, DAYS) or contains_any(fa, ["7 days", "seven days"]), + f"expected {DAYS} days; final={fa!r}") + ok, ev = llm_text_match(fa, f"the appraisal offer is valid for {DAYS} days", + "How many days is a CarMax appraisal offer good for?") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_13.py b/sites/carmax/verify/verify_13.py new file mode 100644 index 00000000..730a1bf8 --- /dev/null +++ b/sites/carmax/verify/verify_13.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--13: sign in as alice (who has two saved cars), remove the saved +vehicle with the HIGHER mileage, then report the year/make/model/store of the remaining one. + +Deterministic-first: nav login + saved | DB after-state: the higher-mileage saved car is +gone and the lower-mileage one remains | answer names the remaining car. + +Note (review): the task text says the two saved cars are "from different makes", but in the +seed both are Honda (2020 Civic 69k mi, 2021 CR-V 57.8k mi). The remove-higher-mileage step +is still deterministic; this verifier checks the actual data, not the (incorrect) wording. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, contains_all, resolve_db, + saved_vehicles_for, Judge, parse_args) + +EMAIL = "alice.j@test.com" + +def main(): + a = parse_args() + j = Judge('CarMax--13', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + ini = saved_vehicles_for(init, EMAIL) or [] # rows: (year, make, model, trim, mileage) sorted by mileage + aft = saved_vehicles_for(after, EMAIL) or [] + higher = max(ini, key=lambda r: r[4]) if ini else None # should be removed + lower = min(ini, key=lambda r: r[4]) if ini else None # should remain + j.check("nav_login", navigated_to(t, "/login"), "expected /login") + # Saved cars are shown on both /saved and the /account page, so either is a valid path. + j.check("nav_saved_or_account", navigated_to(t, "/saved") or navigated_to(t, "/account"), + f"expected /saved or /account; urls={[s.get('url') for s in t.get('steps', [])]}") + j.check("db_one_saved_remains", len(aft) == max(0, len(ini) - 1), + f"initial_saved={len(ini)} after_saved={len(aft)}") + j.check("db_higher_mileage_removed", bool(higher) and higher not in aft, + f"higher-mileage car {higher} should be removed; after={aft}") + j.check("db_lower_mileage_remains", bool(lower) and lower in aft, + f"lower-mileage car {lower} should remain; after={aft}") + if lower: + j.check("answer_names_remaining", contains_all(fa, [str(lower[0]), lower[1], lower[2]]), + f"expected remaining {lower[0]} {lower[1]} {lower[2]}; final={fa!r}") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_14.py b/sites/carmax/verify/verify_14.py new file mode 100644 index 00000000..2d13c10c --- /dev/null +++ b/sites/carmax/verify/verify_14.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--14: sign in as carol.l, identify her active appraisal, then buy a +2022 Honda CR-V applying that appraisal as a trade-in, CarMax Auto Finance, 60-month term, +$3,000 down, 6.49% APR, no MaxCare; place the order and report the order number and total. + +Deterministic-first: DB after-state: carol has a NEW order for a 2022 Honda CR-V with the +trade-in value applied, no MaxCare, 60-mo/6.49%/$3000-down; her appraisal is redeemed | +answer contains the order number and total. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, contains_any, price_mentioned, + resolve_db, orders_for, appraisals_for, Judge, parse_args) + +EMAIL = "carol.l@test.com" + +def main(): + a = parse_args() + j = Judge('CarMax--14', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + ao = orders_for(after, EMAIL) or [] + io = orders_for(init, EMAIL) or [] + new_orders = [o for o in ao if o["order_number"] not in {x["order_number"] for x in io}] + crv = [o for o in new_orders if o["make"] == "Honda" and o["model"] == "CR-V" and o["year"] == 2022] + j.check("nav_checkout", navigated_to(t, "/checkout") or navigated_to(t, "/vehicle"), + "expected a /checkout flow") + j.check("db_new_crv_order", bool(crv), f"new orders={[o['order_number'] for o in new_orders]}") + if crv: + o = crv[-1] + j.check("order_trade_in_applied", (o["trade_in_value"] or 0) > 0, + f"trade_in_value={o['trade_in_value']}") + j.check("order_no_maxcare", (o["maxcare_plan"] or "") == "", f"maxcare_plan={o['maxcare_plan']!r}") + j.check("order_terms", int(o["payment_term_months"] or 0) == 60 and abs((o["payment_apr"] or 0) - 6.49) < 0.01 + and abs((o["down_payment"] or 0) - 3000) < 1, + f"term={o['payment_term_months']} apr={o['payment_apr']} down={o['down_payment']}") + j.check("answer_order_number", contains_any(fa, [o["order_number"]]), + f"expected {o['order_number']}; final={fa!r}") + j.check("answer_total", price_mentioned(fa, int(round(o["total"])), tol=2), + f"expected total ${o['total']:,.2f}") + # appraisal should be redeemed after the trade-in + act_after = appraisals_for(after, EMAIL, "active") or [] + j.check("appraisal_redeemed", len(act_after) == 0, + f"carol should have 0 active appraisals after trade-in; got {act_after}") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_15.py b/sites/carmax/verify/verify_15.py new file mode 100644 index 00000000..53b8a48a --- /dev/null +++ b/sites/carmax/verify/verify_15.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--15: find the cheapest 2023 vehicle in stock, open its detail, then +visit the store that has it; report (a) year/make/model/price, (b) store name & city, +(c) whether that store offers home delivery. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, navigated_re, final_answer, contains_all, + contains_any, price_mentioned, resolve_db, db_query, llm_text_match, + Judge, parse_args) + +def main(): + a = parse_args() + j = Judge('CarMax--15', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + gt = db_query(init, "SELECT v.year, v.make, v.model, v.price, s.name, s.city, s.has_home_delivery " + "FROM vehicles v JOIN stores s ON s.id=v.store_id WHERE v.year=2023 " + "ORDER BY v.price ASC LIMIT 1") if init else [] + yr, mk, md, price, store, city, hd = gt[0] if gt else (2023, "Hyundai", "Elantra", 14400, "CarMax Seattle Lynnwood", "Lynnwood", 1) + hd_word = "yes" if hd else "no" + j.check("nav_vehicle_detail", navigated_re(t, r"/vehicle/"), "expected a vehicle detail page") + j.check("nav_store", navigated_to(t, "/store"), "expected a /store detail page") + j.check("answer_vehicle", contains_all(fa, [str(yr), mk, md]) and price_mentioned(fa, int(price)), + f"expected {yr} {mk} {md} ${int(price):,}; final={fa!r}") + j.check("answer_store", contains_any(fa, [store, city]), f"expected store {city!r}") + j.check("answer_home_delivery", contains_any(fa, [hd_word, "home delivery"]), + f"expected home delivery = {hd_word}") + ok, ev = llm_text_match(fa, f"{yr} {mk} {md} ${int(price):,}; store {store} in {city}; " + f"home delivery: {hd_word}", + "Cheapest 2023 vehicle, its store name/city, and whether the store offers home delivery.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_16.py b/sites/carmax/verify/verify_16.py new file mode 100644 index 00000000..822d7ab2 --- /dev/null +++ b/sites/carmax/verify/verify_16.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--16: open the article 'Getting Pre-Qualified: Shop with Personalized +Financing Terms' and, per the article, state the key difference between pre-qualification +and pre-approval (one sentence). + +Open-ended answer → LLM-anchored on the ACTUAL article body (read from the DB at verify +time), so the grader checks the answer against the site's own text, never model knowledge. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, navigated_re, final_answer, contains_any, + resolve_db, db_query, llm_text_match, Judge, parse_args) + +def main(): + a = parse_args() + j = Judge('CarMax--16', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + row = db_query(init, "SELECT slug, body FROM articles WHERE title LIKE 'Getting Pre-Qualified%'") if init else [] + slug, body = (row[0][0], row[0][1]) if row else ("", "") + j.check("nav_article", (slug and navigated_to(t, slug)) or navigated_re(t, r"/articles/"), + "expected the Getting Pre-Qualified article page") + j.check("answer_nonempty", len(fa) > 0, f"final={fa!r}") + ok, ev = llm_text_match( + fa, + f"The key difference as stated in this article:\n{body[:1500]}", + "What is the key difference between pre-qualification and pre-approval at CarMax, " + "according to the article? (Judge the agent's one-sentence answer against the article text above.)") + j.check("answer_matches_article", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_17.py b/sites/carmax/verify/verify_17.py new file mode 100644 index 00000000..46a12055 --- /dev/null +++ b/sites/carmax/verify/verify_17.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--17: sign in as dan.m and report from order history (a) order number, +(b) vehicle year/make/model, (c) total amount, (d) whether MaxCare was included, +(e) the scheduled pickup date. (Read-only: the order is in the seed.) +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, contains_all, contains_any, + price_mentioned, resolve_db, orders_for, llm_text_match, Judge, parse_args) + +EMAIL = "dan.m@test.com" + +def main(): + a = parse_args() + j = Judge('CarMax--17', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + orders = orders_for(init, EMAIL) or [] + j.check("nav_login", navigated_to(t, "/login"), "expected /login") + j.check("nav_orders", navigated_to(t, "/account/orders") or navigated_to(t, "/order"), + "expected the order-history / order page") + j.check("db_has_order", bool(orders), f"dan.m orders={[o['order_number'] for o in orders]}") + if orders: + o = orders[0] + has_maxcare = bool(o["maxcare_plan"]) + mc_words = ["maxcare", o["maxcare_plan"], "yes", "included"] if has_maxcare else ["no maxcare", "not included", "no"] + j.check("answer_order_number", contains_any(fa, [o["order_number"]]), f"expected {o['order_number']}; final={fa!r}") + j.check("answer_vehicle", contains_all(fa, [str(o["year"]), o["make"], o["model"]]), + f"expected {o['year']} {o['make']} {o['model']}") + j.check("answer_total", price_mentioned(fa, int(round(o["total"])), tol=2), f"expected ${o['total']:,.2f}") + j.check("answer_maxcare", contains_any(fa, [w for w in mc_words if w]), + f"maxcare included={has_maxcare} ({o['maxcare_plan']!r})") + j.check("answer_pickup_date", contains_any(fa, [str(o["pickup_date"])]), f"expected pickup {o['pickup_date']}") + # The five deterministic field checks above fully and precisely verify this + # read-only task; an extra LLM-anchored check only adds false negatives (e.g. it + # rejected a correct answer that rounded $26,685.50 to $26,686), so it is omitted. + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_18.py b/sites/carmax/verify/verify_18.py new file mode 100644 index 00000000..7cc8b00f --- /dev/null +++ b/sites/carmax/verify/verify_18.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--18: on the used-car value page for the 2020 Honda Accord, report +(a) the CarMax average price, (b) the price range (lowest to highest), (c) the number of +2020 Honda Accords currently in stock. + +Note (review): both 2020 Accords are priced identically ($13,000), so the 'range' is +degenerate ($13,000–$13,000). The verifier checks the true aggregates from the DB. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, price_mentioned, resolve_db, + db_query, llm_text_match, Judge, parse_args) + +def main(): + a = parse_args() + j = Judge('CarMax--18', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + row = db_query(init, "SELECT COUNT(*), MIN(price), MAX(price), ROUND(AVG(price)) FROM vehicles " + "WHERE year=2020 AND make='Honda' AND model='Accord'") if init else [] + cnt, lo, hi, avg = row[0] if row else (2, 13000, 13000, 13000) + j.check("nav_value_page", navigated_to(t, "/value/honda/accord/2020") + or navigated_to(t, "/value/honda/accord"), "expected the 2020 Honda Accord value page") + j.check("answer_avg", price_mentioned(fa, int(avg)), f"expected avg ${int(avg):,}; final={fa!r}") + j.check("answer_range", price_mentioned(fa, int(lo)) and price_mentioned(fa, int(hi)), + f"expected range ${int(lo):,}-${int(hi):,}") + j.check("answer_count", price_mentioned(fa, int(cnt)), f"expected count {cnt} in stock") + ok, ev = llm_text_match(fa, f"average ${int(avg):,}; range ${int(lo):,} to ${int(hi):,}; {cnt} in stock", + "2020 Honda Accord: average price, price range (low-high), and count in stock.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_19.py b/sites/carmax/verify/verify_19.py new file mode 100644 index 00000000..3bf55a1e --- /dev/null +++ b/sites/carmax/verify/verify_19.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--19: on the MaxCare extended-service-plans page, compare the Silver, +Gold and Platinum tiers; report (a) the one-time price of each, (b) the price difference +between Gold and Silver, (c) the maximum coverage period (months / miles) of Platinum. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, price_mentioned, contains_any, + llm_text_match, Judge, parse_args) + +# Frozen from the MaxCare page (see review): Silver $1,495 (36mo/50k), Gold $1,895 (48mo/75k), +# Platinum $2,395 (60mo/100k). Gold - Silver = $400. +SILVER, GOLD, PLAT = 1495, 1895, 2395 +DIFF = GOLD - SILVER # 400 +PLAT_MONTHS, PLAT_MILES = 60, 100000 + +def main(): + a = parse_args() + j = Judge('CarMax--19', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + j.check("nav_maxcare", navigated_to(t, "maxcare-service-plans") or navigated_to(t, "maxcare"), + "expected the MaxCare plans page") + j.check("answer_tier_prices", all(price_mentioned(fa, p) for p in (SILVER, GOLD, PLAT)), + f"expected ${SILVER:,}/${GOLD:,}/${PLAT:,}; final={fa!r}") + j.check("answer_gold_minus_silver", price_mentioned(fa, DIFF), f"expected diff ${DIFF}") + j.check("answer_platinum_coverage", + price_mentioned(fa, PLAT_MONTHS) and (price_mentioned(fa, PLAT_MILES) or contains_any(fa, ["100k", "100,000"])), + f"expected Platinum {PLAT_MONTHS} months / {PLAT_MILES:,} miles") + ok, ev = llm_text_match(fa, f"Silver ${SILVER:,}, Gold ${GOLD:,}, Platinum ${PLAT:,}; " + f"Gold-Silver = ${DIFF}; Platinum covers {PLAT_MONTHS} months / {PLAT_MILES:,} miles", + "MaxCare tier prices, Gold-Silver difference, and Platinum max coverage.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_2.py b/sites/carmax/verify/verify_2.py new file mode 100644 index 00000000..02c210ae --- /dev/null +++ b/sites/carmax/verify/verify_2.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--2: filter AWD SUVs under $25,000 sorted by lowest price; +report year/make/model/trim/price of the cheapest. + +Deterministic-first: nav to filtered inventory | answer names the true cheapest AWD SUV. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, contains_all, + price_mentioned, resolve_db, db_query, llm_text_match, Judge, parse_args) + +def main(): + a = parse_args() + j = Judge('CarMax--2', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + gt = db_query(init, "SELECT year,make,model,trim,price FROM vehicles WHERE body_style='SUV' " + "AND drive_type='AWD' AND price<25000 ORDER BY price ASC LIMIT 1") if init else [] + yr, mk, md, tr, price = gt[0] if gt else (2019, "Kia", "Sportage", "LX", 10500) + j.check("nav_inventory_filtered", navigated_to(t, "/cars"), "expected an inventory /cars page") + j.check("answer_names_cheapest", contains_all(fa, [str(yr), mk, md]), f"expected {yr} {mk} {md}; final={fa!r}") + j.check("answer_trim", contains_all(fa, [tr]), f"expected trim {tr!r}") + j.check("answer_price", price_mentioned(fa, int(price)), f"expected ${int(price):,}") + ok, ev = llm_text_match(fa, f"{yr} {mk} {md} {tr}, ${int(price):,}", + "The cheapest AWD SUV under $25,000 (year/make/model/trim/price).") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_3.py b/sites/carmax/verify/verify_3.py new file mode 100644 index 00000000..352318e9 --- /dev/null +++ b/sites/carmax/verify/verify_3.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--3: search a Tesla Model 3 with UNDER 50,000 miles, sort by +lowest mileage, open the lowest-mileage one, report price/mileage/exterior color/store. + +*** BLOCKED — TASK CURRENTLY UNSOLVABLE *** +Inventory has no Tesla Model 3 under 50,000 miles (lowest is 52,838), so there is no +correct target to open and no correct answer. This verifier encodes the task's INTENDED +requirement, so it will (correctly) FAIL until the env is fixed — either seed a sub-50k +Tesla Model 3 or relax the task to e.g. <60,000 (see review finding A1). Once fixed, the +checks below become satisfiable unchanged. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_re, final_answer, resolve_db, db_query, + contains_any, llm_text_match, Judge, parse_args) + +def main(): + a = parse_args() + j = Judge('CarMax--3', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + elig = db_query(init, "SELECT year,trim,mileage,price,exterior_color," + "(SELECT city FROM stores WHERE id=store_id) " + "FROM vehicles WHERE make='Tesla' AND model='Model 3' AND mileage<50000 " + "ORDER BY mileage ASC LIMIT 1") if init else [] + # (1) Precondition: a sub-50k Tesla Model 3 must EXIST to be openable. Fails today. + j.check("env_has_tesla_model3_under_50k", bool(elig), + "no Tesla Model 3 under 50,000 mi in inventory — task is unsolvable until seeded/relaxed") + # (2) Agent must have opened a Tesla Model 3 detail page. + j.check("nav_tesla_model3_detail", navigated_re(t, r"/vehicle/.*tesla-model-3"), + "expected a Tesla Model 3 /vehicle/ page") + # (3) Answer must report the eligible car's details (only checkable once one exists). + if elig: + yr, tr, mi, price, color, city = elig[0] + ok, ev = llm_text_match(fa, f"{yr} Tesla Model 3 {tr}, ${int(price):,}, {int(mi):,} mi, " + f"{color}, store city {city}", + "Report the lowest-mileage sub-50k Tesla Model 3's price/mileage/color/store.") + j.check("answer_consistent", ok, ev, llm=True) + else: + j.check("answer_consistent", False, "no eligible vehicle to anchor the answer against") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_4.py b/sites/carmax/verify/verify_4.py new file mode 100644 index 00000000..872fe888 --- /dev/null +++ b/sites/carmax/verify/verify_4.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--4: open any 2022 Honda CR-V detail; report horsepower, +combined MPG, exterior color, and its store. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_re, navigated_to, final_answer, contains_any, + price_mentioned, resolve_db, db_query, llm_text_match, Judge, parse_args) + +def main(): + a = parse_args() + j = Judge('CarMax--4', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + gt = db_query(init, "SELECT v.horsepower, v.mpg_combined, v.exterior_color, s.name, s.city " + "FROM vehicles v JOIN stores s ON s.id=v.store_id " + "WHERE v.year=2022 AND v.make='Honda' AND v.model='CR-V'") if init else [] + hp, mpg, color, store, city = gt[0] if gt else (190, 29, "Crystal Black Pearl", "CarMax Houston Katy", "Katy") + j.check("nav_2022_crv_detail", navigated_re(t, r"/vehicle/.*2022-honda-cr-v") or navigated_to(t, "honda-cr-v"), + "expected a 2022 Honda CR-V /vehicle/ page") + j.check("answer_hp", price_mentioned(fa, int(hp)), f"expected {hp} hp; final={fa!r}") + j.check("answer_mpg", price_mentioned(fa, int(mpg)), f"expected {mpg} mpg") + j.check("answer_color", contains_any(fa, [color]), f"expected color {color!r}") + j.check("answer_store", contains_any(fa, [store, city]), f"expected store {city!r}") + ok, ev = llm_text_match(fa, f"{hp} hp, {mpg} mpg combined, {color}, store {store} ({city})", + "Report a 2022 Honda CR-V's horsepower, combined MPG, exterior color, store.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_5.py b/sites/carmax/verify/verify_5.py new file mode 100644 index 00000000..5cdc3858 --- /dev/null +++ b/sites/carmax/verify/verify_5.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--5: on the 2022 Honda Civic research page, list every available +trim, then report the RepairPal reliability rating and the average customer rating. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, contains_any, price_mentioned, + resolve_db, db_query, last_shot, llm_text_match, llm_screenshot_shows, + Judge, parse_args) + +def main(): + a = parse_args() + j = Judge('CarMax--5', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + rows = db_query(init, "SELECT DISTINCT trim, repairpal_rating, customer_rating FROM vehicles " + "WHERE year=2022 AND make='Honda' AND model='Civic'") if init else [] + trims = sorted({r[0] for r in rows}) or ["EX"] + repairpal = rows[0][1] if rows else 3.5 + # The research page shows the AVERAGE of this make/model/year's reviews when any + # exist, else the vehicle's customer_rating (mirrors research_model_year in app.py). + rev = db_query(init, "SELECT ROUND(AVG(rating), 1) FROM reviews " + "WHERE make_slug='honda' AND model_slug='civic' AND year=2022") if init else [] + cust = (rev[0][0] if rev and rev[0][0] is not None else (rows[0][2] if rows else 4.0)) + j.check("nav_research_civic_2022", navigated_to(t, "/research/honda/civic/2022"), + "expected /research/honda/civic/2022") + j.check("answer_lists_a_trim", contains_any(fa, trims), f"expected a trim from {trims}; final={fa!r}") + j.check("answer_repairpal", contains_any(fa, [str(repairpal)]), f"expected RepairPal {repairpal}") + j.check("answer_customer_rating", contains_any(fa, [str(cust)]), f"expected customer rating {cust}") + ok, ev = llm_text_match(fa, f"trims: {trims}; RepairPal reliability {repairpal}; avg customer rating {cust}", + "List the 2022 Honda Civic trims and report the RepairPal rating and avg customer rating.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_6.py b/sites/carmax/verify/verify_6.py new file mode 100644 index 00000000..c0631d7a --- /dev/null +++ b/sites/carmax/verify/verify_6.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--6: add a 2022 Accord, Camry, Altima to the comparison tool; +report which has the most horsepower and which the best combined MPG. + +Deterministic-first: nav to /compare (and to the 3 vehicles) | answer attributes most-HP +and best-MPG correctly. Compare is session-scoped, so the DB isn't a reliable per-user +signal here; the check is nav + LLM-anchored on the frozen HP/MPG ground truth. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, resolve_db, db_query, + llm_text_match, Judge, parse_args) + +def main(): + a = parse_args() + j = Judge('CarMax--6', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + specs = {} + for mk, md in [("Honda", "Accord"), ("Toyota", "Camry"), ("Nissan", "Altima")]: + r = db_query(init, "SELECT horsepower, mpg_combined FROM vehicles WHERE year=2022 AND make=? AND model=?", + (mk, md)) if init else [] + specs[md] = r[0] if r else None + # Ground truth from data: most HP and best MPG. + valid = {k: v for k, v in specs.items() if v} + most_hp = max(valid, key=lambda k: valid[k][0]) if valid else "Camry" + best_mpg = max(valid, key=lambda k: valid[k][1]) if valid else "Accord" + j.check("nav_compare", navigated_to(t, "/compare"), "expected the /compare page") + j.check("answer_names_all_three", all(x in fa for x in ["Accord", "Camry", "Altima"]), + f"final={fa!r}") + ok, ev = llm_text_match(fa, f"most horsepower: {most_hp} ({valid.get(most_hp,['?'])[0]} hp); " + f"best combined MPG: {best_mpg} ({valid.get(best_mpg,['?','?'])[1]} mpg)", + "Among 2022 Accord/Camry/Altima, which has the most HP and which the best combined MPG?") + j.check("answer_hp_mpg_correct", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_7.py b/sites/carmax/verify/verify_7.py new file mode 100644 index 00000000..a1911d82 --- /dev/null +++ b/sites/carmax/verify/verify_7.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--7: get an instant offer to sell a 2018 Toyota Camry LE, 78,500 mi, +good condition, ZIP 30303, no accidents, one owner; report the offer amount and expiry. + +Deterministic-first: nav to sell-my-car | DB after-state: an appraisal for that 2018 Camry +exists in the after DB and NOT in the initial seed (proves the agent created it) | +answer contains the offer amount + expiry that the env computed for it. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, navigated_re, final_answer, price_mentioned, + contains_any, resolve_db, db_query, llm_text_match, Judge, parse_args) + +def main(): + a = parse_args() + j = Judge('CarMax--7', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + q = ("SELECT offer_amount, offer_valid_until FROM appraisals WHERE year=2018 AND make='Toyota' " + "AND model='Camry' AND mileage=78500") + aft = db_query(after, q) if after else [] + ini = db_query(init, q) if init else [] + j.check("nav_sell_my_car", navigated_to(t, "/sell-my-car"), "expected /sell-my-car flow") + j.check("db_appraisal_created", len(aft) > len(ini), + f"after={len(aft)} appraisal(s) for the 2018 Camry vs initial={len(ini)} (agent must create one)") + if aft: + offer, expires = aft[-1] + j.check("answer_offer_amount", price_mentioned(fa, int(offer)), f"expected offer ${int(offer):,}; final={fa!r}") + j.check("answer_expiry", contains_any(fa, [str(expires)]), f"expected expiry {expires}") + ok, ev = llm_text_match(fa, f"offer ${int(offer):,}, valid until {expires}", + "Report the instant-offer dollar amount and expiration date.") + j.check("answer_consistent", ok, ev, llm=True) + else: + j.check("answer_offer_amount", False, "no appraisal row to anchor the offer against") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_8.py b/sites/carmax/verify/verify_8.py new file mode 100644 index 00000000..10647838 --- /dev/null +++ b/sites/carmax/verify/verify_8.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--8: store locator; report (a) how many states have >=1 store and +(b) the street address of any CA store. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, contains_any, price_mentioned, + resolve_db, db_query, llm_text_match, Judge, parse_args) + +def main(): + a = parse_args() + j = Judge('CarMax--8', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + init = resolve_db(a.initial_db, a.container, "instance_seed") + n_states = db_query(init, "SELECT COUNT(DISTINCT state) FROM stores")[0][0] if init else 12 + ca = db_query(init, "SELECT name, street, city FROM stores WHERE state='CA'") if init else [] + ca_streets = [r[1] for r in ca] or ["6101 Auto Center Dr"] + ca_cities = [r[2] for r in ca] or ["Buena Park"] + j.check("nav_stores", navigated_to(t, "/stores") or navigated_to(t, "/store"), + "expected the store locator") + j.check("answer_state_count", price_mentioned(fa, int(n_states)), f"expected {n_states} states; final={fa!r}") + j.check("answer_ca_address", contains_any(fa, ca_streets + ca_cities), + f"expected a CA store street/city from {ca_streets} / {ca_cities}") + ok, ev = llm_text_match(fa, f"{n_states} states with a store; a CA store street such as " + f"{ca_streets[0]} in {ca_cities[0]}", + "How many states have a CarMax store, and the street address of a CA store?") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_9.py b/sites/carmax/verify/verify_9.py new file mode 100644 index 00000000..0f099a54 --- /dev/null +++ b/sites/carmax/verify/verify_9.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Verifier for CarMax--9: register a new account (Test Buyer / new.buyer.benchmark@test.com, +ZIP 30303), then get pre-qualified (80k income, full-time, $500 max monthly, $2,000 down, +72-month term, good credit); report the estimated APR. + +Deterministic-first: nav register + pre-qual | DB after-state: the new user exists (not in +initial seed) AND has a pre-qual APR set | answer contains that computed APR. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, final_answer, contains_any, resolve_db, + db_query, user_exists, user_prequal, llm_text_match, Judge, parse_args) + +EMAIL = "new.buyer.benchmark@test.com" + +def main(): + a = parse_args() + j = Judge('CarMax--9', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + j.check("nav_register", navigated_to(t, "/register"), "expected /register") + j.check("nav_prequal", navigated_to(t, "/pre-qual"), "expected /pre-qual flow") + j.check("db_user_created", user_exists(after, EMAIL) and not user_exists(init, EMAIL), + f"user {EMAIL} present after={user_exists(after, EMAIL)} initial={user_exists(init, EMAIL)}") + pq = user_prequal(after, EMAIL) + apr = pq[1] if pq else None + j.check("db_prequal_apr_set", bool(apr), f"pre_qual row/apr for {EMAIL}: {pq}") + if apr: + # answer must contain the APR the env computed (robust: read from DB, don't hardcode) + apr_str = f"{apr:.2f}".rstrip("0").rstrip(".") + j.check("answer_apr", contains_any(fa, [str(apr), apr_str, f"{apr:.2f}"]), + f"expected APR {apr}; final={fa!r}") + ok, ev = llm_text_match(fa, f"estimated APR {apr}%", + "Report the estimated APR shown on the pre-qualification result.") + j.check("answer_consistent", ok, ev, llm=True) + else: + j.check("answer_apr", False, "no pre-qual APR to anchor against") + j.emit() + +if __name__ == "__main__": + main() diff --git a/sites/carmax/verify/verify_lib.py b/sites/carmax/verify/verify_lib.py new file mode 100644 index 00000000..6fc043ff --- /dev/null +++ b/sites/carmax/verify/verify_lib.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""verify_lib.py — shared deterministic + LLM utilities for CarMax task verification. + +Philosophy: DETERMINISTIC FIRST (mirrors sites/merriam_webster/verify/verify_lib.py). + 1. Trajectory navigation check (anti knowledge-shortcut): the agent MUST have opened + the relevant on-site page; a correct answer with no matching navigation is a + memory-recall shortcut = FAIL. (CarMax inventory is fictional, so this is strong.) + 2. Answer check: exact / token-containment / numeric against frozen ground truth. + 3. DB after-state check (stateful tasks): query the SQLite instance DB directly — the + strongest signal (saved_vehicles / reservations / test_drives / orders / appraisals). + 4. LLM utilities (text match, screenshot-contains) ONLY where exact matching is brittle, + ALWAYS anchored on ground truth; the model verifies *presence*, never supplies knowledge. + +Input signature (per task): + --run_dir DIR agent trajectory dir: trajectory.json + screenshots/step_NNN.png + --initial_db PATH initial-state SQLite DB (default: fetched instance_seed from container) + --after_db PATH after-state SQLite DB (default: fetched live instance DB from container) + --container NAME docker container to fetch DBs from (default: $WH_CONTAINER or wh-review) + --no_llm skip LLM-based checks (deterministic-only) +Output: JSON {task_id, pass, reason, evidence[]} to stdout; exit 0 on PASS, 1 on FAIL. +""" +import base64, json, os, re, sqlite3, subprocess, sys, tempfile, urllib.request +from pathlib import Path +from dataclasses import dataclass +import simpleArgParser as sap + +SITE = "carmax" + +# ---------------------------------------------------------------- trajectory +def load_run(run_dir): + d = Path(run_dir) + traj = json.loads((d / "trajectory.json").read_text()) + traj["_run_dir"] = d + traj["_shots"] = {p.name: p for p in sorted((d / "screenshots").glob("step_*.png"))} + return traj + +def step_urls(traj): + return [s.get("url", "") for s in traj.get("steps", [])] + +def navigated_to(traj, substr, times=1): + return sum(1 for u in step_urls(traj) if substr in u) >= times + +def navigated_any(traj, substrs): + return any(navigated_to(traj, s) for s in substrs) + +def navigated_re(traj, pattern): + rx = re.compile(pattern) + return any(rx.search(u or "") for u in step_urls(traj)) + +def final_answer(traj): + return (traj.get("final_answer") or "").strip() + +def _shot(traj, name): + if not name: + return None + p = traj["_shots"].get(Path(name).name) + return p if (p and p.exists()) else None + +def shot_after_url(traj, substr): + for s in traj.get("steps", []): + if substr in s.get("url", ""): + p = _shot(traj, s.get("screenshot_after")) + if p: + return p + return None + +def last_shot(traj): + for s in reversed(traj.get("steps", [])): + p = _shot(traj, s.get("screenshot_after")) or _shot(traj, s.get("screenshot_before")) + if p: + return p + shots = sorted(traj["_shots"].values()) + return shots[-1] if shots else None + +# ---------------------------------------------------------------- deterministic answer match +def norm(s): + return re.sub(r"\s+", " ", (s or "").strip()).casefold() + +def answer_equals(final, expected): + return norm(final) == norm(expected) + +def contains_all(final, tokens): + f = norm(final) + return all(norm(t) in f for t in tokens) + +def contains_any(final, tokens): + f = norm(final) + return any(norm(t) in f for t in tokens) + +def extract_prices(text): + """Return list of dollar amounts as ints, e.g. '$10,500' -> 10500.""" + return [int(m.replace(",", "")) for m in re.findall(r"\$?\s?([\d]{1,3}(?:,\d{3})+|\d{4,6})", text or "")] + +def price_mentioned(final, amount, tol=0): + """True if `amount` (int dollars) appears in final answer (comma or plain).""" + f = norm(final) + cands = {str(amount), f"{amount:,}"} + if tol: + return any(abs(p - amount) <= tol for p in extract_prices(final)) + return any(c in f for c in cands) + +def extract_ints(text): + return [int(m.replace(",", "")) for m in re.findall(r"\b(\d{1,3}(?:,\d{3})+|\d+)\b", text or "")] + +# ---------------------------------------------------------------- DB state +def fetch_db(container, kind): + """kind: 'instance' (after) or 'instance_seed' (initial). docker cp -> temp file.""" + src = f"{container}:/opt/WebSyn/{SITE}/{kind}/{SITE}.db" + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + r = subprocess.run(["docker", "cp", src, path], capture_output=True, text=True) + if r.returncode != 0: + try: + os.unlink(path) + except OSError: + pass + raise RuntimeError(f"docker cp {src} failed: {r.stderr.strip()}") + return path + +def resolve_db(arg, container, kind): + if arg: + return arg + try: + return fetch_db(container, kind) + except Exception: + return None # caller treats None as "unavailable" and FAILs that check + +def db_query(db_path, sql, params=()): + con = sqlite3.connect(db_path) + try: + return con.execute(sql, params).fetchall() + finally: + con.close() + +# --- CarMax-specific state helpers (return None if db unavailable) ----------- +def saved_vehicles_for(db_path, email): + if not db_path: + return None + rows = db_query(db_path, + "SELECT v.year, v.make, v.model, v.trim, v.mileage FROM saved_vehicles s " + "JOIN users u ON u.id=s.user_id JOIN vehicles v ON v.id=s.vehicle_id " + "WHERE u.email=? ORDER BY v.mileage", (email,)) + return [tuple(r) for r in rows] + +def reservations_for(db_path, email, status=None): + if not db_path: + return None + sql = ("SELECT v.year, v.make, v.model, r.appointment_date, r.status FROM reservations r " + "JOIN users u ON u.id=r.user_id JOIN vehicles v ON v.id=r.vehicle_id WHERE u.email=?") + p = [email] + if status: + sql += " AND r.status=?"; p.append(status) + return [tuple(r) for r in db_query(db_path, sql, tuple(p))] + +def test_drives_for(db_path, email): + if not db_path: + return None + return [tuple(r) for r in db_query(db_path, + "SELECT v.year, v.make, v.model, t.location_type, t.scheduled_date, t.scheduled_time, " + "t.notes, t.status FROM test_drives t JOIN users u ON u.id=t.user_id " + "JOIN vehicles v ON v.id=t.vehicle_id WHERE u.email=?", (email,))] + +def orders_for(db_path, email): + if not db_path: + return None + return [dict(zip( + ["order_number", "total", "maxcare_plan", "pickup_or_delivery", "pickup_date", + "trade_in_value", "payment_apr", "payment_term_months", "down_payment", "status", + "year", "make", "model"], + r)) for r in db_query(db_path, + "SELECT o.order_number, o.total, o.maxcare_plan, o.pickup_or_delivery, o.pickup_date, " + "o.trade_in_value, o.payment_apr, o.payment_term_months, o.down_payment, o.status, " + "v.year, v.make, v.model FROM orders o JOIN users u ON u.id=o.user_id " + "JOIN vehicles v ON v.id=o.vehicle_id WHERE u.email=?", (email,))] + +def appraisals_for(db_path, email, status=None): + if not db_path: + return None + sql = ("SELECT year, make, model, offer_amount, status FROM appraisals a " + "JOIN users u ON u.id=a.user_id WHERE u.email=?") + p = [email] + if status: + sql += " AND a.status=?"; p.append(status) + return [tuple(r) for r in db_query(db_path, sql, tuple(p))] + +def user_exists(db_path, email): + if not db_path: + return None + return bool(db_query(db_path, "SELECT 1 FROM users WHERE email=?", (email,))) + +def user_prequal(db_path, email): + """Return (pre_qual_active, pre_qual_apr) for a user, or None.""" + if not db_path: + return None + r = db_query(db_path, "SELECT pre_qual_active, pre_qual_apr FROM users WHERE email=?", (email,)) + return tuple(r[0]) if r else None + +# ---------------------------------------------------------------- shared LLM utilities (anchored) +_NO_LLM = False + +def _llm_config(): + return (os.environ.get("OPENAI_API_KEY", ""), + os.environ.get("OPENAI_BASE_URL", ""), + os.environ.get("JUDGE_MODEL", "")) + +def _chat(messages, max_tokens=1024): + if _NO_LLM: + return None + key, base, model = _llm_config() + if not (key and base and model): + return None + payload = {"model": model, "messages": messages, "max_tokens": max_tokens, "temperature": 1.0} + # Accept OPENAI_BASE_URL as either the base (".../api", like agent.py/eval_judge) or a + # full chat-completions URL (".../chat/completions", the merriam_webster convention). + b = base.rstrip("/") + endpoint = b if b.endswith("/chat/completions") else b + "/chat/completions" + req = urllib.request.Request(endpoint, + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", + "Authorization": f"Bearer {key}"}) + try: + data = json.loads(urllib.request.urlopen(req, timeout=180).read()) + return data["choices"][0]["message"]["content"] + except Exception: + return None + +def _verdict(out): + if not out: + return False, "" + s = out.strip() + return s.upper().startswith("PASS"), s + +def llm_text_match(agent_answer, ground_truth, question): + if _NO_LLM: + return False, "[skipped: --no_llm]" + out = _chat([{"role": "user", "content": + f"You are a STRICT binary grader.\nQuestion: {question}\n" + f"Ground-truth answer (ANCHOR — judge against THIS, never use your own knowledge): {ground_truth}\n" + f"Agent's answer: {agent_answer}\n" + f"Decide PASS or FAIL ignoring case/punctuation/word order/surrounding prose. " + f"PASS only if the agent's answer is consistent with the ground truth AND actually answers the question. " + f"Line 1: PASS or FAIL. Line 2: one-sentence reason."}]) + return _verdict(out) + +def llm_screenshot_shows(shot_path, must_show, question=""): + if _NO_LLM: + return False, "[skipped: --no_llm]" + b64 = base64.b64encode(Path(shot_path).read_bytes()).decode() + out = _chat([{"role": "user", "content": [ + {"type": "text", "text": + f"You are a STRICT binary grader. Only what is VISIBLY rendered in this screenshot counts.\n" + f"Question the page should answer: {question}\n" + f"Expected content to verify PRESENCE of: {must_show}\n" + f"PASS only if the expected content (or a semantically equivalent on-screen value) is visibly shown. " + f"Do NOT use prior knowledge — judge only the rendered pixels.\n" + f"Line 1: PASS or FAIL. Line 2: quote the visible evidence."}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}]}]) + return _verdict(out) + +# ---------------------------------------------------------------- judge harness + CLI +class Judge: + def __init__(self, task_id, no_llm=False): + global _NO_LLM + _NO_LLM = bool(no_llm) + self.task_id = task_id + self.no_llm = no_llm + self.ok = True + self.reason = "" + self.evidence = [] + + def check(self, name, cond, evidence="", llm=False): + if llm and self.no_llm: + self.evidence.append(f"[SKIP] {name} (--no_llm)") + return True + if cond: + self.evidence.append(f"[PASS] {name}: {evidence}") + else: + self.ok = False + if not self.reason: + self.reason = name + self.evidence.append(f"[FAIL] {name}: {evidence}") + return bool(cond) + + def emit(self): + print(json.dumps({"task_id": self.task_id, "pass": self.ok, + "reason": self.reason, "evidence": self.evidence}, indent=2)) + sys.exit(0 if self.ok else 1) + +def parse_args(): + @dataclass + class VerifyArgs: + run_dir: str = "" + initial_db: str = "" + after_db: str = "" + container: str = os.environ.get("WH_CONTAINER", "wh-review") + no_llm: bool = False + + def post_process(self): + if not self.run_dir: + raise SystemExit("--run_dir is required") + return sap.parse_args(VerifyArgs) diff --git a/websyn_start.sh b/websyn_start.sh index 72defad8..8ce28387 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -5,7 +5,7 @@ set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn) + cambridge_dictionary coursera espn carmax) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" @@ -17,7 +17,7 @@ for d in "${SITES[@]}"; do cp -a "/opt/WebSyn/$d/instance_seed" "/opt/WebSyn/$d/instance" done -echo "[WebSyn] Starting 15 sites on ports ${BASE_PORT}-$((BASE_PORT + 14))..." +echo "[WebSyn] Starting ${#SITES[@]} sites on ports ${BASE_PORT}-$((BASE_PORT + ${#SITES[@]} - 1))..." for i in "${!SITES[@]}"; do site="${SITES[$i]}" port=$((BASE_PORT + i)) @@ -51,8 +51,8 @@ except Exception: exit(1) ready=$((ready + 1)) fi done - echo " [${elapsed}/${max_wait}s] ${ready}/15 sites ready" - if [ $ready -eq 15 ]; then + echo " [${elapsed}/${max_wait}s] ${ready}/${#SITES[@]} sites ready" + if [ $ready -eq ${#SITES[@]} ]; then break fi done