2026-06-15 14:20:56 -04:00
2026-06-15 14:20:56 -04:00
2026-06-15 11:23:05 -04:00
2026-06-15 14:20:56 -04:00
2026-06-15 14:19:14 -04:00
2026-06-15 11:23:05 -04:00
2026-06-15 11:23:05 -04:00
2026-06-15 11:23:05 -04:00
2026-06-15 14:20:56 -04:00
2026-06-15 11:23:05 -04:00
2026-06-15 11:23:05 -04:00
2026-06-15 11:23:05 -04:00
2026-06-15 11:23:05 -04:00

Classifieds — Phase 1 (Foundation)

App factory, config, extensions, users/plans schema, full auth (register / login / logout / email-verify / password-reset), RBAC, i18n scaffold (EN/VI/ES) with accent-insensitive normalizer, Turnstile hook.

Stack

Flask · MySQL 8.0 · Redis · Gunicorn · systemd · Nginx · Ubuntu 22.04


Local setup

python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env          # edit secrets

Quick smoke test (no MySQL/Redis needed)

A committed test exercises the whole Phase-1 surface against a temp SQLite file with the limiter in-memory:

python -m tests.test_smoke      # or: pytest -q

It checks: plan seeding, accent normalizer (phở→pho, ñandú→nandu), register → email-verify (+5 trust) → login/logout, password reset round-trip, duplicate-email and bad-password rejection, home/healthz/404.

Note: it runs over https://localhost with a Referer header on purpose — production sets PREFERRED_URL_SCHEME=https, so Flask-WTF enforces the secure referrer CSRF check. Real browsers send Referer; the test mirrors that.

Manual run with the dev server

Flask-SQLAlchemy resolves a relative sqlite:///dev.db against the instance/ folder, not the CWD. To reset, delete instance/dev.db (or use an absolute DATABASE_URL=sqlite:////abs/path/dev.db).

Set in .env: DATABASE_URL=sqlite:////tmp/classifieds_dev.db (absolute), then:

export FLASK_APP=wsgi:app
flask db init
flask db migrate -m "phase1 users+plans+trust"
flask db upgrade
python seed.py --admin admin@example.com 'StrongPass123'
flask run

Open http://127.0.0.1:5000

Real MySQL 8.0

CREATE DATABASE classifieds CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'classifieds'@'127.0.0.1' IDENTIFIED BY 'change-me';
GRANT ALL PRIVILEGES ON classifieds.* TO 'classifieds'@'127.0.0.1';
FLUSH PRIVILEGES;

Leave DATABASE_URL blank in .env (URI is built from DB_*). Then run the same flask db ... + seed.py sequence.


i18n (translations)

pybabel extract -F babel.cfg -o messages.pot .
pybabel init -i messages.pot -d app/translations -l vi
pybabel init -i messages.pot -d app/translations -l es
# ...translate the .po files...
pybabel compile -d app/translations

Re-extract + pybabel update after adding new _() strings.


What works now

  • Register → email verify link (dev: printed to console) → verify grants +5 trust.
  • Login / logout, remember-me, rate-limited, suspended-account block.
  • Password reset (enumeration-safe), signed expiring tokens.
  • Language switch persists to session + profile.
  • Argon2 hashing, CSRF on all forms, RBAC decorators ready.
  • Free plan auto-assigned on registration.

Notes / Phase-1 limits

  • Turnstile bypassed when keys blank (dev). Set keys in .env for prod.
  • Email prints to console until SMTP (Brevo) configured.
  • requirements.txt uses PyMySQL. For the SQLite smoke test no driver needed.

Production deploy (outline)

  1. Code → /opt/classifieds, venv, pip install -r requirements.txt.
  2. .env with prod secrets, FLASK_CONFIG=prod, real SECRET_KEY.
  3. MySQL DB + user (above). flask db upgrade && python seed.py.
  4. deploy/classifieds.service/etc/systemd/system/, systemctl enable --now classifieds.
  5. deploy/nginx.conf.sample → adapt, enable site, add TLS (certbot), reload Nginx.
  6. Redis running for sessions + rate-limit + (later) queue.

Phase 2 — Listings core (built)

New models: Category (self-referential subcategories + field_schema JSON), Listing (+ title_norm, denormalized hot columns, lat/lng, status, expiry), ListingImage, ZipGeo, Metro.

Features:

  • Listing CRUD with owner/moderator authorization; mark-sold; my-listings.
  • Category-specific fields driven by field_schema (text/number/select/bool), validated server-side; dynamic form toggles fields by selected category.
  • Tier enforcement: active-listing cap, images-per-listing cap, listing life (expires_at) — all read from plans.config.
  • Image pipeline (Pillow): validate, re-encode to JPEG (strips EXIF/metadata), thumbnail, randomized filenames, per-listing media folder.
  • Browse + filters (category, state, price range, condition/job_type hot fields).
  • Accent-insensitive keyword search via title_norm (phở matches "pho").
  • Radius search: ZIP geocode → bounding-box SQL prefilter → exact haversine refine.
  • Expiry sweep: flask expire-listings (wire the systemd timer below).

Expiry sweep (systemd timer)

cp deploy/classifieds-expire.{service,timer} /etc/systemd/system/
systemctl enable --now classifieds-expire.timer

Seed

python seed.py now also seeds the 6 categories + subcategories and a small sample of zip_geo rows. Replace the ZIP sample with the full dataset (SimpleMaps US ZIP or Census ZCTA, ~42k rows) before production.


MySQL upgrades (optional, post-Phase-2)

The Phase-2 code is portable (runs on SQLite for tests, MySQL in prod). Three spots can be upgraded to native MySQL features when you want them:

  1. Spatial radius — replace lat/lng bounding-box + haversine with a generated POINT column + SPATIAL INDEX and ST_Distance_Sphere:
    ALTER TABLE listings ADD COLUMN geo POINT
      GENERATED ALWAYS AS (ST_SRID(POINT(lng, lat), 4326)) STORED,
      ADD SPATIAL INDEX spx_listings_geo (geo);
    
  2. Generated hot columns — instead of app-maintained attr_* columns, derive them from JSON:
    ALTER TABLE listings ADD COLUMN attr_condition VARCHAR(40)
      GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(attributes,'$.condition'))) STORED,
      ADD INDEX ix_attr_condition (attr_condition);
    
  3. Full-text search — add a FULLTEXT index and switch the keyword filter to MATCH ... AGAINST for relevance + speed:
    ALTER TABLE listings ADD FULLTEXT INDEX ftx_listings (title, body);
    
    (Keep title_norm for accent-insensitive matching; combine as needed.)

Next: Phase 3 — Messaging + favorites

conversations, user-to-user messages, saved listings, contact masking gated by trust tier.

S
Description
Classifieds Ads app
Readme
242 KiB
Languages
Python 69.2%
HTML 25.8%
CSS 4.9%
Mako 0.1%