Compare commits

..
10 Commits
Author SHA1 Message Date
nngo b17e4bb209 Jul 13 - Fix missing table 2026-07-13 17:28:14 -04:00
nngo 9ba7595920 Jul 13 - Update listings page with State list and searchable 2026-07-13 17:19:39 -04:00
nngo 505158b2e0 Jul 13 - Fix listings page layout broken 2026-07-13 17:14:03 -04:00
nngo a5c800e87c Jul 13 - Optimize code 2 2026-07-13 17:07:30 -04:00
nngo 00a9ba7770 Jul 13 - Optimize code 2026-07-13 16:52:00 -04:00
nngo 936d860342 Jul 13 - Change theme 2 2026-07-13 16:34:31 -04:00
nngo 61d6d33242 Jul 13 - Change theme 2026-07-13 16:12:49 -04:00
nngo ae544af6e1 06/18 Update document 2026-06-18 09:44:32 -04:00
nngo d54e5e2127 06/18 Phase 7 2026-06-18 09:29:33 -04:00
nngo d1383a9835 06/16 Phase 6 (continue) 2026-06-17 17:51:03 -04:00
56 changed files with 2293 additions and 594 deletions
+1
View File
@@ -4,5 +4,6 @@ __pycache__/
.env .env
dev.db dev.db
*.mo *.mo
!app/translations/**/*.mo
instance/ instance/
.DS_Store .DS_Store
+259 -86
View File
@@ -42,7 +42,7 @@ Each category has subcategories and a JSON-driven `field_schema`
| Reverse proxy / static | Nginx | | Reverse proxy / static | Nginx |
| Process manager | systemd | | Process manager | systemd |
| OS | Ubuntu 22.04 Server | | OS | Ubuntu 22.04 Server |
| Background jobs | APScheduler or RQ (expiry sweep, email, image processing) | | Background jobs | systemd timers (expiry/boost/keyword sweeps). RQ planned — not yet installed |
| Payments | Stripe (Billing + Payment Intents + Tax + Customer Portal) | | Payments | Stripe (Billing + Payment Intents + Tax + Customer Portal) |
| Email | SMTP relay via Brevo (smart relay, existing infra) | | Email | SMTP relay via Brevo (smart relay, existing infra) |
| i18n | Flask-Babel | | i18n | Flask-Babel |
@@ -102,7 +102,7 @@ Nginx
- `classifieds.service` — Gunicorn web process - `classifieds.service` — Gunicorn web process
- `classifieds-expire.service` + `classifieds-expire.timer` — hourly: listing expiry - `classifieds-expire.service` + `classifieds-expire.timer` — hourly: listing expiry
- `classifieds-nightly.service` + `classifieds-nightly.timer` — 2am: boost expiry + - `classifieds-nightly.service` + `classifieds-nightly.timer` — 2am: boost expiry +
promoted keyword cleanup + subscription reconcile promoted keyword cleanup + subscription reconcile + expiry warning/expired emails
--- ---
@@ -127,7 +127,7 @@ classifieds/
│ ├── models/ │ ├── models/
│ │ ├── __init__.py # Exports all models (import order matters) │ │ ├── __init__.py # Exports all models (import order matters)
│ │ ├── enums.py # Role, UserStatus, TrustTier, TrustEventType, │ │ ├── enums.py # Role, UserStatus, TrustTier, TrustEventType,
│ │ │ # ListingStatus, Lang │ │ │ # ListingStatus, Lang, ReportReason
│ │ ├── plan.py # Plan (tier config JSON) │ │ ├── plan.py # Plan (tier config JSON)
│ │ ├── user.py # User (Argon2, RBAC helpers, Flask-Login) │ │ ├── user.py # User (Argon2, RBAC helpers, Flask-Login)
│ │ ├── trust.py # TrustEvent (append-only trust ledger) │ │ ├── trust.py # TrustEvent (append-only trust ledger)
@@ -137,16 +137,26 @@ classifieds/
│ │ ├── messaging.py # Conversation + Message │ │ ├── messaging.py # Conversation + Message
│ │ ├── favorite.py # Favorite │ │ ├── favorite.py # Favorite
│ │ ├── payments.py # Subscription, Transaction, Boost │ │ ├── payments.py # Subscription, Transaction, Boost
│ │ ── ads.py # Ad, Sponsor, PromotedKeyword │ │ ── ads.py # Ad, Sponsor, PromotedKeyword
│ │ ├── report.py # Report (user-submitted listing reports)
│ │ ├── audit.py # AuditLog (append-only admin action trail)
│ │ ├── setting.py # Setting (key/JSON-value admin runtime config)
│ │ └── review.py # Review (15 star seller reviews, unique per listing+author)
│ │ │ │
│ ├── blueprints/ │ ├── blueprints/
│ │ ├── auth/ # register, login, logout, verify-email, reset │ │ ├── auth/ # register, login, logout, verify-email, reset
│ │ ├── main/ # index, /healthz │ │ ├── main/ # index, /healthz, /robots.txt, /sitemap.xml,
│ │ │ # /classifieds/category/<slug>, /classifieds/state/<state>
│ │ ├── i18n/ # /lang/<code> locale switcher │ │ ├── i18n/ # /lang/<code> locale switcher
│ │ ├── listings/ # browse+promoted, detail, create, edit, delete, images │ │ ├── listings/ # browse+promoted, detail, create, edit, delete, images,
│ │ │ # report, review
│ │ ├── messaging/ # inbox, conversation, start, favorites, /api/unread │ │ ├── messaging/ # inbox, conversation, start, favorites, /api/unread
│ │ ├── payments/ # pricing, checkout, portal, webhook, boost, billing │ │ ├── payments/ # pricing, checkout, portal, webhook, boost, billing
│ │ ── ads/ # click tracking, sponsor directory, inject_ads() │ │ ── ads/ # click tracking, sponsor directory, inject_ads()
│ │ └── admin/ # full admin backend (dashboard, users, listings,
│ │ # reports, categories, plans, ads, sponsors,
│ │ # promoted-keywords, transactions, analytics,
│ │ # audit log, settings)
│ │ │ │
│ ├── services/ │ ├── services/
│ │ ├── email.py # send_email (SMTP or dev console) │ │ ├── email.py # send_email (SMTP or dev console)
@@ -155,34 +165,54 @@ classifieds/
│ │ ├── geo.py # geocode_zip, haversine_mi, bounding_box │ │ ├── geo.py # geocode_zip, haversine_mi, bounding_box
│ │ ├── field_schema.py # validate_attributes, hot_values │ │ ├── field_schema.py # validate_attributes, hot_values
│ │ ├── images.py # process_upload, delete_image_files │ │ ├── images.py # process_upload, delete_image_files
│ │ ├── listings.py # create/update/browse/search/radius/expiry │ │ ├── listings.py # create/update/browse/search/radius/expiry +
│ │ │ # keyword blocklist enforcement
│ │ ├── messaging.py # conversations, send, mark-read, inbox, unread │ │ ├── messaging.py # conversations, send, mark-read, inbox, unread
│ │ ├── favorites.py # toggle, is_favorited, user_favorites │ │ ├── favorites.py # toggle, is_favorited, user_favorites
│ │ ├── contact.py # mask_body, contact_revealed, contact_density │ │ ├── contact.py # mask_body, contact_revealed, contact_density
│ │ ├── billing.py # Stripe checkout, portal, webhooks, sync, reconcile │ │ ├── billing.py # Stripe checkout, portal, webhooks, sync, reconcile
│ │ ── ads.py # get_ad, record_impression/click, promoted_listings, │ │ ── ads.py # get_ad, record_impression/click, promoted_listings,
│ │ # active_sponsors, expire_promoted_keywords │ │ # active_sponsors, expire_promoted_keywords
│ │ ├── reports.py # create_report (auto-flag at threshold), ReportError
│ │ ├── moderation.py # flag_queue, approve, hide, remove (+ audit_log)
│ │ ├── audit.py # log_action — append AuditLog row, caller commits
│ │ ├── settings.py # get_setting, set_setting (runtime config from DB)
│ │ ├── admin_dashboard.py # KPI queries: active_listings, new_users, mrr,
│ │ │ # revenue_30d, flag_queue_depth
│ │ ├── admin_users.py # search_query, set_status, set_tier, adjust_trust
│ │ ├── expiry_notifications.py # warn_expiring(days), notify_expired — email sweeps
│ │ └── reviews.py # create_review, seller_rating
│ │ │ │
│ ├── utils/ │ ├── utils/
│ │ ├── __init__.py # RBAC decorators: role_required, admin_required, │ │ ├── __init__.py # RBAC decorators: role_required, admin_required,
│ │ │ # moderator_required │ │ │ # moderator_required
│ │ ├── security.py # hash_password, verify_password, generate_token, │ │ ├── security.py # hash_password, verify_password, generate_token,
│ │ │ # read_token (itsdangerous) │ │ │ # read_token (itsdangerous)
│ │ ── text.py # normalize() — accent-insensitive (phở→pho, ñ→n) │ │ ── text.py # normalize() — accent-insensitive (phở→pho, ñ→n)
│ │ └── time.py # utcnow() — naive-UTC helper (replaces datetime.utcnow)
│ │ │ │
│ ├── templates/ │ ├── templates/
│ │ ├── base.html # Layout: nav + header/footer ad slots + lang switcher │ │ ├── base.html # Layout: responsive nav, OG/meta blocks, toast JS,
│ │ │ # header/footer ad slots, lang switcher
│ │ ├── index.html # Landing page │ │ ├── index.html # Landing page
│ │ ├── sitemap.xml # XML sitemap template
│ │ ├── auth/ # login, register, reset_request, reset, _macros │ │ ├── auth/ # login, register, reset_request, reset, _macros
│ │ ├── listings/ # browse (sidebar+inline ads), detail, form, mine │ │ ├── listings/ # browse, detail (JSON-LD, reviews), form, mine,
│ │ │ # review_form
│ │ ├── main/ # category_landing.html, state_landing.html
│ │ ├── messaging/ # inbox, conversation, start, favorites │ │ ├── messaging/ # inbox, conversation, start, favorites
│ │ ├── payments/ # pricing, billing, boost, success │ │ ├── payments/ # pricing, billing, boost, success
│ │ ├── ads/ # _slot.html (reusable ad slot partial) │ │ ├── ads/ # _slot.html (reusable ad slot partial)
│ │ ├── sponsors/ # directory.html │ │ ├── sponsors/ # directory.html
│ │ ── errors/ # 403, 404, 500 │ │ ── admin/ # dashboard, users, user_detail, listings, reports,
│ │ │ # categories, category_schema, plans, plan_edit,
│ │ │ # ads, ad_edit, sponsors, sponsor_edit,
│ │ │ # promoted_keywords, promoted_keyword_new,
│ │ │ # transactions, analytics, audit, settings, _nav
│ │ └── errors/ # 403, 404, 500, maintenance
│ │ │ │
│ ├── static/ │ ├── static/
│ │ └── style.css # Single CSS file (Phase 15 accumulated) │ │ └── style.css # Single CSS file (Phase 17 accumulated)
│ │ │ │
│ └── translations/ # Flask-Babel .po/.mo for vi + es │ └── translations/ # Flask-Babel .po/.mo for vi + es
@@ -192,23 +222,28 @@ classifieds/
│ ├── classifieds.service # systemd: Gunicorn web │ ├── classifieds.service # systemd: Gunicorn web
│ ├── classifieds-expire.service # systemd: oneshot listing expiry │ ├── classifieds-expire.service # systemd: oneshot listing expiry
│ ├── classifieds-expire.timer # systemd: hourly │ ├── classifieds-expire.timer # systemd: hourly
│ ├── classifieds-nightly.service # systemd: boost expiry + keyword cleanup + reconcile │ ├── classifieds-nightly.service # systemd: boost expiry + keyword cleanup +
│ │ # subscription reconcile + expiry emails
│ ├── classifieds-nightly.timer # systemd: 2am daily │ ├── classifieds-nightly.timer # systemd: 2am daily
│ ├── gunicorn.conf.py # workers, socket, timeouts │ ├── gunicorn.conf.py # workers, socket, timeouts
│ └── nginx.conf.sample # HTTPS redirect + proxy + static + media │ └── nginx.conf.sample # HTTPS redirect + proxy + static + media
└── tests/ └── tests/
└── test_smoke.py # 58-check integration test (SQLite + in-memory Redis) └── test_smoke.py # Integration test (SQLite + in-memory Redis)
# isolated temp media dir; all Phase 16 checks green
``` ```
--- ---
## 5. Route Map (34 routes, Phase 15) ## 5. Route Map (70+ routes, Phase 17)
### Public & Auth (Phase 1)
| Method | Path | Blueprint | Auth | | Method | Path | Blueprint | Auth |
|---|---|---|---| |---|---|---|---|
| GET | `/` | main | — | | GET | `/` | main | — |
| GET | `/healthz` | main | — | | GET | `/healthz` | main | — |
| GET | `/robots.txt` | main | — |
| GET | `/sitemap.xml` | main | — |
| GET/POST | `/auth/register` | auth | — | | GET/POST | `/auth/register` | auth | — |
| GET/POST | `/auth/login` | auth | — | | GET/POST | `/auth/login` | auth | — |
| GET | `/auth/logout` | auth | login | | GET | `/auth/logout` | auth | login |
@@ -216,6 +251,16 @@ classifieds/
| GET/POST | `/auth/reset` | auth | — | | GET/POST | `/auth/reset` | auth | — |
| GET/POST | `/auth/reset/<token>` | auth | — | | GET/POST | `/auth/reset/<token>` | auth | — |
| GET | `/lang/<code>` | i18n | — | | GET | `/lang/<code>` | i18n | — |
### SEO Landing Pages (Phase 7)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/classifieds/category/<slug>` | main | — |
| GET | `/classifieds/state/<state>` | main | — |
### Listings (Phase 27)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/listings` | listings | — | | GET | `/listings` | listings | — |
| GET | `/listings/new` | listings | login | | GET | `/listings/new` | listings | login |
| GET | `/listings/<id>` | listings | — | | GET | `/listings/<id>` | listings | — |
@@ -223,25 +268,79 @@ classifieds/
| POST | `/listings/<id>/delete` | listings | login+owner | | POST | `/listings/<id>/delete` | listings | login+owner |
| POST | `/listings/<id>/sold` | listings | login+owner | | POST | `/listings/<id>/sold` | listings | login+owner |
| POST | `/listings/<id>/images/<img_id>/delete` | listings | login+owner | | POST | `/listings/<id>/images/<img_id>/delete` | listings | login+owner |
| GET/POST | `/listings/<id>/contact` | messaging | login | | POST | `/listings/<id>/report` | listings | login |
| POST | `/listings/<id>/favorite` | messaging | login | | GET/POST | `/listings/<id>/review` | listings | login |
| GET/POST | `/listings/<id>/boost` | payments | login+owner |
| GET | `/listings/<id>/boost/success` | payments | login |
| GET | `/media/<path>` | listings | — | | GET | `/media/<path>` | listings | — |
| GET | `/my/listings` | listings | login | | GET | `/my/listings` | listings | login |
### Messaging & Social (Phase 3)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET/POST | `/listings/<id>/contact` | messaging | login |
| POST | `/listings/<id>/favorite` | messaging | login |
| GET | `/my/favorites` | messaging | login | | GET | `/my/favorites` | messaging | login |
| GET | `/my/billing` | payments | login |
| GET | `/messages` | messaging | login | | GET | `/messages` | messaging | login |
| GET/POST | `/messages/<id>` | messaging | login+participant | | GET/POST | `/messages/<id>` | messaging | login+participant |
| GET | `/api/unread` | messaging | login | | GET | `/api/unread` | messaging | login |
### Payments (Phase 4)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET/POST | `/listings/<id>/boost` | payments | login+owner |
| GET | `/listings/<id>/boost/success` | payments | login |
| GET | `/my/billing` | payments | login |
| GET | `/pricing` | payments | — | | GET | `/pricing` | payments | — |
| GET | `/billing/subscribe/<slug>` | payments | login | | GET | `/billing/subscribe/<slug>` | payments | login |
| GET | `/billing/portal` | payments | login | | GET | `/billing/portal` | payments | login |
| GET | `/billing/success` | payments | login | | GET | `/billing/success` | payments | login |
| POST | `/billing/webhook` | payments | CSRF-exempt | | POST | `/billing/webhook` | payments | CSRF-exempt |
### Ads & Sponsors (Phase 5)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/ads/<id>/click` | ads | — | | GET | `/ads/<id>/click` | ads | — |
| GET | `/sponsors` | ads | — | | GET | `/sponsors` | ads | — |
### Admin (Phase 6)
| Method | Path | Blueprint | Auth |
|---|---|---|---|
| GET | `/admin` | admin | admin |
| GET | `/admin/users` | admin | admin |
| GET | `/admin/users/<id>` | admin | admin |
| POST | `/admin/users/<id>/status` | admin | admin |
| POST | `/admin/users/<id>/tier` | admin | admin |
| POST | `/admin/users/<id>/trust` | admin | admin |
| POST | `/admin/users/<id>/impersonate` | admin | admin |
| GET | `/admin/listings` | admin | admin/mod |
| POST | `/admin/listings/<id>/approve` | admin | admin/mod |
| POST | `/admin/listings/<id>/hide` | admin | admin/mod |
| POST | `/admin/listings/<id>/remove` | admin | admin/mod |
| GET | `/admin/reports` | admin | admin/mod |
| POST | `/admin/reports/<id>/resolve` | admin | admin/mod |
| GET | `/admin/categories` | admin | admin |
| GET/POST | `/admin/categories/new` | admin | admin |
| GET/POST | `/admin/categories/<id>/edit` | admin | admin |
| GET/POST | `/admin/categories/<id>/schema` | admin | admin |
| GET | `/admin/plans` | admin | admin |
| GET/POST | `/admin/plans/<id>/edit` | admin | admin |
| GET | `/admin/ads` | admin | admin |
| GET/POST | `/admin/ads/new` | admin | admin |
| GET/POST | `/admin/ads/<id>/edit` | admin | admin |
| POST | `/admin/ads/<id>/delete` | admin | admin |
| POST | `/admin/ads/<id>/toggle` | admin | admin |
| GET | `/admin/sponsors` | admin | admin |
| GET/POST | `/admin/sponsors/new` | admin | admin |
| GET/POST | `/admin/sponsors/<id>/edit` | admin | admin |
| POST | `/admin/sponsors/<id>/delete` | admin | admin |
| GET | `/admin/promoted-keywords` | admin | admin |
| GET/POST | `/admin/promoted-keywords/new` | admin | admin |
| POST | `/admin/promoted-keywords/<id>/delete` | admin | admin |
| GET | `/admin/transactions` | admin | admin |
| POST | `/admin/transactions/<id>/refund` | admin | admin |
| GET | `/admin/analytics` | admin | admin |
| GET | `/admin/audit` | admin | admin |
| GET/POST | `/admin/settings` | admin | admin |
--- ---
## 6. User Roles & RBAC ## 6. User Roles & RBAC
@@ -405,10 +504,22 @@ audit_log id, actor_id→users, action, target_type, target_id,
settings key PK, value JSON settings key PK, value JSON
(registration_open, ads_enabled, maintenance_mode, (registration_open, ads_enabled, maintenance_mode,
flag_threshold, new_user_trust_gate_days, etc.) flag_threshold, keyword_blocklist, new_user_trust_gate_days,
contact_density_threshold)
```
### Reviews (Phase 7)
```
reviews id, listing_id→listings, author_id→users, seller_id→users,
rating SMALLINT (15, CHECK CONSTRAINT),
body TEXT nullable, created_at
UNIQUE(listing_id, author_id)
```
### Deferred
```
listing_translations id, listing_id→listings, lang, title, body, cached_at listing_translations id, listing_id→listings, lang, title, body, cached_at
(Phase 7: auto-translate cache) (Phase 7: auto-translate cache — not yet implemented)
``` ```
--- ---
@@ -486,6 +597,49 @@ listing_translations id, listing_id→listings, lang, title, body, cached_at
- `active_sponsors(tier, category_id)` — running sponsors filtered by tier/category - `active_sponsors(tier, category_id)` — running sponsors filtered by tier/category
- `expire_promoted_keywords()` — removes expired rows; nightly timer - `expire_promoted_keywords()` — removes expired rows; nightly timer
### `services/reports.py` (Phase 6)
- `create_report(listing, reporter, reason, note)` — writes Report, auto-flags listing
when distinct reporter count ≥ `flag_threshold` setting (default 5)
- `ReportError` — raised for duplicate reports or self-reports
### `services/moderation.py` (Phase 6)
- `flag_queue(page, per_page)` — listings sorted by `flag_count × recency`
- `approve(listing, actor)` — clears flags, sets active, logs to audit_log
- `hide(listing, actor)` — sets flagged status, logs
- `remove(listing, actor)` — sets removed status, logs
### `services/audit.py` (Phase 6)
- `log_action(actor, action, target_type, target_id, meta)` — appends AuditLog row
### `services/settings.py` (Phase 6)
- `get_setting(key, default)` — reads from `settings` table, cached in app context
- `set_setting(key, value)` — upserts, invalidates cache
- Keys: `registration_open`, `ads_enabled`, `maintenance_mode`, `flag_threshold`,
`keyword_blocklist` (list), `new_user_trust_gate_days`, `contact_density_threshold`
### `services/admin_dashboard.py` (Phase 6)
- `kpis()` → dict with `active_listings`, `new_users_7d`, `new_users_30d`,
`mrr_cents`, `revenue_30d_cents`, `flag_queue_depth`
- `signups_per_day(days)`, `listings_per_day(days)`, `revenue_per_day(days)` → lists
- `top_categories(n)` — by active listing count
### `services/admin_users.py` (Phase 6)
- `search_query(q, role, status, tier)` — filtered User query
- `set_status(user, status, actor)` — ban/suspend/activate + audit log
- `set_tier(user, plan, actor)` — tier override + audit log
- `adjust_trust(user, delta, actor)` — adds trust event, recomputes tier + audit log
### `services/expiry_notifications.py` (Phase 7)
- `warn_expiring(days=3)` — emails listing owners whose listings expire within N days;
marks `_warn_sent` in `attributes` JSON to prevent duplicates
- `notify_expired()` — emails owners of newly-expired listings;
marks `_expired_sent` in `attributes` JSON; both are CLI commands via `__init__.py`
### `services/reviews.py` (Phase 7)
- `create_review(listing, author, rating, body)` — validates sold status + not-self;
catches `IntegrityError` for duplicate review
- `seller_rating(user_id)``{"avg": float|None, "count": int}`
--- ---
## 11. i18n (Trilingual: EN / VI / ES) ## 11. i18n (Trilingual: EN / VI / ES)
@@ -557,6 +711,9 @@ Phase 6: flag threshold → auto-flip, keyword blocklist, reports queue, audit_l
- Contact masking for low-trust users - Contact masking for low-trust users
- Signed time-limited tokens for email verify + password reset - Signed time-limited tokens for email verify + password reset
- ProxyFix for correct `is_secure` + client-IP behind Nginx - ProxyFix for correct `is_secure` + client-IP behind Nginx
- Mid-session ban enforcement: `enforce_active_account` before_request hook logs
out any authenticated user whose `status != active` on their next request
(Flask-Login only checks `is_active` at login time)
- Stripe webhook signature verification on every event - Stripe webhook signature verification on every event
- Secrets via `.env` only — never committed - Secrets via `.env` only — never committed
- HTTPS only in prod; `SESSION_COOKIE_SECURE=True` in ProdConfig - HTTPS only in prod; `SESSION_COOKIE_SECURE=True` in ProdConfig
@@ -643,7 +800,7 @@ flask shell
```bash ```bash
python3 -m venv venv && source venv/bin/activate python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
python -m tests.test_smoke # 58 checks, all green python -m tests.test_smoke # all checks green (Phase 16 complete)
``` ```
### Dev server ### Dev server
@@ -677,6 +834,8 @@ accounts. If `caching_sha2_password` errors: `pip install cryptography`.
# 2. python3 -m venv venv && pip install -r requirements.txt # 2. python3 -m venv venv && pip install -r requirements.txt
# 3. cp .env.example .env && nano .env (no inline comments!) # 3. cp .env.example .env && nano .env (no inline comments!)
# 4. flask db upgrade && python seed.py # 4. flask db upgrade && python seed.py
# 4b. Compile translations (committed .mo travel with the repo, but recompile
# after any .po change): pybabel compile -d app/translations
# 5. Copy systemd units: # 5. Copy systemd units:
cp deploy/classifieds.service /etc/systemd/system/ cp deploy/classifieds.service /etc/systemd/system/
cp deploy/classifieds-expire.{service,timer} /etc/systemd/system/ cp deploy/classifieds-expire.{service,timer} /etc/systemd/system/
@@ -695,7 +854,9 @@ certbot --nginx -d classifieds.ngodanguyen.tech
Nginx config independently of DNS Nginx config independently of DNS
- DNS A record must point to server's public IP - DNS A record must point to server's public IP
- `curl -4 ifconfig.me` vs `dig +short classifieds.ngodanguyen.tech` must match - `curl -4 ifconfig.me` vs `dig +short classifieds.ngodanguyen.tech` must match
- CSS cache busting: copy to `style.v2.css` and update `base.html` link - CSS cache busting: bump the `?v=N` query string on the `style.css` link in
`base.html` (single `style.css` file; the old `style.vN.css` copy approach was
dropped)
--- ---
@@ -708,6 +869,9 @@ certbot --nginx -d classifieds.ngodanguyen.tech
- All datetimes UTC in DB. Localize only at render. - All datetimes UTC in DB. Localize only at render.
- Money: integer cents everywhere. Never `float` for currency. - Money: integer cents everywhere. Never `float` for currency.
- `db.session.get(Model, pk)` not `Model.query.get(pk)` (SA 2.0 deprecated). - `db.session.get(Model, pk)` not `Model.query.get(pk)` (SA 2.0 deprecated).
- UTC now: use `utcnow()` from `app/utils/time.py` — never `datetime.utcnow()`
(deprecated on Python 3.12+). The helper returns a naive UTC datetime to match
the naive `DateTime` columns; do not mix in tz-aware datetimes.
- Enums: DB `ENUM` columns + Python `str, enum.Enum` in `models/enums.py`. - Enums: DB `ENUM` columns + Python `str, enum.Enum` in `models/enums.py`.
- Validate `listings.attributes` against `field_schema` server-side on every save. - Validate `listings.attributes` against `field_schema` server-side on every save.
- i18n: all user-facing strings in `_()`. No hardcoded English in templates. - i18n: all user-facing strings in `_()`. No hardcoded English in templates.
@@ -800,95 +964,93 @@ certbot --nginx -d classifieds.ngodanguyen.tech
--- ---
### 🔲 Phase 6 — Admin Backend (Next) ### Phase 6 — Admin Backend (Done)
**Dashboard** **Dashboard**
- [ ] `/admin` dashboard: KPI cards (active listings, new users 7d/30d, MRR, - [x] `/admin` dashboard: KPI cards (active listings, new users 7d/30d, MRR,
ad revenue, flag-queue depth) ad revenue, flag-queue depth)
- [ ] Mini charts: signups/day, listings/day, revenue trend (last 30d) - [x] Data tables: signups/day, listings/day, revenue trend (last 30d)
**User management** **User management**
- [ ] `/admin/users` — searchable/filterable table (role, status, tier, trust) - [x] `/admin/users` — searchable/filterable table (role, status, tier, trust)
- [ ] User detail: profile, listing history, subscription, trust events - [x] User detail: profile, listing history, subscription, trust events
- [ ] Actions: ban/suspend/activate, tier override, trust adjust, impersonate (→ audit_log) - [x] Actions: ban/suspend/activate, tier override, trust adjust, impersonate (→ audit_log)
- [ ] Bulk actions: ban selected, send email to selected
**Listing moderation** **Listing moderation**
- [ ] `/admin/listings` — flag queue sorted by `flag_count × recency` - [x] `/admin/listings` — flag queue sorted by `flag_count × recency`
- [ ] Quick actions: approve (clear flags), hide (flagged), remove, view - [x] Quick actions: approve (clear flags), hide (flagged), remove, view
- [ ] Bulk approve / bulk remove - [x] Auto-flag threshold: N distinct-user flags → auto-flip to `flagged`
- [ ] Auto-flag threshold: N distinct-user flags → auto-flip to `flagged`
(threshold stored in `settings` table, default 5) (threshold stored in `settings` table, default 5)
- [ ] Keyword blocklist editor (stored in `settings`, checked on listing submit) - [x] Keyword blocklist editor (stored in `settings`, checked on listing submit)
- [ ] Duplicate body detection (hash `body` on submit, flag if seen within 24h)
**Reports queue** **Reports queue**
- [ ] `/admin/reports` — flagged content with reporter reasons - [x] `/admin/reports` — flagged content with reporter reasons
- [ ] Mark resolved / escalate actions - [x] Mark resolved action
- [ ] `reports` table migration - [x] `reports` + `audit_log` + `settings` table models + routes
**Category management** **Category management**
- [ ] `/admin/categories` — CRUD, reorder (sort_order field) - [x] `/admin/categories` — CRUD, reorder (sort_order field)
- [ ] Field schema editor per category (add/remove fields, type/required/options) - [x] Field schema editor per category (add/remove fields, type/required/options)
**Plan / pricing management** **Plan / pricing management**
- [ ] `/admin/plans` — edit `config` JSON limits, name, price, Stripe price ID - [x] `/admin/plans` — edit `config` JSON limits, name, price, Stripe price ID
- [ ] No-redeploy: limits read at runtime from DB - [x] No-redeploy: limits read at runtime from DB
**Ads & sponsors management** **Ads & sponsors management**
- [ ] `/admin/ads`upload creative, set slot/targeting/schedule, stats - [x] `/admin/ads`CRUD, slot/targeting/schedule, impressions/clicks/CTR stats
- [ ] `/admin/sponsors` — CRUD sponsor entries, assign category - [x] `/admin/sponsors` — CRUD sponsor entries, assign category
- [ ] Ad performance report (impressions, clicks, CTR) - [x] `/admin/promoted-keywords` — assign keyword → listing, set priority + expiry
- [ ] `/admin/promoted-keywords` — assign keyword → listing, set priority + expiry
**Transactions & billing** **Transactions & billing**
- [ ] `/admin/transactions` — full log, filter by type/status/date - [x] `/admin/transactions` — full log, filter by type/status/date
- [ ] Refund action (Stripe Refund API + local refund transaction) - [x] Refund action (Stripe Refund API + local refund transaction)
- [ ] Failed payments list
**Settings** **Settings**
- [ ] `/admin/settings` — toggle UI for `settings` table: - [x] `/admin/settings` — toggle UI for `settings` table:
`registration_open`, `ads_enabled`, `maintenance_mode`, `flag_threshold`, `registration_open`, `ads_enabled`, `maintenance_mode`, `flag_threshold`,
`new_user_trust_gate_days`, `contact_density_threshold` `keyword_blocklist`, `new_user_trust_gate_days`, `contact_density_threshold`
**Audit log** **Audit log**
- [ ] All admin write actions → `audit_log` - [x] All admin write actions → `audit_log`
- [ ] `/admin/audit` — searchable trail - [x] `/admin/audit` — searchable trail
**Analytics** **Analytics**
- [ ] Traffic: page views/day, top pages, search terms - [x] Conversions: registrations/day, listings/day, revenue/day
- [ ] Conversions: registrations, listings posted, messages sent, boosts purchased - [x] Top categories by listing count; ad CTR summary
- [ ] Top categories + metros by listing count / views
**Tests** **Tests**
- [ ] Smoke: admin 200, non-admin 403 - [x] Smoke: admin 200, non-admin 403 for all admin routes
- [ ] Smoke: flag threshold auto-flips listing status - [x] Smoke: flag threshold auto-flips listing status
- [ ] Smoke: impersonate logs to audit_log - [x] Smoke: impersonate logs to audit_log
- [x] Smoke: settings (registration_open, ads_enabled, maintenance_mode,
contact_density_threshold, new_user_trust_gate_days) all verified
--- ---
### 🔲 Phase 7 — Polish ### 🔄 Phase 7 — Polish (In Progress)
**SEO & discoverability** **SEO & discoverability**
- [x] State landing pages `/classifieds/state/<state>`
- [x] Category landing pages `/classifieds/category/<slug>`
- [x] Dynamic `<title>` + `<meta description>` blocks in `base.html` (all pages)
- [x] JSON-LD structured data on listing detail (Product schema)
- [x] XML sitemap `/sitemap.xml` (static pages + active categories + 500 listings)
- [x] `robots.txt` (disallows admin/auth/my/messages/billing, includes Sitemap URL)
- [x] Open Graph tags (og:title, og:description, og:type, og:image, og:url + canonical)
- [ ] Metro landing pages `/classifieds/<metro-slug>` - [ ] Metro landing pages `/classifieds/<metro-slug>`
- [ ] State landing pages `/classifieds/state/<state>`
- [ ] Category landing pages `/classifieds/category/<slug>`
- [ ] Dynamic `<title>` + `<meta description>` on all pages
- [ ] JSON-LD structured data on listing detail (Product schema)
- [ ] XML sitemap `/sitemap.xml` (listings + categories + metros)
- [ ] `robots.txt`
- [ ] Open Graph tags (listing title, price, cover image)
**Email notifications** **Email notifications**
- [ ] Listing expiry warning (3 days before `expires_at`) - [x] Listing expiry warning `warn_expiring(days=3)` + `flask warn-expiring-listings` CLI
- [ ] Listing expired (with renew CTA) - [x] Listing expired notification — `notify_expired()` + `flask notify-expired-listings` CLI
- [ ] Move message notifications to RQ worker (async) - [ ] Move message notifications to RQ worker (async)
- [ ] Weekly digest email (new listings in saved categories) — opt-in - [ ] Weekly digest email (new listings in saved categories) — opt-in
**Reviews** **Reviews**
- [ ] `reviews` table (listing_id, author_id, rating TINYINT, body) - [x] `reviews` table model (listing_id, author_id, seller_id, rating 15, body)
- [ ] Leave review after mark-sold - [x] `POST /listings/<id>/review` — leave review after mark-sold (buyer only, once)
- [ ] Seller aggregate rating on profile + listing detail - [x] `services/reviews.py``create_review`, `seller_rating`
- [x] Seller aggregate rating shown on listing detail sidebar
- [x] Full review list displayed below listing
**Translation (deferred from Phase 2)** **Translation (deferred from Phase 2)**
- [ ] Per-listing "Translate" button → DeepL/Google API - [ ] Per-listing "Translate" button → DeepL/Google API
@@ -898,23 +1060,26 @@ certbot --nginx -d classifieds.ngodanguyen.tech
**Performance** **Performance**
- [ ] Redis sessions (`SESSION_TYPE=redis`) - [ ] Redis sessions (`SESSION_TYPE=redis`)
- [ ] Query caching for hot browse (Redis, 60s TTL) - [ ] Query caching for hot browse (Redis, 60s TTL)
- [ ] Lazy-load images (`loading="lazy"`) - [x] Lazy-load images (`loading="lazy"`, eager for first image)
- [ ] WebP thumbnails - [ ] WebP thumbnails
- [ ] MySQL `FULLTEXT(title, body)` + `MATCH ... AGAINST` - [ ] MySQL `FULLTEXT(title, body)` + `MATCH ... AGAINST`
- [ ] Keyset pagination for large datasets - [ ] Keyset pagination for large datasets
**UX & mobile** **UX & mobile**
- [ ] Responsive nav (hamburger on mobile) - [x] Responsive nav (hamburger on mobile, CSS + JS toggle)
- [ ] Listing image lightbox - [ ] Listing image lightbox
- [ ] "Load more" / infinite scroll on browse - [ ] "Load more" / infinite scroll on browse
- [ ] Toast notifications (non-blocking flash) - [x] Toast notifications (auto-dismiss after 5s with JS)
- [ ] "Back to results" preserving filter state - [ ] "Back to results" preserving filter state
- [ ] Listing preview before publish - [ ] Listing preview before publish
**i18n completion** **i18n completion**
- [ ] Extract all `_()` strings to `.pot` - [x] Translate VI + ES `.po` files (75-string Phase 12 catalog: auth + listings chrome)
- [ ] Translate VI + ES `.po` files - [x] Compile `.mo` for vi + es; verified loadable via gettext (placeholders + plural-forms intact)
- [ ] Compile `.mo`, test all three locales - [x] `.mo` files un-gitignored so they deploy (see Known Issues)
- [ ] Re-extract `.pot` to capture Phase 37 strings (messaging/payments/admin) —
needs `pybabel extract` (Babel not currently installed); then translate + recompile
- [ ] Test all three locales end-to-end in the running app
--- ---
@@ -964,9 +1129,17 @@ certbot --nginx -d classifieds.ngodanguyen.tech
| `.env` inline comments | MUST NOT use `# comments` after values — python-dotenv does not strip them → `int()` ValueError on startup. | | `.env` inline comments | MUST NOT use `# comments` after values — python-dotenv does not strip them → `int()` ValueError on startup. |
| Stripe webhook | `/billing/webhook` is `@csrf.exempt`. Always verify `Stripe-Signature` header first. | | Stripe webhook | `/billing/webhook` is `@csrf.exempt`. Always verify `Stripe-Signature` header first. |
| MySQL user grants | Must create BOTH `@'localhost'` AND `@'127.0.0.1'` — MySQL treats them as different accounts. | | MySQL user grants | Must create BOTH `@'localhost'` AND `@'127.0.0.1'` — MySQL treats them as different accounts. |
| CSS cache busting | Nginx serves static with 30d expires. To bust: copy to `style.vN.css` and update `base.html` link. | | CSS cache busting | Nginx serves static with 30d expires. To bust: bump the `?v=N` query string on the `style.css` link in `base.html`. |
| Translations (.mo) | `*.mo` is gitignored **except** `app/translations/**` (negated) so compiled catalogs deploy with the code — the no-build-step deploy has no compile phase. Recompile with `pybabel compile -d app/translations` after editing any `.po`. |
| Python / datetime | Target 3.113.12 for prod parity. `datetime.utcnow()` is deprecated on 3.12+ — use `utcnow()` from `app/utils/time.py`. |
--- ---
_End of spec. Phase 15 complete, 58 smoke-test checks green. _End of spec. Phase 16 complete. Phase 7 (Polish) in progress — SEO, UX, email
Next: Phase 6 (Admin Backend)._ notifications, reviews, and VI/ES translation compilation done (existing catalog).
Remaining: metro landing pages, i18n re-extraction for Phase 37 strings, Redis
sessions, WebP thumbnails, listing lightbox, load-more, "back to results",
translation cache, weekly digest.
Note: run the smoke suite in a venv matching `requirements.txt` (Flask 3.0.3 /
Python 3.113.12); the ambient Python 3.14 env lacks `flask_login` and drifts from
the pins._
+35 -1
View File
@@ -45,7 +45,7 @@ def _init_login(app):
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
from app.models.user import User from app.models.user import User
return User.query.get(int(user_id)) return db.session.get(User, int(user_id))
def _init_babel(app): def _init_babel(app):
@@ -122,6 +122,26 @@ def _register_context(app):
def _register_hooks(app): def _register_hooks(app):
@app.before_request
def enforce_active_account():
# A ban/suspension applied mid-session must take effect on the next
# request, not just at next login. Flask-Login only checks is_active
# when logging in, so we re-check the live status here.
if not current_user.is_authenticated:
return None
if getattr(current_user, "is_active", True):
return None
from flask import flash, redirect, url_for
from flask_babel import gettext as _
from flask_login import logout_user
ep = request.endpoint or ""
if ep in ("static", "auth.logout", "auth.login"):
return None
logout_user()
flash(_("Your account is no longer active. Please contact support."),
"warning")
return redirect(url_for("auth.login"))
@app.before_request @app.before_request
def check_maintenance(): def check_maintenance():
from app.services.settings import get_setting from app.services.settings import get_setting
@@ -163,3 +183,17 @@ def _register_cli(app):
from app.services.billing import reconcile_subscriptions from app.services.billing import reconcile_subscriptions
checked, fixed = reconcile_subscriptions() checked, fixed = reconcile_subscriptions()
print(f"Reconciled {checked} subscription(s), fixed {fixed}.") print(f"Reconciled {checked} subscription(s), fixed {fixed}.")
@app.cli.command("warn-expiring-listings")
def warn_expiring_cmd():
"""Nightly: email owners of listings expiring within 3 days."""
from app.services.expiry_notifications import warn_expiring
n = warn_expiring(days=3)
print(f"Sent {n} expiry warning email(s).")
@app.cli.command("notify-expired-listings")
def notify_expired_cmd():
"""Nightly: email owners of newly-expired listings."""
from app.services.expiry_notifications import notify_expired
n = notify_expired()
print(f"Sent {n} expiry notification email(s).")
+353
View File
@@ -1,5 +1,7 @@
"""Admin backend: dashboard KPIs + user management. Admin-only.""" """Admin backend: dashboard KPIs + user management. Admin-only."""
import json import json
from datetime import datetime, timedelta
from app.utils.time import utcnow
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort from flask import Blueprint, render_template, request, redirect, url_for, flash, abort
from flask_login import current_user from flask_login import current_user
from flask_babel import gettext as _ from flask_babel import gettext as _
@@ -13,6 +15,7 @@ from app.models.trust import TrustEvent
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.report import Report from app.models.report import Report
from app.models.payments import Transaction from app.models.payments import Transaction
from app.models.ads import Ad, Sponsor, PromotedKeyword
from app.models.enums import UserStatus, TrustEventType, ListingStatus from app.models.enums import UserStatus, TrustEventType, ListingStatus
from app.utils import admin_required from app.utils import admin_required
from app.services import admin_users as usvc from app.services import admin_users as usvc
@@ -402,3 +405,353 @@ def plan_edit(plan_id):
config_str = json.dumps(plan.config or {}, indent=2) config_str = json.dumps(plan.config or {}, indent=2)
return render_template("admin/plan_edit.html", plan=plan, return render_template("admin/plan_edit.html", plan=plan,
config_str=config_str, error=error) config_str=config_str, error=error)
# ---------------------------------------------------------------------------
# Ads management
# ---------------------------------------------------------------------------
def _parse_dt(s, fallback=None):
"""Parse 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM' strings into datetime."""
if not s:
return fallback
for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d"):
try:
return datetime.strptime(s.strip(), fmt)
except ValueError:
continue
return fallback
@admin_bp.route("/admin/ads")
@admin_required
def admin_ads():
ad_list = Ad.query.order_by(Ad.created_at.desc()).all()
return render_template("admin/ads.html", ad_list=ad_list)
@admin_bp.route("/admin/ads/new", methods=["GET", "POST"])
@admin_required
def admin_ad_new():
error = None
if request.method == "POST":
try:
ad = Ad(
advertiser_name=request.form["advertiser_name"].strip(),
slot=request.form["slot"],
target_url=request.form["target_url"].strip(),
alt_text=request.form.get("alt_text", "").strip() or None,
creative_path=request.form.get("creative_path", "").strip() or None,
lang=request.form.get("lang", "").strip() or None,
geo_state=request.form.get("geo_state", "").strip().upper() or None,
starts_at=_parse_dt(request.form.get("starts_at"),
utcnow()),
ends_at=_parse_dt(request.form.get("ends_at"),
utcnow() + timedelta(days=30)),
is_active=request.form.get("is_active") == "on",
)
db.session.add(ad)
audit.log_action(current_user, "ad.created", "ad", None,
meta={"advertiser": ad.advertiser_name,
"slot": ad.slot})
db.session.commit()
flash(_("Ad created."), "success")
return redirect(url_for("admin.admin_ads"))
except Exception as exc:
db.session.rollback()
error = str(exc)
return render_template("admin/ad_edit.html", ad=None, error=error)
@admin_bp.route("/admin/ads/<int:ad_id>", methods=["GET", "POST"])
@admin_required
def admin_ad_edit(ad_id):
ad = db.get_or_404(Ad, ad_id)
error = None
if request.method == "POST":
try:
ad.advertiser_name = request.form["advertiser_name"].strip()
ad.slot = request.form["slot"]
ad.target_url = request.form["target_url"].strip()
ad.alt_text = request.form.get("alt_text", "").strip() or None
ad.creative_path = request.form.get("creative_path", "").strip() or None
ad.lang = request.form.get("lang", "").strip() or None
ad.geo_state = request.form.get("geo_state", "").strip().upper() or None
ad.starts_at = _parse_dt(request.form.get("starts_at"), ad.starts_at)
ad.ends_at = _parse_dt(request.form.get("ends_at"), ad.ends_at)
ad.is_active = request.form.get("is_active") == "on"
audit.log_action(current_user, "ad.updated", "ad", ad.id,
meta={"advertiser": ad.advertiser_name})
db.session.commit()
flash(_("Ad updated."), "success")
return redirect(url_for("admin.admin_ads"))
except Exception as exc:
db.session.rollback()
error = str(exc)
return render_template("admin/ad_edit.html", ad=ad, error=error)
@admin_bp.route("/admin/ads/<int:ad_id>/delete", methods=["POST"])
@admin_required
def admin_ad_delete(ad_id):
ad = db.get_or_404(Ad, ad_id)
audit.log_action(current_user, "ad.deleted", "ad", ad.id,
meta={"advertiser": ad.advertiser_name})
db.session.delete(ad)
db.session.commit()
flash(_("Ad deleted."), "info")
return redirect(url_for("admin.admin_ads"))
@admin_bp.route("/admin/ads/<int:ad_id>/toggle", methods=["POST"])
@admin_required
def admin_ad_toggle(ad_id):
ad = db.get_or_404(Ad, ad_id)
ad.is_active = not ad.is_active
audit.log_action(current_user, "ad.toggled", "ad", ad.id,
meta={"is_active": ad.is_active})
db.session.commit()
flash(_("Ad %(state)s.", state=_("enabled") if ad.is_active else _("disabled")),
"success")
return redirect(url_for("admin.admin_ads"))
# ---------------------------------------------------------------------------
# Sponsors management
# ---------------------------------------------------------------------------
@admin_bp.route("/admin/sponsors")
@admin_required
def admin_sponsors():
sponsors = Sponsor.query.order_by(Sponsor.created_at.desc()).all()
return render_template("admin/sponsors.html", sponsors=sponsors)
@admin_bp.route("/admin/sponsors/new", methods=["GET", "POST"])
@admin_required
def admin_sponsor_new():
cats = Category.query.filter_by(parent_id=None, is_active=True).order_by(
Category.sort_order, Category.name).all()
error = None
if request.method == "POST":
try:
cat_id = request.form.get("category_id", type=int) or None
sp = Sponsor(
name=request.form["name"].strip(),
url=request.form["url"].strip(),
tagline=request.form.get("tagline", "").strip() or None,
logo_path=request.form.get("logo_path", "").strip() or None,
tier=request.form.get("tier", "directory"),
category_id=cat_id,
starts_at=_parse_dt(request.form.get("starts_at"), utcnow()),
ends_at=_parse_dt(request.form.get("ends_at"),
utcnow() + timedelta(days=30)),
is_active=request.form.get("is_active") == "on",
)
db.session.add(sp)
audit.log_action(current_user, "sponsor.created", "sponsor", None,
meta={"name": sp.name})
db.session.commit()
flash(_("Sponsor created."), "success")
return redirect(url_for("admin.admin_sponsors"))
except Exception as exc:
db.session.rollback()
error = str(exc)
return render_template("admin/sponsor_edit.html", sponsor=None,
categories=cats, error=error)
@admin_bp.route("/admin/sponsors/<int:sponsor_id>", methods=["GET", "POST"])
@admin_required
def admin_sponsor_edit(sponsor_id):
sp = db.get_or_404(Sponsor, sponsor_id)
cats = Category.query.filter_by(parent_id=None, is_active=True).order_by(
Category.sort_order, Category.name).all()
error = None
if request.method == "POST":
try:
sp.name = request.form["name"].strip()
sp.url = request.form["url"].strip()
sp.tagline = request.form.get("tagline", "").strip() or None
sp.logo_path = request.form.get("logo_path", "").strip() or None
sp.tier = request.form.get("tier", "directory")
sp.category_id = request.form.get("category_id", type=int) or None
sp.starts_at = _parse_dt(request.form.get("starts_at"), sp.starts_at)
sp.ends_at = _parse_dt(request.form.get("ends_at"), sp.ends_at)
sp.is_active = request.form.get("is_active") == "on"
audit.log_action(current_user, "sponsor.updated", "sponsor", sp.id,
meta={"name": sp.name})
db.session.commit()
flash(_("Sponsor updated."), "success")
return redirect(url_for("admin.admin_sponsors"))
except Exception as exc:
db.session.rollback()
error = str(exc)
return render_template("admin/sponsor_edit.html", sponsor=sp,
categories=cats, error=error)
@admin_bp.route("/admin/sponsors/<int:sponsor_id>/delete", methods=["POST"])
@admin_required
def admin_sponsor_delete(sponsor_id):
sp = db.get_or_404(Sponsor, sponsor_id)
audit.log_action(current_user, "sponsor.deleted", "sponsor", sp.id,
meta={"name": sp.name})
db.session.delete(sp)
db.session.commit()
flash(_("Sponsor deleted."), "info")
return redirect(url_for("admin.admin_sponsors"))
# ---------------------------------------------------------------------------
# Promoted keywords
# ---------------------------------------------------------------------------
@admin_bp.route("/admin/promoted-keywords")
@admin_required
def admin_promoted_keywords():
pks = (PromotedKeyword.query
.order_by(PromotedKeyword.expires_at.desc()).all())
return render_template("admin/promoted_keywords.html", pks=pks)
@admin_bp.route("/admin/promoted-keywords/new", methods=["GET", "POST"])
@admin_required
def admin_promoted_keyword_new():
error = None
if request.method == "POST":
listing_id = request.form.get("listing_id", type=int)
keyword = request.form.get("keyword", "").strip()
priority = request.form.get("priority", 0, type=int)
expires_at = _parse_dt(request.form.get("expires_at"),
utcnow() + timedelta(days=7))
listing = db.session.get(Listing, listing_id) if listing_id else None
if not listing:
error = "Listing not found."
elif not keyword:
error = "Keyword required."
else:
try:
pk = PromotedKeyword(keyword=keyword, listing_id=listing_id,
priority=priority, expires_at=expires_at)
db.session.add(pk)
audit.log_action(current_user, "promoted_keyword.created",
"promoted_keyword", None,
meta={"keyword": keyword, "listing_id": listing_id})
db.session.commit()
flash(_("Promoted keyword added."), "success")
return redirect(url_for("admin.admin_promoted_keywords"))
except Exception as exc:
db.session.rollback()
error = str(exc)
return render_template("admin/promoted_keyword_new.html", error=error)
@admin_bp.route("/admin/promoted-keywords/<int:pk_id>/delete", methods=["POST"])
@admin_required
def admin_promoted_keyword_delete(pk_id):
pk = db.get_or_404(PromotedKeyword, pk_id)
audit.log_action(current_user, "promoted_keyword.deleted",
"promoted_keyword", pk.id,
meta={"keyword": pk.keyword})
db.session.delete(pk)
db.session.commit()
flash(_("Promoted keyword removed."), "info")
return redirect(url_for("admin.admin_promoted_keywords"))
# ---------------------------------------------------------------------------
# Analytics
# ---------------------------------------------------------------------------
@admin_bp.route("/admin/analytics")
@admin_required
def analytics():
since_30 = utcnow() - timedelta(days=30)
# signups per day (last 30 days)
signups_raw = (db.session.query(
db.func.date(User.created_at).label("day"),
db.func.count().label("n"))
.filter(User.created_at >= since_30)
.group_by(db.func.date(User.created_at))
.order_by(db.func.date(User.created_at))
.all())
# listings posted per day (last 30 days)
listings_raw = (db.session.query(
db.func.date(Listing.created_at).label("day"),
db.func.count().label("n"))
.filter(Listing.created_at >= since_30)
.group_by(db.func.date(Listing.created_at))
.order_by(db.func.date(Listing.created_at))
.all())
# revenue per day (last 30 days)
revenue_raw = (db.session.query(
db.func.date(Transaction.created_at).label("day"),
db.func.sum(Transaction.amount_cents).label("cents"))
.filter(Transaction.created_at >= since_30,
Transaction.status == "succeeded")
.group_by(db.func.date(Transaction.created_at))
.order_by(db.func.date(Transaction.created_at))
.all())
# top categories by listing count (active)
top_cats = (db.session.query(
Category.name,
db.func.count(Listing.id).label("n"))
.join(Listing, Listing.category_id == Category.id)
.filter(Listing.status == ListingStatus.active)
.group_by(Category.id, Category.name)
.order_by(db.func.count(Listing.id).desc())
.limit(10).all())
# ad performance summary
ad_stats = (db.session.query(
db.func.sum(Ad.impressions).label("total_impressions"),
db.func.sum(Ad.clicks).label("total_clicks"))
.filter(Ad.is_active == True)
.first())
return render_template("admin/analytics.html",
signups=signups_raw,
listings_chart=listings_raw,
revenue_chart=revenue_raw,
top_cats=top_cats,
ad_stats=ad_stats)
# ---------------------------------------------------------------------------
# Refund action
# ---------------------------------------------------------------------------
@admin_bp.route("/admin/transactions/<int:txn_id>/refund", methods=["POST"])
@admin_required
def refund_transaction(txn_id):
txn = db.get_or_404(Transaction, txn_id)
if txn.status != "succeeded":
flash(_("Only succeeded transactions can be refunded."), "danger")
return redirect(url_for("admin.transactions"))
from app.services.billing import stripe_enabled
if stripe_enabled():
import stripe as stripe_lib
try:
stripe_lib.Refund.create(payment_intent=txn.stripe_object_id)
except stripe_lib.error.StripeError as exc:
flash(_("Stripe refund failed: %(m)s", m=str(exc)), "danger")
return redirect(url_for("admin.transactions"))
# record local refund transaction
refund_txn = Transaction(
user_id=txn.user_id,
type="refund",
amount_cents=-abs(txn.amount_cents),
currency=txn.currency or "usd",
stripe_object_id=txn.stripe_object_id,
status="succeeded",
meta={"refunded_txn_id": txn.id},
)
db.session.add(refund_txn)
audit.log_action(current_user, "transaction.refunded", "transaction", txn.id,
meta={"amount_cents": txn.amount_cents,
"user_id": txn.user_id})
db.session.commit()
flash(_("Refund recorded."), "success")
return redirect(url_for("admin.transactions"))
+2 -1
View File
@@ -1,5 +1,6 @@
"""Auth flows: register, login, logout, email verification, password reset.""" """Auth flows: register, login, logout, email verification, password reset."""
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from flask import (Blueprint, render_template, redirect, url_for, flash, from flask import (Blueprint, render_template, redirect, url_for, flash,
request, current_app, abort) request, current_app, abort)
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
@@ -82,7 +83,7 @@ def login():
flash(_("This account is suspended."), "danger") flash(_("This account is suspended."), "danger")
return render_template("auth/login.html", form=form) return render_template("auth/login.html", form=form)
login_user(user, remember=form.remember.data) login_user(user, remember=form.remember.data)
user.last_login_at = datetime.utcnow() user.last_login_at = utcnow()
db.session.commit() db.session.commit()
nxt = request.args.get("next") nxt = request.args.get("next")
if nxt and nxt.startswith("/"): if nxt and nxt.startswith("/"):
+37 -1
View File
@@ -14,6 +14,7 @@ from app.services import listings as svc
from app.services.geo import geocode_zip from app.services.geo import geocode_zip
from app.services.images import process_upload, delete_image_files, ImageError from app.services.images import process_upload, delete_image_files, ImageError
from app.services import reports as rsvc from app.services import reports as rsvc
from app.services import reviews as rev_svc
from app.blueprints.listings.forms import ListingForm, ImageUploadForm from app.blueprints.listings.forms import ListingForm, ImageUploadForm
listings_bp = Blueprint("listings", __name__) listings_bp = Blueprint("listings", __name__)
@@ -103,8 +104,18 @@ def detail(listing_id):
if not is_owner: if not is_owner:
listing.view_count = (listing.view_count or 0) + 1 listing.view_count = (listing.view_count or 0) + 1
db.session.commit() db.session.commit()
# determine if current user can leave a review
can_review = False
if (current_user.is_authenticated and not is_owner
and listing.status == ListingStatus.sold):
from app.models.review import Review
already = Review.query.filter_by(
listing_id=listing.id, author_id=current_user.id).first()
can_review = already is None
listing_rating = rev_svc.seller_rating(listing.user_id)
return render_template("listings/detail.html", listing=listing, return render_template("listings/detail.html", listing=listing,
is_owner=is_owner) is_owner=is_owner, can_review=can_review,
listing_rating=listing_rating)
# --- create --- # --- create ---
@@ -243,6 +254,31 @@ def report(listing_id):
return redirect(url_for("listings.detail", listing_id=listing.id)) return redirect(url_for("listings.detail", listing_id=listing.id))
# --- leave review ---
@listings_bp.route("/listings/<int:listing_id>/review", methods=["GET", "POST"])
@login_required
def leave_review(listing_id):
listing = Listing.query.get_or_404(listing_id)
if listing.status != ListingStatus.sold:
flash(_("You can only review sold listings."), "warning")
return redirect(url_for("listings.detail", listing_id=listing.id))
if listing.user_id == current_user.id:
flash(_("You cannot review your own listing."), "warning")
return redirect(url_for("listings.detail", listing_id=listing.id))
if request.method == "POST":
rating = request.form.get("rating", type=int)
body = request.form.get("body", "").strip()
try:
rev_svc.create_review(listing, current_user, rating, body)
flash(_("Review submitted. Thanks!"), "success")
return redirect(url_for("listings.detail", listing_id=listing.id))
except rev_svc.ReviewError as e:
flash(str(e), "danger")
return render_template("listings/review_form.html", listing=listing)
# --- my listings --- # --- my listings ---
@listings_bp.route("/my/listings") @listings_bp.route("/my/listings")
@login_required @login_required
+92 -2
View File
@@ -1,5 +1,7 @@
"""Main blueprint: landing page and health check.""" """Main blueprint: landing page, health check, SEO files."""
from flask import Blueprint, render_template, jsonify from datetime import datetime
from app.utils.time import utcnow
from flask import Blueprint, render_template, jsonify, Response, request
main_bp = Blueprint("main", __name__) main_bp = Blueprint("main", __name__)
@@ -12,3 +14,91 @@ def index():
@main_bp.route("/healthz") @main_bp.route("/healthz")
def healthz(): def healthz():
return jsonify(status="ok") return jsonify(status="ok")
@main_bp.route("/robots.txt")
def robots():
lines = [
"User-agent: *",
"Disallow: /admin",
"Disallow: /auth/",
"Disallow: /my/",
"Disallow: /messages",
"Disallow: /billing/",
f"Sitemap: {request.host_url}sitemap.xml",
]
return Response("\n".join(lines), mimetype="text/plain")
@main_bp.route("/sitemap.xml")
def sitemap():
from app.extensions import db
from app.models.listing import Listing
from app.models.category import Category
from app.models.enums import ListingStatus
base = request.host_url.rstrip("/")
urls = []
# static pages
for path in ("", "/listings", "/pricing", "/sponsors"):
urls.append({"loc": f"{base}{path}", "changefreq": "daily", "priority": "0.8"})
# categories
for cat in Category.query.filter_by(is_active=True).all():
urls.append({
"loc": f"{base}/listings?category={cat.id}",
"changefreq": "daily",
"priority": "0.7",
})
# active listings (last 500 by bump/created for crawl budget)
listings = (Listing.query
.filter(Listing.status == ListingStatus.active,
Listing.expires_at > utcnow())
.order_by(Listing.created_at.desc())
.limit(500).all())
for l in listings:
urls.append({
"loc": f"{base}/listings/{l.id}",
"lastmod": l.updated_at.strftime("%Y-%m-%d"),
"changefreq": "weekly",
"priority": "0.6",
})
xml = render_template("sitemap.xml", urls=urls)
return Response(xml, mimetype="application/xml")
# ---------------------------------------------------------------------------
# SEO landing pages
# ---------------------------------------------------------------------------
@main_bp.route("/classifieds/category/<slug>")
def category_landing(slug):
from app.extensions import db
from app.models.category import Category
from app.models.listing import Listing
from app.models.enums import ListingStatus
cat = Category.query.filter_by(slug=slug, is_active=True).first_or_404()
listings = (Listing.query
.filter(Listing.category_id == cat.id,
Listing.status == ListingStatus.active,
Listing.expires_at > utcnow())
.order_by(Listing.created_at.desc())
.limit(20).all())
return render_template("main/category_landing.html", cat=cat, listings=listings)
@main_bp.route("/classifieds/state/<state>")
def state_landing(state):
from app.extensions import db
from app.models.listing import Listing
from app.models.enums import ListingStatus
state = state.upper()
listings = (Listing.query
.filter(Listing.state == state,
Listing.status == ListingStatus.active,
Listing.expires_at > utcnow())
.order_by(Listing.created_at.desc())
.limit(20).all())
return render_template("main/state_landing.html", state=state, listings=listings)
+2 -1
View File
@@ -12,8 +12,9 @@ from app.models.payments import Subscription, Transaction, Boost
from app.models.ads import Ad, Sponsor, PromotedKeyword from app.models.ads import Ad, Sponsor, PromotedKeyword
from app.models.audit import AuditLog from app.models.audit import AuditLog
from app.models.setting import Setting from app.models.setting import Setting
from app.models.review import Review
__all__ = ["Plan", "User", "TrustEvent", "Category", "Listing", __all__ = ["Plan", "User", "TrustEvent", "Category", "Listing",
"ListingImage", "ZipGeo", "Metro", "Conversation", "Message", "ListingImage", "ZipGeo", "Metro", "Conversation", "Message",
"Favorite", "Report", "Subscription", "Transaction", "Boost", "Favorite", "Report", "Subscription", "Transaction", "Boost",
"Ad", "Sponsor", "PromotedKeyword", "AuditLog", "Setting"] "Ad", "Sponsor", "PromotedKeyword", "AuditLog", "Setting", "Review"]
+9 -8
View File
@@ -10,6 +10,7 @@ Category sponsor FK wired to categories.sponsor_id (set separately).
PromotedKeyword: pinned listing for a search keyword (promoted search). PromotedKeyword: pinned listing for a search keyword (promoted search).
""" """
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db from app.extensions import db
@@ -37,9 +38,9 @@ class Ad(db.Model):
clicks = db.Column(db.Integer, nullable=False, default=0) clicks = db.Column(db.Integer, nullable=False, default=0)
is_active = db.Column(db.Boolean, nullable=False, default=True) is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=datetime.utcnow, nullable=False) onupdate=utcnow, nullable=False)
@property @property
def ctr(self): def ctr(self):
@@ -49,7 +50,7 @@ class Ad(db.Model):
@property @property
def is_running(self): def is_running(self):
now = datetime.utcnow() now = utcnow()
return self.is_active and self.starts_at <= now <= self.ends_at return self.is_active and self.starts_at <= now <= self.ends_at
def __repr__(self): def __repr__(self):
@@ -75,14 +76,14 @@ class Sponsor(db.Model):
starts_at = db.Column(db.DateTime, nullable=False) starts_at = db.Column(db.DateTime, nullable=False)
ends_at = db.Column(db.DateTime, nullable=False) ends_at = db.Column(db.DateTime, nullable=False)
is_active = db.Column(db.Boolean, nullable=False, default=True) is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
category = db.relationship("Category", category = db.relationship("Category",
backref=db.backref("sponsors", lazy="selectin")) backref=db.backref("sponsors", lazy="selectin"))
@property @property
def is_running(self): def is_running(self):
now = datetime.utcnow() now = utcnow()
return self.is_active and self.starts_at <= now <= self.ends_at return self.is_active and self.starts_at <= now <= self.ends_at
def __repr__(self): def __repr__(self):
@@ -103,7 +104,7 @@ class PromotedKeyword(db.Model):
db.ForeignKey("listings.id"), nullable=False) db.ForeignKey("listings.id"), nullable=False)
priority = db.Column(db.Integer, nullable=False, default=0) priority = db.Column(db.Integer, nullable=False, default=0)
expires_at = db.Column(db.DateTime, nullable=False, index=True) expires_at = db.Column(db.DateTime, nullable=False, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
listing = db.relationship("Listing", listing = db.relationship("Listing",
backref=db.backref("promoted_keywords", backref=db.backref("promoted_keywords",
@@ -111,7 +112,7 @@ class PromotedKeyword(db.Model):
@property @property
def is_active(self): def is_active(self):
return self.expires_at > datetime.utcnow() return self.expires_at > utcnow()
def __repr__(self): def __repr__(self):
return f"<PromotedKeyword '{self.keyword}' L{self.listing_id}>" return f"<PromotedKeyword '{self.keyword}' L{self.listing_id}>"
+2 -1
View File
@@ -1,5 +1,6 @@
"""Append-only audit trail for admin write actions.""" """Append-only audit trail for admin write actions."""
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy import JSON from sqlalchemy import JSON
from app.extensions import db from app.extensions import db
@@ -16,7 +17,7 @@ class AuditLog(db.Model):
target_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), target_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
nullable=True, index=True) nullable=True, index=True)
meta = db.Column(JSON, nullable=True) meta = db.Column(JSON, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
actor = db.relationship("User", backref=db.backref("audit_logs", lazy="dynamic")) actor = db.relationship("User", backref=db.backref("audit_logs", lazy="dynamic"))
+4 -3
View File
@@ -14,6 +14,7 @@ Supported types: text, number, select, bool. `hot:true` marks a field whose
value is denormalized onto an indexed Listing column for fast filtering. value is denormalized onto an indexed Listing column for fast filtering.
""" """
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy import JSON from sqlalchemy import JSON
from app.extensions import db from app.extensions import db
@@ -33,9 +34,9 @@ class Category(db.Model):
sponsor_id = db.Column(db.BigInteger, nullable=True) # FK wired in Phase 5 sponsor_id = db.Column(db.BigInteger, nullable=True) # FK wired in Phase 5
is_active = db.Column(db.Boolean, nullable=False, default=True) is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=datetime.utcnow, nullable=False) onupdate=utcnow, nullable=False)
children = db.relationship("Category", backref=db.backref("parent", children = db.relationship("Category", backref=db.backref("parent",
remote_side=[id]), lazy="selectin") remote_side=[id]), lazy="selectin")
+2 -1
View File
@@ -1,5 +1,6 @@
"""Favorite (saved listing). One row per user+listing pair.""" """Favorite (saved listing). One row per user+listing pair."""
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db from app.extensions import db
@@ -15,7 +16,7 @@ class Favorite(db.Model):
db.ForeignKey("users.id"), nullable=False, index=True) db.ForeignKey("users.id"), nullable=False, index=True)
listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("listings.id"), nullable=False, index=True) db.ForeignKey("listings.id"), nullable=False, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
user = db.relationship("User", user = db.relationship("User",
backref=db.backref("favorites", lazy="dynamic")) backref=db.backref("favorites", lazy="dynamic"))
+6 -5
View File
@@ -10,6 +10,7 @@ Portability notes (see README "MySQL upgrades"):
MySQL add a FULLTEXT(title, body) index and switch to MATCH ... AGAINST. MySQL add a FULLTEXT(title, body) index and switch to MATCH ... AGAINST.
""" """
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy import JSON, Index from sqlalchemy import JSON, Index
from app.extensions import db from app.extensions import db
from app.models.enums import ListingStatus, Lang from app.models.enums import ListingStatus, Lang
@@ -55,9 +56,9 @@ class Listing(db.Model):
view_count = db.Column(db.Integer, nullable=False, default=0) view_count = db.Column(db.Integer, nullable=False, default=0)
expires_at = db.Column(db.DateTime, nullable=False, index=True) expires_at = db.Column(db.DateTime, nullable=False, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=datetime.utcnow, nullable=False) onupdate=utcnow, nullable=False)
user = db.relationship("User", backref=db.backref("listings", lazy="dynamic")) user = db.relationship("User", backref=db.backref("listings", lazy="dynamic"))
category = db.relationship("Category", back_populates="listings") category = db.relationship("Category", back_populates="listings")
@@ -73,7 +74,7 @@ class Listing(db.Model):
@property @property
def is_live(self): def is_live(self):
return (self.status == ListingStatus.active return (self.status == ListingStatus.active
and self.expires_at > datetime.utcnow()) and self.expires_at > utcnow())
@property @property
def price_display(self): def price_display(self):
@@ -101,7 +102,7 @@ class ListingImage(db.Model):
sort_order = db.Column(db.Integer, nullable=False, default=0) sort_order = db.Column(db.Integer, nullable=False, default=0)
width = db.Column(db.Integer, nullable=True) width = db.Column(db.Integer, nullable=True)
height = db.Column(db.Integer, nullable=True) height = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
listing = db.relationship("Listing", back_populates="images") listing = db.relationship("Listing", back_populates="images")
+4 -3
View File
@@ -5,6 +5,7 @@ The seller is always listing.user; buyer is any other authenticated user.
Messages are append-only; soft-delete not needed at Phase 3. Messages are append-only; soft-delete not needed at Phase 3.
""" """
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db from app.extensions import db
@@ -23,7 +24,7 @@ class Conversation(db.Model):
seller_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), seller_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("users.id"), nullable=False, index=True) db.ForeignKey("users.id"), nullable=False, index=True)
last_message_at = db.Column(db.DateTime, nullable=True, index=True) last_message_at = db.Column(db.DateTime, nullable=True, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
listing = db.relationship("Listing", listing = db.relationship("Listing",
backref=db.backref("conversations", lazy="dynamic")) backref=db.backref("conversations", lazy="dynamic"))
@@ -58,7 +59,7 @@ class Message(db.Model):
db.ForeignKey("users.id"), nullable=False) db.ForeignKey("users.id"), nullable=False)
body = db.Column(db.Text, nullable=False) body = db.Column(db.Text, nullable=False)
read_at = db.Column(db.DateTime, nullable=True) read_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
conversation = db.relationship("Conversation", back_populates="messages") conversation = db.relationship("Conversation", back_populates="messages")
sender = db.relationship("User", sender = db.relationship("User",
@@ -70,7 +71,7 @@ class Message(db.Model):
def mark_read(self): def mark_read(self):
if self.read_at is None: if self.read_at is None:
self.read_at = datetime.utcnow() self.read_at = utcnow()
def __repr__(self): def __repr__(self):
return f"<Message {self.id} C{self.conversation_id}>" return f"<Message {self.id} C{self.conversation_id}>"
+7 -6
View File
@@ -5,6 +5,7 @@ and are reconciled via webhooks + nightly job.
Money stored as integer cents throughout. Money stored as integer cents throughout.
""" """
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy import JSON from sqlalchemy import JSON
from app.extensions import db from app.extensions import db
@@ -27,9 +28,9 @@ class Subscription(db.Model):
nullable=False, default="active") nullable=False, default="active")
current_period_end = db.Column(db.DateTime, nullable=True) current_period_end = db.Column(db.DateTime, nullable=True)
cancel_at_period_end = db.Column(db.Boolean, nullable=False, default=False) cancel_at_period_end = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=datetime.utcnow, nullable=False) onupdate=utcnow, nullable=False)
user = db.relationship("User", user = db.relationship("User",
backref=db.backref("subscription", uselist=False)) backref=db.backref("subscription", uselist=False))
@@ -59,7 +60,7 @@ class Transaction(db.Model):
stripe_object_id = db.Column(db.String(64), nullable=True, index=True) stripe_object_id = db.Column(db.String(64), nullable=True, index=True)
status = db.Column(db.String(20), nullable=False, default="succeeded") status = db.Column(db.String(20), nullable=False, default="succeeded")
meta = db.Column(JSON, nullable=True) meta = db.Column(JSON, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
user = db.relationship("User", user = db.relationship("User",
backref=db.backref("transactions", lazy="dynamic")) backref=db.backref("transactions", lazy="dynamic"))
@@ -92,7 +93,7 @@ class Boost(db.Model):
transaction_id = db.Column( transaction_id = db.Column(
db.BigInteger().with_variant(db.Integer, "sqlite"), db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("transactions.id"), nullable=True) db.ForeignKey("transactions.id"), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
listing = db.relationship("Listing", listing = db.relationship("Listing",
backref=db.backref("boosts", lazy="selectin")) backref=db.backref("boosts", lazy="selectin"))
@@ -102,7 +103,7 @@ class Boost(db.Model):
@property @property
def is_active(self): def is_active(self):
return self.expires_at > datetime.utcnow() return self.expires_at > utcnow()
def __repr__(self): def __repr__(self):
return f"<Boost {self.type} L{self.listing_id} exp={self.expires_at.date()}>" return f"<Boost {self.type} L{self.listing_id} exp={self.expires_at.date()}>"
+4 -3
View File
@@ -1,5 +1,6 @@
"""Plan model. Tier limits live in `config` JSON, editable in admin without redeploy.""" """Plan model. Tier limits live in `config` JSON, editable in admin without redeploy."""
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy import JSON from sqlalchemy import JSON
from app.extensions import db from app.extensions import db
@@ -16,9 +17,9 @@ class Plan(db.Model):
is_active = db.Column(db.Boolean, nullable=False, default=True) is_active = db.Column(db.Boolean, nullable=False, default=True)
sort_order = db.Column(db.Integer, nullable=False, default=0) sort_order = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=datetime.utcnow, nullable=False) onupdate=utcnow, nullable=False)
users = db.relationship("User", back_populates="tier", lazy="dynamic") users = db.relationship("User", back_populates="tier", lazy="dynamic")
+2 -1
View File
@@ -1,5 +1,6 @@
"""User-submitted reports against listings (spam/abuse moderation).""" """User-submitted reports against listings (spam/abuse moderation)."""
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db from app.extensions import db
from app.models.enums import ReportReason from app.models.enums import ReportReason
@@ -15,7 +16,7 @@ class Report(db.Model):
db.ForeignKey("users.id"), nullable=False, index=True) db.ForeignKey("users.id"), nullable=False, index=True)
reason = db.Column(db.Enum(ReportReason), nullable=False) reason = db.Column(db.Enum(ReportReason), nullable=False)
note = db.Column(db.Text, nullable=True) note = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
listing = db.relationship("Listing", backref=db.backref("reports", lazy="dynamic")) listing = db.relationship("Listing", backref=db.backref("reports", lazy="dynamic"))
reporter = db.relationship("User", backref=db.backref("reports_filed", lazy="dynamic")) reporter = db.relationship("User", backref=db.backref("reports_filed", lazy="dynamic"))
+35
View File
@@ -0,0 +1,35 @@
"""Seller reviews — left by buyers after a listing is marked sold."""
from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db
class Review(db.Model):
__tablename__ = "reviews"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("listings.id"), nullable=False, index=True)
author_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("users.id"), nullable=False, index=True)
seller_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("users.id"), nullable=False, index=True)
rating = db.Column(db.SmallInteger, nullable=False) # 15
body = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
listing = db.relationship("Listing",
backref=db.backref("reviews", lazy="dynamic"))
author = db.relationship("User", foreign_keys=[author_id],
backref=db.backref("reviews_written", lazy="dynamic"))
seller = db.relationship("User", foreign_keys=[seller_id],
backref=db.backref("reviews_received", lazy="dynamic"))
__table_args__ = (
db.UniqueConstraint("listing_id", "author_id", name="uq_review_listing_author"),
db.CheckConstraint("rating BETWEEN 1 AND 5", name="ck_review_rating"),
)
def __repr__(self):
return f"<Review L{self.listing_id} by u{self.author_id} {self.rating}*>"
+3 -2
View File
@@ -1,5 +1,6 @@
"""Admin-editable runtime settings (registration_open, flag_threshold, etc.).""" """Admin-editable runtime settings (registration_open, flag_threshold, etc.)."""
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy import JSON from sqlalchemy import JSON
from app.extensions import db from app.extensions import db
@@ -9,8 +10,8 @@ class Setting(db.Model):
key = db.Column(db.String(80), primary_key=True) key = db.Column(db.String(80), primary_key=True)
value = db.Column(JSON, nullable=True) value = db.Column(JSON, nullable=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=datetime.utcnow, nullable=False) onupdate=utcnow, nullable=False)
def __repr__(self): def __repr__(self):
return f"<Setting {self.key}>" return f"<Setting {self.key}>"
+2 -1
View File
@@ -1,5 +1,6 @@
"""TrustEvent model. Append-only events that adjust a user's trust score.""" """TrustEvent model. Append-only events that adjust a user's trust score."""
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db from app.extensions import db
from app.models.enums import TrustEventType from app.models.enums import TrustEventType
@@ -11,7 +12,7 @@ class TrustEvent(db.Model):
user_id = db.Column(db.BigInteger, db.ForeignKey("users.id"), nullable=False, index=True) user_id = db.Column(db.BigInteger, db.ForeignKey("users.id"), nullable=False, index=True)
type = db.Column(db.Enum(TrustEventType), nullable=False) type = db.Column(db.Enum(TrustEventType), nullable=False)
delta = db.Column(db.Integer, nullable=False, default=0) delta = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
user = db.relationship("User", back_populates="trust_events") user = db.relationship("User", back_populates="trust_events")
+4 -3
View File
@@ -1,5 +1,6 @@
"""User model. Argon2 password hashing, role/trust fields, Flask-Login mixin.""" """User model. Argon2 password hashing, role/trust fields, Flask-Login mixin."""
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from flask_login import UserMixin from flask_login import UserMixin
from app.extensions import db from app.extensions import db
from app.models.enums import Role, UserStatus, TrustTier from app.models.enums import Role, UserStatus, TrustTier
@@ -28,9 +29,9 @@ class User(UserMixin, db.Model):
email_verified = db.Column(db.Boolean, nullable=False, default=False) email_verified = db.Column(db.Boolean, nullable=False, default=False)
last_login_at = db.Column(db.DateTime, nullable=True) last_login_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, updated_at = db.Column(db.DateTime, default=utcnow,
onupdate=datetime.utcnow, nullable=False) onupdate=utcnow, nullable=False)
trust_events = db.relationship("TrustEvent", back_populates="user", trust_events = db.relationship("TrustEvent", back_populates="user",
lazy="dynamic", cascade="all, delete-orphan") lazy="dynamic", cascade="all, delete-orphan")
+4 -3
View File
@@ -1,5 +1,6 @@
"""Admin dashboard KPI queries. Money returned as integer cents.""" """Admin dashboard KPI queries. Money returned as integer cents."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.utils.time import utcnow
from app.extensions import db from app.extensions import db
from app.models.user import User from app.models.user import User
from app.models.listing import Listing from app.models.listing import Listing
@@ -13,12 +14,12 @@ _ACTIVE_SUB_STATUSES = ("active", "trialing")
def active_listings_count(): def active_listings_count():
return Listing.query.filter( return Listing.query.filter(
Listing.status == ListingStatus.active, Listing.status == ListingStatus.active,
Listing.expires_at > datetime.utcnow(), Listing.expires_at > utcnow(),
).count() ).count()
def new_users_count(days): def new_users_count(days):
since = datetime.utcnow() - timedelta(days=days) since = utcnow() - timedelta(days=days)
return User.query.filter(User.created_at >= since).count() return User.query.filter(User.created_at >= since).count()
@@ -32,7 +33,7 @@ def mrr_cents():
def revenue_30d_cents(): def revenue_30d_cents():
"""Subscription + boost transactions in the last 30 days.""" """Subscription + boost transactions in the last 30 days."""
since = datetime.utcnow() - timedelta(days=30) since = utcnow() - timedelta(days=30)
return (db.session.query(db.func.coalesce(db.func.sum(Transaction.amount_cents), 0)) return (db.session.query(db.func.coalesce(db.func.sum(Transaction.amount_cents), 0))
.filter(Transaction.type.in_(("subscription", "boost")), .filter(Transaction.type.in_(("subscription", "boost")),
Transaction.created_at >= since, Transaction.created_at >= since,
+5 -4
View File
@@ -20,6 +20,7 @@ Sponsors:
""" """
import random import random
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from app.extensions import db from app.extensions import db
from app.models.ads import Ad, Sponsor, PromotedKeyword from app.models.ads import Ad, Sponsor, PromotedKeyword
from app.models.listing import Listing from app.models.listing import Listing
@@ -28,7 +29,7 @@ from app.utils.text import normalize
def get_ad(slot: str, lang: str = None, state: str = None) -> "Ad | None": def get_ad(slot: str, lang: str = None, state: str = None) -> "Ad | None":
"""Return one active ad for the slot, targeted then untargeted fallback.""" """Return one active ad for the slot, targeted then untargeted fallback."""
now = datetime.utcnow() now = utcnow()
base = Ad.query.filter( base = Ad.query.filter(
Ad.slot == slot, Ad.slot == slot,
Ad.is_active == True, Ad.is_active == True,
@@ -76,7 +77,7 @@ def promoted_listings(keyword: str) -> list:
if not keyword: if not keyword:
return [] return []
norm = normalize(keyword) norm = normalize(keyword)
now = datetime.utcnow() now = utcnow()
rows = (PromotedKeyword.query rows = (PromotedKeyword.query
.filter(PromotedKeyword.expires_at > now) .filter(PromotedKeyword.expires_at > now)
.order_by(PromotedKeyword.priority.desc()) .order_by(PromotedKeyword.priority.desc())
@@ -94,7 +95,7 @@ def promoted_listings(keyword: str) -> list:
def active_sponsors(tier: str = None, category_id: int = None) -> list: def active_sponsors(tier: str = None, category_id: int = None) -> list:
"""Return running sponsors, optionally filtered by tier and category.""" """Return running sponsors, optionally filtered by tier and category."""
now = datetime.utcnow() now = utcnow()
q = Sponsor.query.filter( q = Sponsor.query.filter(
Sponsor.is_active == True, Sponsor.is_active == True,
Sponsor.starts_at <= now, Sponsor.starts_at <= now,
@@ -109,7 +110,7 @@ def active_sponsors(tier: str = None, category_id: int = None) -> list:
def expire_promoted_keywords() -> int: def expire_promoted_keywords() -> int:
"""Remove expired promoted keyword rows. Returns count.""" """Remove expired promoted keyword rows. Returns count."""
now = datetime.utcnow() now = utcnow()
expired = PromotedKeyword.query.filter( expired = PromotedKeyword.query.filter(
PromotedKeyword.expires_at <= now).all() PromotedKeyword.expires_at <= now).all()
n = len(expired) n = len(expired)
+4 -3
View File
@@ -9,6 +9,7 @@ Key rules:
- stripe_enabled() guard lets the app run with no keys in dev. - stripe_enabled() guard lets the app run with no keys in dev.
""" """
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.utils.time import utcnow
import stripe import stripe
from flask import current_app from flask import current_app
from app.extensions import db from app.extensions import db
@@ -140,7 +141,7 @@ def activate_boost(user_id: int, listing_id: int, boost_type: str,
db.session.add(txn) db.session.add(txn)
db.session.flush() db.session.flush()
expires_at = datetime.utcnow() + timedelta(days=info["days"]) expires_at = utcnow() + timedelta(days=info["days"])
boost = Boost(listing_id=listing_id, user_id=user_id, boost = Boost(listing_id=listing_id, user_id=user_id,
type=boost_type, expires_at=expires_at, type=boost_type, expires_at=expires_at,
transaction_id=txn.id) transaction_id=txn.id)
@@ -152,7 +153,7 @@ def activate_boost(user_id: int, listing_id: int, boost_type: str,
if boost_type == "featured": if boost_type == "featured":
listing.is_featured = True listing.is_featured = True
if boost_type == "bump": if boost_type == "bump":
listing.bump_at = datetime.utcnow() listing.bump_at = utcnow()
db.session.commit() db.session.commit()
return boost return boost
@@ -307,7 +308,7 @@ def _record_sub_transaction(user_id, session):
def expire_boosts() -> int: def expire_boosts() -> int:
"""Clear expired boosts and revert listing effects. Returns count.""" """Clear expired boosts and revert listing effects. Returns count."""
now = datetime.utcnow() now = utcnow()
expired = Boost.query.filter(Boost.expires_at <= now).all() expired = Boost.query.filter(Boost.expires_at <= now).all()
n = 0 n = 0
for boost in expired: for boost in expired:
+7 -4
View File
@@ -10,14 +10,17 @@ moderators can spot scraping attempts.
""" """
import re import re
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from app.models.enums import TrustTier from app.models.enums import TrustTier
from app.services.settings import get_setting from app.services.settings import get_setting
# patterns # patterns
_PHONE_RE = re.compile( _PHONE_RE = re.compile(
r"(\+?1[\s\-.]?)?" r"(?<!\d)" # not mid-way through a longer digit run
r"(\(?\d{3}\)?[\s\-.])" r"(?:\+?1[\s\-.]?)?" # optional country code
r"\d{3}[\s\-.]\d{4}" r"\(?\d{3}\)?[\s\-.]?" # area code — separators now optional
r"\d{3}[\s\-.]?\d{4}" # so 5551234567 is caught, not just 555-123-4567
r"(?!\d)"
) )
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}") _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
_URL_RE = re.compile(r"https?://\S+|www\.\S+", re.I) _URL_RE = re.compile(r"https?://\S+|www\.\S+", re.I)
@@ -32,7 +35,7 @@ def contact_revealed(user) -> bool:
return False return False
gate_days = get_setting("new_user_trust_gate_days", 0) gate_days = get_setting("new_user_trust_gate_days", 0)
if gate_days and user.created_at: if gate_days and user.created_at:
if (datetime.utcnow() - user.created_at).days < gate_days: if (utcnow() - user.created_at).days < gate_days:
return False return False
return True return True
+90
View File
@@ -0,0 +1,90 @@
"""Listing expiry warning and expired notification emails.
Two sweeps (called from nightly systemd timer):
- warn_expiring(days=3): send one warning email per listing expiring within N days
that hasn't already received one.
- notify_expired(): send "your listing expired" email for newly-expired listings
that haven't been notified yet.
We track state via a JSON field on the listing. Rather than adding columns, we
use the existing `attributes` JSON and a private `_notified` sub-key so no migration
is required. The key is prefixed with `_` to avoid colliding with user attributes.
"""
from datetime import datetime, timedelta
from app.utils.time import utcnow
from flask import current_app
from app.extensions import db
from app.models.listing import Listing
from app.models.enums import ListingStatus
from app.services.email import send_email
def _already_notified(listing, key: str) -> bool:
attrs = listing.attributes or {}
return bool(attrs.get(key))
def _mark_notified(listing, key: str):
attrs = dict(listing.attributes or {})
attrs[key] = utcnow().isoformat()
listing.attributes = attrs
def warn_expiring(days: int = 3) -> int:
"""Send warning emails for listings expiring within `days` days. Returns count."""
now = utcnow()
window_end = now + timedelta(days=days)
soon = (Listing.query
.filter(Listing.status == ListingStatus.active,
Listing.expires_at > now,
Listing.expires_at <= window_end)
.all())
sent = 0
for listing in soon:
if _already_notified(listing, "_warn_sent"):
continue
user = listing.user
if not user or not user.email:
continue
days_left = (listing.expires_at - now).days
subject = f"Your listing '{listing.title[:50]}' expires in {days_left} day(s)"
body = (
f"Hi {user.display_name},\n\n"
f"Your listing \"{listing.title}\" will expire in {days_left} day(s) "
f"({listing.expires_at.strftime('%Y-%m-%d')}).\n\n"
f"To keep it active, edit and re-save it, or purchase a boost.\n\n"
f"View your listing: {current_app.config.get('SERVER_NAME', '')}/listings/{listing.id}\n\n"
f"— Classifieds"
)
if send_email(user.email, subject, body):
_mark_notified(listing, "_warn_sent")
db.session.commit()
sent += 1
return sent
def notify_expired() -> int:
"""Send 'your listing expired' emails for newly-expired listings. Returns count."""
expired = (Listing.query
.filter(Listing.status == ListingStatus.expired)
.all())
sent = 0
for listing in expired:
if _already_notified(listing, "_expired_sent"):
continue
user = listing.user
if not user or not user.email:
continue
subject = f"Your listing '{listing.title[:50]}' has expired"
body = (
f"Hi {user.display_name},\n\n"
f"Your listing \"{listing.title}\" expired on "
f"{listing.expires_at.strftime('%Y-%m-%d')}.\n\n"
f"To re-list it, create a new listing or upgrade your plan for longer listing life.\n\n"
f"— Classifieds"
)
if send_email(user.email, subject, body):
_mark_notified(listing, "_expired_sent")
db.session.commit()
sent += 1
return sent
+2 -1
View File
@@ -4,6 +4,7 @@ Portable across MySQL and SQLite (no spatial extension needed). For large-scale
deployments, see README for the MySQL POINT + SPATIAL INDEX upgrade. deployments, see README for the MySQL POINT + SPATIAL INDEX upgrade.
""" """
import math import math
from app.extensions import db
from app.models.geo import ZipGeo from app.models.geo import ZipGeo
EARTH_MI = 3958.7613 # mean earth radius, miles EARTH_MI = 3958.7613 # mean earth radius, miles
@@ -11,7 +12,7 @@ EARTH_MI = 3958.7613 # mean earth radius, miles
def geocode_zip(zip_code): def geocode_zip(zip_code):
"""Return (lat, lng, city, state, metro) or None.""" """Return (lat, lng, city, state, metro) or None."""
row = ZipGeo.query.get((zip_code or "").strip()) row = db.session.get(ZipGeo, (zip_code or "").strip())
if row is None: if row is None:
return None return None
return row.lat, row.lng, row.city, row.state, row.metro return row.lat, row.lng, row.city, row.state, row.metro
+15 -5
View File
@@ -1,7 +1,9 @@
"""Listing business logic: creation/edit with tier enforcement, the """Listing business logic: creation/edit with tier enforcement, the
browse/search/radius query builder, and the expiry sweep. browse/search/radius query builder, and the expiry sweep.
""" """
import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.utils.time import utcnow
from app.extensions import db from app.extensions import db
from app.models.listing import Listing from app.models.listing import Listing
from app.models.enums import ListingStatus, Lang from app.models.enums import ListingStatus, Lang
@@ -30,7 +32,7 @@ def active_count(user):
return Listing.query.filter( return Listing.query.filter(
Listing.user_id == user.id, Listing.user_id == user.id,
Listing.status == ListingStatus.active, Listing.status == ListingStatus.active,
Listing.expires_at > datetime.utcnow(), Listing.expires_at > utcnow(),
).count() ).count()
@@ -55,7 +57,15 @@ def _blocklist_hit(title, body):
if not blocklist: if not blocklist:
return False return False
hay = normalize(f"{title} {body}") hay = normalize(f"{title} {body}")
return any(normalize(term) in hay for term in blocklist if term) for term in blocklist:
if not term:
continue
t = normalize(term).strip()
# word-boundary match so "ass" doesn't flag "class"; multi-word
# phrases still match as an internal-boundaried run.
if t and re.search(rf"\b{re.escape(t)}\b", hay):
return True
return False
# --- create / update --- # --- create / update ---
@@ -78,7 +88,7 @@ def create_listing(user, category, *, title, body, lang, price_cents,
price_cents=price_cents, price_cents=price_cents,
attributes=cleaned, attributes=cleaned,
status=ListingStatus.active, status=ListingStatus.active,
expires_at=datetime.utcnow() + timedelta(days=_life_days(user)), expires_at=utcnow() + timedelta(days=_life_days(user)),
**hot_values(cleaned), **hot_values(cleaned),
) )
if _blocklist_hit(title, body): if _blocklist_hit(title, body):
@@ -125,7 +135,7 @@ def browse_query(*, category_id=None, q=None, state=None, min_price=None,
"""Base query of live listings with optional filters (no radius).""" """Base query of live listings with optional filters (no radius)."""
query = Listing.query.filter( query = Listing.query.filter(
Listing.status == ListingStatus.active, Listing.status == ListingStatus.active,
Listing.expires_at > datetime.utcnow(), Listing.expires_at > utcnow(),
) )
if category_id: if category_id:
query = query.filter(Listing.category_id == category_id) query = query.filter(Listing.category_id == category_id)
@@ -186,7 +196,7 @@ def search_with_radius(base_query, lat, lng, radius_mi):
# --- expiry sweep (scheduler / CLI) --- # --- expiry sweep (scheduler / CLI) ---
def expire_due_listings(): def expire_due_listings():
"""Flip active listings past expires_at to expired. Returns count.""" """Flip active listings past expires_at to expired. Returns count."""
now = datetime.utcnow() now = utcnow()
due = Listing.query.filter( due = Listing.query.filter(
Listing.status == ListingStatus.active, Listing.status == ListingStatus.active,
Listing.expires_at <= now, Listing.expires_at <= now,
+2 -1
View File
@@ -8,6 +8,7 @@ Rules:
for now sent inline — fast enough at low volume). for now sent inline — fast enough at low volume).
""" """
from datetime import datetime from datetime import datetime
from app.utils.time import utcnow
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from flask import current_app from flask import current_app
from app.extensions import db from app.extensions import db
@@ -67,7 +68,7 @@ def send_message(conversation, sender, body: str) -> Message:
body=body, body=body,
) )
db.session.add(msg) db.session.add(msg)
conversation.last_message_at = datetime.utcnow() conversation.last_message_at = utcnow()
db.session.commit() db.session.commit()
if flagged: if flagged:
+48
View File
@@ -0,0 +1,48 @@
"""Review business logic."""
from sqlalchemy.exc import IntegrityError
from app.extensions import db
from app.models.review import Review
class ReviewError(ValueError):
pass
def create_review(listing, author, rating: int, body: str | None = None):
"""Leave a review for a seller. Listing must be sold. Returns Review."""
from app.models.enums import ListingStatus
if listing.status != ListingStatus.sold:
raise ReviewError("can only review sold listings")
if listing.user_id == author.id:
raise ReviewError("cannot review your own listing")
if rating not in range(1, 6):
raise ReviewError("rating must be 15")
review = Review(
listing_id=listing.id,
author_id=author.id,
seller_id=listing.user_id,
rating=rating,
body=(body or "").strip() or None,
)
db.session.add(review)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
raise ReviewError("you have already reviewed this listing")
return review
def seller_rating(user_id) -> dict:
"""Return avg rating and count for a seller."""
from app.extensions import db
result = (db.session.query(
db.func.round(db.func.avg(Review.rating), 1).label("avg"),
db.func.count(Review.id).label("count"))
.filter(Review.seller_id == user_id)
.first())
return {
"avg": float(result.avg) if result.avg else None,
"count": result.count or 0,
}
+274 -147
View File
@@ -1,203 +1,330 @@
/* =========================================================================
Classifieds "Stoop" warm-marketplace theme
Terracotta + cream, Newsreader serif headings / Hanken Grotesk body.
Drop-in replacement for app/static/style.css. Class contract unchanged.
========================================================================= */
:root{ :root{
--bg:#f6f7f9; --fg:#1c2430; --muted:#6b7785; --line:#e3e7ec; /* accent */
--brand:#1f6feb; --brand-d:#1a5fd0; --ok:#1f9d55; --warn:#b7791f; --accent:#C15F3C; --accent-h:#A44E2F; --accent-soft:#FBEFE7;
--danger:#d64545; --info:#2b6cb0; --card:#fff; /* surfaces */
--page:#F7F1E7; --page-top:#F3E7D2; --card:#FFFDF8; --input:#FDFBF6;
--panel:#EFE7D6; --pill:#F7EEDD; --ink-panel:#2B2723;
/* lines */
--line:#E6DCCB; --line-2:#EFE7D8; --line-strong:#D8CDB8;
/* text */
--ink:#2B2723; --body:#4A433B; --muted:#6F665B; --muted-2:#8A8072; --faint:#A99B85;
/* status */
--ok:#4C7A5B; --ok-bg:#E6EFE6; --ok-line:#CBDDCB;
--warn:#B7893B; --warn-bg:#F6ECD7; --warn-line:#E7D3A6;
--danger:#B4472F; --danger-bg:#F6E2DB; --danger-line:#E6C3B6;
--info:#3E6DA3; --info-bg:#E4ECF4; --info-line:#CBDAEA;
--gold:#DFA24E;
/* tints */
--tint-a:#F3E0D4; --tint-b:#E2E9DC; --tint-c:#E6E2D2;
/* type */
--serif:'Newsreader',Georgia,'Times New Roman',serif;
--sans:'Hanken Grotesk',system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
--mono:'Space Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
/* aliases kept for any legacy refs */
--bg:var(--page); --fg:var(--ink); --brand:var(--accent); --brand-d:var(--accent-h);
} }
*{box-sizing:border-box} *{box-sizing:border-box}
body{margin:0;font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif; html,body{margin:0;padding:0}
background:var(--bg);color:var(--fg)} body{
.wrap{max-width:960px;margin:0 auto;padding:0 16px} font:16px/1.5 var(--sans);
a{color:var(--brand);text-decoration:none} background:var(--page);color:var(--ink);
a:hover{text-decoration:underline} -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
}
.wrap{max-width:1180px;margin:0 auto;padding:0 24px}
a{color:var(--accent);text-decoration:none}
a:hover{color:var(--accent-h)}
::placeholder{color:var(--faint)}
.site-header{background:var(--card);border-bottom:1px solid var(--line)} h1,h2,h3{font-family:var(--serif);font-weight:500;letter-spacing:-.01em;color:var(--ink)}
.site-header .wrap{display:flex;align-items:center;justify-content:space-between;height:56px} h1{font-size:clamp(28px,4vw,40px);line-height:1.08}
.brand{font-weight:700;font-size:18px;color:var(--fg)} h2{font-size:26px} h3{font-size:19px;font-weight:600}
.nav{display:flex;align-items:center;gap:14px}
.nav .hi{color:var(--muted)}
.langs{display:flex;gap:6px;margin-left:8px}
.langs a{font-size:12px;color:var(--muted);border:1px solid var(--line);
padding:2px 6px;border-radius:4px}
.langs a.on{background:var(--brand);color:#fff;border-color:var(--brand)}
.btn{display:inline-block;background:var(--brand);color:#fff;border:0; /* ---------- header ---------- */
padding:9px 16px;border-radius:8px;cursor:pointer;font-size:15px} .site-header{
.btn:hover{background:var(--brand-d);text-decoration:none} position:sticky;top:0;z-index:50;
.btn-lg{padding:12px 22px;font-size:17px} background:rgba(247,241,231,.88);backdrop-filter:blur(10px);
border-bottom:1px solid var(--line);
}
.site-header .wrap{display:flex;align-items:center;justify-content:space-between;
gap:20px;min-height:60px;padding-top:12px;padding-bottom:12px}
.brand{display:inline-flex;align-items:center;gap:10px;
font-family:var(--serif);font-weight:600;font-size:23px;letter-spacing:-.01em;color:var(--ink)}
.brand:hover{color:var(--ink)}
.brand-mark{width:34px;height:34px;border-radius:11px;background:var(--accent);
display:inline-flex;align-items:center;justify-content:center;
color:#FFF7EE;font-family:var(--serif);font-size:21px;font-weight:600;line-height:1}
.nav{display:flex;align-items:center;gap:20px;flex-wrap:wrap}
.nav a{font-size:15px;font-weight:600;color:var(--muted);padding:4px 1px;
border-bottom:2px solid transparent}
.nav a:hover{color:var(--accent)}
.nav a.on{color:var(--accent);border-bottom-color:var(--accent)}
.nav .hi{color:var(--muted-2);font-weight:600;font-size:14px}
.langs{display:flex;gap:6px;margin-left:6px}
.langs a{font-size:12px;font-weight:600;color:var(--muted-2);border:1px solid var(--line);
padding:3px 8px;border-radius:999px}
.langs a:hover{border-color:var(--accent);color:var(--accent)}
.langs a.on{background:var(--accent);color:#FFF7EE;border-color:var(--accent)}
main.wrap{padding-top:24px;padding-bottom:48px;display:block;width:100%} /* ---------- buttons ---------- */
.hero{text-align:center;padding:48px 0} .btn{display:inline-block;background:var(--accent);color:#FFF7EE;border:1px solid var(--accent);
.hero h1{font-size:32px;margin:0 0 8px} padding:10px 18px;border-radius:11px;cursor:pointer;font:600 15px/1 var(--sans);
.hero p{color:var(--muted);margin:0 0 24px} transition:filter .15s,border-color .15s,color .15s}
.btn:hover{filter:brightness(.93);color:#FFF7EE}
.btn-lg{padding:13px 24px;font-size:16px}
.btn.ghost{background:var(--card);color:var(--ink);border-color:var(--line-strong)}
.btn.ghost:hover{filter:none;border-color:var(--accent);color:var(--accent)}
.btn.danger{background:var(--danger);border-color:var(--danger);color:#FFF7EE}
.btn.danger:hover{color:#FFF7EE}
.btn.tiny{padding:5px 11px;font-size:12px;border-radius:8px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px; /* ---------- hero ---------- */
padding:24px} .hero{padding:60px 0 52px;
.card.narrow{max-width:420px;margin:0 auto} background:linear-gradient(180deg,var(--page-top) 0%,var(--page) 100%);
border-bottom:1px solid var(--line);margin:0 -24px 0;padding-left:24px;padding-right:24px}
.hero h1{margin:0 0 14px;font-size:clamp(36px,5.2vw,58px);max-width:16ch;text-wrap:balance}
.hero p{color:var(--muted);margin:0 0 28px;font-size:19px;max-width:54ch}
.hero .btn{margin-right:10px}
/* ---------- cards / forms ---------- */
.card{background:var(--card);border:1px solid var(--line);border-radius:15px;padding:24px}
.card.narrow{max-width:440px;margin:0 auto}
.card h2{margin-top:0} .card h2{margin-top:0}
.field{margin-bottom:16px;display:flex;flex-direction:column;gap:6px}
.field{margin-bottom:14px;display:flex;flex-direction:column;gap:4px} .field label{font-size:14px;font-weight:600;color:var(--ink)}
.field label{font-size:14px;color:var(--muted)} .input{width:100%;padding:11px 13px;border:1px solid var(--line);border-radius:10px;
.input{padding:9px 11px;border:1px solid var(--line);border-radius:8px;font-size:15px} background:var(--input);font:15px var(--sans);color:var(--ink)}
.input:focus{outline:2px solid var(--brand);border-color:var(--brand)} .input:focus{outline:none;border-color:var(--accent)}
.check{display:flex;align-items:center;gap:6px;font-size:14px;color:var(--muted); select.input{cursor:pointer}
margin-bottom:14px} .check{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--muted);margin-bottom:16px}
input[type=checkbox],input[type=radio]{accent-color:var(--accent)}
.errors{color:var(--danger);font-size:13px;margin:2px 0 0;padding-left:18px} .errors{color:var(--danger);font-size:13px;margin:2px 0 0;padding-left:18px}
.muted{color:var(--muted);font-size:14px;margin-top:14px} .muted{color:var(--muted);font-size:14px;margin-top:14px}
.small{font-size:12px}
.form-wide{max-width:660px;margin:0 auto}
.row2{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:14px}
.flashes{margin-bottom:18px;display:flex;flex-direction:column;gap:8px} /* ---------- flashes ---------- */
.flash{padding:10px 14px;border-radius:8px;border:1px solid var(--line)} .flashes{margin:18px 0;display:flex;flex-direction:column;gap:8px}
.flash-success{background:#e8f6ee;border-color:#bfe3cd;color:var(--ok)} .flash{padding:11px 15px;border-radius:11px;border:1px solid var(--line);font-size:14px;
.flash-danger{background:#fdeaea;border-color:#f4c4c4;color:var(--danger)} transition:opacity .4s}
.flash-warning{background:#fdf4e3;border-color:#f0dcae;color:var(--warn)} .flash-success{background:var(--ok-bg);border-color:var(--ok-line);color:var(--ok)}
.flash-info{background:#e9f1fb;border-color:#cadcf3;color:var(--info)} .flash-danger{background:var(--danger-bg);border-color:var(--danger-line);color:var(--danger)}
.flash-warning{background:var(--warn-bg);border-color:var(--warn-line);color:var(--warn)}
.flash-info{background:var(--info-bg);border-color:var(--info-line);color:var(--info)}
.site-footer{border-top:1px solid var(--line);color:var(--muted); /* ---------- footer ---------- */
font-size:13px;padding:18px 0} .site-footer{border-top:1px solid var(--line);color:var(--muted);font-size:14px;
padding:28px 0;margin-top:48px}
.site-footer a{font-weight:600}
.cf-turnstile{margin:0 0 14px} .cf-turnstile{margin:0 0 14px}
/* --- Phase 2: listings --- */ /* ---------- browse / grid / tiles ---------- */
.btn.ghost{background:#fff;color:var(--brand);border:1px solid var(--brand)} main.wrap{padding-top:28px;padding-bottom:56px;display:block;width:100%}
.btn.danger{background:var(--danger)} .browse{display:grid;grid-template-columns:270px 1fr;gap:28px;align-items:start;width:100%}
.btn.tiny{padding:2px 8px;font-size:12px;border-radius:6px} .browse .filters{min-width:0;position:sticky;top:84px}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.form-wide{max-width:640px;margin:0 auto}
.browse{display:grid;grid-template-columns:260px 1fr;gap:20px;align-items:start;width:100%}
.browse .filters{min-width:0}
.browse .results{min-width:0;width:100%} .browse .results{min-width:0;width:100%}
.filters h3{margin-top:0} .filters h3{margin:0 0 14px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,220px));gap:14px} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:18px}
.tile{padding:0;overflow:hidden;display:block;color:var(--fg);width:100%;max-width:220px} .tile{padding:0;overflow:hidden;display:flex;flex-direction:column;color:var(--ink);
.tile:hover{text-decoration:none;box-shadow:0 2px 10px rgba(0,0,0,.08)} width:100%;background:var(--card);border:1px solid var(--line);border-radius:15px;
.thumb{width:100%;height:140px;object-fit:cover;display:block;background:#eef1f4} transition:border-color .15s,box-shadow .15s,transform .15s}
.thumb.noimg{display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px} .tile:hover{border-color:var(--line-strong);
.tile-body{padding:10px} box-shadow:0 14px 30px -20px rgba(43,39,35,.4);transform:translateY(-2px);color:var(--ink)}
.tile-title{font-weight:600;font-size:14px;line-height:1.3;margin-bottom:4px} .thumb{width:100%;height:170px;aspect-ratio:4/3;object-fit:cover;display:block;
.tile-meta{display:flex;gap:8px;align-items:center} background-image:repeating-linear-gradient(135deg,#EFE6D6 0 11px,#F4EDE0 11px 22px)}
.price{color:var(--ok);font-weight:700} .thumb.noimg{display:flex;align-items:center;justify-content:center;
.price.big{font-size:24px} color:var(--faint);font:11px var(--mono)}
.small{font-size:12px} .tile-body{padding:13px 14px 15px}
.badge{display:inline-block;font-size:11px;padding:2px 7px;border-radius:10px;background:#eef1f4;color:var(--muted)} .tile-title{font-weight:600;font-size:15px;line-height:1.3;margin-bottom:5px;color:var(--ink)}
.badge.ok{background:#e8f6ee;color:var(--ok)} .tile-meta{display:flex;gap:8px;align-items:center;margin-bottom:4px}
.badge.warn{background:#fdf4e3;color:var(--warn)} .price{color:var(--accent);font-weight:700;font-size:18px}
.badge.cat{background:#e9f1fb;color:var(--info)} .price.big{font-size:34px;font-weight:700}
.pager{margin-top:18px;display:flex;gap:16px} .pager{margin-top:28px;display:flex;gap:8px;align-items:center;justify-content:center}
.detail{display:grid;grid-template-columns:1fr 280px;gap:20px;align-items:start} .pager a{background:var(--card);border:1px solid var(--line);border-radius:9px;
.detail-meta{display:flex;gap:10px;align-items:center;margin:8px 0} padding:9px 15px;font-size:14px;font-weight:600;color:var(--ink)}
.gallery{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0} .pager a:hover{border-color:var(--accent);color:var(--accent)}
.gallery img{max-width:220px;border-radius:8px}
.body{margin:14px 0;line-height:1.6} /* ---------- badges ---------- */
.attrs{border-collapse:collapse;width:100%} .badge{display:inline-block;font-size:12px;font-weight:600;padding:4px 10px;border-radius:999px;
.attrs th,.attrs td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line)} background:var(--pill);color:var(--muted);border:1px solid transparent}
.attrs th{color:var(--muted);font-weight:600;width:40%} .badge.ok{background:var(--ok-bg);color:var(--ok);border-color:var(--ok-line)}
.detail-side .btn{display:block;margin-bottom:8px;text-align:center} .badge.warn{background:var(--warn-bg);color:var(--warn);border-color:var(--warn-line)}
.detail-side form{margin:0} .badge.cat{background:var(--tint-a);color:#8A4A2E}
.seller{margin-bottom:14px} .badge.expiring{background:var(--warn-bg);color:var(--warn);border-color:var(--warn-line)}
.mine-head{display:flex;align-items:center;gap:14px;margin-bottom:14px} .badge.sponsored{background:var(--pill);color:var(--warn);border-color:var(--warn-line)}
/* ---------- detail ---------- */
.detail{display:grid;grid-template-columns:1fr 300px;gap:28px;align-items:start}
.detail-main h1{margin:0 0 12px}
.detail-meta{display:flex;gap:10px;align-items:center;margin:10px 0}
.detail-side{position:sticky;top:84px}
.detail-side .btn{display:block;margin-bottom:9px;text-align:center;width:100%}
.detail-side form{margin:0 0 9px}
.seller{margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid var(--line-2)}
.gallery{display:flex;flex-wrap:wrap;gap:10px;margin:14px 0}
.gallery img{max-width:220px;border-radius:12px;border:1px solid var(--line)}
.body{margin:16px 0;line-height:1.65;color:var(--body);font-size:16px}
.attrs{border-collapse:collapse;width:100%;margin-top:8px}
.attrs th,.attrs td{text-align:left;padding:11px 4px;border-bottom:1px solid var(--line-2);font-size:15px}
.attrs th{color:var(--muted-2);font-weight:600;width:40%}
.report-form{display:flex;flex-direction:column;gap:6px;margin-top:12px;
border-top:1px solid var(--line-2);padding-top:12px}
/* mine / tables */
.mine-head{display:flex;align-items:center;gap:14px;margin-bottom:16px}
.mine-head .btn{margin-left:auto} .mine-head .btn{margin-left:auto}
table.list{width:100%;border-collapse:collapse} table.list{width:100%;border-collapse:collapse}
table.list td{padding:8px 10px;border-bottom:1px solid var(--line)} table.list td{padding:11px 12px;border-bottom:1px solid var(--line-2)}
.thumbs{display:flex;gap:10px;flex-wrap:wrap} .thumbs{display:flex;gap:10px;flex-wrap:wrap}
.thumb-wrap{position:relative} .thumb-wrap{position:relative}
.thumb-wrap img{width:90px;height:90px;object-fit:cover;border-radius:8px} .thumb-wrap img{width:90px;height:90px;object-fit:cover;border-radius:10px;border:1px solid var(--line)}
.thumb-wrap form{position:absolute;top:2px;right:2px;margin:0} .thumb-wrap form{position:absolute;top:3px;right:3px;margin:0}
@media(max-width:760px){.browse,.detail{grid-template-columns:1fr}}
/* --- Phase 3: messaging + favorites --- */ /* landing grids */
.listing-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:18px;margin-top:18px}
.listing-card{display:flex;flex-direction:column;background:var(--card);
border:1px solid var(--line);border-radius:15px;overflow:hidden;text-decoration:none;
color:var(--ink);transition:border-color .15s,box-shadow .15s,transform .15s}
.listing-card:hover{border-color:var(--line-strong);
box-shadow:0 14px 30px -20px rgba(43,39,35,.4);transform:translateY(-2px);color:var(--ink)}
.listing-card img{width:100%;aspect-ratio:4/3;object-fit:cover}
.listing-info{padding:13px 14px 15px}
.listing-title{font-weight:600;margin-bottom:4px;
overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
/* ---------- messaging ---------- */
.msg-link{position:relative} .msg-link{position:relative}
.badge-count{display:inline-flex;align-items:center;justify-content:center; .badge-count{display:inline-flex;align-items:center;justify-content:center;
background:var(--danger);color:#fff;border-radius:10px;font-size:11px; background:var(--accent);color:#FFF7EE;border-radius:999px;font-size:11px;
min-width:18px;height:18px;padding:0 5px;font-weight:700;vertical-align:middle} min-width:18px;height:18px;padding:0 5px;font-weight:700;vertical-align:middle}
.badge-count.sm{min-width:16px;height:16px;font-size:10px} .badge-count.sm{min-width:16px;height:16px;font-size:10px}
.inbox-head{display:flex;align-items:center;gap:10px;margin-bottom:16px} .inbox-head{display:flex;align-items:center;gap:10px;margin-bottom:18px}
.inbox-head h2{margin:0} .inbox-head h2{margin:0}
.conv-list{list-style:none;padding:0;margin:0} .conv-list{list-style:none;padding:0;margin:0;background:var(--card);
.conv-row{border-bottom:1px solid var(--line);padding:12px 0} border:1px solid var(--line);border-radius:15px;overflow:hidden}
.conv-row a{display:block;text-decoration:none;color:var(--fg)} .conv-row{border-top:1px solid var(--line-2);padding:0}
.conv-row a:hover{background:var(--bg);border-radius:8px;padding:4px;margin:-4px} .conv-row:first-child{border-top:none}
.conv-row a{display:block;text-decoration:none;color:var(--ink);padding:14px 16px}
.conv-row a:hover{background:#FBF6EC;color:var(--ink)}
.conv-row.unread .conv-who{font-weight:700} .conv-row.unread .conv-who{font-weight:700}
.conv-meta{display:flex;align-items:center;gap:8px;margin-bottom:2px} .conv-meta{display:flex;align-items:center;gap:8px;margin-bottom:2px}
.conv-who{flex:1} .conv-who{flex:1}
.conv-time{margin-left:auto} .conv-time{margin-left:auto;color:var(--faint);font-size:12px}
.conv-listing,.conv-preview{margin-top:2px} .conv-listing,.conv-preview{margin-top:2px;color:var(--muted);font-size:14px}
.conv-wrap{display:flex;flex-direction:column;gap:16px;max-width:680px;margin:0 auto} .conv-wrap{display:flex;flex-direction:column;gap:16px;max-width:700px;margin:0 auto}
.conv-header h3{margin:8px 0 4px} .conv-header h3{margin:8px 0 4px}
.thread{display:flex;flex-direction:column;gap:12px} .thread{display:flex;flex-direction:column;gap:12px}
.bubble{max-width:80%;padding:12px 14px;border-radius:16px;line-height:1.5} .bubble{max-width:80%;padding:12px 15px;border-radius:16px;line-height:1.5;font-size:15px}
.bubble.mine{align-self:flex-end;background:var(--brand);color:#fff;border-bottom-right-radius:4px} .bubble.mine{align-self:flex-end;background:var(--accent);color:#FFF7EE;border-bottom-right-radius:5px}
.bubble.theirs{align-self:flex-start;background:var(--card);border:1px solid var(--line);border-bottom-left-radius:4px} .bubble.theirs{align-self:flex-start;background:var(--card);border:1px solid var(--line);
border-bottom-left-radius:5px;color:var(--ink)}
.bubble-meta{margin-top:6px;font-size:11px;opacity:.7} .bubble-meta{margin-top:6px;font-size:11px;opacity:.7}
.bubble.mine .bubble-meta{text-align:right} .bubble.mine .bubble-meta{text-align:right}
.reply-box{padding:16px} .reply-box{padding:16px}
.mask-notice{background:#fdf4e3;border:1px solid #f0dcae;border-radius:8px; .mask-notice{background:var(--warn-bg);border:1px solid var(--warn-line);border-radius:10px;
padding:8px 12px;margin-top:8px} padding:9px 13px;margin-top:8px;font-size:14px;color:var(--warn)}
/* --- Phase 4: payments / pricing --- */ /* ---------- pricing / billing ---------- */
.pricing-wrap{max-width:900px;margin:0 auto;padding:24px 0} .pricing-wrap{max-width:960px;margin:0 auto;padding:44px 0}
.pricing-head{text-align:center;margin-bottom:32px} .pricing-head{text-align:center;margin-bottom:40px}
.pricing-head h1{font-size:28px;margin:0 0 8px} .pricing-head h1{font-size:clamp(30px,4vw,44px);margin:0 0 12px}
.plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:20px} .pricing-head .muted{font-size:18px}
.plan-card{position:relative;display:flex;flex-direction:column;gap:0} .plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px;align-items:stretch}
.plan-card.current{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand)} .plan-card{position:relative;display:flex;flex-direction:column;gap:14px;border-radius:18px;padding:28px 24px}
.plan-badge{position:absolute;top:-10px;left:50%;transform:translateX(-50%); .plan-card.current{border:2px solid var(--accent);
background:var(--brand);color:#fff;font-size:11px;padding:2px 10px; box-shadow:0 22px 44px -26px rgba(43,39,35,.5)}
border-radius:10px;white-space:nowrap} .plan-badge{position:absolute;top:-13px;left:50%;transform:translateX(-50%);
.plan-name{font-weight:700;font-size:16px;margin-bottom:8px} background:var(--accent);color:#FFF7EE;font-size:12px;font-weight:600;
.plan-price{margin-bottom:16px} padding:5px 14px;border-radius:999px;white-space:nowrap}
.price-amount{font-size:28px;font-weight:800;color:var(--fg)} .plan-name{font-family:var(--serif);font-weight:600;font-size:23px;color:var(--ink)}
.price-period{color:var(--muted);font-size:14px} .plan-price{margin:0}
.plan-features{list-style:none;padding:0;margin:0 0 20px;flex:1; .price-amount{font-size:38px;font-weight:700;letter-spacing:-.02em;color:var(--ink)}
display:flex;flex-direction:column;gap:6px;font-size:14px;color:var(--muted)} .price-period{color:var(--muted-2);font-size:15px}
.plan-features .feat-yes{color:var(--ok)} .plan-features{list-style:none;padding:16px 0 0;margin:0;flex:1;border-top:1px solid var(--line-2);
.plan-features .feat-yes::before{content:"✓ "} display:flex;flex-direction:column;gap:11px;font-size:14px;color:var(--body)}
.plan-features li{display:flex;gap:9px;align-items:flex-start}
.plan-features .feat-yes{color:var(--body)}
.plan-features li::before{content:"✓";color:var(--accent);font-weight:700;flex:none}
.plan-btn{display:block;text-align:center;margin-top:auto} .plan-btn{display:block;text-align:center;margin-top:auto}
.plan-btn.disabled{background:#e3e7ec;color:var(--muted);cursor:default} .plan-btn.disabled{background:var(--line);border-color:var(--line);color:var(--muted-2);cursor:default}
.billing-wrap{max-width:680px;margin:0 auto} .plan-btn.disabled:hover{filter:none}
.billing-section{margin-bottom:28px;padding-bottom:24px;border-bottom:1px solid var(--line)} .billing-wrap{max-width:700px;margin:0 auto}
.billing-section{margin-bottom:28px;padding-bottom:24px;border-bottom:1px solid var(--line-2)}
.billing-section:last-child{border-bottom:0} .billing-section:last-child{border-bottom:0}
.billing-section h3{margin-top:0} .billing-section h3{margin-top:0}
.billing-plan{display:flex;align-items:center;gap:10px;margin-bottom:12px} .billing-plan{display:flex;align-items:center;gap:10px;margin-bottom:12px}
.billing-actions{display:flex;gap:10px} .billing-actions{display:flex;gap:10px}
.boost-options{display:flex;flex-direction:column;gap:10px;margin-bottom:20px} .boost-options{display:flex;flex-direction:column;gap:10px;margin-bottom:20px}
.boost-option{display:flex;align-items:center;gap:12px;padding:14px; .boost-option{display:flex;align-items:center;gap:12px;padding:14px;
border:1px solid var(--line);border-radius:10px;cursor:pointer} border:1px solid var(--line);border-radius:12px;cursor:pointer;background:var(--card)}
.boost-option:has(input:checked){border-color:var(--brand);background:#f0f6ff} .boost-option:has(input:checked){border-color:var(--accent);background:var(--accent-soft)}
.boost-option.active-boost{opacity:.6;cursor:default} .boost-option.active-boost{opacity:.6;cursor:default}
.boost-option input{accent-color:var(--brand)}
.boost-info{flex:1} .boost-info{flex:1}
.boost-label{font-weight:600;font-size:14px} .boost-label{font-weight:600;font-size:14px}
.boost-price{font-weight:700;color:var(--ok);font-size:15px} .boost-price{font-weight:700;color:var(--accent);font-size:15px}
.upgrade-prompt{background:#e9f1fb;border:1px solid #cadcf3;border-radius:8px; .upgrade-prompt{background:var(--info-bg);border:1px solid var(--info-line);border-radius:11px;
padding:10px 14px;margin-bottom:14px;font-size:14px;color:var(--info)} padding:11px 15px;margin-bottom:14px;font-size:14px;color:var(--info)}
.upgrade-prompt a{font-weight:600;color:var(--brand)} .upgrade-prompt a{font-weight:600}
/* --- Phase 5: ads & sponsors --- */ /* ---------- ads / sponsors ---------- */
.ad-slot{margin:8px 0;text-align:center;position:relative} .ad-slot{margin:10px 0;text-align:center;position:relative}
.ad-label{position:absolute;top:2px;left:4px;font-size:9px;color:var(--muted); .ad-label{position:absolute;top:2px;left:6px;font:9px var(--mono);color:var(--faint);
background:var(--bg);padding:0 3px;border-radius:3px;opacity:.7;z-index:1} background:var(--page);padding:0 4px;border-radius:3px;opacity:.8;z-index:1;text-transform:uppercase;letter-spacing:.08em}
.ad-img{max-width:100%;border-radius:8px;display:block;margin:0 auto} .ad-img{max-width:100%;border-radius:12px;display:block;margin:0 auto}
.ad-text-creative{background:#eef1f4;border-radius:8px;padding:12px; .ad-text-creative{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;
font-size:13px;color:var(--muted);text-align:center;min-height:60px; font-size:13px;color:var(--muted);text-align:center;min-height:60px;
display:flex;align-items:center;justify-content:center} display:flex;align-items:center;justify-content:center}
.ad-header{border-bottom:1px solid var(--line);padding:6px 0} .ad-header{border-bottom:1px solid var(--line);padding:6px 0}
.ad-footer{border-top:1px solid var(--line);padding:6px 0} .ad-footer{border-top:1px solid var(--line);padding:6px 0}
.badge.sponsored{background:#fff8e1;color:#b7791f;border:1px solid #f0dcae} .sponsor-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:18px;margin-top:18px}
.sponsor-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-top:16px} .sponsor-card{display:flex;flex-direction:column;align-items:center;text-align:center;
.sponsor-card{display:flex;flex-direction:column;align-items:center; padding:22px;text-decoration:none;color:var(--ink)}
text-align:center;padding:20px;text-decoration:none;color:var(--fg)} .sponsor-card:hover{box-shadow:0 14px 30px -20px rgba(43,39,35,.4);color:var(--ink)}
.sponsor-card:hover{box-shadow:0 2px 10px rgba(0,0,0,.08);text-decoration:none}
.sponsor-logo{max-width:140px;max-height:80px;object-fit:contain;margin-bottom:8px} .sponsor-logo{max-width:140px;max-height:80px;object-fit:contain;margin-bottom:8px}
.sponsor-name-only{font-weight:700;font-size:16px;margin-bottom:8px} .sponsor-name-only{font-weight:700;font-size:16px;margin-bottom:8px}
/* --- Phase 6: admin --- */ /* ---------- admin ---------- */
.admin-nav{display:flex;gap:16px;margin-bottom:16px;border-bottom:1px solid var(--line);padding-bottom:10px} .admin-nav{display:flex;gap:18px;margin-bottom:18px;border-bottom:1px solid var(--line);padding-bottom:12px;flex-wrap:wrap}
.admin-nav a{color:var(--muted);font-weight:600} .admin-nav a{color:var(--muted);font-weight:600}
.admin-nav a.on{color:var(--brand)} .admin-nav a:hover{color:var(--accent)}
.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-top:14px} .admin-nav a.on{color:var(--accent)}
.kpi-card{padding:16px} .kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-top:16px}
.kpi-label{color:var(--muted);font-size:13px;margin-bottom:6px} .kpi-card{padding:18px}
.kpi-value{font-size:26px;font-weight:800} .kpi-label{color:var(--muted-2);font-size:13px;margin-bottom:6px}
.kpi-value{font-size:28px;font-weight:700;font-family:var(--serif)}
.admin-filter-row{display:flex;gap:12px;align-items:end;margin-bottom:16px;flex-wrap:wrap} .admin-filter-row{display:flex;gap:12px;align-items:end;margin-bottom:16px;flex-wrap:wrap}
.admin-filter-row .field{margin-bottom:0} .admin-filter-row .field{margin-bottom:0}
.report-form{display:flex;flex-direction:column;gap:6px;margin-top:10px} .settings-panel{margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--line-2)}
.settings-panel{margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--line)}
.settings-panel textarea.input{min-height:80px;font-family:inherit} .settings-panel textarea.input{min-height:80px;font-family:inherit}
/* ---------- reviews ---------- */
.review-stars{color:var(--gold);font-size:18px}
.review-block{border-top:1px solid var(--line-2);padding-top:12px;margin-top:12px}
.rating-summary{font-size:22px;font-weight:700;display:flex;align-items:center;gap:8px}
/* ---------- mobile nav ---------- */
.nav-toggle{display:none;background:none;border:none;font-size:22px;cursor:pointer;
color:var(--ink);padding:4px 8px;line-height:1}
@media(max-width:820px){.browse,.detail{grid-template-columns:1fr}
.browse .filters,.detail-side{position:static}}
@media(max-width:720px){
.site-header .wrap{flex-wrap:wrap;min-height:auto;padding:10px 24px;gap:8px}
.nav-toggle{display:block}
.nav{display:none;flex-direction:column;align-items:flex-start;width:100%;gap:0;padding-bottom:8px}
.nav.open{display:flex}
.nav a,.nav .btn,.nav .hi{padding:9px 0;width:100%;border-bottom:1px solid var(--line-2)}
.nav a.on{border-bottom-color:var(--line-2)}
.nav .langs{flex-direction:row;margin-left:0;padding:9px 0;border-bottom:none}
.hero{padding-left:24px;padding-right:24px}
}
/* hero eyebrow */
.hero-eyebrow{margin:0 0 14px;font-size:14px;font-weight:600;letter-spacing:.08em;
text-transform:uppercase;color:var(--accent)}
-136
View File
@@ -1,136 +0,0 @@
:root{
--bg:#f6f7f9; --fg:#1c2430; --muted:#6b7785; --line:#e3e7ec;
--brand:#1f6feb; --brand-d:#1a5fd0; --ok:#1f9d55; --warn:#b7791f;
--danger:#d64545; --info:#2b6cb0; --card:#fff;
}
*{box-sizing:border-box}
body{margin:0;font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
background:var(--bg);color:var(--fg)}
.wrap{max-width:960px;margin:0 auto;padding:0 16px}
a{color:var(--brand);text-decoration:none}
a:hover{text-decoration:underline}
.site-header{background:var(--card);border-bottom:1px solid var(--line)}
.site-header .wrap{display:flex;align-items:center;justify-content:space-between;height:56px}
.brand{font-weight:700;font-size:18px;color:var(--fg)}
.nav{display:flex;align-items:center;gap:14px}
.nav .hi{color:var(--muted)}
.langs{display:flex;gap:6px;margin-left:8px}
.langs a{font-size:12px;color:var(--muted);border:1px solid var(--line);
padding:2px 6px;border-radius:4px}
.langs a.on{background:var(--brand);color:#fff;border-color:var(--brand)}
.btn{display:inline-block;background:var(--brand);color:#fff;border:0;
padding:9px 16px;border-radius:8px;cursor:pointer;font-size:15px}
.btn:hover{background:var(--brand-d);text-decoration:none}
.btn-lg{padding:12px 22px;font-size:17px}
main.wrap{padding-top:24px;padding-bottom:48px;display:block;width:100%}
.hero{text-align:center;padding:48px 0}
.hero h1{font-size:32px;margin:0 0 8px}
.hero p{color:var(--muted);margin:0 0 24px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;
padding:24px}
.card.narrow{max-width:420px;margin:0 auto}
.card h2{margin-top:0}
.field{margin-bottom:14px;display:flex;flex-direction:column;gap:4px}
.field label{font-size:14px;color:var(--muted)}
.input{padding:9px 11px;border:1px solid var(--line);border-radius:8px;font-size:15px}
.input:focus{outline:2px solid var(--brand);border-color:var(--brand)}
.check{display:flex;align-items:center;gap:6px;font-size:14px;color:var(--muted);
margin-bottom:14px}
.errors{color:var(--danger);font-size:13px;margin:2px 0 0;padding-left:18px}
.muted{color:var(--muted);font-size:14px;margin-top:14px}
.flashes{margin-bottom:18px;display:flex;flex-direction:column;gap:8px}
.flash{padding:10px 14px;border-radius:8px;border:1px solid var(--line)}
.flash-success{background:#e8f6ee;border-color:#bfe3cd;color:var(--ok)}
.flash-danger{background:#fdeaea;border-color:#f4c4c4;color:var(--danger)}
.flash-warning{background:#fdf4e3;border-color:#f0dcae;color:var(--warn)}
.flash-info{background:#e9f1fb;border-color:#cadcf3;color:var(--info)}
.site-footer{border-top:1px solid var(--line);color:var(--muted);
font-size:13px;padding:18px 0}
.cf-turnstile{margin:0 0 14px}
/* --- Phase 2: listings --- */
.btn.ghost{background:#fff;color:var(--brand);border:1px solid var(--brand)}
.btn.danger{background:var(--danger)}
.btn.tiny{padding:2px 8px;font-size:12px;border-radius:6px}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.form-wide{max-width:640px;margin:0 auto}
.browse{display:grid;grid-template-columns:260px 1fr;gap:20px;align-items:start;width:100%}
.browse .filters{min-width:0}
.browse .results{min-width:0;width:100%}
.filters h3{margin-top:0}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,220px));gap:14px}
.tile{padding:0;overflow:hidden;display:block;color:var(--fg);width:100%;max-width:220px}
.tile:hover{text-decoration:none;box-shadow:0 2px 10px rgba(0,0,0,.08)}
.thumb{width:100%;height:140px;object-fit:cover;display:block;background:#eef1f4}
.thumb.noimg{display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px}
.tile-body{padding:10px}
.tile-title{font-weight:600;font-size:14px;line-height:1.3;margin-bottom:4px}
.tile-meta{display:flex;gap:8px;align-items:center}
.price{color:var(--ok);font-weight:700}
.price.big{font-size:24px}
.small{font-size:12px}
.badge{display:inline-block;font-size:11px;padding:2px 7px;border-radius:10px;background:#eef1f4;color:var(--muted)}
.badge.ok{background:#e8f6ee;color:var(--ok)}
.badge.warn{background:#fdf4e3;color:var(--warn)}
.badge.cat{background:#e9f1fb;color:var(--info)}
.pager{margin-top:18px;display:flex;gap:16px}
.detail{display:grid;grid-template-columns:1fr 280px;gap:20px;align-items:start}
.detail-meta{display:flex;gap:10px;align-items:center;margin:8px 0}
.gallery{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}
.gallery img{max-width:220px;border-radius:8px}
.body{margin:14px 0;line-height:1.6}
.attrs{border-collapse:collapse;width:100%}
.attrs th,.attrs td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line)}
.attrs th{color:var(--muted);font-weight:600;width:40%}
.detail-side .btn{display:block;margin-bottom:8px;text-align:center}
.detail-side form{margin:0}
.seller{margin-bottom:14px}
.mine-head{display:flex;align-items:center;gap:14px;margin-bottom:14px}
.mine-head .btn{margin-left:auto}
table.list{width:100%;border-collapse:collapse}
table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
.thumbs{display:flex;gap:10px;flex-wrap:wrap}
.thumb-wrap{position:relative}
.thumb-wrap img{width:90px;height:90px;object-fit:cover;border-radius:8px}
.thumb-wrap form{position:absolute;top:2px;right:2px;margin:0}
@media(max-width:760px){.browse,.detail{grid-template-columns:1fr}}
/* --- Phase 3: messaging + favorites --- */
.msg-link{position:relative}
.badge-count{display:inline-flex;align-items:center;justify-content:center;
background:var(--danger);color:#fff;border-radius:10px;font-size:11px;
min-width:18px;height:18px;padding:0 5px;font-weight:700;vertical-align:middle}
.badge-count.sm{min-width:16px;height:16px;font-size:10px}
.inbox-head{display:flex;align-items:center;gap:10px;margin-bottom:16px}
.inbox-head h2{margin:0}
.conv-list{list-style:none;padding:0;margin:0}
.conv-row{border-bottom:1px solid var(--line);padding:12px 0}
.conv-row a{display:block;text-decoration:none;color:var(--fg)}
.conv-row a:hover{background:var(--bg);border-radius:8px;padding:4px;margin:-4px}
.conv-row.unread .conv-who{font-weight:700}
.conv-meta{display:flex;align-items:center;gap:8px;margin-bottom:2px}
.conv-who{flex:1}
.conv-time{margin-left:auto}
.conv-listing,.conv-preview{margin-top:2px}
.conv-wrap{display:flex;flex-direction:column;gap:16px;max-width:680px;margin:0 auto}
.conv-header h3{margin:8px 0 4px}
.thread{display:flex;flex-direction:column;gap:12px}
.bubble{max-width:80%;padding:12px 14px;border-radius:16px;line-height:1.5}
.bubble.mine{align-self:flex-end;background:var(--brand);color:#fff;border-bottom-right-radius:4px}
.bubble.theirs{align-self:flex-start;background:var(--card);border:1px solid var(--line);border-bottom-left-radius:4px}
.bubble-meta{margin-top:6px;font-size:11px;opacity:.7}
.bubble.mine .bubble-meta{text-align:right}
.reply-box{padding:16px}
.mask-notice{background:#fdf4e3;border:1px solid #f0dcae;border-radius:8px;
padding:8px 12px;margin-top:8px}
.link-btn{background:none;border:none;padding:0;color:inherit;font:inherit;cursor:pointer;text-decoration:none}
.link-btn:hover{text-decoration:underline}
.logout-form{display:inline}
+4
View File
@@ -5,7 +5,11 @@
<a href="{{ url_for('admin.reports') }}" class="{{ 'on' if request.endpoint == 'admin.reports' else '' }}">{{ _('Reports') }}</a> <a href="{{ url_for('admin.reports') }}" class="{{ 'on' if request.endpoint == 'admin.reports' else '' }}">{{ _('Reports') }}</a>
<a href="{{ url_for('admin.categories') }}" class="{{ 'on' if request.endpoint in ('admin.categories', 'admin.toggle_category', 'admin.category_schema') else '' }}">{{ _('Categories') }}</a> <a href="{{ url_for('admin.categories') }}" class="{{ 'on' if request.endpoint in ('admin.categories', 'admin.toggle_category', 'admin.category_schema') else '' }}">{{ _('Categories') }}</a>
<a href="{{ url_for('admin.plans') }}" class="{{ 'on' if request.endpoint in ('admin.plans', 'admin.plan_edit') else '' }}">{{ _('Plans') }}</a> <a href="{{ url_for('admin.plans') }}" class="{{ 'on' if request.endpoint in ('admin.plans', 'admin.plan_edit') else '' }}">{{ _('Plans') }}</a>
<a href="{{ url_for('admin.admin_ads') }}" class="{{ 'on' if request.endpoint in ('admin.admin_ads', 'admin.admin_ad_new', 'admin.admin_ad_edit') else '' }}">{{ _('Ads') }}</a>
<a href="{{ url_for('admin.admin_sponsors') }}" class="{{ 'on' if request.endpoint in ('admin.admin_sponsors', 'admin.admin_sponsor_new', 'admin.admin_sponsor_edit') else '' }}">{{ _('Sponsors') }}</a>
<a href="{{ url_for('admin.admin_promoted_keywords') }}" class="{{ 'on' if request.endpoint in ('admin.admin_promoted_keywords', 'admin.admin_promoted_keyword_new') else '' }}">{{ _('Keywords') }}</a>
<a href="{{ url_for('admin.transactions') }}" class="{{ 'on' if request.endpoint == 'admin.transactions' else '' }}">{{ _('Transactions') }}</a> <a href="{{ url_for('admin.transactions') }}" class="{{ 'on' if request.endpoint == 'admin.transactions' else '' }}">{{ _('Transactions') }}</a>
<a href="{{ url_for('admin.analytics') }}" class="{{ 'on' if request.endpoint == 'admin.analytics' else '' }}">{{ _('Analytics') }}</a>
<a href="{{ url_for('admin.audit_log') }}" class="{{ 'on' if request.endpoint == 'admin.audit_log' else '' }}">{{ _('Audit log') }}</a> <a href="{{ url_for('admin.audit_log') }}" class="{{ 'on' if request.endpoint == 'admin.audit_log' else '' }}">{{ _('Audit log') }}</a>
<a href="{{ url_for('admin.settings') }}" class="{{ 'on' if request.endpoint == 'admin.settings' else '' }}">{{ _('Settings') }}</a> <a href="{{ url_for('admin.settings') }}" class="{{ 'on' if request.endpoint == 'admin.settings' else '' }}">{{ _('Settings') }}</a>
</nav> </nav>
+76
View File
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · %(t)s', t=_('Edit ad') if ad else _('New ad')) }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ _('Edit ad') if ad else _('New ad') }}</h2>
{% if error %}<p class="alert danger">{{ error }}</p>{% endif %}
<form method="post" class="settings-panel">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label>{{ _('Advertiser name') }} *</label>
<input class="input" name="advertiser_name" required
value="{{ ad.advertiser_name if ad else '' }}">
</div>
<div class="field">
<label>{{ _('Slot') }} *</label>
<select class="input" name="slot" required>
{% for s in ['header', 'sidebar', 'inline', 'footer'] %}
<option value="{{ s }}" {{ 'selected' if ad and ad.slot == s }}>{{ s }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>{{ _('Target URL') }} *</label>
<input class="input" name="target_url" required
value="{{ ad.target_url if ad else '' }}">
</div>
<div class="field">
<label>{{ _('Creative path (relative under media/ads/)') }}</label>
<input class="input" name="creative_path"
value="{{ ad.creative_path or '' if ad else '' }}"
placeholder="{{ _('e.g. banner.jpg — leave blank for text-only ad') }}">
</div>
<div class="field">
<label>{{ _('Alt text') }}</label>
<input class="input" name="alt_text" value="{{ ad.alt_text or '' if ad else '' }}">
</div>
<div class="field">
<label>{{ _('Language targeting (blank = all)') }}</label>
<select class="input" name="lang">
<option value="">{{ _('All') }}</option>
{% for l in ['en', 'vi', 'es'] %}
<option value="{{ l }}" {{ 'selected' if ad and ad.lang == l }}>{{ l }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>{{ _('State targeting (2-letter, blank = all)') }}</label>
<input class="input" name="geo_state" maxlength="2"
value="{{ ad.geo_state or '' if ad else '' }}" placeholder="CA">
</div>
<div class="field">
<label>{{ _('Starts at (YYYY-MM-DD)') }}</label>
<input class="input" name="starts_at" type="date"
value="{{ ad.starts_at.strftime('%Y-%m-%d') if ad else '' }}">
</div>
<div class="field">
<label>{{ _('Ends at (YYYY-MM-DD)') }}</label>
<input class="input" name="ends_at" type="date"
value="{{ ad.ends_at.strftime('%Y-%m-%d') if ad else '' }}">
</div>
<div class="check">
<input type="checkbox" name="is_active" id="is_active"
{{ 'checked' if (not ad or ad.is_active) }}>
<label for="is_active">{{ _('Active') }}</label>
</div>
<div class="billing-actions">
<button class="btn" type="submit">{{ _('Save') }}</button>
<a class="btn ghost" href="{{ url_for('admin.admin_ads') }}">{{ _('Cancel') }}</a>
</div>
</form>
</div>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Ads') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>{{ _('Ads') }}</h2>
<a class="btn" href="{{ url_for('admin.admin_ad_new') }}">{{ _('+ New ad') }}</a>
</div>
{% if not ad_list %}<p class="muted">{{ _('No ads yet.') }}</p>{% endif %}
<table class="list">
<thead><tr>
<th>{{ _('Advertiser') }}</th>
<th>{{ _('Slot') }}</th>
<th>{{ _('Targeting') }}</th>
<th>{{ _('Schedule') }}</th>
<th>{{ _('Imp.') }}</th>
<th>{{ _('Clicks') }}</th>
<th>{{ _('CTR') }}</th>
<th>{{ _('Status') }}</th>
<th></th>
</tr></thead>
{% for ad in ad_list %}
<tr>
<td><a href="{{ url_for('admin.admin_ad_edit', ad_id=ad.id) }}">{{ ad.advertiser_name }}</a></td>
<td><span class="badge cat">{{ ad.slot }}</span></td>
<td class="muted small">
{% if ad.lang %}lang={{ ad.lang }}{% endif %}
{% if ad.geo_state %} state={{ ad.geo_state }}{% endif %}
{% if not ad.lang and not ad.geo_state %}—{% endif %}
</td>
<td class="muted small">
{{ ad.starts_at.strftime('%Y-%m-%d') }}
{{ ad.ends_at.strftime('%Y-%m-%d') }}
</td>
<td>{{ ad.impressions }}</td>
<td>{{ ad.clicks }}</td>
<td>{{ ad.ctr }}%</td>
<td>
{% if ad.is_running %}
<span class="badge ok">{{ _('Live') }}</span>
{% elif ad.is_active %}
<span class="badge cat">{{ _('Scheduled') }}</span>
{% else %}
<span class="badge warn">{{ _('Off') }}</span>
{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('admin.admin_ad_toggle', ad_id=ad.id) }}" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn ghost tiny" type="submit">{{ _('Toggle') }}</button>
</form>
<form method="post" action="{{ url_for('admin.admin_ad_delete', ad_id=ad.id) }}" style="display:inline"
onsubmit="return confirm('{{ _('Delete this ad?') }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn danger tiny" type="submit">{{ _('Del') }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
+108
View File
@@ -0,0 +1,108 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Analytics') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ _('Analytics — last 30 days') }}</h2>
<div class="kpi-grid">
<div class="card kpi-card">
<div class="kpi-label">{{ _('New signups (30d)') }}</div>
<div class="kpi-value">{{ signups|sum(attribute='n') }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('Listings posted (30d)') }}</div>
<div class="kpi-value">{{ listings_chart|sum(attribute='n') }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('Revenue (30d)') }}</div>
<div class="kpi-value">${{ '%.2f'|format((revenue_chart|sum(attribute='cents') or 0) / 100) }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('Ad impressions (active ads)') }}</div>
<div class="kpi-value">{{ ad_stats.total_impressions or 0 }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('Ad clicks (active ads)') }}</div>
<div class="kpi-value">{{ ad_stats.total_clicks or 0 }}</div>
</div>
<div class="card kpi-card">
<div class="kpi-label">{{ _('Overall CTR') }}</div>
<div class="kpi-value">
{% if ad_stats.total_impressions %}
{{ '%.2f'|format(ad_stats.total_clicks / ad_stats.total_impressions * 100) }}%
{% else %}—{% endif %}
</div>
</div>
</div>
</div>
<div class="card">
<h3>{{ _('Signups per day') }}</h3>
{% if signups %}
<table class="list">
<thead><tr><th>{{ _('Date') }}</th><th>{{ _('Signups') }}</th></tr></thead>
{% for row in signups %}
<tr>
<td class="muted small">{{ row.day }}</td>
<td>{{ row.n }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">{{ _('No signups in the last 30 days.') }}</p>
{% endif %}
</div>
<div class="card">
<h3>{{ _('Listings posted per day') }}</h3>
{% if listings_chart %}
<table class="list">
<thead><tr><th>{{ _('Date') }}</th><th>{{ _('Listings') }}</th></tr></thead>
{% for row in listings_chart %}
<tr>
<td class="muted small">{{ row.day }}</td>
<td>{{ row.n }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">{{ _('No listings posted in the last 30 days.') }}</p>
{% endif %}
</div>
<div class="card">
<h3>{{ _('Revenue per day') }}</h3>
{% if revenue_chart %}
<table class="list">
<thead><tr><th>{{ _('Date') }}</th><th>{{ _('Revenue') }}</th></tr></thead>
{% for row in revenue_chart %}
<tr>
<td class="muted small">{{ row.day }}</td>
<td>${{ '%.2f'|format((row.cents or 0) / 100) }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">{{ _('No revenue in the last 30 days.') }}</p>
{% endif %}
</div>
<div class="card">
<h3>{{ _('Top categories by active listings') }}</h3>
{% if top_cats %}
<table class="list">
<thead><tr><th>{{ _('Category') }}</th><th>{{ _('Active listings') }}</th></tr></thead>
{% for name, n in top_cats %}
<tr>
<td>{{ name }}</td>
<td>{{ n }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">{{ _('No listings.') }}</p>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Assign keyword') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ _('Assign promoted keyword') }}</h2>
{% if error %}<p class="alert danger">{{ error }}</p>{% endif %}
<p class="muted">
{{ _('The listing will appear at the top of search results when the keyword is matched (accent-insensitive).') }}
</p>
<form method="post" class="settings-panel">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label>{{ _('Listing ID') }} *</label>
<input class="input" name="listing_id" type="number" required min="1"
placeholder="{{ _('Enter the numeric listing ID') }}">
</div>
<div class="field">
<label>{{ _('Keyword') }} *</label>
<input class="input" name="keyword" required maxlength="80"
placeholder="{{ _('e.g. pho, cleaning service') }}">
</div>
<div class="field">
<label>{{ _('Priority (higher = shown first)') }}</label>
<input class="input" name="priority" type="number" value="0">
</div>
<div class="field">
<label>{{ _('Expires at (YYYY-MM-DD)') }}</label>
<input class="input" name="expires_at" type="date">
</div>
<div class="billing-actions">
<button class="btn" type="submit">{{ _('Save') }}</button>
<a class="btn ghost" href="{{ url_for('admin.admin_promoted_keywords') }}">{{ _('Cancel') }}</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Promoted Keywords') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>{{ _('Promoted keywords') }}</h2>
<a class="btn" href="{{ url_for('admin.admin_promoted_keyword_new') }}">{{ _('+ Assign keyword') }}</a>
</div>
{% if not pks %}<p class="muted">{{ _('No promoted keywords.') }}</p>{% endif %}
<table class="list">
<thead><tr>
<th>{{ _('Keyword') }}</th>
<th>{{ _('Listing') }}</th>
<th>{{ _('Priority') }}</th>
<th>{{ _('Expires') }}</th>
<th>{{ _('Status') }}</th>
<th></th>
</tr></thead>
{% for pk in pks %}
<tr>
<td><code>{{ pk.keyword }}</code></td>
<td>
{% if pk.listing %}
<a href="{{ url_for('listings.detail', listing_id=pk.listing_id) }}">
{{ pk.listing.title[:60] }}
</a>
{% else %}<span class="muted"></span>{% endif %}
</td>
<td>{{ pk.priority }}</td>
<td class="muted small">{{ pk.expires_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if pk.is_active %}
<span class="badge ok">{{ _('Active') }}</span>
{% else %}
<span class="badge warn">{{ _('Expired') }}</span>
{% endif %}
</td>
<td>
<form method="post"
action="{{ url_for('admin.admin_promoted_keyword_delete', pk_id=pk.id) }}"
style="display:inline" onsubmit="return confirm('{{ _('Remove?') }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn danger tiny" type="submit">{{ _('Remove') }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · %(t)s', t=_('Edit sponsor') if sponsor else _('New sponsor')) }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<h2>{{ _('Edit sponsor') if sponsor else _('New sponsor') }}</h2>
{% if error %}<p class="alert danger">{{ error }}</p>{% endif %}
<form method="post" class="settings-panel">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label>{{ _('Name') }} *</label>
<input class="input" name="name" required
value="{{ sponsor.name if sponsor else '' }}">
</div>
<div class="field">
<label>{{ _('URL') }} *</label>
<input class="input" name="url" required
value="{{ sponsor.url if sponsor else '' }}">
</div>
<div class="field">
<label>{{ _('Tagline') }}</label>
<input class="input" name="tagline"
value="{{ sponsor.tagline or '' if sponsor else '' }}">
</div>
<div class="field">
<label>{{ _('Logo path (relative under media/)') }}</label>
<input class="input" name="logo_path"
value="{{ sponsor.logo_path or '' if sponsor else '' }}"
placeholder="{{ _('e.g. sponsors/logo.png') }}">
</div>
<div class="field">
<label>{{ _('Tier') }}</label>
<select class="input" name="tier">
{% for t in ['directory', 'category'] %}
<option value="{{ t }}" {{ 'selected' if sponsor and sponsor.tier == t }}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>{{ _('Category (for category-tier sponsors)') }}</label>
<select class="input" name="category_id">
<option value="">{{ _('None') }}</option>
{% for cat in categories %}
<option value="{{ cat.id }}"
{{ 'selected' if sponsor and sponsor.category_id == cat.id }}>
{{ cat.name }}
</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>{{ _('Starts at (YYYY-MM-DD)') }}</label>
<input class="input" name="starts_at" type="date"
value="{{ sponsor.starts_at.strftime('%Y-%m-%d') if sponsor else '' }}">
</div>
<div class="field">
<label>{{ _('Ends at (YYYY-MM-DD)') }}</label>
<input class="input" name="ends_at" type="date"
value="{{ sponsor.ends_at.strftime('%Y-%m-%d') if sponsor else '' }}">
</div>
<div class="check">
<input type="checkbox" name="is_active" id="is_active"
{{ 'checked' if (not sponsor or sponsor.is_active) }}>
<label for="is_active">{{ _('Active') }}</label>
</div>
<div class="billing-actions">
<button class="btn" type="submit">{{ _('Save') }}</button>
<a class="btn ghost" href="{{ url_for('admin.admin_sponsors') }}">{{ _('Cancel') }}</a>
</div>
</form>
</div>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}{{ _('Admin · Sponsors') }}{% endblock %}
{% block content %}
{% include "admin/_nav.html" %}
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>{{ _('Sponsors') }}</h2>
<a class="btn" href="{{ url_for('admin.admin_sponsor_new') }}">{{ _('+ New sponsor') }}</a>
</div>
{% if not sponsors %}<p class="muted">{{ _('No sponsors yet.') }}</p>{% endif %}
<table class="list">
<thead><tr>
<th>{{ _('Name') }}</th>
<th>{{ _('Tier') }}</th>
<th>{{ _('Category') }}</th>
<th>{{ _('Schedule') }}</th>
<th>{{ _('Status') }}</th>
<th></th>
</tr></thead>
{% for sp in sponsors %}
<tr>
<td><a href="{{ url_for('admin.admin_sponsor_edit', sponsor_id=sp.id) }}">{{ sp.name }}</a></td>
<td><span class="badge cat">{{ sp.tier }}</span></td>
<td class="muted small">{{ sp.category.name if sp.category else '—' }}</td>
<td class="muted small">
{{ sp.starts_at.strftime('%Y-%m-%d') }}
{{ sp.ends_at.strftime('%Y-%m-%d') }}
</td>
<td>
{% if sp.is_running %}
<span class="badge ok">{{ _('Live') }}</span>
{% elif sp.is_active %}
<span class="badge cat">{{ _('Scheduled') }}</span>
{% else %}
<span class="badge warn">{{ _('Off') }}</span>
{% endif %}
</td>
<td>
<form method="post" action="{{ url_for('admin.admin_sponsor_delete', sponsor_id=sp.id) }}"
style="display:inline" onsubmit="return confirm('{{ _('Delete?') }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn danger tiny" type="submit">{{ _('Del') }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
+11
View File
@@ -37,6 +37,7 @@
<th>{{ _('Amount') }}</th> <th>{{ _('Amount') }}</th>
<th>{{ _('Status') }}</th> <th>{{ _('Status') }}</th>
<th>{{ _('Stripe ID') }}</th> <th>{{ _('Stripe ID') }}</th>
<th></th>
</tr></thead> </tr></thead>
{% for txn in pagination.items %} {% for txn in pagination.items %}
<tr> <tr>
@@ -46,6 +47,16 @@
<td>${{ '%.2f'|format(txn.amount_cents / 100) }}</td> <td>${{ '%.2f'|format(txn.amount_cents / 100) }}</td>
<td><span class="badge {{ 'ok' if txn.status == 'succeeded' else 'warn' }}">{{ txn.status }}</span></td> <td><span class="badge {{ 'ok' if txn.status == 'succeeded' else 'warn' }}">{{ txn.status }}</span></td>
<td class="muted small">{{ txn.stripe_object_id or '—' }}</td> <td class="muted small">{{ txn.stripe_object_id or '—' }}</td>
<td>
{% if txn.status == 'succeeded' and txn.type != 'refund' %}
<form method="post"
action="{{ url_for('admin.refund_transaction', txn_id=txn.id) }}"
onsubmit="return confirm('{{ _('Issue refund for $%(a)s?', a='%.2f'|format(txn.amount_cents/100)) }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn ghost tiny" type="submit">{{ _('Refund') }}</button>
</form>
{% endif %}
</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="6" class="muted">{{ _('No transactions.') }}</td></tr> <tr><td colspan="6" class="muted">{{ _('No transactions.') }}</td></tr>
+29 -1
View File
@@ -4,6 +4,19 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Classifieds{% endblock %}</title> <title>{% block title %}Classifieds{% endblock %}</title>
{% block meta_description %}<meta name="description" content="Classifieds — buy, sell, find jobs and services in your local Vietnamese and Hispanic community across the US.">{% endblock %}
{# Open Graph #}
<meta property="og:site_name" content="Classifieds">
<meta property="og:title" content="{% block og_title %}Classifieds{% endblock %}">
<meta property="og:description" content="{% block og_description %}Buy, sell, find jobs and services in your local community.{% endblock %}">
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
{% block og_image %}{% endblock %}
{% block og_url %}<meta property="og:url" content="{{ request.url }}">{% endblock %}
{# Canonical #}
<link rel="canonical" href="{{ request.base_url }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500;6..72,600&family=Hanken+Grotesk:wght@400;500;600;700&family=Space+Mono&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
@@ -11,7 +24,8 @@
<header class="site-header"> <header class="site-header">
{% set slot = "header" %}{% include "ads/_slot.html" %} {% set slot = "header" %}{% include "ads/_slot.html" %}
<div class="wrap"> <div class="wrap">
<a class="brand" href="{{ url_for('main.index') }}">Classifieds</a> <a class="brand" href="{{ url_for('main.index') }}"><span class="brand-mark">C</span>Classifieds</a>
<button class="nav-toggle" aria-label="{{ _('Menu') }}" onclick="document.querySelector('.nav').classList.toggle('open')">&#9776;</button>
<nav class="nav"> <nav class="nav">
<a href="{{ url_for('listings.browse') }}">{{ _('Browse') }}</a> <a href="{{ url_for('listings.browse') }}">{{ _('Browse') }}</a>
<a href="{{ url_for('payments.pricing') }}">{{ _('Pricing') }}</a> <a href="{{ url_for('payments.pricing') }}">{{ _('Pricing') }}</a>
@@ -60,5 +74,19 @@
{% set slot = "footer" %}{% include "ads/_slot.html" %} {% set slot = "footer" %}{% include "ads/_slot.html" %}
<div class="wrap">© {{ 2025 }} Classifieds · <a href="{{ url_for('ads.sponsor_directory') }}">{{ _('Sponsors') }}</a></div> <div class="wrap">© {{ 2025 }} Classifieds · <a href="{{ url_for('ads.sponsor_directory') }}">{{ _('Sponsors') }}</a></div>
</footer> </footer>
{% block scripts %}{% endblock %}
<script>
// Auto-dismiss flash toasts after 5 s
document.querySelectorAll('.flash').forEach(function(el) {
setTimeout(function(){ el.style.opacity='0'; setTimeout(function(){ el.remove(); }, 400); }, 5000);
el.style.transition = 'opacity .4s';
});
// Close mobile nav on link click
document.querySelectorAll('.nav a').forEach(function(a) {
a.addEventListener('click', function() {
document.querySelector('.nav').classList.remove('open');
});
});
</script>
</body> </body>
</html> </html>
+3 -1
View File
@@ -1,7 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ _('Classifieds — Home') }}{% endblock %} {% block title %}{{ _('Classifieds — Buy, Sell &amp; Find Services') }}{% endblock %}
{% block meta_description %}<meta name="description" content="{{ _('Free classifieds for Vietnamese and Hispanic communities across the US. Buy, sell, find jobs, services, and more.') }}">{% endblock %}
{% block content %} {% block content %}
<section class="hero"> <section class="hero">
<p class="hero-eyebrow">{{ _('Your local community marketplace') }}</p>
<h1>{{ _('Find what you need. Post what you offer.') }}</h1> <h1>{{ _('Find what you need. Post what you offer.') }}</h1>
<p>{{ _('Buy, sell, request, hire, and connect across your community.') }}</p> <p>{{ _('Buy, sell, request, hire, and connect across your community.') }}</p>
<a class="btn btn-lg" href="{{ url_for('listings.browse') }}">{{ _('Browse listings') }}</a> <a class="btn btn-lg" href="{{ url_for('listings.browse') }}">{{ _('Browse listings') }}</a>
+59 -2
View File
@@ -1,5 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ _('Browse listings') }}{% endblock %} {% block title %}
{% if filters.get('q') %}{{ _('Results for "%(q)s"', q=filters.get('q')) }} — Classifieds
{% elif filters.get('category') %}{{ _('Browse listings') }} — Classifieds
{% else %}{{ _('Browse listings') }} — Classifieds
{% endif %}
{% endblock %}
{% block meta_description %}<meta name="description" content="{{ _('Browse local classifieds — for sale, jobs, services, and more.') }}">{% endblock %}
{% block content %} {% block content %}
<div class="browse"> <div class="browse">
<aside class="filters card"> <aside class="filters card">
@@ -18,8 +24,29 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
{% set US_STATES = [
('AL','Alabama'),('AK','Alaska'),('AZ','Arizona'),('AR','Arkansas'),
('CA','California'),('CO','Colorado'),('CT','Connecticut'),('DE','Delaware'),
('DC','District of Columbia'),('FL','Florida'),('GA','Georgia'),('HI','Hawaii'),
('ID','Idaho'),('IL','Illinois'),('IN','Indiana'),('IA','Iowa'),('KS','Kansas'),
('KY','Kentucky'),('LA','Louisiana'),('ME','Maine'),('MD','Maryland'),
('MA','Massachusetts'),('MI','Michigan'),('MN','Minnesota'),('MS','Mississippi'),
('MO','Missouri'),('MT','Montana'),('NE','Nebraska'),('NV','Nevada'),
('NH','New Hampshire'),('NJ','New Jersey'),('NM','New Mexico'),('NY','New York'),
('NC','North Carolina'),('ND','North Dakota'),('OH','Ohio'),('OK','Oklahoma'),
('OR','Oregon'),('PA','Pennsylvania'),('RI','Rhode Island'),('SC','South Carolina'),
('SD','South Dakota'),('TN','Tennessee'),('TX','Texas'),('UT','Utah'),
('VT','Vermont'),('VA','Virginia'),('WA','Washington'),('WV','West Virginia'),
('WI','Wisconsin'),('WY','Wyoming')] %}
{% set state_names = dict(US_STATES) %}
{% set cur_state = filters.get('state','')|upper %}
<div class="field"><label>{{ _('State') }}</label> <div class="field"><label>{{ _('State') }}</label>
<input class="input" name="state" maxlength="2" value="{{ filters.get('state','') }}"></div> <input class="input" id="state-search" list="us-states" autocomplete="off"
placeholder="{{ _('Type a state…') }}" value="{{ state_names.get(cur_state, '') }}">
<input type="hidden" name="state" id="state-code" value="{{ cur_state }}">
<datalist id="us-states">
{% for code, name in US_STATES %}<option value="{{ name }}">{{ code }}</option>{% endfor %}
</datalist></div>
<div class="row2"> <div class="row2">
<div class="field"><label>{{ _('Min $') }}</label> <div class="field"><label>{{ _('Min $') }}</label>
<input class="input" name="min_price" type="number" value="{{ filters.get('min_price','') }}"></div> <input class="input" name="min_price" type="number" value="{{ filters.get('min_price','') }}"></div>
@@ -69,4 +96,34 @@
</div> </div>
</section> </section>
</div> </div>
<script>
// State combobox: user searches/selects a full state name; we submit the 2-letter
// code via the hidden field. Also accepts a raw 2-letter code typed directly.
(function () {
var input = document.getElementById('state-search');
var hidden = document.getElementById('state-code');
var dl = document.getElementById('us-states');
if (!input || !hidden || !dl) return;
var byName = {}, byCode = {};
Array.prototype.forEach.call(dl.options, function (o) {
var name = o.value, code = o.textContent.trim();
byName[name.toLowerCase()] = code;
byCode[code.toUpperCase()] = name;
});
function sync() {
var v = input.value.trim();
if (!v) { hidden.value = ''; return; }
if (byName[v.toLowerCase()]) { hidden.value = byName[v.toLowerCase()]; return; }
if (byCode[v.toUpperCase()]) { // user typed the code itself
hidden.value = v.toUpperCase();
input.value = byCode[v.toUpperCase()];
return;
}
hidden.value = ''; // unrecognized → no state filter
}
input.addEventListener('input', sync);
input.addEventListener('change', sync);
})();
</script>
{% endblock %} {% endblock %}
+58 -2
View File
@@ -1,5 +1,27 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ listing.title }}{% endblock %} {% block title %}{{ listing.title }} — Classifieds{% endblock %}
{% block meta_description %}<meta name="description" content="{{ (listing.body[:155] | replace('"', '&quot;') | replace('\n', ' ')) }}...">{% endblock %}
{% block og_title %}{{ listing.title }}{% endblock %}
{% block og_description %}{{ listing.body[:200] | replace('\n', ' ') }}{% endblock %}
{% block og_type %}product{% endblock %}
{% block og_image %}
{% if listing.images %}
<meta property="og:image" content="{{ request.host_url }}{{ url_for('listings.media', rel=listing.images[0].path)[1:] }}">
{% endif %}
{% endblock %}
{% block head %}
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Product",
"name": {{ listing.title | tojson }},
"description": {{ (listing.body[:500] | replace('\n', ' ')) | tojson }},
{% if listing.images %}"image": "{{ request.host_url }}{{ url_for('listings.media', rel=listing.images[0].path)[1:] }}",{% endif %}
{% if listing.price_cents %}"offers": {"@type": "Offer", "price": "{{ '%.2f'|format(listing.price_cents/100) }}", "priceCurrency": "USD"},{% endif %}
"seller": {"@type": "Person", "name": {{ listing.user.display_name | tojson }}}
}
</script>
{% endblock %}
{% block content %} {% block content %}
<article class="detail"> <article class="detail">
<div class="detail-main card"> <div class="detail-main card">
@@ -17,7 +39,7 @@
{% if listing.images %} {% if listing.images %}
<div class="gallery"> <div class="gallery">
{% for img in listing.images %} {% for img in listing.images %}
<img src="{{ url_for('listings.media', rel=img.path) }}" alt=""> <img src="{{ url_for('listings.media', rel=img.path) }}" alt="{{ listing.title }}" loading="{{ 'eager' if loop.first else 'lazy' }}">
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
@@ -39,6 +61,12 @@
<div class="seller"> <div class="seller">
<strong>{{ listing.user.display_name }}</strong> <strong>{{ listing.user.display_name }}</strong>
{% if listing.user.verified %}<span class="badge ok">{{ _('Verified') }}</span>{% endif %} {% if listing.user.verified %}<span class="badge ok">{{ _('Verified') }}</span>{% endif %}
{% if listing_rating and listing_rating.avg %}
<div class="review-stars" style="font-size:14px">
{{ '★' * (listing_rating.avg | round | int) }}{{ '☆' * (5 - (listing_rating.avg | round | int)) }}
<span class="muted" style="font-size:12px">({{ listing_rating.count }})</span>
</div>
{% endif %}
</div> </div>
{% if is_owner %} {% if is_owner %}
<a class="btn" href="{{ url_for('listings.edit', listing_id=listing.id) }}">{{ _('Edit') }}</a> <a class="btn" href="{{ url_for('listings.edit', listing_id=listing.id) }}">{{ _('Edit') }}</a>
@@ -78,7 +106,35 @@
{{ _('Sign in to contact') }} {{ _('Sign in to contact') }}
</a> </a>
{% endif %} {% endif %}
{# Leave-review button for sold listings when viewer had a conversation #}
{% if current_user.is_authenticated and listing.status.value == 'sold'
and not is_owner and can_review %}
<a class="btn ghost" href="{{ url_for('listings.leave_review', listing_id=listing.id) }}">
{{ _('Leave a review') }}
</a>
{% endif %}
{% endif %} {% endif %}
</aside> </aside>
</article> </article>
{# Reviews section #}
{% if listing.reviews.count() %}
<section class="card" style="margin-top:16px">
<h3>{{ _('Reviews') }} ({{ listing.reviews.count() }})</h3>
{% set rating_info = listing_rating %}
{% if rating_info and rating_info.avg %}
<div class="rating-summary">
<span class="review-stars">{{ '★' * (rating_info.avg | round | int) }}{{ '☆' * (5 - (rating_info.avg | round | int)) }}</span>
{{ '%.1f'|format(rating_info.avg) }} / 5 &nbsp;<span class="muted small">({{ rating_info.count }})</span>
</div>
{% endif %}
{% for review in listing.reviews.order_by(none).all() %}
<div class="review-block">
<div class="review-stars">{{ '★' * review.rating }}{{ '☆' * (5 - review.rating) }}</div>
<div class="muted small">{{ review.author.display_name }} · {{ review.created_at.strftime('%Y-%m-%d') }}</div>
{% if review.body %}<p>{{ review.body }}</p>{% endif %}
</div>
{% endfor %}
</section>
{% endif %}
{% endblock %} {% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}{{ _('Leave a review') }} — {{ listing.title }}{% endblock %}
{% block content %}
<div class="card narrow">
<h2>{{ _('Leave a review for "%(t)s"', t=listing.title) }}</h2>
<p class="muted">{{ _('Sold by %(name)s', name=listing.user.display_name) }}</p>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label>{{ _('Rating') }}</label>
<div class="rating-picker">
{% for v in [5, 4, 3, 2, 1] %}
<label>
<input type="radio" name="rating" value="{{ v }}" required>
{{ '★' * v }}{{ '☆' * (5 - v) }}
</label>
{% endfor %}
</div>
</div>
<div class="field">
<label>{{ _('Comment (optional)') }}</label>
<textarea class="input" name="body" rows="4" maxlength="1000"></textarea>
</div>
<button class="btn" type="submit">{{ _('Submit review') }}</button>
<a class="btn ghost" href="{{ url_for('listings.detail', listing_id=listing.id) }}">{{ _('Cancel') }}</a>
</form>
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}{{ cat.name }} {{ _('classifieds') }} — Classifieds{% endblock %}
{% block meta_description %}<meta name="description" content="{{ _('Browse %(cat)s listings near you — buy, sell, and connect with your local community.', cat=cat.name) }}">{% endblock %}
{% block og_title %}{{ cat.name }} {{ _('classifieds') }}{% endblock %}
{% block content %}
<div class="card">
<h1>{{ cat.name }} {{ _('listings') }}</h1>
<p class="muted">
{{ _('%(n)s active listings', n=listings|length) }} ·
<a href="{{ url_for('listings.browse', category=cat.id) }}">{{ _('See all with filters') }}</a>
</p>
{% if not listings %}
<p class="muted">{{ _('No listings yet in this category.') }}</p>
{% else %}
<div class="listing-grid">
{% for l in listings %}
<a class="listing-card" href="{{ url_for('listings.detail', listing_id=l.id) }}">
{% if l.images %}
<img src="{{ url_for('listings.media', rel=l.images[0].thumb_path) }}"
alt="{{ l.title }}" loading="lazy">
{% endif %}
<div class="listing-info">
<div class="listing-title">{{ l.title }}</div>
{% if l.price_display %}<div class="price">{{ l.price_display }}</div>{% endif %}
<div class="muted small">{{ l.city or '' }}{% if l.city and l.state %}, {% endif %}{{ l.state or '' }}</div>
</div>
</a>
{% endfor %}
</div>
{% endif %}
{% if cat.children %}
<h2>{{ _('Subcategories') }}</h2>
<ul>
{% for sub in cat.children %}
{% if sub.is_active %}
<li><a href="{{ url_for('listings.browse', category=sub.id) }}">{{ sub.name }}</a></li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
</div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}{{ state }} {{ _('classifieds') }} — Classifieds{% endblock %}
{% block meta_description %}<meta name="description" content="{{ _('Browse local classifieds in %(state)s — buy, sell, find jobs and services.', state=state) }}">{% endblock %}
{% block og_title %}{{ state }} {{ _('classifieds') }}{% endblock %}
{% block content %}
<div class="card">
<h1>{{ state }} {{ _('listings') }}</h1>
<p class="muted">
{{ _('%(n)s active listings', n=listings|length) }} ·
<a href="{{ url_for('listings.browse', state=state) }}">{{ _('See all with filters') }}</a>
</p>
{% if not listings %}
<p class="muted">{{ _('No listings in %(state)s yet.', state=state) }}</p>
{% else %}
<div class="listing-grid">
{% for l in listings %}
<a class="listing-card" href="{{ url_for('listings.detail', listing_id=l.id) }}">
{% if l.images %}
<img src="{{ url_for('listings.media', rel=l.images[0].thumb_path) }}"
alt="{{ l.title }}" loading="lazy">
{% endif %}
<div class="listing-info">
<div class="listing-title">{{ l.title }}</div>
{% if l.price_display %}<div class="price">{{ l.price_display }}</div>{% endif %}
<div class="muted small">{{ l.city or '' }}{% if l.city and l.state %}, {% endif %}{{ l.state or '' }}</div>
</div>
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{% for u in urls %}
<url>
<loc>{{ u.loc }}</loc>
{% if u.get('lastmod') %}<lastmod>{{ u.lastmod }}</lastmod>{% endif %}
<changefreq>{{ u.changefreq }}</changefreq>
<priority>{{ u.priority }}</priority>
</url>
{% endfor %}
</urlset>
Binary file not shown.
+75 -75
View File
@@ -20,313 +20,313 @@ msgstr ""
#: app/blueprints/auth/routes.py:28 #: app/blueprints/auth/routes.py:28
msgid "Verify your email" msgid "Verify your email"
msgstr "" msgstr "Verifica tu correo electrónico"
#: app/blueprints/auth/routes.py:29 #: app/blueprints/auth/routes.py:29
#, python-format #, python-format
msgid "Confirm your account: %(link)s" msgid "Confirm your account: %(link)s"
msgstr "" msgstr "Confirma tu cuenta: %(link)s"
#: app/blueprints/auth/routes.py:40 #: app/blueprints/auth/routes.py:40
msgid "Captcha verification failed." msgid "Captcha verification failed."
msgstr "" msgstr "La verificación de Captcha falló."
#: app/blueprints/auth/routes.py:45 #: app/blueprints/auth/routes.py:45
msgid "An account with that email already exists." msgid "An account with that email already exists."
msgstr "" msgstr "Ya existe una cuenta con ese correo electrónico."
#: app/blueprints/auth/routes.py:59 #: app/blueprints/auth/routes.py:59
msgid "Account created. Check your email to verify." msgid "Account created. Check your email to verify."
msgstr "" msgstr "Cuenta creada. Revisa tu correo para verificar."
#: app/blueprints/auth/routes.py:74 #: app/blueprints/auth/routes.py:74
msgid "Invalid email or password." msgid "Invalid email or password."
msgstr "" msgstr "Correo o contraseña no válidos."
#: app/blueprints/auth/routes.py:77 #: app/blueprints/auth/routes.py:77
msgid "This account is suspended." msgid "This account is suspended."
msgstr "" msgstr "Esta cuenta está suspendida."
#: app/blueprints/auth/routes.py:93 #: app/blueprints/auth/routes.py:93
msgid "Signed out." msgid "Signed out."
msgstr "" msgstr "Sesión cerrada."
#: app/blueprints/auth/routes.py:102 #: app/blueprints/auth/routes.py:102
msgid "Verification link is invalid or expired." msgid "Verification link is invalid or expired."
msgstr "" msgstr "El enlace de verificación no es válido o ha caducado."
#: app/blueprints/auth/routes.py:111 #: app/blueprints/auth/routes.py:111
msgid "Email verified. You're all set." msgid "Email verified. You're all set."
msgstr "" msgstr "Correo verificado. Todo listo."
#: app/blueprints/auth/routes.py:124 #: app/blueprints/auth/routes.py:124
msgid "Reset your password" msgid "Reset your password"
msgstr "" msgstr "Restablece tu contraseña"
#: app/blueprints/auth/routes.py:125 #: app/blueprints/auth/routes.py:125
#, python-format #, python-format
msgid "Reset link: %(link)s" msgid "Reset link: %(link)s"
msgstr "" msgstr "Enlace de restablecimiento: %(link)s"
#: app/blueprints/auth/routes.py:127 #: app/blueprints/auth/routes.py:127
msgid "If that email exists, a reset link has been sent." msgid "If that email exists, a reset link has been sent."
msgstr "" msgstr "Si ese correo existe, se ha enviado un enlace de restablecimiento."
#: app/blueprints/auth/routes.py:137 #: app/blueprints/auth/routes.py:137
msgid "Reset link is invalid or expired." msgid "Reset link is invalid or expired."
msgstr "" msgstr "El enlace de restablecimiento no es válido o ha caducado."
#: app/blueprints/auth/routes.py:146 #: app/blueprints/auth/routes.py:146
msgid "Password updated. Sign in." msgid "Password updated. Sign in."
msgstr "" msgstr "Contraseña actualizada. Inicia sesión."
#: app/blueprints/listings/routes.py:72 #: app/blueprints/listings/routes.py:72
msgid "ZIP not found; showing all results." msgid "ZIP not found; showing all results."
msgstr "" msgstr "Código postal no encontrado; mostrando todos los resultados."
#: app/blueprints/listings/routes.py:103 #: app/blueprints/listings/routes.py:103
msgid "You've reached your plan's active-listing limit." msgid "You've reached your plan's active-listing limit."
msgstr "" msgstr "Has alcanzado el límite de anuncios activos de tu plan."
#: app/blueprints/listings/routes.py:125 app/blueprints/listings/routes.py:171 #: app/blueprints/listings/routes.py:125 app/blueprints/listings/routes.py:171
#, python-format #, python-format
msgid "%(f)s: %(m)s" msgid "%(f)s: %(m)s"
msgstr "" msgstr "%(f)s: %(m)s"
#: app/blueprints/listings/routes.py:133 #: app/blueprints/listings/routes.py:133
msgid "Listing published." msgid "Listing published."
msgstr "" msgstr "Anuncio publicado."
#: app/blueprints/listings/routes.py:176 #: app/blueprints/listings/routes.py:176
msgid "Listing updated." msgid "Listing updated."
msgstr "" msgstr "Anuncio actualizado."
#: app/blueprints/listings/routes.py:194 #: app/blueprints/listings/routes.py:194
msgid "Listing deleted." msgid "Listing deleted."
msgstr "" msgstr "Anuncio eliminado."
#: app/blueprints/listings/routes.py:207 #: app/blueprints/listings/routes.py:207
msgid "Marked as sold." msgid "Marked as sold."
msgstr "" msgstr "Marcado como vendido."
#: app/blueprints/listings/routes.py:236 #: app/blueprints/listings/routes.py:236
msgid "Photo removed." msgid "Photo removed."
msgstr "" msgstr "Foto eliminada."
#: app/blueprints/listings/routes.py:258 #: app/blueprints/listings/routes.py:258
#, python-format #, python-format
msgid "Photo limit (%(n)s) reached for your plan." msgid "Photo limit (%(n)s) reached for your plan."
msgstr "" msgstr "Se alcanzó el límite de fotos (%(n)s) de tu plan."
#: app/blueprints/listings/routes.py:265 #: app/blueprints/listings/routes.py:265
#, python-format #, python-format
msgid "Image skipped: %(m)s" msgid "Image skipped: %(m)s"
msgstr "" msgstr "Imagen omitida: %(m)s"
#: app/templates/base.html:15 #: app/templates/base.html:15
msgid "Browse" msgid "Browse"
msgstr "" msgstr "Explorar"
#: app/templates/base.html:17 #: app/templates/base.html:17
msgid "Post" msgid "Post"
msgstr "" msgstr "Publicar"
#: app/templates/base.html:18 app/templates/listings/mine.html:2 #: app/templates/base.html:18 app/templates/listings/mine.html:2
#: app/templates/listings/mine.html:6 #: app/templates/listings/mine.html:6
msgid "My listings" msgid "My listings"
msgstr "" msgstr "Mis anuncios"
#: app/templates/base.html:20 #: app/templates/base.html:20
msgid "Sign out" msgid "Sign out"
msgstr "" msgstr "Cerrar sesión"
#: app/templates/auth/login.html:3 app/templates/auth/login.html:6 #: app/templates/auth/login.html:3 app/templates/auth/login.html:6
#: app/templates/base.html:22 #: app/templates/base.html:22
msgid "Sign in" msgid "Sign in"
msgstr "" msgstr "Iniciar sesión"
#: app/templates/auth/register.html:3 app/templates/base.html:23 #: app/templates/auth/register.html:3 app/templates/base.html:23
msgid "Register" msgid "Register"
msgstr "" msgstr "Registrarse"
#: app/templates/index.html:2 #: app/templates/index.html:2
msgid "Classifieds — Home" msgid "Classifieds — Home"
msgstr "" msgstr "Clasificados — Inicio"
#: app/templates/index.html:5 #: app/templates/index.html:5
msgid "Find what you need. Post what you offer." msgid "Find what you need. Post what you offer."
msgstr "" msgstr "Encuentra lo que necesitas. Publica lo que ofreces."
#: app/templates/index.html:6 #: app/templates/index.html:6
msgid "Buy, sell, request, hire, and connect across your community." msgid "Buy, sell, request, hire, and connect across your community."
msgstr "" msgstr "Compra, vende, solicita, contrata y conecta en tu comunidad."
#: app/templates/index.html:7 app/templates/listings/browse.html:2 #: app/templates/index.html:7 app/templates/listings/browse.html:2
msgid "Browse listings" msgid "Browse listings"
msgstr "" msgstr "Explorar anuncios"
#: app/templates/index.html:9 #: app/templates/index.html:9
msgid "Get started" msgid "Get started"
msgstr "" msgstr "Comenzar"
#: app/templates/auth/login.html:15 #: app/templates/auth/login.html:15
msgid "Forgot password?" msgid "Forgot password?"
msgstr "" msgstr "¿Olvidaste tu contraseña?"
#: app/templates/auth/login.html:16 app/templates/auth/register.html:6 #: app/templates/auth/login.html:16 app/templates/auth/register.html:6
msgid "Create account" msgid "Create account"
msgstr "" msgstr "Crear cuenta"
#: app/templates/auth/register.html:16 #: app/templates/auth/register.html:16
msgid "Already have an account? Sign in" msgid "Already have an account? Sign in"
msgstr "" msgstr "¿Ya tienes una cuenta? Inicia sesión"
#: app/templates/auth/reset.html:3 app/templates/auth/reset.html:6 #: app/templates/auth/reset.html:3 app/templates/auth/reset.html:6
msgid "Set new password" msgid "Set new password"
msgstr "" msgstr "Establecer nueva contraseña"
#: app/templates/auth/reset_request.html:3 #: app/templates/auth/reset_request.html:3
#: app/templates/auth/reset_request.html:6 #: app/templates/auth/reset_request.html:6
msgid "Reset password" msgid "Reset password"
msgstr "" msgstr "Restablecer contraseña"
#: app/templates/errors/403.html:3 #: app/templates/errors/403.html:3
msgid "Forbidden — you do not have access." msgid "Forbidden — you do not have access."
msgstr "" msgstr "Prohibido: no tienes acceso."
#: app/templates/errors/403.html:3 app/templates/errors/404.html:3 #: app/templates/errors/403.html:3 app/templates/errors/404.html:3
#: app/templates/errors/500.html:3 #: app/templates/errors/500.html:3
msgid "Back home" msgid "Back home"
msgstr "" msgstr "Volver al inicio"
#: app/templates/errors/404.html:3 #: app/templates/errors/404.html:3
msgid "Not found." msgid "Not found."
msgstr "" msgstr "No encontrado."
#: app/templates/errors/500.html:3 #: app/templates/errors/500.html:3
msgid "Something went wrong." msgid "Something went wrong."
msgstr "" msgstr "Algo salió mal."
#: app/templates/listings/browse.html:6 #: app/templates/listings/browse.html:6
msgid "Filter" msgid "Filter"
msgstr "" msgstr "Filtrar"
#: app/templates/listings/browse.html:9 #: app/templates/listings/browse.html:9
msgid "Keyword" msgid "Keyword"
msgstr "" msgstr "Palabra clave"
#: app/templates/listings/browse.html:13 #: app/templates/listings/browse.html:13
msgid "Category" msgid "Category"
msgstr "" msgstr "Categoría"
#: app/templates/listings/browse.html:15 #: app/templates/listings/browse.html:15
msgid "All" msgid "All"
msgstr "" msgstr "Todos"
#: app/templates/listings/browse.html:21 #: app/templates/listings/browse.html:21
msgid "State" msgid "State"
msgstr "" msgstr "Estado"
#: app/templates/listings/browse.html:24 #: app/templates/listings/browse.html:24
msgid "Min $" msgid "Min $"
msgstr "" msgstr "Mín $"
#: app/templates/listings/browse.html:26 #: app/templates/listings/browse.html:26
msgid "Max $" msgid "Max $"
msgstr "" msgstr "Máx $"
#: app/templates/listings/browse.html:30 #: app/templates/listings/browse.html:30
msgid "Near ZIP" msgid "Near ZIP"
msgstr "" msgstr "Cerca del código postal"
#: app/templates/listings/browse.html:32 #: app/templates/listings/browse.html:32
msgid "Radius (mi)" msgid "Radius (mi)"
msgstr "" msgstr "Radio (mi)"
#: app/templates/listings/browse.html:35 #: app/templates/listings/browse.html:35
msgid "Apply" msgid "Apply"
msgstr "" msgstr "Aplicar"
#: app/templates/listings/browse.html:40 #: app/templates/listings/browse.html:40
#, python-format #, python-format
msgid "%(n)s results within %(r)s mi of %(z)s" msgid "%(n)s results within %(r)s mi of %(z)s"
msgstr "" msgstr "%(n)s resultados dentro de %(r)s mi de %(z)s"
#: app/templates/listings/browse.html:41 #: app/templates/listings/browse.html:41
msgid "No listings found." msgid "No listings found."
msgstr "" msgstr "No se encontraron anuncios."
#: app/templates/listings/browse.html:46 #: app/templates/listings/browse.html:46
msgid "No photo" msgid "No photo"
msgstr "" msgstr "Sin foto"
#: app/templates/listings/browse.html:51 #: app/templates/listings/browse.html:51
msgid "Featured" msgid "Featured"
msgstr "" msgstr "Destacado"
#: app/templates/listings/browse.html:63 #: app/templates/listings/browse.html:63
msgid "Prev" msgid "Prev"
msgstr "" msgstr "Anterior"
#: app/templates/listings/browse.html:64 #: app/templates/listings/browse.html:64
msgid "Next" msgid "Next"
msgstr "" msgstr "Siguiente"
#: app/templates/listings/detail.html:14 #: app/templates/listings/detail.html:14
#, python-format #, python-format
msgid "%(n)s views" msgid "%(n)s views"
msgstr "" msgstr "%(n)s vistas"
#: app/templates/listings/detail.html:41 #: app/templates/listings/detail.html:41
msgid "Verified" msgid "Verified"
msgstr "" msgstr "Verificado"
#: app/templates/listings/detail.html:44 app/templates/listings/mine.html:19 #: app/templates/listings/detail.html:44 app/templates/listings/mine.html:19
msgid "Edit" msgid "Edit"
msgstr "" msgstr "Editar"
#: app/templates/listings/detail.html:47 #: app/templates/listings/detail.html:47
msgid "Mark sold" msgid "Mark sold"
msgstr "" msgstr "Marcar vendido"
#: app/templates/listings/detail.html:51 #: app/templates/listings/detail.html:51
msgid "Delete this listing?" msgid "Delete this listing?"
msgstr "" msgstr "¿Eliminar este anuncio?"
#: app/templates/listings/detail.html:52 #: app/templates/listings/detail.html:52
msgid "Delete" msgid "Delete"
msgstr "" msgstr "Eliminar"
#: app/templates/listings/detail.html:55 #: app/templates/listings/detail.html:55
msgid "Messaging arrives in Phase 3." msgid "Messaging arrives in Phase 3."
msgstr "" msgstr "La mensajería llega en la Fase 3."
#: app/templates/listings/form.html:3 app/templates/listings/form.html:6 #: app/templates/listings/form.html:3 app/templates/listings/form.html:6
msgid "Edit listing" msgid "Edit listing"
msgstr "" msgstr "Editar anuncio"
#: app/templates/listings/form.html:3 app/templates/listings/form.html:6 #: app/templates/listings/form.html:3 app/templates/listings/form.html:6
msgid "New listing" msgid "New listing"
msgstr "" msgstr "Nuevo anuncio"
#: app/templates/listings/form.html:47 #: app/templates/listings/form.html:47
msgid "Current photos" msgid "Current photos"
msgstr "" msgstr "Fotos actuales"
#: app/templates/listings/mine.html:7 #: app/templates/listings/mine.html:7
#, python-format #, python-format
msgid "Active: %(a)s" msgid "Active: %(a)s"
msgstr "" msgstr "Activos: %(a)s"
#: app/templates/listings/mine.html:8 #: app/templates/listings/mine.html:8
msgid "Post new" msgid "Post new"
msgstr "" msgstr "Publicar nuevo"
#: app/templates/listings/mine.html:10 #: app/templates/listings/mine.html:10
msgid "No listings yet." msgid "No listings yet."
msgstr "" msgstr "Aún no hay anuncios."
#: app/templates/listings/mine.html:18 #: app/templates/listings/mine.html:18
msgid "views" msgid "views"
msgstr "" msgstr "vistas"
Binary file not shown.
+75 -75
View File
@@ -20,313 +20,313 @@ msgstr ""
#: app/blueprints/auth/routes.py:28 #: app/blueprints/auth/routes.py:28
msgid "Verify your email" msgid "Verify your email"
msgstr "" msgstr "Xác minh email của bạn"
#: app/blueprints/auth/routes.py:29 #: app/blueprints/auth/routes.py:29
#, python-format #, python-format
msgid "Confirm your account: %(link)s" msgid "Confirm your account: %(link)s"
msgstr "" msgstr "Xác nhận tài khoản của bạn: %(link)s"
#: app/blueprints/auth/routes.py:40 #: app/blueprints/auth/routes.py:40
msgid "Captcha verification failed." msgid "Captcha verification failed."
msgstr "" msgstr "Xác minh Captcha thất bại."
#: app/blueprints/auth/routes.py:45 #: app/blueprints/auth/routes.py:45
msgid "An account with that email already exists." msgid "An account with that email already exists."
msgstr "" msgstr "Đã tồn tại tài khoản với email đó."
#: app/blueprints/auth/routes.py:59 #: app/blueprints/auth/routes.py:59
msgid "Account created. Check your email to verify." msgid "Account created. Check your email to verify."
msgstr "" msgstr "Đã tạo tài khoản. Kiểm tra email để xác minh."
#: app/blueprints/auth/routes.py:74 #: app/blueprints/auth/routes.py:74
msgid "Invalid email or password." msgid "Invalid email or password."
msgstr "" msgstr "Email hoặc mật khẩu không hợp lệ."
#: app/blueprints/auth/routes.py:77 #: app/blueprints/auth/routes.py:77
msgid "This account is suspended." msgid "This account is suspended."
msgstr "" msgstr "Tài khoản này đã bị tạm khóa."
#: app/blueprints/auth/routes.py:93 #: app/blueprints/auth/routes.py:93
msgid "Signed out." msgid "Signed out."
msgstr "" msgstr "Đã đăng xuất."
#: app/blueprints/auth/routes.py:102 #: app/blueprints/auth/routes.py:102
msgid "Verification link is invalid or expired." msgid "Verification link is invalid or expired."
msgstr "" msgstr "Liên kết xác minh không hợp lệ hoặc đã hết hạn."
#: app/blueprints/auth/routes.py:111 #: app/blueprints/auth/routes.py:111
msgid "Email verified. You're all set." msgid "Email verified. You're all set."
msgstr "" msgstr "Đã xác minh email. Bạn đã sẵn sàng."
#: app/blueprints/auth/routes.py:124 #: app/blueprints/auth/routes.py:124
msgid "Reset your password" msgid "Reset your password"
msgstr "" msgstr "Đặt lại mật khẩu của bạn"
#: app/blueprints/auth/routes.py:125 #: app/blueprints/auth/routes.py:125
#, python-format #, python-format
msgid "Reset link: %(link)s" msgid "Reset link: %(link)s"
msgstr "" msgstr "Liên kết đặt lại: %(link)s"
#: app/blueprints/auth/routes.py:127 #: app/blueprints/auth/routes.py:127
msgid "If that email exists, a reset link has been sent." msgid "If that email exists, a reset link has been sent."
msgstr "" msgstr "Nếu email đó tồn tại, liên kết đặt lại đã được gửi."
#: app/blueprints/auth/routes.py:137 #: app/blueprints/auth/routes.py:137
msgid "Reset link is invalid or expired." msgid "Reset link is invalid or expired."
msgstr "" msgstr "Liên kết đặt lại không hợp lệ hoặc đã hết hạn."
#: app/blueprints/auth/routes.py:146 #: app/blueprints/auth/routes.py:146
msgid "Password updated. Sign in." msgid "Password updated. Sign in."
msgstr "" msgstr "Đã cập nhật mật khẩu. Đăng nhập."
#: app/blueprints/listings/routes.py:72 #: app/blueprints/listings/routes.py:72
msgid "ZIP not found; showing all results." msgid "ZIP not found; showing all results."
msgstr "" msgstr "Không tìm thấy mã ZIP; hiển thị tất cả kết quả."
#: app/blueprints/listings/routes.py:103 #: app/blueprints/listings/routes.py:103
msgid "You've reached your plan's active-listing limit." msgid "You've reached your plan's active-listing limit."
msgstr "" msgstr "Bạn đã đạt đến giới hạn tin đang đăng của gói."
#: app/blueprints/listings/routes.py:125 app/blueprints/listings/routes.py:171 #: app/blueprints/listings/routes.py:125 app/blueprints/listings/routes.py:171
#, python-format #, python-format
msgid "%(f)s: %(m)s" msgid "%(f)s: %(m)s"
msgstr "" msgstr "%(f)s: %(m)s"
#: app/blueprints/listings/routes.py:133 #: app/blueprints/listings/routes.py:133
msgid "Listing published." msgid "Listing published."
msgstr "" msgstr "Đã đăng tin."
#: app/blueprints/listings/routes.py:176 #: app/blueprints/listings/routes.py:176
msgid "Listing updated." msgid "Listing updated."
msgstr "" msgstr "Đã cập nhật tin."
#: app/blueprints/listings/routes.py:194 #: app/blueprints/listings/routes.py:194
msgid "Listing deleted." msgid "Listing deleted."
msgstr "" msgstr "Đã xóa tin."
#: app/blueprints/listings/routes.py:207 #: app/blueprints/listings/routes.py:207
msgid "Marked as sold." msgid "Marked as sold."
msgstr "" msgstr "Đã đánh dấu là đã bán."
#: app/blueprints/listings/routes.py:236 #: app/blueprints/listings/routes.py:236
msgid "Photo removed." msgid "Photo removed."
msgstr "" msgstr "Đã xóa ảnh."
#: app/blueprints/listings/routes.py:258 #: app/blueprints/listings/routes.py:258
#, python-format #, python-format
msgid "Photo limit (%(n)s) reached for your plan." msgid "Photo limit (%(n)s) reached for your plan."
msgstr "" msgstr "Đã đạt giới hạn ảnh (%(n)s) của gói."
#: app/blueprints/listings/routes.py:265 #: app/blueprints/listings/routes.py:265
#, python-format #, python-format
msgid "Image skipped: %(m)s" msgid "Image skipped: %(m)s"
msgstr "" msgstr "Đã bỏ qua ảnh: %(m)s"
#: app/templates/base.html:15 #: app/templates/base.html:15
msgid "Browse" msgid "Browse"
msgstr "" msgstr "Duyệt"
#: app/templates/base.html:17 #: app/templates/base.html:17
msgid "Post" msgid "Post"
msgstr "" msgstr "Đăng tin"
#: app/templates/base.html:18 app/templates/listings/mine.html:2 #: app/templates/base.html:18 app/templates/listings/mine.html:2
#: app/templates/listings/mine.html:6 #: app/templates/listings/mine.html:6
msgid "My listings" msgid "My listings"
msgstr "" msgstr "Tin của tôi"
#: app/templates/base.html:20 #: app/templates/base.html:20
msgid "Sign out" msgid "Sign out"
msgstr "" msgstr "Đăng xuất"
#: app/templates/auth/login.html:3 app/templates/auth/login.html:6 #: app/templates/auth/login.html:3 app/templates/auth/login.html:6
#: app/templates/base.html:22 #: app/templates/base.html:22
msgid "Sign in" msgid "Sign in"
msgstr "" msgstr "Đăng nhập"
#: app/templates/auth/register.html:3 app/templates/base.html:23 #: app/templates/auth/register.html:3 app/templates/base.html:23
msgid "Register" msgid "Register"
msgstr "" msgstr "Đăng ký"
#: app/templates/index.html:2 #: app/templates/index.html:2
msgid "Classifieds — Home" msgid "Classifieds — Home"
msgstr "" msgstr "Rao vặt — Trang chủ"
#: app/templates/index.html:5 #: app/templates/index.html:5
msgid "Find what you need. Post what you offer." msgid "Find what you need. Post what you offer."
msgstr "" msgstr "Tìm thứ bạn cần. Đăng thứ bạn có."
#: app/templates/index.html:6 #: app/templates/index.html:6
msgid "Buy, sell, request, hire, and connect across your community." msgid "Buy, sell, request, hire, and connect across your community."
msgstr "" msgstr "Mua, bán, yêu cầu, tuyển dụng và kết nối trong cộng đồng của bạn."
#: app/templates/index.html:7 app/templates/listings/browse.html:2 #: app/templates/index.html:7 app/templates/listings/browse.html:2
msgid "Browse listings" msgid "Browse listings"
msgstr "" msgstr "Duyệt tin đăng"
#: app/templates/index.html:9 #: app/templates/index.html:9
msgid "Get started" msgid "Get started"
msgstr "" msgstr "Bắt đầu"
#: app/templates/auth/login.html:15 #: app/templates/auth/login.html:15
msgid "Forgot password?" msgid "Forgot password?"
msgstr "" msgstr "Quên mật khẩu?"
#: app/templates/auth/login.html:16 app/templates/auth/register.html:6 #: app/templates/auth/login.html:16 app/templates/auth/register.html:6
msgid "Create account" msgid "Create account"
msgstr "" msgstr "Tạo tài khoản"
#: app/templates/auth/register.html:16 #: app/templates/auth/register.html:16
msgid "Already have an account? Sign in" msgid "Already have an account? Sign in"
msgstr "" msgstr "Đã có tài khoản? Đăng nhập"
#: app/templates/auth/reset.html:3 app/templates/auth/reset.html:6 #: app/templates/auth/reset.html:3 app/templates/auth/reset.html:6
msgid "Set new password" msgid "Set new password"
msgstr "" msgstr "Đặt mật khẩu mới"
#: app/templates/auth/reset_request.html:3 #: app/templates/auth/reset_request.html:3
#: app/templates/auth/reset_request.html:6 #: app/templates/auth/reset_request.html:6
msgid "Reset password" msgid "Reset password"
msgstr "" msgstr "Đặt lại mật khẩu"
#: app/templates/errors/403.html:3 #: app/templates/errors/403.html:3
msgid "Forbidden — you do not have access." msgid "Forbidden — you do not have access."
msgstr "" msgstr "Bị cấm — bạn không có quyền truy cập."
#: app/templates/errors/403.html:3 app/templates/errors/404.html:3 #: app/templates/errors/403.html:3 app/templates/errors/404.html:3
#: app/templates/errors/500.html:3 #: app/templates/errors/500.html:3
msgid "Back home" msgid "Back home"
msgstr "" msgstr "Về trang chủ"
#: app/templates/errors/404.html:3 #: app/templates/errors/404.html:3
msgid "Not found." msgid "Not found."
msgstr "" msgstr "Không tìm thấy."
#: app/templates/errors/500.html:3 #: app/templates/errors/500.html:3
msgid "Something went wrong." msgid "Something went wrong."
msgstr "" msgstr "Đã xảy ra lỗi."
#: app/templates/listings/browse.html:6 #: app/templates/listings/browse.html:6
msgid "Filter" msgid "Filter"
msgstr "" msgstr "Lọc"
#: app/templates/listings/browse.html:9 #: app/templates/listings/browse.html:9
msgid "Keyword" msgid "Keyword"
msgstr "" msgstr "Từ khóa"
#: app/templates/listings/browse.html:13 #: app/templates/listings/browse.html:13
msgid "Category" msgid "Category"
msgstr "" msgstr "Danh mục"
#: app/templates/listings/browse.html:15 #: app/templates/listings/browse.html:15
msgid "All" msgid "All"
msgstr "" msgstr "Tất cả"
#: app/templates/listings/browse.html:21 #: app/templates/listings/browse.html:21
msgid "State" msgid "State"
msgstr "" msgstr "Tiểu bang"
#: app/templates/listings/browse.html:24 #: app/templates/listings/browse.html:24
msgid "Min $" msgid "Min $"
msgstr "" msgstr "Giá tối thiểu $"
#: app/templates/listings/browse.html:26 #: app/templates/listings/browse.html:26
msgid "Max $" msgid "Max $"
msgstr "" msgstr "Giá tối đa $"
#: app/templates/listings/browse.html:30 #: app/templates/listings/browse.html:30
msgid "Near ZIP" msgid "Near ZIP"
msgstr "" msgstr "Gần mã ZIP"
#: app/templates/listings/browse.html:32 #: app/templates/listings/browse.html:32
msgid "Radius (mi)" msgid "Radius (mi)"
msgstr "" msgstr "Bán kính (dặm)"
#: app/templates/listings/browse.html:35 #: app/templates/listings/browse.html:35
msgid "Apply" msgid "Apply"
msgstr "" msgstr "Áp dụng"
#: app/templates/listings/browse.html:40 #: app/templates/listings/browse.html:40
#, python-format #, python-format
msgid "%(n)s results within %(r)s mi of %(z)s" msgid "%(n)s results within %(r)s mi of %(z)s"
msgstr "" msgstr "%(n)s kết quả trong vòng %(r)s dặm quanh %(z)s"
#: app/templates/listings/browse.html:41 #: app/templates/listings/browse.html:41
msgid "No listings found." msgid "No listings found."
msgstr "" msgstr "Không tìm thấy tin đăng."
#: app/templates/listings/browse.html:46 #: app/templates/listings/browse.html:46
msgid "No photo" msgid "No photo"
msgstr "" msgstr "Không có ảnh"
#: app/templates/listings/browse.html:51 #: app/templates/listings/browse.html:51
msgid "Featured" msgid "Featured"
msgstr "" msgstr "Nổi bật"
#: app/templates/listings/browse.html:63 #: app/templates/listings/browse.html:63
msgid "Prev" msgid "Prev"
msgstr "" msgstr "Trước"
#: app/templates/listings/browse.html:64 #: app/templates/listings/browse.html:64
msgid "Next" msgid "Next"
msgstr "" msgstr "Sau"
#: app/templates/listings/detail.html:14 #: app/templates/listings/detail.html:14
#, python-format #, python-format
msgid "%(n)s views" msgid "%(n)s views"
msgstr "" msgstr "%(n)s lượt xem"
#: app/templates/listings/detail.html:41 #: app/templates/listings/detail.html:41
msgid "Verified" msgid "Verified"
msgstr "" msgstr "Đã xác minh"
#: app/templates/listings/detail.html:44 app/templates/listings/mine.html:19 #: app/templates/listings/detail.html:44 app/templates/listings/mine.html:19
msgid "Edit" msgid "Edit"
msgstr "" msgstr "Sửa"
#: app/templates/listings/detail.html:47 #: app/templates/listings/detail.html:47
msgid "Mark sold" msgid "Mark sold"
msgstr "" msgstr "Đánh dấu đã bán"
#: app/templates/listings/detail.html:51 #: app/templates/listings/detail.html:51
msgid "Delete this listing?" msgid "Delete this listing?"
msgstr "" msgstr "Xóa tin này?"
#: app/templates/listings/detail.html:52 #: app/templates/listings/detail.html:52
msgid "Delete" msgid "Delete"
msgstr "" msgstr "Xóa"
#: app/templates/listings/detail.html:55 #: app/templates/listings/detail.html:55
msgid "Messaging arrives in Phase 3." msgid "Messaging arrives in Phase 3."
msgstr "" msgstr "Tính năng nhắn tin sẽ có ở Giai đoạn 3."
#: app/templates/listings/form.html:3 app/templates/listings/form.html:6 #: app/templates/listings/form.html:3 app/templates/listings/form.html:6
msgid "Edit listing" msgid "Edit listing"
msgstr "" msgstr "Sửa tin"
#: app/templates/listings/form.html:3 app/templates/listings/form.html:6 #: app/templates/listings/form.html:3 app/templates/listings/form.html:6
msgid "New listing" msgid "New listing"
msgstr "" msgstr "Tin mới"
#: app/templates/listings/form.html:47 #: app/templates/listings/form.html:47
msgid "Current photos" msgid "Current photos"
msgstr "" msgstr "Ảnh hiện tại"
#: app/templates/listings/mine.html:7 #: app/templates/listings/mine.html:7
#, python-format #, python-format
msgid "Active: %(a)s" msgid "Active: %(a)s"
msgstr "" msgstr "Đang đăng: %(a)s"
#: app/templates/listings/mine.html:8 #: app/templates/listings/mine.html:8
msgid "Post new" msgid "Post new"
msgstr "" msgstr "Đăng tin mới"
#: app/templates/listings/mine.html:10 #: app/templates/listings/mine.html:10
msgid "No listings yet." msgid "No listings yet."
msgstr "" msgstr "Chưa có tin đăng."
#: app/templates/listings/mine.html:18 #: app/templates/listings/mine.html:18
msgid "views" msgid "views"
msgstr "" msgstr "lượt xem"
+14
View File
@@ -0,0 +1,14 @@
"""Time helpers.
`datetime.utcnow()` is deprecated on Python 3.12+. This helper preserves the
existing *naive UTC* semantics the codebase relies on (DB columns are naive
`DateTime`, and comparisons assume naive UTC) while avoiding the deprecation:
we build an aware UTC datetime and strip the tzinfo, yielding the exact same
value `datetime.utcnow()` used to return.
"""
from datetime import datetime, timezone
def utcnow() -> datetime:
"""Current UTC time as a naive datetime (tzinfo stripped)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
@@ -0,0 +1,52 @@
"""reviews table (Phase 7)
Revision ID: b7f3c9a2d451
Revises: 482a73a8c5bb
Create Date: 2026-07-13 17:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b7f3c9a2d451'
down_revision = '482a73a8c5bb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('reviews',
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), autoincrement=True, nullable=False),
sa.Column('listing_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
sa.Column('author_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
sa.Column('seller_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
sa.Column('rating', sa.SmallInteger(), nullable=False),
sa.Column('body', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['listing_id'], ['listings.id'], ),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['seller_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('listing_id', 'author_id', name='uq_review_listing_author'),
sa.CheckConstraint('rating BETWEEN 1 AND 5', name='ck_review_rating')
)
with op.batch_alter_table('reviews', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_reviews_listing_id'), ['listing_id'], unique=False)
batch_op.create_index(batch_op.f('ix_reviews_author_id'), ['author_id'], unique=False)
batch_op.create_index(batch_op.f('ix_reviews_seller_id'), ['seller_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('reviews', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_reviews_seller_id'))
batch_op.drop_index(batch_op.f('ix_reviews_author_id'))
batch_op.drop_index(batch_op.f('ix_reviews_listing_id'))
op.drop_table('reviews')
# ### end Alembic commands ###
+17 -5
View File
@@ -10,7 +10,7 @@ import os
import re import re
import io import io
import logging import logging
import shutil
import tempfile import tempfile
os.environ.setdefault("SECRET_KEY", "test-secret") os.environ.setdefault("SECRET_KEY", "test-secret")
@@ -20,6 +20,11 @@ _SMOKE_DB = os.path.join(tempfile.gettempdir(), "classifieds_smoke.db")
if os.path.exists(_SMOKE_DB): if os.path.exists(_SMOKE_DB):
os.remove(_SMOKE_DB) os.remove(_SMOKE_DB)
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_SMOKE_DB}") os.environ.setdefault("DATABASE_URL", f"sqlite:///{_SMOKE_DB}")
# Use a dedicated temp media dir so image counts are deterministic across runs.
_SMOKE_MEDIA = os.path.join(tempfile.gettempdir(), "classifieds_smoke_media")
if os.path.exists(_SMOKE_MEDIA):
shutil.rmtree(_SMOKE_MEDIA)
os.environ["MEDIA_ROOT"] = _SMOKE_MEDIA
os.environ.setdefault("REDIS_URL", "memory://") # limiter in-memory os.environ.setdefault("REDIS_URL", "memory://") # limiter in-memory
os.environ.setdefault("FLASK_CONFIG", "dev") os.environ.setdefault("FLASK_CONFIG", "dev")
@@ -273,7 +278,7 @@ def _phase4(app):
user_fresh = db.session.get(User, user.id) user_fresh = db.session.get(User, user.id)
assert user_fresh.role == Role.subscriber assert user_fresh.role == Role.subscriber
assert user_fresh.tier.slug == "pro" assert user_fresh.tier.slug == "pro"
print("sync_subscription user upgraded to pro subscriber: ok") print("sync_subscription -> user upgraded to pro subscriber: ok")
# --- downgrade to free --- # --- downgrade to free ---
bsvc.downgrade_to_free(user.id) bsvc.downgrade_to_free(user.id)
@@ -659,7 +664,8 @@ def _phase6(app):
login("t@example.com", "NewPass456") login("t@example.com", "NewPass456")
for path in ("/admin", "/admin/listings", "/admin/reports", "/admin/settings", for path in ("/admin", "/admin/listings", "/admin/reports", "/admin/settings",
"/admin/categories", "/admin/plans", "/admin/transactions", "/admin/categories", "/admin/plans", "/admin/transactions",
"/admin/audit"): "/admin/audit", "/admin/ads", "/admin/sponsors",
"/admin/promoted-keywords", "/admin/analytics"):
assert c.get(path, base_url=B).status_code == 403 assert c.get(path, base_url=B).status_code == 403
print("non-admin /admin* -> 403: ok") print("non-admin /admin* -> 403: ok")
c.get("/auth/logout", base_url=B) c.get("/auth/logout", base_url=B)
@@ -674,7 +680,11 @@ def _phase6(app):
"/admin/listings", "/admin/reports", "/admin/settings", "/admin/listings", "/admin/reports", "/admin/settings",
"/admin/categories", f"/admin/categories/{cat_id}/schema", "/admin/categories", f"/admin/categories/{cat_id}/schema",
"/admin/plans", f"/admin/plans/{plan_id}", "/admin/plans", f"/admin/plans/{plan_id}",
"/admin/transactions", "/admin/audit"): "/admin/transactions", "/admin/audit",
"/admin/ads", "/admin/ads/new",
"/admin/sponsors", "/admin/sponsors/new",
"/admin/promoted-keywords", "/admin/promoted-keywords/new",
"/admin/analytics"):
code = c.get(path, base_url=B).status_code code = c.get(path, base_url=B).status_code
assert code == 200, f"{path} -> {code}" assert code == 200, f"{path} -> {code}"
print(f"{code} {path}") print(f"{code} {path}")
@@ -993,7 +1003,9 @@ def _phase2(app):
db.session.add(img); db.session.commit() db.session.add(img); db.session.commit()
assert img.path.endswith(".jpg") and img.thumb_path and img.width <= 1600 assert img.path.endswith(".jpg") and img.thumb_path and img.width <= 1600
import os import os
media = os.path.join(app.instance_path, "media", str(l1.id)) media_root = (app.config.get("MEDIA_ROOT")
or os.path.join(app.instance_path, "media"))
media = os.path.join(media_root, str(l1.id))
assert os.path.isdir(media) and len(os.listdir(media)) == 2 assert os.path.isdir(media) and len(os.listdir(media)) == 2
print("image pipeline (re-encode + thumbnail + EXIF strip): ok") print("image pipeline (re-encode + thumbnail + EXIF strip): ok")