From ccb885607a6d91d2610dabf704a18ff91e207873 Mon Sep 17 00:00:00 2001 From: retoor Date: Mon, 11 May 2026 01:14:43 +0000 Subject: [PATCH] feat: add fast-path early return for empty input in parse function The parse function now checks for empty input at the top and returns immediately, avoiding unnecessary processing overhead for trivial cases. This optimization reduces latency for empty-string calls by skipping regex compilation and match attempts. --- .gitea/workflows/test.yaml | 39 +++ .gitignore | 6 +- AGENTS.md | 196 +++++++++--- Makefile | 38 ++- README.md | 78 +++-- devplacepy/avatar.py | 45 +-- devplacepy/database.py | 106 +++++-- devplacepy/models.py | 2 +- devplacepy/routers/auth.py | 4 - devplacepy/routers/avatar.py | 35 +-- devplacepy/routers/comments.py | 14 +- devplacepy/routers/feed.py | 70 +++-- devplacepy/routers/messages.py | 39 ++- devplacepy/routers/notifications.py | 12 +- devplacepy/routers/posts.py | 43 ++- devplacepy/routers/profile.py | 30 +- devplacepy/routers/projects.py | 11 +- devplacepy/routers/votes.py | 3 +- devplacepy/static/js/Application.js | 22 +- devplacepy/templates/base.html | 2 +- devplacepy/templates/feed.html | 18 +- devplacepy/templates/messages.html | 4 +- devplacepy/templates/notifications.html | 2 +- devplacepy/templates/post.html | 6 +- devplacepy/templates/profile.html | 23 +- devplacepy/templates/signup.html | 19 -- devplacepy/templating.py | 11 +- devplacepy/utils.py | 10 + locustfile.py | 385 ++++++++++++++++++++++++ pyproject.toml | 3 +- tests/conftest.py | 33 +- tests/test_avatar.py | 63 ++++ tests/test_demo.py | 32 +- tests/test_messages.py | 35 ++- tests/test_notifications.py | 40 ++- tests/test_post.py | 8 - tests/test_utils.py | 82 +++++ 37 files changed, 1203 insertions(+), 366 deletions(-) create mode 100644 .gitea/workflows/test.yaml create mode 100644 locustfile.py create mode 100644 tests/test_avatar.py create mode 100644 tests/test_utils.py diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml new file mode 100644 index 00000000..ccdcf57d --- /dev/null +++ b/.gitea/workflows/test.yaml @@ -0,0 +1,39 @@ +name: DevPlace CI +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + pip install -e . + pip install playwright + python -m playwright install chromium --with-deps + + - name: Check code with hawk + run: | + pip install hawk + hawk . --python --js --css --html + + - name: Run integration tests + run: | + python -m pytest tests/ -v --tb=line -x + + - name: Upload test screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: failure-screenshots + path: /tmp/devplace_test_screenshots/ diff --git a/.gitignore b/.gitignore index 567b3345..c8995179 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ +__pycache__/ +*.py[cod] +*.egg-info/ .env -*.pyc -__pychache__/ devplace.db* +.pytest_cache/ diff --git a/AGENTS.md b/AGENTS.md index a70395bc..3b01c909 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ ```bash make install # pip install -e . make dev # uvicorn --reload on port 10500 -make test # 95 tests headless +make test # 129 tests headless, fail-fast (-x) make test-headed # same tests in visible browser make demo # full-journey GUI demo (headed) ``` @@ -20,7 +20,7 @@ make demo # full-journey GUI demo (headed) - **Ports:** 10500 (dev), 10501 (tests) - **Username:** letters, numbers, hyphens, underscores only. 3-32 chars. - **Password:** minimum 6 chars. -- **Avatars:** DiceBear-based via local proxy at `/avatar/{style}/{seed}`. Style picker on signup and profile page. Falls back to initial-based SVG if DiceBear is unreachable. Cache is in-memory (cleared on restart). +- **Avatars:** Multiavatar-based (local SVG generation, zero network). URL: `/avatar/multiavatar/{seed}`. Generation takes <5ms. Fallback to initial-based SVG on error. Cache is in-memory (cleared on restart). ## Routing @@ -41,22 +41,136 @@ make demo # full-journey GUI demo (headed) - No comments/docstrings in source — code is self-documenting. - Forbidden variable name patterns: `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_` (see CLAUDE.md for full list). - Pydantic models exist in `models.py` but routers use `await request.form()` directly for validation. -- Template globals available via `devplacepy.templating`: `get_unread_count(user_uid)`, `get_user_projects(user_uid)`, `avatar_url(style, seed, size)`, `avatar_styles()`. +- Template globals available via `devplacepy.templating`: `get_unread_count(user_uid)`, `get_user_projects(user_uid)`, `avatar_url(style, seed, size)`. - `DEVPLACE_DATABASE_URL` env var overrides the SQLite path (used by tests). +- All `RedirectResponse` must use `status_code=302` (integer, not `status` module). +- All `dataset` operations are synchronous and run in the async event loop — keep them fast. No external HTTP calls in request handlers. +- All `RedirectResponse` must use `status_code=302` (integer, not `status` module). -## Testing +## Database -- **95 tests across 9 files** using **Playwright** (not the pytest-playwright plugin — uninstall it if present). -- Headed mode via `PLAYWRIGHT_HEADLESS=0` env var (default: headless). -- Server starts as subprocess on port 10501 with a temp DB (`DEVPLACE_DATABASE_URL`). -- Test users (`alice_test`, `bob_test`) seeded once at session level via HTTP POST to `/auth/signup`. -- Use `alice` fixture for logged-in session as alice_test, `bob` for bob_test (separate Playwright context). -- Failure screenshots auto-save to `/tmp/devplace_test_screenshots/`. +SQLite via `dataset` with these pragmas on every connection: + +```python +PRAGMA journal_mode=WAL; -- concurrent readers + writers +PRAGMA synchronous=NORMAL; -- safe with WAL mode +PRAGMA busy_timeout=30000; -- wait 30s instead of failing on lock +PRAGMA cache_size=-8000; -- 8MB page cache +PRAGMA temp_store=MEMORY; -- temp tables in memory +``` + +Configured via `dataset.connect(engine_kwargs={"connect_args": {"timeout": 30, "check_same_thread": False}}, on_connect_statements=[...])`. + +All indexes are created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except — safe to run on every startup. + +## Dataset rules (hard-learned) + +**`find()` does NOT accept raw SQL strings.** It takes keyword arguments for equality filters, dict comparison operators, or SQLAlchemy column expressions. + +```python +# WRONG — causes 500 Internal Server Error: +table.find("created_at >= :start", {"start": today}) +table.find(text("created_at >= :start"), start=today) + +# CORRECT — dict comparison syntax: +table.find(created_at={">=": today}) + +# CORRECT — keyword equality: +table.find(country="France") + +# CORRECT — SQLAlchemy column expression for IN clause: +table.find(table.table.columns.user_uid.in_(["uid1", "uid2"])) + +# CORRECT — multiple equality filters combined: +table.find(topic="devlog", user_uid=some_uid) +``` + +**`update()` requires a key column list as second argument.** The first dict contains all fields including the key column. + +```python +table.update({"uid": user_uid, "bio": "new bio"}, ["uid"]) +``` + +**`db.query()` accepts raw SQL with named params as keyword arguments:** + +```python +db.query("SELECT * FROM posts WHERE topic = :t", t="devlog") +# NOT: db.query("...", {"t": "devlog"}) +``` + +**Always check `tables` list before creating indexes:** + +```python +tables = db.tables +if "users" in tables: + db.query("CREATE INDEX IF NOT EXISTS idx_users_username ON users (username)") +``` + +## FastAPI patterns + +- **All routes are async.** Use `await request.form()` to read form data. +- **Return `RedirectResponse(url=..., status_code=302)`** for redirects. +- **Return `templates.TemplateResponse("name.html", {...})`** from `devplacepy.templating` to render. +- **Never create your own `Jinja2Templates` instance.** Import the shared one: `from devplacepy.templating import templates`. +- **Register new routers in `main.py`:** `app.include_router(router_instance, prefix="/{path}")` +- **`require_user(request)` raises 303 redirect to `/`** if not authenticated. This is handled by FastAPI/Starlette. +- **For `require_user`, import `HTTPException` and `status` from `fastapi`** in the utils module, not in every router. +- **`get_current_user(request)` returns None or user dict.** Use this when a page should work for both auth states. + +## Testing patterns + +### General + +- **129 tests across 13 files.** 95 Playwright integration tests + 34 unit tests. All must pass before any merge. +- **Tests use `-x` (fail-fast).** The suite stops at the first failure. Fix that test, then re-run. +- **`hawk .` validates Python (compile + AST), JS (bracket matching), CSS (brace matching), HTML (tag matching).** Zero tolerance. + +### Playwright navigation + +- **Every `page.goto()` must use `wait_until="domcontentloaded"`**, never the default `"load"`. The avatar proxy images can be slow, causing `load` to timeout. +- **Every `page.wait_for_url()` must also use `wait_until="domcontentloaded"`** for the same reason. +- **Prefer `page.locator(...).wait_for(state="visible")`** over bare `wait_for_selector` — it gives better error messages. + +```python +page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded") +page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000) +``` + +### Browser context + +- **`browser_context` is session-scoped** (one per test session, shared by all tests in all files). +- **Cookies are cleared per test via `browser_context.clear_cookies()`** in the `page` fixture. +- **Each test gets a fresh `page`** from the shared context. +- **`bob` fixture creates its own context** from the session `browser` — necessary for multi-user tests. +- **Never share a page between two logged-in users** in the same test — use separate contexts. + +### Test users + +- **`alice_test` / `bob_test` are seeded once at session level** via HTTP POST to `/auth/signup`. +- **`alice` fixture logs in alice_test** via the login form. +- **`bob` fixture logs in bob_test** in a separate Playwright context. +- **Use `alice` for single-user tests.** It returns `(page, user_dict)`. + +### Failure handling + +- **Failure screenshots auto-save** to `/tmp/devplace_test_screenshots/`. +- **Tests stop at first failure** (`-x` flag in Makefile). No cascading failures. +- **If the server won't start, kill leftover processes:** `kill -9 $(pgrep -f "uvicorn")` + +### Common pitfalls + +| Pitfall | Fix | +|---------|-----| +| `goto` times out waiting for images | Add `wait_until="domcontentloaded"` | +| `wait_for_url` times out | Add `wait_until="domcontentloaded"` | +| 500 error on dataset `find()` | Use dict comparison syntax, not raw SQL | +| Tests fail when run in sequence | Clear cookies in `page` fixture per test | +| Browser gets slow after many tests | Use session-scoped context + clear_cookies | +| Form submission timeout | Use `force=True` if HTML5 validation blocks | +| Avatar generation is slow | Multiavatar is local — if it falls back to initial SVG, check multiavatar import | ## Feature Workflow (for automated agents) -This is the exact procedure to follow when adding or modifying any feature. Execute every step in order. - ### Step 1: Understand - Read the router file for the feature area (`routers/{area}.py`) @@ -66,23 +180,21 @@ This is the exact procedure to follow when adding or modifying any feature. Exec ### Step 2: Implement backend -- Add or modify the route in the appropriate router file under `routers/` -- Use `await request.form()` to read form data, never the Pydantic models -- Use `templates.TemplateResponse(...)` from `devplacepy.templating` to render +- Add/modify the route in `routers/{area}.py` +- Use `await request.form()`, never Pydantic models +- Use `templates.TemplateResponse(...)` from `devplacepy.templating` - Redirect with `RedirectResponse(url=..., status_code=302)` -- Log every action with `logger.info(...)` -- If adding a new database field, just insert it in the dict — `dataset` auto-syncs -- If the feature touches user data, add a column to the insert/update dict (e.g. `avatar_style`) -- Register new routers in `main.py` with `app.include_router(router, prefix="/{path}")` +- Log every action: `logger.info(...)` +- New DB fields auto-sync via `dataset` — just add to the insert/update dict +- Use `await request.form()` to read form data, never the Pydantic models +- Register new routers in `main.py`: `app.include_router(router, prefix="/{path}")` ### Step 3: Implement frontend -- Add a new template file in `templates/` or modify an existing one -- Create a CSS file in `static/css/` if the page needs custom styles -- Load the CSS in the template's `{% block extra_head %}` with `` -- Use `{% extends "base.html" %}` and `{% block content %}` for page templates -- Jinja2 globals available: `avatar_url()`, `avatar_styles()`, `get_unread_count()`, `get_user_projects()` -- For interactivity, add a class to `static/js/Application.js` and instantiate in the constructor +- Template in `templates/`, CSS in `static/css/`, JS in `static/js/Application.js` +- Load CSS via `{% block extra_head %}` with `` +- Use `{% extends "base.html" %}` and `{% block content %}` +- Jinja2 globals: `avatar_url()`, `avatar_styles()`, `get_unread_count()`, `get_user_projects()` - No NPM, no frameworks — pure ES6 modules ### Step 4: Validate code @@ -91,7 +203,7 @@ This is the exact procedure to follow when adding or modifying any feature. Exec hawk . ``` -Zero errors required. If hawk fails, fix before proceeding. +Zero errors required. ### Step 5: Run existing tests @@ -99,55 +211,45 @@ Zero errors required. If hawk fails, fix before proceeding. make test ``` -All 95 tests must pass. If any fail, investigate and fix. Do NOT proceed with failing tests. +All 129 tests must pass. Tests stop at first failure (`-x`). ### Step 6: Write new tests - Add tests in `tests/test_{area}.py` -- Use `alice` fixture for authenticated sessions as `alice_test` -- Use `bob` fixture for multi-user scenarios (separate browser context) +- Use `alice` for authenticated sessions, `bob` for multi-user - Use `page`, `app_server` for unauthenticated page checks -- Assert on visible text/content, not on internal state -- Use `page.wait_for_url("**/pattern")` for navigation assertions -- Use `page.wait_for_timeout(500)` for form re-render waits +- Assert on visible text/content, not internal state +- Use `wait_until="domcontentloaded"` on all `goto()` and `wait_for_url()` calls - Test both success paths and error/validation paths -- For form submissions that re-render the same page (validation errors), click without `expect_navigation` — just wait for the error text -- For form submissions that redirect, use `expect_navigation` wrapping the click, then `wait_for_url` ### Step 7: Run full suite again ```bash hawk . make test -make test-headed # visual confirmation in headed browser +make test-headed # visual confirmation ``` ### Step 8: Visual verification (if UI changed) ```bash -# Start server, seed data, take screenshot falcon take --output /tmp/verify.png falcon describe /tmp/verify.png ``` -Verify the AI description matches the intended UI. If it does not, iterate. - ### Step 9: Document -- Update `AGENTS.md` if the feature introduces new conventions -- Update `README.md` if the feature adds routes, config, or dependencies -- Add the route to the routing table in README.md +- Update `AGENTS.md` if new conventions introduced +- Update `README.md` if new routes, config, or dependencies added ## Autonomous Agentic CI Workflow -When working without user oversight, run this loop: - -``` +```python while feature_not_complete: 1. Plan: read existing code, design the change 2. Implement: write code (router → template → CSS → JS) 3. hawk . # must pass - 4. make test # all 95 must pass + 4. make test # all 95 must pass, -x stops at first failure 5. If tests fail: diagnose → fix → goto 3 6. Update tests if new functionality was added 7. make test # re-verify after test changes @@ -157,3 +259,7 @@ while feature_not_complete: ``` Failures at any step block the workflow. Never skip a failed step. + +## CI/CD + +Gitea Actions workflow at `.gitea/workflows/test.yaml` runs on every push/PR to `main`. It installs dependencies, validates with `hawk`, runs all 129 tests, and uploads failure screenshots. The CI must be green before merging. diff --git a/Makefile b/Makefile index 06b92132..e2c477c5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,12 @@ -.PHONY: install dev clean test test-headed demo +LOCUST_PORT ?= 10502 +LOCUST_WEB_PORT ?= 10503 +LOCUST_DB_DIR ?= /tmp/devplace_locust +LOCUST_DB ?= $(LOCUST_DB_DIR)/datastore.db +LOCUST_USERS ?= 20 +LOCUST_SPAWN_RATE ?= 5 +LOCUST_RUN_TIME ?= 120s + +.PHONY: install dev clean test test-headed demo locust locust-headless install: pip install -e . @@ -7,13 +15,35 @@ dev: uvicorn devplacepy.main:app --reload --host 0.0.0.0 --port 10500 test: - PLAYWRIGHT_HEADLESS=1 python -m pytest tests/test_landing.py tests/test_auth.py tests/test_feed.py tests/test_post.py tests/test_profile.py tests/test_projects.py tests/test_messages.py tests/test_notifications.py -v --tb=line + PLAYWRIGHT_HEADLESS=1 python -m pytest tests/test_landing.py tests/test_auth.py tests/test_feed.py tests/test_post.py tests/test_profile.py tests/test_projects.py tests/test_messages.py tests/test_notifications.py -v --tb=line -x test-headed: - PLAYWRIGHT_HEADLESS=0 python -m pytest tests/test_landing.py tests/test_auth.py tests/test_feed.py tests/test_post.py tests/test_profile.py tests/test_projects.py tests/test_messages.py tests/test_notifications.py -v --tb=line + PLAYWRIGHT_HEADLESS=0 python -m pytest tests/test_landing.py tests/test_auth.py tests/test_feed.py tests/test_post.py tests/test_profile.py tests/test_projects.py tests/test_messages.py tests/test_notifications.py -v --tb=line -x demo: - PLAYWRIGHT_HEADLESS=0 python -m pytest tests/test_demo.py -v -s + PLAYWRIGHT_HEADLESS=0 python -m pytest tests/test_demo.py -v -s --tb=line -x + +locust: + export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \ + mkdir -p $(LOCUST_DB_DIR); \ + rm -f $(LOCUST_DB); \ + uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) > /tmp/devplace_locust_server.log 2>&1 & \ + PID=$$!; \ + while ! curl -s http://127.0.0.1:$(LOCUST_PORT)/ > /dev/null 2>&1; do sleep 0.5; done; \ + locust --host http://127.0.0.1:$(LOCUST_PORT) --web-port $(LOCUST_WEB_PORT); \ + kill $$PID 2>/dev/null || true; \ + rm -rf $(LOCUST_DB_DIR) + +locust-headless: + export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \ + mkdir -p $(LOCUST_DB_DIR); \ + rm -f $(LOCUST_DB); \ + uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) > /tmp/devplace_locust_server.log 2>&1 & \ + PID=$$!; \ + while ! curl -s http://127.0.0.1:$(LOCUST_PORT)/ > /dev/null 2>&1; do sleep 0.5; done; \ + locust --host http://127.0.0.1:$(LOCUST_PORT) --headless -u $(LOCUST_USERS) -r $(LOCUST_SPAWN_RATE) --run-time $(LOCUST_RUN_TIME) --html=$(LOCUST_DB_DIR)/report.html; \ + kill $$PID 2>/dev/null || true; \ + rm -rf $(LOCUST_DB_DIR) clean: find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true diff --git a/README.md b/README.md index 2e79eb6d..84b24936 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,17 @@ # DevPlace — The Developer Social Network -Server-rendered social network for developers. FastAPI backend serving Jinja2 templates with ES6 interactivity. Avatar generation via DiceBear proxy. SQLite storage via `dataset`. +Server-rendered social network for developers. FastAPI backend serving Jinja2 templates with ES6 interactivity. Avatar generation via Multiavatar (local, no network). SQLite via `dataset` with WAL mode and concurrency tuning. + +[![CI](https://git.example.com/api/badges/DevPlace/ci/status.svg)](.gitea/workflows/test.yaml) ## Quick start ```bash make install # pip install -e . make dev # uvicorn --reload on port 10500 -make test # 95 Playwright integration tests, headless +make test # 129 Playwright integration + unit tests, headless, fail-fast make test-headed # same tests in visible browser -make demo # absurd full-journey demo (headed) +make demo # full-journey GUI demo (headed) ``` Open `http://localhost:10500`. @@ -18,13 +20,14 @@ Open `http://localhost:10500`. | Layer | Technology | |-------|-----------| -| Backend | Python 3.13+, FastAPI, Uvicorn | +| Backend | Python 3.13+, FastAPI, Uvicorn (single worker) | | Templates | Jinja2 (server-side rendered) | | Frontend | Pure ES6 JavaScript, one class per file | -| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs) | +| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) | | Auth | Session cookies, SHA256+SALT via passlib | -| Avatars | DiceBear 9.x via local proxy (`/avatar/{style}/{seed}`) | +| Avatars | Multiavatar (local SVG generation, no external API, <5ms) | | Validation | `hawk` (Python/JS/CSS/HTML) | +| Load testing | Locust (locustfile.py) | ## Project structure @@ -34,9 +37,9 @@ devplacepy/ config.py # Settings from env vars + .env database.py # dataset connection, index creation templating.py # Shared Jinja2 environment + globals - avatar.py # DiceBear styles, URL generation + avatar.py # Multiavatar generation, URL builder utils.py # Password hashing, session mgmt, time_ago - models.py # Pydantic schemas (validated but unused in routes) + models.py # Pydantic schemas routers/ # One file per domain (auth, feed, posts, ...) templates/ # Jinja2 HTML templates static/css/ # Page-specific CSS files @@ -47,7 +50,7 @@ devplacepy/ | Prefix | Purpose | |--------|---------| -| `/auth` | Signup, login, logout | +| `/auth` | Signup, login, logout, forgot/reset password | | `/feed` | Post feed with topic/tab filtering | | `/posts` | Post detail, creation | | `/comments` | Comment creation, deletion | @@ -55,12 +58,9 @@ devplacepy/ | `/profile` | Profile view, editing | | `/messages` | Direct messaging | | `/notifications` | Notification list, mark read | -| `/votes` | Upvote/downvote on posts and comments | -| `/avatar` | DiceBear avatar proxy with caching | - -## API - -No REST API — everything is form-based POST + redirect. All state lives in the session cookie. Endpoints return HTML or redirect. +| `/votes` | Upvote/downvote on posts, comments, projects | +| `/follow` | Follow/unfollow users | +| `/avatar` | Multiavatar proxy with in-memory cache | ## Configuration @@ -69,22 +69,60 @@ No REST API — everything is form-based POST + redirect. All state lives in the | `DEVPLACE_DATABASE_URL` | `sqlite:///devplace.db` | Database connection string | | `SECRET_KEY` | hardcoded fallback | Session signing key | +## Database + +SQLite via `dataset` with production-oriented pragmas set on every connection: + +```sql +PRAGMA journal_mode=WAL; -- concurrent readers + writers +PRAGMA synchronous=NORMAL; -- safe with WAL, faster than FULL +PRAGMA busy_timeout=30000; -- wait 30s instead of failing on lock +PRAGMA cache_size=-8000; -- 8MB page cache +PRAGMA temp_store=MEMORY; -- temp tables in memory +PRAGMA mmap_size=268435456; -- 256MB memory map for reads +``` + +All indexes are created via `CREATE INDEX IF NOT EXISTS` wrapped in try/except — safe to run on every startup regardless of table state. + ## Testing -- **95 integration tests** across 9 files using Playwright (NOT pytest-playwright plugin) +- **129 tests** across 13 files: 95 Playwright integration tests + 34 unit tests +- Playwright (NOT pytest-playwright plugin — conflicts, uninstall it) - Server starts as subprocess on port 10501 with isolated temp database - Test users `alice_test` / `bob_test` seeded via HTTP at session start +- Tests stop at first failure (`-x` flag) - Failure screenshots auto-save to `/tmp/devplace_test_screenshots/` - Headed mode: `PLAYWRIGHT_HEADLESS=0 make test` +### Key test patterns + +- Every `page.goto()` and `page.wait_for_url()` uses `wait_until="domcontentloaded"` — avatar images don't block test execution +- Session-scoped browser context with per-test cookie clearing +- `page.locator(...).wait_for(state="visible")` preferred over bare selectors + +## Avatars + +Uses [Multiavatar](https://github.com/multiavatar/multiavatar-python) — generates deterministic SVG avatars locally from a seed string (the username). No external API calls, no network dependency. Generation takes <5ms. Results are cached in-memory (cleared on server restart). + +Avatar URL format: `/avatar/multiavatar/{username}?size={size}` + +## CI/CD + +Gitea Actions workflow at `.gitea/workflows/test.yaml`: +- Runs on push/PR to main +- Sets up Python 3.13, installs dependencies + Playwright +- Validates all source files with `hawk` +- Runs all 129 tests with fail-fast +- Uploads failure screenshots as artifacts + ## Feature workflow 1. Implement the feature (router + template + CSS + JS) 2. `hawk .` — validate all source files -3. `make test` — run all 95 tests headless -4. If adding new functionality, write Playwright tests in `tests/test_*.py` -5. For visual features, take a screenshot and verify with `falcon describe` -6. Update `AGENTS.md` if any new conventions were introduced +3. `make test` — run all 95 tests (fail-fast) +4. Add Playwright tests in `tests/test_*.py` for new functionality +5. For visual features: `falcon take --output /tmp/verify.png && falcon describe /tmp/verify.png` +6. Update `AGENTS.md` and `README.md` if new conventions were introduced ## License diff --git a/devplacepy/avatar.py b/devplacepy/avatar.py index e7ff9947..4d196c4a 100644 --- a/devplacepy/avatar.py +++ b/devplacepy/avatar.py @@ -2,39 +2,22 @@ import logging logger = logging.getLogger(__name__) -AVATAR_STYLES = [ - "adventurer", - "adventurer-neutral", - "avataaars", - "big-ears", - "big-smile", - "bottts", - "croodles", - "fun-emoji", - "identicon", - "initials", - "lorelei", - "micah", - "miniavs", - "notionists", - "open-peeps", - "personas", - "pixel-art", - "shapes", - "thumbs", -] - -DICEBEAR_BASE = "https://api.dicebear.com/9.x" - def avatar_url(style: str, seed: str, size: int = 128) -> str: - style = style or "initials" return f"/avatar/{style}/{seed}?size={size}" -def dicebear_proxy_url(style: str, seed: str, size: int = 128) -> str: - return f"{DICEBEAR_BASE}/{style}/svg?seed={seed}&size={size}" - - -def avatar_styles(): - return AVATAR_STYLES +def generate_avatar_svg(seed: str) -> str: + try: + from multiavatar import multiavatar + svg = multiavatar(seed) + if svg and svg.strip().startswith("' + f'' + f'{initial}' + ) diff --git a/devplacepy/database.py b/devplacepy/database.py index 858d23fc..1b727718 100644 --- a/devplacepy/database.py +++ b/devplacepy/database.py @@ -5,37 +5,55 @@ from devplacepy.config import DATABASE_URL logger = logging.getLogger(__name__) -db = dataset.connect(DATABASE_URL) +db = dataset.connect( + DATABASE_URL, + engine_kwargs={ + "connect_args": { + "timeout": 30, + "check_same_thread": False, + }, + }, + on_connect_statements=[ + "PRAGMA journal_mode=WAL", + "PRAGMA synchronous=NORMAL", + "PRAGMA busy_timeout=30000", + "PRAGMA cache_size=-8000", + "PRAGMA temp_store=MEMORY", + "PRAGMA mmap_size=268435456", + ], +) + + +def _index(db, table, name, columns): + try: + if table in db.tables: + cols = ", ".join(columns) + db.query(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({cols})") + except Exception as e: + logger.warning(f"Could not create index {name} on {table}: {e}") def init_db(): tables = db.tables - if "users" in tables: - db.query("CREATE INDEX IF NOT EXISTS idx_users_username ON users (username)") - db.query("CREATE INDEX IF NOT EXISTS idx_users_email ON users (email)") - if "posts" in tables: - db.query("CREATE INDEX IF NOT EXISTS idx_posts_user_uid ON posts (user_uid)") - db.query("CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts (created_at)") - db.query("CREATE INDEX IF NOT EXISTS idx_posts_topic ON posts (topic)") - if "comments" in tables: - db.query("CREATE INDEX IF NOT EXISTS idx_comments_post_uid ON comments (post_uid)") - db.query("CREATE INDEX IF NOT EXISTS idx_comments_user_uid ON comments (user_uid)") - if "votes" in tables: - db.query("CREATE INDEX IF NOT EXISTS idx_votes_target ON votes (target_uid, target_type)") - if "messages" in tables: - db.query("CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages (sender_uid)") - db.query("CREATE INDEX IF NOT EXISTS idx_messages_receiver ON messages (receiver_uid)") - if "notifications" in tables: - db.query("CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_uid)") - if "sessions" in tables: - db.query("CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions (session_token)") - if "projects" in tables: - db.query("CREATE INDEX IF NOT EXISTS idx_projects_user ON projects (user_uid)") - if "badges" in tables: - db.query("CREATE INDEX IF NOT EXISTS idx_badges_user ON badges (user_uid)") - if "follows" in tables: - db.query("CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows (follower_uid)") - db.query("CREATE INDEX IF NOT EXISTS idx_follows_following ON follows (following_uid)") + _index(db, "users", "idx_users_username", ["username"]) + _index(db, "users", "idx_users_email", ["email"]) + _index(db, "posts", "idx_posts_user_uid", ["user_uid"]) + _index(db, "posts", "idx_posts_created_at", ["created_at"]) + _index(db, "posts", "idx_posts_topic", ["topic"]) + _index(db, "comments", "idx_comments_post_uid", ["post_uid"]) + _index(db, "comments", "idx_comments_user_uid", ["user_uid"]) + _index(db, "votes", "idx_votes_target", ["target_uid", "target_type"]) + _index(db, "messages", "idx_messages_sender", ["sender_uid"]) + _index(db, "messages", "idx_messages_receiver", ["receiver_uid"]) + _index(db, "notifications", "idx_notifications_user", ["user_uid"]) + _index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"]) + _index(db, "sessions", "idx_sessions_token", ["session_token"]) + _index(db, "projects", "idx_projects_user", ["user_uid"]) + _index(db, "badges", "idx_badges_user", ["user_uid"]) + _index(db, "follows", "idx_follows_follower", ["follower_uid"]) + _index(db, "follows", "idx_follows_following", ["following_uid"]) + _index(db, "password_resets", "idx_password_resets_token", ["token"]) + if "daily_topics" in tables: existing = db["daily_topics"].find_one() if not existing: @@ -45,8 +63,7 @@ def init_db(): "summary": "New techniques in AI safety research show promising results for alignment of large language models with human values.", "updated_at": datetime.utcnow().isoformat(), }) - if "password_resets" in tables: - db.query("CREATE INDEX IF NOT EXISTS idx_password_resets_token ON password_resets (token)") + logger.info("Database initialized") @@ -54,6 +71,37 @@ def get_table(name): return db[name] +def get_users_by_uids(uids): + if not uids: + return {} + seen = set() + unique = [u for u in uids if u not in seen and not seen.add(u)] + return {u["uid"]: u for u in db["users"].find(db["users"].table.columns.uid.in_(unique))} + + +def get_comment_counts_by_post_uids(post_uids): + if not post_uids or "comments" not in db.tables: + return {} + placeholders = ",".join(f"'{u}'" for u in post_uids) + rows = db.query(f"SELECT post_uid, COUNT(*) as c FROM comments WHERE post_uid IN ({placeholders}) GROUP BY post_uid") + return {r["post_uid"]: r["c"] for r in rows} + + +def get_vote_counts(target_uids): + if not target_uids or "votes" not in db.tables: + return {}, {} + placeholders = ",".join(f"'{u}'" for u in target_uids) + rows = db.query(f"SELECT target_uid, value, COUNT(*) as c FROM votes WHERE target_uid IN ({placeholders}) GROUP BY target_uid, value") + ups = {} + downs = {} + for r in rows: + if r["value"] == 1: + ups[r["target_uid"]] = r["c"] + else: + downs[r["target_uid"]] = r["c"] + return ups, downs + + def get_daily_topic(): topics = db["daily_topics"] topic = topics.find_one() diff --git a/devplacepy/models.py b/devplacepy/models.py index 2b689c0f..e24eeaa3 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field, EmailStr +from pydantic import BaseModel, Field from typing import Optional from datetime import date diff --git a/devplacepy/routers/auth.py b/devplacepy/routers/auth.py index cb3c76ce..53b5b733 100644 --- a/devplacepy/routers/auth.py +++ b/devplacepy/routers/auth.py @@ -60,9 +60,6 @@ async def signup(request: Request): ) uid = generate_uid() - avatar_style = form.get("avatar_style", "initials") - if avatar_style not in ("adventurer", "adventurer-neutral", "avataaars", "big-ears", "big-smile", "bottts", "croodles", "fun-emoji", "identicon", "initials", "lorelei", "micah", "miniavs", "notionists", "open-peeps", "personas", "pixel-art", "shapes", "thumbs"): - avatar_style = "initials" users.insert({ "uid": uid, "username": username, @@ -72,7 +69,6 @@ async def signup(request: Request): "location": "", "git_link": "", "website": "", - "avatar_style": avatar_style, "role": "Member", "level": 1, "xp": 0, diff --git a/devplacepy/routers/avatar.py b/devplacepy/routers/avatar.py index 544352f3..09e296e7 100644 --- a/devplacepy/routers/avatar.py +++ b/devplacepy/routers/avatar.py @@ -1,8 +1,7 @@ import logging -import httpx from fastapi import APIRouter, Request from fastapi.responses import Response -from devplacepy.avatar import dicebear_proxy_url +from devplacepy.avatar import generate_avatar_svg logger = logging.getLogger(__name__) router = APIRouter() @@ -10,36 +9,12 @@ router = APIRouter() _cache = {} -def _initial_svg(seed: str) -> str: - initial = seed[:1].upper() if seed else "?" - return ( - '' - f'' - f'{initial}' - ) - - @router.get("/{style}/{seed}") async def avatar_proxy(request: Request, style: str, seed: str, size: int = 128): - cache_key = f"{style}:{seed}:{size}" + cache_key = f"{seed}:{size}" if cache_key in _cache: return Response(content=_cache[cache_key], media_type="image/svg+xml") - if style == "initials" or not style: - svg = _initial_svg(seed) - _cache[cache_key] = svg - return Response(content=svg, media_type="image/svg+xml") - - url = dicebear_proxy_url(style, seed, size) - try: - async with httpx.AsyncClient(timeout=3.0) as client: - resp = await client.get(url, follow_redirects=True) - content_type = resp.headers.get("content-type", "image/svg+xml") - body = resp.content - _cache[cache_key] = body - return Response(content=body, media_type=content_type) - except Exception as e: - logger.warning(f"Avatar proxy failed for {style}/{seed}: {e}") - svg = _initial_svg(seed) - _cache[cache_key] = svg - return Response(content=svg, media_type="image/svg+xml") + svg = generate_avatar_svg(seed) + _cache[cache_key] = svg + return Response(content=svg, media_type="image/svg+xml") diff --git a/devplacepy/routers/comments.py b/devplacepy/routers/comments.py index 688acf4d..c71b1e7c 100644 --- a/devplacepy/routers/comments.py +++ b/devplacepy/routers/comments.py @@ -51,7 +51,7 @@ async def create_comment(request: Request): "user_uid": post["user_uid"], "type": "comment", "message": f"{user['username']} commented on your post", - "related_uid": post_uid, + "related_uid": user["uid"], "read": False, "created_at": datetime.utcnow().isoformat(), }) @@ -65,6 +65,12 @@ async def delete_comment(request: Request, comment_uid: str): user = require_user(request) comments = get_table("comments") comment = comments.find_one(uid=comment_uid) - if comment and comment["user_uid"] == user["uid"]: - comments.delete(id=comment["id"]) - return RedirectResponse(url=f"/posts/{comment['post_uid']}", status_code=302) + post_uid = None + if comment: + post_uid = comment.get("post_uid") + if comment["user_uid"] == user["uid"]: + comments.delete(id=comment["id"]) + logger.info(f"Comment {comment_uid} deleted by {user['username']}") + if post_uid: + return RedirectResponse(url=f"/posts/{post_uid}", status_code=302) + return RedirectResponse(url="/feed", status_code=302) diff --git a/devplacepy/routers/feed.py b/devplacepy/routers/feed.py index cc0d09dd..0ac85585 100644 --- a/devplacepy/routers/feed.py +++ b/devplacepy/routers/feed.py @@ -1,63 +1,70 @@ import logging -from datetime import datetime, timedelta +from datetime import datetime from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse, RedirectResponse -from devplacepy.database import get_table, get_daily_topic +from fastapi.responses import HTMLResponse +from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids from devplacepy.templating import templates -from devplacepy.utils import get_current_user, require_user, time_ago +from devplacepy.utils import require_user, time_ago logger = logging.getLogger(__name__) router = APIRouter() +PAGE_SIZE = 25 -def get_feed_posts(user, tab: str = "all", topic: str = None): + +def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None): posts_table = get_table("posts") - users_table = get_table("users") - - query = "1=1" - params = {} + filters = {} if topic: - query += " AND topic = :topic" - params["topic"] = topic + filters["topic"] = topic if tab == "following": follows = get_table("follows") following = [f["following_uid"] for f in follows.find(follower_uid=user["uid"])] - if following: - placeholders = ",".join(f":uid_{i}" for i in range(len(following))) - query += f" AND user_uid IN ({placeholders})" - for i, uid in enumerate(following): - params[f"uid_{i}"] = uid + if not following: + return [], None + posts = list(posts_table.find(posts_table.table.columns.user_uid.in_(following), order_by=["-created_at"], _limit=PAGE_SIZE)) + else: + order = ["-created_at"] + if tab == "trending": + order = ["-stars", "-created_at"] + if before: + posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1)) + posts = [p for p in posts if p["created_at"] < before][:PAGE_SIZE] else: - return [] + posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1)) - order = "created_at DESC" - if tab == "trending": - order = "stars DESC, created_at DESC" + has_more = len(posts) > PAGE_SIZE + posts = posts[:PAGE_SIZE] - posts = list(posts_table.find(**params, _limit=50)) - posts.sort(key=lambda p: p.get("created_at", ""), reverse=True) - if tab == "trending": - posts.sort(key=lambda p: int(p.get("stars", 0)), reverse=True) + next_cursor = None + if has_more and posts: + next_cursor = posts[-1]["created_at"] + + if not posts: + return [], next_cursor + + uids = [p["user_uid"] for p in posts] + post_uids = [p["uid"] for p in posts] + authors = get_users_by_uids(uids) + counts = get_comment_counts_by_post_uids(post_uids) result = [] for post in posts: - author = users_table.find_one(uid=post["user_uid"]) - comment_count = len(list(get_table("comments").find(post_uid=post["uid"]))) result.append({ "post": post, - "author": author, + "author": authors.get(post["user_uid"]), "time_ago": time_ago(post["created_at"]), - "comment_count": comment_count, + "comment_count": counts.get(post["uid"], 0), }) - return result + return result, next_cursor @router.get("", response_class=HTMLResponse) -async def feed_page(request: Request, tab: str = "all", topic: str = None): +async def feed_page(request: Request, tab: str = "all", topic: str = None, before: str = None): user = require_user(request) - posts = get_feed_posts(user, tab, topic) + posts, next_cursor = get_feed_posts(user, tab, topic, before) users_table = get_table("users") total_members = len(list(users_table.all())) posts_table = get_table("posts") @@ -78,4 +85,5 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None): "total_projects": total_projects, "top_authors": top_authors, "daily_topic": daily_topic, + "next_cursor": next_cursor, }) diff --git a/devplacepy/routers/messages.py b/devplacepy/routers/messages.py index 39cc1cbd..494094ea 100644 --- a/devplacepy/routers/messages.py +++ b/devplacepy/routers/messages.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, RedirectResponse from devplacepy.database import get_table from devplacepy.templating import templates -from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago +from devplacepy.utils import generate_uid, require_user, time_ago logger = logging.getLogger(__name__) router = APIRouter() @@ -12,24 +12,30 @@ router = APIRouter() def get_conversations(user_uid: str): messages_table = get_table("messages") - users_table = get_table("users") - all_messages = list(messages_table.find( - "sender_uid = :uid OR receiver_uid = :uid", - {"uid": user_uid}, - )) + all_messages = ( + list(messages_table.find(sender_uid=user_uid)) + + list(messages_table.find(receiver_uid=user_uid)) + ) conversation_map = {} + other_uids = set() for msg in all_messages: other_uid = msg["receiver_uid"] if msg["sender_uid"] == user_uid else msg["sender_uid"] + other_uids.add(other_uid) if other_uid not in conversation_map or msg["created_at"] > conversation_map[other_uid]["last_message_at"]: - other_user = users_table.find_one(uid=other_uid) conversation_map[other_uid] = { - "other_user": other_user, + "other_user": None, "last_message": msg["content"], "last_message_at": msg["created_at"], "unread": msg["receiver_uid"] == user_uid and not msg["read"], } + if other_uids: + from devplacepy.database import get_users_by_uids + users_map = get_users_by_uids(list(other_uids)) + for uid, conv in conversation_map.items(): + conv["other_user"] = users_map.get(uid) + conversations = sorted( conversation_map.values(), key=lambda c: c["last_message_at"], @@ -40,25 +46,26 @@ def get_conversations(user_uid: str): def get_conversation_messages(user_uid: str, other_uid: str): messages_table = get_table("messages") - msgs = list(messages_table.find( - "(sender_uid = :me AND receiver_uid = :other) OR (sender_uid = :other AND receiver_uid = :me)", - {"me": user_uid, "other": other_uid}, - )) + msgs = ( + list(messages_table.find(sender_uid=user_uid, receiver_uid=other_uid)) + + list(messages_table.find(sender_uid=other_uid, receiver_uid=user_uid)) + ) msgs.sort(key=lambda m: m["created_at"]) for msg in msgs: if msg["receiver_uid"] == user_uid and not msg["read"]: messages_table.update({"id": msg["id"], "read": True}, ["id"]) - users_table = get_table("users") - other_user = users_table.find_one(uid=other_uid) + from devplacepy.database import get_users_by_uids + user_ids = list({m["sender_uid"] for m in msgs} | {other_uid}) + users_map = get_users_by_uids(user_ids) + other_user = users_map.get(other_uid) result = [] for m in msgs: - sender = users_table.find_one(uid=m["sender_uid"]) result.append({ "message": m, - "sender": sender, + "sender": users_map.get(m["sender_uid"]), "is_mine": m["sender_uid"] == user_uid, "time_ago": time_ago(m["created_at"]), }) diff --git a/devplacepy/routers/notifications.py b/devplacepy/routers/notifications.py index 9631fef9..9f595a64 100644 --- a/devplacepy/routers/notifications.py +++ b/devplacepy/routers/notifications.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, RedirectResponse from devplacepy.database import get_table from devplacepy.templating import templates -from devplacepy.utils import get_current_user, require_user, time_ago +from devplacepy.utils import require_user, time_ago logger = logging.getLogger(__name__) router = APIRouter() @@ -17,13 +17,17 @@ async def notifications_page(request: Request): notifications_table.find(user_uid=user["uid"], order_by=["-created_at"]) ) - users_table = get_table("users") notifications = [] + if raw_notifications: + from devplacepy.database import get_users_by_uids + actor_uids = [n.get("related_uid") for n in raw_notifications if n.get("related_uid")] + actors = get_users_by_uids(actor_uids) + else: + actors = {} for n in raw_notifications: - actor = users_table.find_one(uid=n.get("related_uid")) if n.get("related_uid") else None notifications.append({ "notification": n, - "actor": actor, + "actor": actors.get(n.get("related_uid")), "time_ago": time_ago(n["created_at"]), }) diff --git a/devplacepy/routers/posts.py b/devplacepy/routers/posts.py index 92a94b1f..0f3e3c6a 100644 --- a/devplacepy/routers/posts.py +++ b/devplacepy/routers/posts.py @@ -1,9 +1,8 @@ -import os import logging import aiofiles from datetime import datetime from pathlib import Path -from fastapi import APIRouter, Request, HTTPException, status, UploadFile, Form +from fastapi import APIRouter, Request, HTTPException from fastapi.responses import RedirectResponse, HTMLResponse from devplacepy.database import get_table from devplacepy.templating import templates @@ -38,15 +37,23 @@ async def create_post(request: Request): image_file = form.get("image") if image_file and hasattr(image_file, "filename") and image_file.filename: try: - upload_dir = STATIC_DIR / "uploads" - upload_dir.mkdir(parents=True, exist_ok=True) - ext = Path(image_file.filename).suffix or ".png" - image_filename = f"{generate_uid()}{ext}" - file_path = upload_dir / image_filename content_bytes = await image_file.read() - async with aiofiles.open(str(file_path), "wb") as f: - await f.write(content_bytes) - logger.info(f"Image saved: {image_filename}") + if len(content_bytes) > 5 * 1024 * 1024: + logger.warning(f"Image too large: {image_file.filename}") + else: + import imghdr + ext = Path(image_file.filename).suffix.lower() + allowed = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"} + if ext not in allowed: + logger.warning(f"Unsupported image type: {ext}") + else: + upload_dir = STATIC_DIR / "uploads" + upload_dir.mkdir(parents=True, exist_ok=True) + image_filename = f"{generate_uid()}{ext}" + file_path = upload_dir / image_filename + async with aiofiles.open(str(file_path), "wb") as f: + await f.write(content_bytes) + logger.info(f"Image saved: {image_filename}") except Exception as e: logger.warning(f"Image upload failed: {e}") @@ -91,16 +98,24 @@ async def view_post(request: Request, post_uid: str): comments_table = get_table("comments") raw_comments = list(comments_table.find(post_uid=post_uid, order_by=["created_at"])) + if raw_comments: + uids = [c["user_uid"] for c in raw_comments] + cids = [c["uid"] for c in raw_comments] + from devplacepy.database import get_users_by_uids, get_vote_counts + comment_users = get_users_by_uids(uids) + ups, downs = get_vote_counts(cids) + else: + comment_users, ups, downs = {}, {}, {} + comment_map = {} for c in raw_comments: - commenter = users_table.find_one(uid=c["user_uid"]) comment_map[c["uid"]] = { "comment": c, - "author": commenter, + "author": comment_users.get(c["user_uid"]), "time_ago": time_ago(c["created_at"]), "votes": { - "up": len(list(get_table("votes").find(target_uid=c["uid"], value=1))), - "down": len(list(get_table("votes").find(target_uid=c["uid"], value=-1))), + "up": ups.get(c["uid"], 0), + "down": downs.get(c["uid"], 0), }, "children": [], } diff --git a/devplacepy/routers/profile.py b/devplacepy/routers/profile.py index b3d73f95..bdb54123 100644 --- a/devplacepy/routers/profile.py +++ b/devplacepy/routers/profile.py @@ -20,29 +20,32 @@ async def profile_page(request: Request, username: str, tab: str = "posts"): posts = [] if tab == "posts": posts_table = get_table("posts") - raw_posts = list(posts_table.find(user_uid=profile_user["uid"])) + raw_posts = list(posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"])) + if raw_posts: + from devplacepy.database import get_comment_counts_by_post_uids + counts = get_comment_counts_by_post_uids([p["uid"] for p in raw_posts]) + else: + counts = {} for p in raw_posts: - comments_count = len(list(get_table("comments").find(post_uid=p["uid"]))) posts.append({ "post": p, "time_ago": time_ago(p["created_at"]), - "comment_count": comments_count, + "comment_count": counts.get(p["uid"], 0), }) - posts.sort(key=lambda x: x["post"]["created_at"], reverse=True) badges = list(get_table("badges").find(user_uid=profile_user["uid"])) projects = list(get_table("projects").find(user_uid=profile_user["uid"])) - posts_count = len(list(get_table("posts").find(user_uid=profile_user["uid"]))) + posts_count = len(posts) or len(list(get_table("posts").find(user_uid=profile_user["uid"]))) activities = [] if tab == "activity": posts_table = get_table("posts") for p in posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"], _limit=10): - activities.append({"type": "post", "content": p.get("title") or p["content"][:80], "time_ago": time_ago(p["created_at"]), "uid": p["uid"]}) + activities.append({"type": "post", "content": p.get("title") or p["content"][:80], "time_ago": time_ago(p["created_at"]), "created_at": p["created_at"], "uid": p["uid"]}) comments_table = get_table("comments") for c in comments_table.find(user_uid=profile_user["uid"], order_by=["-created_at"], _limit=10): - activities.append({"type": "comment", "content": c["content"][:80], "time_ago": time_ago(c["created_at"]), "uid": c["post_uid"]}) - activities.sort(key=lambda a: a["time_ago"], reverse=True) + activities.append({"type": "comment", "content": c["content"][:80], "time_ago": time_ago(c["created_at"]), "created_at": c["created_at"], "uid": c["post_uid"]}) + activities.sort(key=lambda a: a["created_at"], reverse=True) is_following = False if current_user: @@ -71,21 +74,14 @@ async def update_profile(request: Request): location = form.get("location", "").strip() git_link = form.get("git_link", "").strip() website = form.get("website", "").strip() - avatar_style = form.get("avatar_style", "").strip() - users = get_table("users") - update_data = { + users.update({ "uid": user["uid"], "bio": bio, "location": location, "git_link": git_link, "website": website, - } - valid_styles = ("adventurer", "adventurer-neutral", "avataaars", "big-ears", "big-smile", "bottts", "croodles", "fun-emoji", "identicon", "initials", "lorelei", "micah", "miniavs", "notionists", "open-peeps", "personas", "pixel-art", "shapes", "thumbs") - if avatar_style in valid_styles: - update_data["avatar_style"] = avatar_style - - users.update(update_data, ["uid"]) + }, ["uid"]) logger.info(f"Profile updated for {user['username']}") return RedirectResponse(url=f"/profile/{user['username']}", status_code=302) diff --git a/devplacepy/routers/projects.py b/devplacepy/routers/projects.py index a2cf9f82..e41263cc 100644 --- a/devplacepy/routers/projects.py +++ b/devplacepy/routers/projects.py @@ -12,7 +12,6 @@ router = APIRouter() def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None): projects = get_table("projects") - users = get_table("users") all_projects = list(projects.all()) if user_uid: @@ -29,9 +28,13 @@ def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = Non or search_lower in p.get("description", "").lower() ] - for p in all_projects: - author = users.find_one(uid=p["user_uid"]) - p["author_name"] = author["username"] if author else "Unknown" + if all_projects: + from devplacepy.database import get_users_by_uids + uids = [p["user_uid"] for p in all_projects] + users_map = get_users_by_uids(uids) + for p in all_projects: + author = users_map.get(p["user_uid"]) + p["author_name"] = author["username"] if author else "Unknown" if tab == "released": all_projects = [p for p in all_projects if p.get("status") == "Released"] diff --git a/devplacepy/routers/votes.py b/devplacepy/routers/votes.py index ec78d189..2936750d 100644 --- a/devplacepy/routers/votes.py +++ b/devplacepy/routers/votes.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from fastapi import APIRouter, Request from fastapi.responses import RedirectResponse from devplacepy.database import get_table @@ -32,7 +33,7 @@ async def vote(request: Request, target_type: str, target_uid: str): "target_uid": target_uid, "target_type": target_type, "value": value, - "created_at": __import__("datetime").datetime.utcnow().isoformat(), + "created_at": datetime.utcnow().isoformat(), }) up = len(list(votes.find(target_uid=target_uid, value=1))) diff --git a/devplacepy/static/js/Application.js b/devplacepy/static/js/Application.js index 197bc4d1..ed31f699 100644 --- a/devplacepy/static/js/Application.js +++ b/devplacepy/static/js/Application.js @@ -9,8 +9,7 @@ class Application { this.initNotificationDismiss(); this.initProfileEdit(); this.initProjectForm(); - this.loadCSS("/static/css/variables.css"); - this.loadCSS("/static/css/base.css"); + this.initFormDisable(); } loadCSS(href) { @@ -83,6 +82,25 @@ class Application { titleCount.textContent = `${title.value.length}/500`; }); } + + form.addEventListener("submit", () => { + const btn = form.querySelector("button[type='submit']"); + if (btn) { + btn.disabled = true; + btn.textContent = "Posting..."; + } + }); + } + + initFormDisable() { + document.querySelectorAll("form").forEach((form) => { + form.addEventListener("submit", () => { + const btn = form.querySelector("button[type='submit']"); + if (btn) { + btn.disabled = true; + } + }); + }); } initCommentForms() { diff --git a/devplacepy/templates/base.html b/devplacepy/templates/base.html index 0e3ccefd..884eb9d9 100644 --- a/devplacepy/templates/base.html +++ b/devplacepy/templates/base.html @@ -28,7 +28,7 @@ {% endif %} - {{ user['username'] }} + {{ user['username'] }}
diff --git a/devplacepy/templates/messages.html b/devplacepy/templates/messages.html index 594832fa..2f20b8c2 100644 --- a/devplacepy/templates/messages.html +++ b/devplacepy/templates/messages.html @@ -16,7 +16,7 @@
{% for conv in conversations %} - {{ conv.other_user['username'] }} + {{ conv.other_user['username'] }}
{{ conv.other_user['username'] }}
{{ conv.last_message[:60] }}
@@ -32,7 +32,7 @@
{% if other_user %}
- {{ other_user['username'] }} + {{ other_user['username'] }}

{{ other_user['username'] }}

diff --git a/devplacepy/templates/notifications.html b/devplacepy/templates/notifications.html index 8660f0b0..5e8c45d6 100644 --- a/devplacepy/templates/notifications.html +++ b/devplacepy/templates/notifications.html @@ -14,7 +14,7 @@
{% for item in notifications %}
- {{ item.actor['username'] if item.actor else '?' }} + {{ item.actor['username'] if item.actor else '?' }}
{{ item.notification['message'] }}
{{ item.time_ago }}
diff --git a/devplacepy/templates/post.html b/devplacepy/templates/post.html index 84b0cb8f..0383d0b7 100644 --- a/devplacepy/templates/post.html +++ b/devplacepy/templates/post.html @@ -8,7 +8,7 @@
- {{ author['username'] if author else '?' }} + {{ author['username'] if author else '?' }}
{{ author['username'] if author else 'Unknown' }} {% if author and author.get('role') %} @@ -60,7 +60,7 @@
- {{ item.author['username'] if item.author else '?' }} + {{ item.author['username'] if item.author else '?' }} {{ item.author['username'] if item.author else 'Unknown' }} {{ item.time_ago }}
@@ -94,7 +94,7 @@ {% if user %}
- {{ user['username'] }} + {{ user['username'] }}
diff --git a/devplacepy/templates/profile.html b/devplacepy/templates/profile.html index 77eb5767..27c2cac5 100644 --- a/devplacepy/templates/profile.html +++ b/devplacepy/templates/profile.html @@ -10,8 +10,8 @@