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 prod # uvicorn with 2 workers, backlog 8192 (production)
|
||||||
make test # Playwright integration + unit tests (fail-fast -x)
|
make test # Playwright integration + unit tests (fail-fast -x)
|
||||||
make test-headed # same tests in visible browser
|
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 # Locust load test (interactive web UI)
|
||||||
make locust-headless # Locust in headless CLI mode (for CI)
|
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
|
LOCUST_RUN_TIME ?= 120s
|
||||||
DEVPLACE_RATE_LIMIT ?= 1000000
|
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:
|
install:
|
||||||
pip install -e .
|
pip install -e .
|
||||||
@ -24,9 +24,6 @@ test:
|
|||||||
test-headed:
|
test-headed:
|
||||||
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -v --tb=line -x
|
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:
|
locust:
|
||||||
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
|
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
|
||||||
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
|
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
|
||||||
|
|||||||
@ -11,7 +11,6 @@ make install # pip install -e .
|
|||||||
make dev # uvicorn --reload on port 10500
|
make dev # uvicorn --reload on port 10500
|
||||||
make test # Playwright integration + unit tests, headless, fail-fast
|
make test # Playwright integration + unit tests, headless, fail-fast
|
||||||
make test-headed # same tests in visible browser
|
make test-headed # same tests in visible browser
|
||||||
make demo # full-journey GUI demo (headed)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:10500`.
|
Open `http://localhost:10500`.
|
||||||
|
|||||||
@ -250,7 +250,11 @@ def get_attachments_batch(target_type, uids):
|
|||||||
def _row_to_attachment(row):
|
def _row_to_attachment(row):
|
||||||
stored_name = row.get("stored_name", "")
|
stored_name = row.get("stored_name", "")
|
||||||
directory = row.get("directory", "")
|
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 {
|
return {
|
||||||
"uid": row["uid"],
|
"uid": row["uid"],
|
||||||
"original_filename": row.get("original_filename", ""),
|
"original_filename": row.get("original_filename", ""),
|
||||||
|
|||||||
@ -58,43 +58,22 @@ def cmd_news_sanitize(args):
|
|||||||
|
|
||||||
|
|
||||||
def cmd_attachments_prune(args):
|
def cmd_attachments_prune(args):
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
from devplacepy.database import db
|
from devplacepy.database import db
|
||||||
from devplacepy.config import STATIC_DIR
|
from devplacepy.attachments import delete_attachment
|
||||||
deleted_records = 0
|
|
||||||
deleted_files = 0
|
|
||||||
freed_bytes = 0
|
|
||||||
|
|
||||||
if "attachments" in db.tables:
|
if "attachments" not in db.tables:
|
||||||
orphans = list(db["attachments"].find(resource_uid=""))
|
print("Attachments table does not exist")
|
||||||
orphans += list(db["attachments"].find(resource_type=""))
|
return
|
||||||
seen = set()
|
|
||||||
unique_orphans = []
|
|
||||||
for o in orphans:
|
|
||||||
if o["uid"] not in seen:
|
|
||||||
seen.add(o["uid"])
|
|
||||||
unique_orphans.append(o)
|
|
||||||
|
|
||||||
for att in unique_orphans:
|
cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat()
|
||||||
sp = att.get("storage_path", "")
|
orphans = [
|
||||||
if sp:
|
att for att in db["attachments"].find(target_type="", target_uid="")
|
||||||
fp = STATIC_DIR / "uploads" / sp
|
if att.get("created_at", "") < cutoff
|
||||||
try:
|
]
|
||||||
if fp.exists():
|
for att in orphans:
|
||||||
freed_bytes += fp.stat().st_size
|
delete_attachment(att["uid"])
|
||||||
fp.unlink()
|
print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h")
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@ -123,6 +102,7 @@ def main():
|
|||||||
attachments = sub.add_parser("attachments", help="Attachment management")
|
attachments = sub.add_parser("attachments", help="Attachment management")
|
||||||
att_sub = attachments.add_subparsers(title="action", dest="action")
|
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 = 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)
|
att_prune.set_defaults(func=cmd_attachments_prune)
|
||||||
|
|
||||||
args = parser.parse_args()
|
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):
|
def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None):
|
||||||
posts_table = get_table("posts")
|
posts_table = get_table("posts")
|
||||||
|
order = ["-stars", "-created_at"] if tab == "trending" else ["-created_at"]
|
||||||
filters = {}
|
|
||||||
if topic:
|
|
||||||
filters["topic"] = topic
|
|
||||||
|
|
||||||
if tab == "following":
|
if tab == "following":
|
||||||
if not user:
|
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"])]
|
following = [f["following_uid"] for f in follows.find(follower_uid=user["uid"])]
|
||||||
if not following:
|
if not following:
|
||||||
return [], None
|
return [], None
|
||||||
posts = list(posts_table.find(posts_table.table.columns.user_uid.in_(following), order_by=["-created_at"], _limit=PAGE_SIZE))
|
clauses = [posts_table.table.columns.user_uid.in_(following)]
|
||||||
else:
|
|
||||||
order = ["-created_at"]
|
|
||||||
if tab == "trending":
|
|
||||||
order = ["-stars", "-created_at"]
|
|
||||||
if before:
|
if before:
|
||||||
posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1))
|
clauses.append(posts_table.table.columns.created_at < before)
|
||||||
posts = [p for p in posts if p["created_at"] < before][:PAGE_SIZE]
|
posts = list(posts_table.find(*clauses, order_by=order, _limit=PAGE_SIZE + 1))
|
||||||
else:
|
else:
|
||||||
posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1))
|
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
|
has_more = len(posts) > PAGE_SIZE
|
||||||
posts = posts[:PAGE_SIZE]
|
posts = posts[:PAGE_SIZE]
|
||||||
|
|
||||||
next_cursor = None
|
next_cursor = posts[-1]["created_at"] if has_more and posts else None
|
||||||
if has_more and posts:
|
|
||||||
next_cursor = posts[-1]["created_at"]
|
|
||||||
|
|
||||||
if not posts:
|
if not posts:
|
||||||
return [], next_cursor
|
return [], next_cursor
|
||||||
|
|||||||
@ -10,6 +10,8 @@ from devplacepy.seo import base_seo_context
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
PAGE_SIZE = 25
|
||||||
|
|
||||||
|
|
||||||
def _group_label(created_at: str) -> str:
|
def _group_label(created_at: str) -> str:
|
||||||
try:
|
try:
|
||||||
@ -29,15 +31,24 @@ def _group_label(created_at: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse)
|
@router.get("", response_class=HTMLResponse)
|
||||||
async def notifications_page(request: Request):
|
async def notifications_page(request: Request, before: str = None):
|
||||||
user = require_user(request)
|
user = require_user(request)
|
||||||
|
|
||||||
|
next_cursor = None
|
||||||
try:
|
try:
|
||||||
notifications_table = get_table("notifications")
|
notifications_table = get_table("notifications")
|
||||||
|
filters = {"user_uid": user["uid"]}
|
||||||
|
if before:
|
||||||
|
filters["created_at"] = {"<": before}
|
||||||
raw_notifications = list(
|
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 = []
|
enriched = []
|
||||||
if raw_notifications:
|
if raw_notifications:
|
||||||
from devplacepy.database import get_users_by_uids
|
from devplacepy.database import get_users_by_uids
|
||||||
@ -83,6 +94,7 @@ async def notifications_page(request: Request):
|
|||||||
"request": request,
|
"request": request,
|
||||||
"user": user,
|
"user": user,
|
||||||
"notification_groups": groups,
|
"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")
|
votes = get_table("votes")
|
||||||
existing = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
|
existing = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
|
||||||
|
|
||||||
|
did_upvote = False
|
||||||
if existing:
|
if existing:
|
||||||
if int(existing["value"]) == value:
|
if int(existing["value"]) == value:
|
||||||
votes.delete(id=existing["id"])
|
votes.delete(id=existing["id"])
|
||||||
else:
|
else:
|
||||||
votes.update({"id": existing["id"], "value": value}, ["id"])
|
votes.update({"id": existing["id"], "value": value}, ["id"])
|
||||||
|
did_upvote = value == 1
|
||||||
else:
|
else:
|
||||||
votes.insert({
|
votes.insert({
|
||||||
"uid": generate_uid(),
|
"uid": generate_uid(),
|
||||||
@ -35,6 +37,7 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
|
|||||||
"value": value,
|
"value": value,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
})
|
})
|
||||||
|
did_upvote = value == 1
|
||||||
|
|
||||||
up_count = votes.count(target_uid=target_uid, value=1)
|
up_count = votes.count(target_uid=target_uid, value=1)
|
||||||
down_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)
|
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)
|
owner_uid = get_target_owner_uid(target_type, target_uid)
|
||||||
if owner_uid and owner_uid != user["uid"]:
|
if owner_uid and owner_uid != user["uid"]:
|
||||||
target_url = resolve_object_url(target_type, target_uid)
|
target_url = resolve_object_url(target_type, target_uid)
|
||||||
|
|||||||
@ -39,5 +39,11 @@
|
|||||||
<div class="empty-state">No notifications yet</div>
|
<div class="empty-state">No notifications yet</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -1,4 +1,69 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from tests.conftest import BASE_URL
|
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):
|
def test_notifications_page_loads(alice):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user