parent
51832664c4
commit
3a4c6b14d5
@ -8,7 +8,6 @@ make dev # uvicorn --reload on port 10500, backlog 4096
|
||||
make prod # uvicorn with 2 workers, backlog 8192 (production)
|
||||
make test # Playwright integration + unit tests (fail-fast -x)
|
||||
make test-headed # same tests in visible browser
|
||||
make demo # full-journey GUI demo (headed)
|
||||
make locust # Locust load test (interactive web UI)
|
||||
make locust-headless # Locust in headless CLI mode (for CI)
|
||||
```
|
||||
|
||||
5
Makefile
5
Makefile
@ -7,7 +7,7 @@ LOCUST_SPAWN_RATE ?= 5
|
||||
LOCUST_RUN_TIME ?= 120s
|
||||
DEVPLACE_RATE_LIMIT ?= 1000000
|
||||
|
||||
.PHONY: install dev clean test test-headed demo locust locust-headless
|
||||
.PHONY: install dev clean test test-headed locust locust-headless
|
||||
|
||||
install:
|
||||
pip install -e .
|
||||
@ -24,9 +24,6 @@ test:
|
||||
test-headed:
|
||||
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -v --tb=line -x
|
||||
|
||||
demo:
|
||||
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/test_demo.py -v -s --tb=line -x
|
||||
|
||||
locust:
|
||||
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
|
||||
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
|
||||
|
||||
@ -11,7 +11,6 @@ make install # pip install -e .
|
||||
make dev # uvicorn --reload on port 10500
|
||||
make test # Playwright integration + unit tests, headless, fail-fast
|
||||
make test-headed # same tests in visible browser
|
||||
make demo # full-journey GUI demo (headed)
|
||||
```
|
||||
|
||||
Open `http://localhost:10500`.
|
||||
|
||||
@ -250,7 +250,11 @@ def get_attachments_batch(target_type, uids):
|
||||
def _row_to_attachment(row):
|
||||
stored_name = row.get("stored_name", "")
|
||||
directory = row.get("directory", "")
|
||||
thumb_name = f"{Path(stored_name).stem}_thumb.jpg" if row.get("has_thumbnail") else None
|
||||
thumb_name = None
|
||||
if row.get("has_thumbnail"):
|
||||
stem = Path(stored_name).stem
|
||||
matches = sorted((ATTACHMENTS_DIR / directory).glob(f"{stem}_thumb.*"))
|
||||
thumb_name = matches[0].name if matches else f"{stem}_thumb.jpg"
|
||||
return {
|
||||
"uid": row["uid"],
|
||||
"original_filename": row.get("original_filename", ""),
|
||||
|
||||
@ -58,43 +58,22 @@ def cmd_news_sanitize(args):
|
||||
|
||||
|
||||
def cmd_attachments_prune(args):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from devplacepy.database import db
|
||||
from devplacepy.config import STATIC_DIR
|
||||
deleted_records = 0
|
||||
deleted_files = 0
|
||||
freed_bytes = 0
|
||||
from devplacepy.attachments import delete_attachment
|
||||
|
||||
if "attachments" in db.tables:
|
||||
orphans = list(db["attachments"].find(resource_uid=""))
|
||||
orphans += list(db["attachments"].find(resource_type=""))
|
||||
seen = set()
|
||||
unique_orphans = []
|
||||
for o in orphans:
|
||||
if o["uid"] not in seen:
|
||||
seen.add(o["uid"])
|
||||
unique_orphans.append(o)
|
||||
if "attachments" not in db.tables:
|
||||
print("Attachments table does not exist")
|
||||
return
|
||||
|
||||
for att in unique_orphans:
|
||||
sp = att.get("storage_path", "")
|
||||
if sp:
|
||||
fp = STATIC_DIR / "uploads" / sp
|
||||
try:
|
||||
if fp.exists():
|
||||
freed_bytes += fp.stat().st_size
|
||||
fp.unlink()
|
||||
deleted_files += 1
|
||||
parent = fp.parent
|
||||
if parent.exists() and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
grandparent = parent.parent
|
||||
if grandparent.exists() and not any(grandparent.iterdir()):
|
||||
grandparent.rmdir()
|
||||
except Exception as e:
|
||||
print(f" Error deleting {sp}: {e}")
|
||||
db["attachments"].delete(id=att["id"])
|
||||
deleted_records += 1
|
||||
|
||||
print(f"Pruned {deleted_records} orphan records, {deleted_files} files, {freed_bytes / 1024:.1f} KB freed")
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat()
|
||||
orphans = [
|
||||
att for att in db["attachments"].find(target_type="", target_uid="")
|
||||
if att.get("created_at", "") < cutoff
|
||||
]
|
||||
for att in orphans:
|
||||
delete_attachment(att["uid"])
|
||||
print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h")
|
||||
|
||||
|
||||
def main():
|
||||
@ -123,6 +102,7 @@ def main():
|
||||
attachments = sub.add_parser("attachments", help="Attachment management")
|
||||
att_sub = attachments.add_subparsers(title="action", dest="action")
|
||||
att_prune = att_sub.add_parser("prune", help="Remove orphaned attachment records and files")
|
||||
att_prune.add_argument("--hours", type=int, default=24, help="Only prune orphans older than this many hours")
|
||||
att_prune.set_defaults(func=cmd_attachments_prune)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@ -16,10 +16,7 @@ PAGE_SIZE = 25
|
||||
|
||||
def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None):
|
||||
posts_table = get_table("posts")
|
||||
|
||||
filters = {}
|
||||
if topic:
|
||||
filters["topic"] = topic
|
||||
order = ["-stars", "-created_at"] if tab == "trending" else ["-created_at"]
|
||||
|
||||
if tab == "following":
|
||||
if not user:
|
||||
@ -28,23 +25,22 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
|
||||
following = [f["following_uid"] for f in follows.find(follower_uid=user["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"]
|
||||
clauses = [posts_table.table.columns.user_uid.in_(following)]
|
||||
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:
|
||||
posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1))
|
||||
clauses.append(posts_table.table.columns.created_at < before)
|
||||
posts = list(posts_table.find(*clauses, order_by=order, _limit=PAGE_SIZE + 1))
|
||||
else:
|
||||
filters = {}
|
||||
if topic:
|
||||
filters["topic"] = topic
|
||||
if before:
|
||||
filters["created_at"] = {"<": before}
|
||||
posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1))
|
||||
|
||||
has_more = len(posts) > PAGE_SIZE
|
||||
posts = posts[:PAGE_SIZE]
|
||||
|
||||
next_cursor = None
|
||||
if has_more and posts:
|
||||
next_cursor = posts[-1]["created_at"]
|
||||
next_cursor = posts[-1]["created_at"] if has_more and posts else None
|
||||
|
||||
if not posts:
|
||||
return [], next_cursor
|
||||
|
||||
@ -10,6 +10,8 @@ from devplacepy.seo import base_seo_context
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
def _group_label(created_at: str) -> str:
|
||||
try:
|
||||
@ -29,15 +31,24 @@ def _group_label(created_at: str) -> str:
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def notifications_page(request: Request):
|
||||
async def notifications_page(request: Request, before: str = None):
|
||||
user = require_user(request)
|
||||
|
||||
next_cursor = None
|
||||
try:
|
||||
notifications_table = get_table("notifications")
|
||||
filters = {"user_uid": user["uid"]}
|
||||
if before:
|
||||
filters["created_at"] = {"<": before}
|
||||
raw_notifications = list(
|
||||
notifications_table.find(user_uid=user["uid"], order_by=["-created_at"])
|
||||
notifications_table.find(**filters, order_by=["-created_at"], _limit=PAGE_SIZE + 1)
|
||||
)
|
||||
|
||||
has_more = len(raw_notifications) > PAGE_SIZE
|
||||
raw_notifications = raw_notifications[:PAGE_SIZE]
|
||||
if has_more and raw_notifications:
|
||||
next_cursor = raw_notifications[-1]["created_at"]
|
||||
|
||||
enriched = []
|
||||
if raw_notifications:
|
||||
from devplacepy.database import get_users_by_uids
|
||||
@ -83,6 +94,7 @@ async def notifications_page(request: Request):
|
||||
"request": request,
|
||||
"user": user,
|
||||
"notification_groups": groups,
|
||||
"next_cursor": next_cursor,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -21,11 +21,13 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
|
||||
votes = get_table("votes")
|
||||
existing = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
|
||||
|
||||
did_upvote = False
|
||||
if existing:
|
||||
if int(existing["value"]) == value:
|
||||
votes.delete(id=existing["id"])
|
||||
else:
|
||||
votes.update({"id": existing["id"], "value": value}, ["id"])
|
||||
did_upvote = value == 1
|
||||
else:
|
||||
votes.insert({
|
||||
"uid": generate_uid(),
|
||||
@ -35,6 +37,7 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
|
||||
"value": value,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
did_upvote = value == 1
|
||||
|
||||
up_count = votes.count(target_uid=target_uid, value=1)
|
||||
down_count = votes.count(target_uid=target_uid, value=-1)
|
||||
@ -42,7 +45,7 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
|
||||
|
||||
update_target_stars(target_type, target_uid, net)
|
||||
|
||||
if value == 1 and target_type in NOTIFY_ON_VOTE:
|
||||
if did_upvote and target_type in NOTIFY_ON_VOTE:
|
||||
owner_uid = get_target_owner_uid(target_type, target_uid)
|
||||
if owner_uid and owner_uid != user["uid"]:
|
||||
target_url = resolve_object_url(target_type, target_uid)
|
||||
|
||||
@ -39,5 +39,11 @@
|
||||
<div class="empty-state">No notifications yet</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if next_cursor %}
|
||||
<div class="load-more-wrap">
|
||||
<a href="/notifications?before={{ next_cursor }}" class="btn btn-secondary btn-sm"><span class="icon">📄</span>Load More</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,4 +1,69 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from tests.conftest import BASE_URL
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
|
||||
def test_notifications_pagination(alice):
|
||||
page, _ = alice
|
||||
alice_row = get_table("users").find_one(username="alice_test")
|
||||
notifications_table = get_table("notifications")
|
||||
notifications_table.delete(user_uid=alice_row["uid"])
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for i in range(30):
|
||||
notifications_table.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": alice_row["uid"],
|
||||
"type": "test",
|
||||
"message": f"pagination-test-msg-{i:02d}",
|
||||
"related_uid": alice_row["uid"],
|
||||
"target_url": "/feed",
|
||||
"read": False,
|
||||
"created_at": (now - timedelta(seconds=i)).isoformat(),
|
||||
})
|
||||
|
||||
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
|
||||
cards = page.locator(".notification-card")
|
||||
assert cards.count() == 25, f"expected 25 cards on page 1, got {cards.count()}"
|
||||
|
||||
load_more = page.locator(".load-more-wrap a")
|
||||
load_more.wait_for(state="visible", timeout=5000)
|
||||
href = load_more.get_attribute("href")
|
||||
assert href and "before=" in href, f"Load More should carry cursor: {href}"
|
||||
|
||||
page.goto(f"{BASE_URL}{href}", wait_until="domcontentloaded")
|
||||
cards2 = page.locator(".notification-card")
|
||||
assert cards2.count() == 5, f"expected 5 cards on page 2, got {cards2.count()}"
|
||||
assert page.locator(".load-more-wrap").count() == 0, "Load More should be absent on final page"
|
||||
|
||||
notifications_table.delete(user_uid=alice_row["uid"])
|
||||
|
||||
|
||||
def test_notifications_no_load_more_when_under_page_size(alice):
|
||||
page, _ = alice
|
||||
alice_row = get_table("users").find_one(username="alice_test")
|
||||
notifications_table = get_table("notifications")
|
||||
notifications_table.delete(user_uid=alice_row["uid"])
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for i in range(3):
|
||||
notifications_table.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": alice_row["uid"],
|
||||
"type": "test",
|
||||
"message": f"small-set-msg-{i}",
|
||||
"related_uid": alice_row["uid"],
|
||||
"target_url": "/feed",
|
||||
"read": False,
|
||||
"created_at": (now - timedelta(seconds=i)).isoformat(),
|
||||
})
|
||||
|
||||
page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
|
||||
assert page.locator(".notification-card").count() == 3
|
||||
assert page.locator(".load-more-wrap").count() == 0
|
||||
|
||||
notifications_table.delete(user_uid=alice_row["uid"])
|
||||
|
||||
|
||||
def test_notifications_page_loads(alice):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user