06/15 Phase 1 + 2 codes
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
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:
|
||||
```bash
|
||||
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:
|
||||
```bash
|
||||
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
|
||||
```sql
|
||||
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)
|
||||
|
||||
```bash
|
||||
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)
|
||||
```bash
|
||||
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`:
|
||||
```sql
|
||||
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:
|
||||
```sql
|
||||
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:
|
||||
```sql
|
||||
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.
|
||||
Reference in New Issue
Block a user