Compare commits

..
6 Commits
Author SHA1 Message Date
retoor 49853e079b Update. 2026-05-23 08:31:16 +02:00
retoor 2afb2038e5 iUpdate 2026-05-23 08:27:04 +02:00
retoor 18c9f20090 Done
DevPlace CI / test (push) Failing after 3m27s
2026-05-23 06:55:11 +02:00
retoor 28bc8c1b74 Update
DevPlace CI / test (push) Failing after 1m6s
2026-05-23 06:21:41 +02:00
retoor 8778d60c21 Update
DevPlace CI / test (push) Has been cancelled
2026-05-23 06:20:58 +02:00
retoor 9c88d2120c Updat
DevPlace CI / test (push) Successful in 4m58s
2026-05-23 05:57:21 +02:00
21 changed files with 746 additions and 259 deletions
+4
View File
@@ -31,3 +31,7 @@ jobs:
with: with:
name: failure-screenshots name: failure-screenshots
path: /tmp/devplace_test_screenshots/ path: /tmp/devplace_test_screenshots/
- name: Deploy to production
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/master'
run: make deploy
+9 -1
View File
@@ -5,6 +5,7 @@ LOCUST_DB ?= $(LOCUST_DB_DIR)/datastore.db
LOCUST_USERS ?= 20 LOCUST_USERS ?= 20
LOCUST_SPAWN_RATE ?= 5 LOCUST_SPAWN_RATE ?= 5
LOCUST_RUN_TIME ?= 120s 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 demo locust locust-headless
@@ -21,13 +22,14 @@ test:
PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -v --tb=line -x PLAYWRIGHT_HEADLESS=1 python -m pytest tests/ -v --tb=line -x
test-headed: test-headed:
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -v -n auto --tb=line -x PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -v --tb=line -x
demo: demo:
PLAYWRIGHT_HEADLESS=0 python -m pytest tests/test_demo.py -v -s --tb=line -x 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); \
mkdir -p $(LOCUST_DB_DIR); \ mkdir -p $(LOCUST_DB_DIR); \
rm -f $(LOCUST_DB); \ rm -f $(LOCUST_DB); \
uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \ uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
@@ -39,6 +41,7 @@ locust:
locust-headless: locust-headless:
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \ export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
mkdir -p $(LOCUST_DB_DIR); \ mkdir -p $(LOCUST_DB_DIR); \
rm -f $(LOCUST_DB); \ rm -f $(LOCUST_DB); \
uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \ uvicorn devplacepy.main:app --host 127.0.0.1 --port $(LOCUST_PORT) --backlog 8192 > /tmp/devplace_locust_server.log 2>&1 & \
@@ -70,3 +73,8 @@ docker-logs:
docker-clean: docker-clean:
docker compose down -v docker compose down -v
deploy:
git checkout production
git merge master
git push origin production
+36
View File
@@ -0,0 +1,36 @@
import time
from collections import OrderedDict
class TTLCache:
def __init__(self, ttl: int, max_size: int = 0):
self.ttl = ttl
self.max_size = max_size
self._store = OrderedDict()
def get(self, key):
entry = self._store.get(key)
if entry is None:
return None
value, expiry = entry
if time.time() >= expiry:
self._store.pop(key, None)
return None
self._store.move_to_end(key)
return value
def set(self, key, value):
self._store[key] = (value, time.time() + self.ttl)
self._store.move_to_end(key)
if self.max_size and len(self._store) > self.max_size:
self._store.popitem(last=False)
def pop(self, key):
self._store.pop(key, None)
def clear(self):
self._store.clear()
def items(self):
now = time.time()
return [(key, value) for key, (value, expiry) in self._store.items() if now < expiry]
+1 -1
View File
@@ -33,7 +33,7 @@ def cmd_news_clear(args):
from devplacepy.database import db from devplacepy.database import db
for table in ("news", "news_images", "news_sync"): for table in ("news", "news_images", "news_sync"):
if table in db.tables: if table in db.tables:
count = len(list(db[table].all())) count = db[table].count()
db[table].delete() db[table].delete()
print(f"Deleted {count} rows from '{table}'") print(f"Deleted {count} rows from '{table}'")
else: else:
+62 -2
View File
@@ -1,6 +1,7 @@
import dataset import dataset
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import DATABASE_URL from devplacepy.config import DATABASE_URL
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -147,6 +148,15 @@ def get_comment_counts_by_post_uids(post_uids):
return {r["target_uid"]: r["c"] for r in rows} return {r["target_uid"]: r["c"] for r in rows}
def get_post_counts_by_user_uids(user_uids):
if not user_uids or "posts" not in db.tables:
return {}
placeholders = ", ".join(f":p{i}" for i in range(len(user_uids)))
params = {f"p{i}": u for i, u in enumerate(user_uids)}
rows = db.query(f"SELECT user_uid, COUNT(*) as c FROM posts WHERE user_uid IN ({placeholders}) GROUP BY user_uid", **params)
return {r["user_uid"]: r["c"] for r in rows}
def get_vote_counts(target_uids): def get_vote_counts(target_uids):
if not target_uids or "votes" not in db.tables: if not target_uids or "votes" not in db.tables:
return {}, {} return {}, {}
@@ -218,6 +228,17 @@ def get_attachments_by_type(resource_type: str, resource_uids: list) -> dict:
return result return result
def get_news_images_by_uids(news_uids: list) -> dict:
if not news_uids or "news_images" not in db.tables:
return {}
images_table = db["news_images"]
rows = images_table.find(images_table.table.columns.news_uid.in_(news_uids), order_by=["uid"])
result = {}
for r in rows:
result.setdefault(r["news_uid"], r["url"])
return result
def delete_attachment_record(uid: str) -> None: def delete_attachment_record(uid: str) -> None:
if "attachments" not in db.tables: if "attachments" not in db.tables:
return return
@@ -252,11 +273,50 @@ def _delete_attachment_file(storage_path: str) -> None:
logger.warning(f"Failed to delete attachment file {storage_path}: {e}") logger.warning(f"Failed to delete attachment file {storage_path}: {e}")
_settings_cache = TTLCache(ttl=60)
def get_setting(key: str, default: str = "") -> str: def get_setting(key: str, default: str = "") -> str:
cached = _settings_cache.get(key)
if cached is not None:
return cached
if "site_settings" not in db.tables: if "site_settings" not in db.tables:
return default return default
entry = db["site_settings"].find_one(key=key) entry = db["site_settings"].find_one(key=key)
return entry["value"] if entry else default if entry is None:
return default
_settings_cache.set(key, entry["value"])
return entry["value"]
def get_int_setting(key: str, default: int) -> int:
raw = get_setting(key, str(default))
try:
return int(raw)
except (TypeError, ValueError):
return default
def clear_settings_cache() -> None:
_settings_cache.clear()
_stats_cache = TTLCache(ttl=30)
def get_site_stats() -> dict:
cached = _stats_cache.get("site")
if cached is not None:
return cached
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
stats = {
"total_members": db["users"].count() if "users" in db.tables else 0,
"posts_today": db["posts"].count(created_at={">=": today_start}) if "posts" in db.tables else 0,
"total_projects": db["projects"].count() if "projects" in db.tables else 0,
"total_gists": db["gists"].count() if "gists" in db.tables else 0,
}
_stats_cache.set("site", stats)
return stats
def resolve_by_slug(table, slug): def resolve_by_slug(table, slug):
+3 -8
View File
@@ -8,7 +8,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from devplacepy.config import STATIC_DIR, PORT from devplacepy.config import STATIC_DIR, PORT
from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
@@ -163,13 +163,8 @@ async def landing(request: Request):
if "news" in db.tables: if "news" in db.tables:
news_table = get_table("news") news_table = get_table("news")
raw = list(news_table.find(show_on_landing=1, order_by=["-synced_at"], _limit=6)) raw = list(news_table.find(show_on_landing=1, order_by=["-synced_at"], _limit=6))
images_table = get_table("news_images") if "news_images" in db.tables else None images_by_news = get_news_images_by_uids([a["uid"] for a in raw])
for a in raw: for a in raw:
image_url = ""
if images_table:
img = images_table.find_one(news_uid=a["uid"])
if img:
image_url = img["url"]
landing_articles.append({ landing_articles.append({
"uid": a["uid"], "uid": a["uid"],
"slug": a.get("slug", ""), "slug": a.get("slug", ""),
@@ -180,7 +175,7 @@ async def landing(request: Request):
"grade": a.get("grade", 0), "grade": a.get("grade", 0),
"synced_at": a.get("synced_at", "") or "", "synced_at": a.get("synced_at", "") or "",
"time_ago": time_ago(a["synced_at"]), "time_ago": time_ago(a["synced_at"]),
"image_url": image_url, "image_url": images_by_news.get(a["uid"], ""),
}) })
landing_posts = [] landing_posts = []
+13 -11
View File
@@ -4,9 +4,9 @@ from datetime import datetime
from fastapi import APIRouter, Request, Form from fastapi import APIRouter, Request, Form
from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, db, build_pagination from devplacepy.database import get_table, db, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema from devplacepy.seo import base_seo_context, site_url, website_schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,13 +23,13 @@ async def admin_index(request: Request):
async def admin_users(request: Request, page: int = 1): async def admin_users(request: Request, page: int = 1):
admin = require_admin(request) admin = require_admin(request)
users_table = get_table("users") users_table = get_table("users")
total = len(list(users_table.all())) total = users_table.count()
pagination = build_pagination(page, total) pagination = build_pagination(page, total)
offset = (pagination["page"] - 1) * pagination["per_page"] offset = (pagination["page"] - 1) * pagination["per_page"]
page_users = list(users_table.find(order_by=["-created_at"], _limit=pagination["per_page"], _offset=offset)) page_users = list(users_table.find(order_by=["-created_at"], _limit=pagination["per_page"], _offset=offset))
post_counts = get_post_counts_by_user_uids([u["uid"] for u in page_users])
for u in page_users: for u in page_users:
posts_count = len(list(get_table("posts").find(user_uid=u["uid"]))) u["posts_count"] = post_counts.get(u["uid"], 0)
u["posts_count"] = posts_count
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
request, request,
@@ -60,6 +60,7 @@ async def admin_user_role(request: Request, uid: str, data: Annotated[AdminRoleF
return RedirectResponse(url="/admin/users", status_code=302) return RedirectResponse(url="/admin/users", status_code=302)
users = get_table("users") users = get_table("users")
users.update({"uid": uid, "role": role}, ["uid"]) users.update({"uid": uid, "role": role}, ["uid"])
clear_user_cache(uid)
logger.info(f"Admin {admin['username']} set user {uid} role to {role}") logger.info(f"Admin {admin['username']} set user {uid} role to {role}")
return RedirectResponse(url="/admin/users", status_code=302) return RedirectResponse(url="/admin/users", status_code=302)
@@ -83,6 +84,7 @@ async def admin_user_toggle(request: Request, uid: str):
if user: if user:
new_state = not user.get("is_active", True) new_state = not user.get("is_active", True)
users.update({"uid": uid, "is_active": new_state}, ["uid"]) users.update({"uid": uid, "is_active": new_state}, ["uid"])
clear_user_cache(uid)
logger.info(f"Admin {admin['username']} {'disabled' if not new_state else 'enabled'} user {uid}") logger.info(f"Admin {admin['username']} {'disabled' if not new_state else 'enabled'} user {uid}")
return RedirectResponse(url="/admin/users", status_code=302) return RedirectResponse(url="/admin/users", status_code=302)
@@ -117,23 +119,20 @@ async def admin_settings(request: Request):
async def admin_news(request: Request, page: int = 1): async def admin_news(request: Request, page: int = 1):
admin = require_admin(request) admin = require_admin(request)
news_table = get_table("news") news_table = get_table("news")
total = len(list(news_table.all())) total = news_table.count()
pagination = build_pagination(page, total) pagination = build_pagination(page, total)
offset = (pagination["page"] - 1) * pagination["per_page"] offset = (pagination["page"] - 1) * pagination["per_page"]
page_articles = list(news_table.find(order_by=["-synced_at"], _limit=pagination["per_page"], _offset=offset)) page_articles = list(news_table.find(order_by=["-synced_at"], _limit=pagination["per_page"], _offset=offset))
images_table = get_table("news_images") images_by_news = get_news_images_by_uids([a["uid"] for a in page_articles])
enriched = [] enriched = []
for a in page_articles: for a in page_articles:
has_image = False
if "news_images" in db.tables:
has_image = images_table.find_one(news_uid=a["uid"]) is not None
enriched.append({ enriched.append({
"article": a, "article": a,
"time_ago": time_ago(a["synced_at"]), "time_ago": time_ago(a["synced_at"]),
"synced_at": a.get("synced_at", ""), "synced_at": a.get("synced_at", ""),
"grade": a.get("grade", 0), "grade": a.get("grade", 0),
"has_image": has_image, "has_image": a["uid"] in images_by_news,
}) })
base = site_url(request) base = site_url(request)
@@ -216,8 +215,11 @@ async def admin_settings_save(request: Request, data: Annotated[AdminSettingsFor
for key, value in data.model_dump().items(): for key, value in data.model_dump().items():
existing = settings.find_one(key=key) existing = settings.find_one(key=key)
if existing: if existing:
if value == "":
continue
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"]) settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])
else: else:
settings.insert({"uid": generate_uid(), "key": key, "value": value}) settings.insert({"uid": generate_uid(), "key": key, "value": value})
clear_settings_cache()
logger.info(f"Admin {admin['username']} updated settings") logger.info(f"Admin {admin['username']} updated settings")
return RedirectResponse(url="/admin/settings", status_code=302) return RedirectResponse(url="/admin/settings", status_code=302)
+1 -1
View File
@@ -64,7 +64,7 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
) )
uid = generate_uid() uid = generate_uid()
is_first = len(list(users.all())) == 0 is_first = users.count() == 0
users.insert({ users.insert({
"uid": uid, "uid": uid,
"username": username, "username": username,
+13 -5
View File
@@ -1,20 +1,28 @@
import hashlib
import logging import logging
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import Response from fastapi.responses import Response
from devplacepy.avatar import generate_avatar_svg from devplacepy.avatar import generate_avatar_svg
from devplacepy.cache import TTLCache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
_cache = {} _cache = TTLCache(ttl=86400, max_size=4096)
_CACHE_CONTROL = "public, max-age=86400, immutable"
@router.get("/{style}/{seed}") @router.get("/{style}/{seed}")
async def avatar_proxy(request: Request, style: str, seed: str, size: int = 128): async def avatar_proxy(request: Request, style: str, seed: str, size: int = 128):
cache_key = f"{seed}:{size}" cache_key = f"{seed}:{size}"
if cache_key in _cache: etag = '"' + hashlib.md5(cache_key.encode("utf-8")).hexdigest() + '"'
return Response(content=_cache[cache_key], media_type="image/svg+xml") headers = {"ETag": etag, "Cache-Control": _CACHE_CONTROL}
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=headers)
svg = _cache.get(cache_key)
if svg is None:
svg = generate_avatar_svg(seed) svg = generate_avatar_svg(seed)
_cache[cache_key] = svg _cache.set(cache_key, svg)
return Response(content=svg, media_type="image/svg+xml") return Response(content=svg, media_type="image/svg+xml", headers=headers)
+6 -12
View File
@@ -1,8 +1,7 @@
import logging import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse 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.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats
from devplacepy.attachments import get_attachments_batch from devplacepy.attachments import get_attachments_batch
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago from devplacepy.utils import get_current_user, time_ago
@@ -70,12 +69,7 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
user = get_current_user(request) user = get_current_user(request)
posts, next_cursor = get_feed_posts(user, tab, topic, before) posts, next_cursor = get_feed_posts(user, tab, topic, before)
users_table = get_table("users") users_table = get_table("users")
total_members = len(list(users_table.all())) stats = get_site_stats()
posts_table = get_table("posts")
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
posts_today = len(list(posts_table.find(created_at={">=": today_start})))
total_projects = len(list(get_table("projects").all()))
total_gists = len(list(get_table("gists").all()))
top_authors = list(users_table.find(stars={">": 0}, order_by=["-stars"], _limit=5)) top_authors = list(users_table.find(stars={">": 0}, order_by=["-stars"], _limit=5))
daily_topic = get_daily_topic() daily_topic = get_daily_topic()
@@ -99,10 +93,10 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
"posts": posts, "posts": posts,
"current_tab": tab, "current_tab": tab,
"current_topic": topic, "current_topic": topic,
"total_members": total_members, "total_members": stats["total_members"],
"posts_today": posts_today, "posts_today": stats["posts_today"],
"total_projects": total_projects, "total_projects": stats["total_projects"],
"total_gists": total_gists, "total_gists": stats["total_gists"],
"top_authors": top_authors, "top_authors": top_authors,
"daily_topic": daily_topic, "daily_topic": daily_topic,
"next_cursor": next_cursor, "next_cursor": next_cursor,
+2 -3
View File
@@ -68,9 +68,8 @@ def get_conversation_messages(user_uid: str, other_uid: str):
msgs.append(m) msgs.append(m)
msgs.sort(key=lambda m: m["created_at"]) msgs.sort(key=lambda m: m["created_at"])
for msg in msgs: with db:
if msg["receiver_uid"] == user_uid and not msg["read"]: db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid)
messages_table.update({"id": msg["id"], "read": True}, ["id"])
from devplacepy.database import get_users_by_uids from devplacepy.database import get_users_by_uids
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid}) user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
+2 -9
View File
@@ -2,7 +2,7 @@ import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, db, load_comments, resolve_by_slug from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago from devplacepy.utils import get_current_user, time_ago
from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine, news_article_schema from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine, news_article_schema
@@ -26,14 +26,7 @@ async def news_page(request: Request):
)) ))
article_uids = [a["uid"] for a in articles] article_uids = [a["uid"] for a in articles]
images_by_news = get_news_images_by_uids(article_uids)
images_by_news = {}
if article_uids and "news_images" in db.tables:
images_table = get_table("news_images")
for uid in article_uids:
img = images_table.find_one(news_uid=uid, order_by=["uid"])
if img:
images_by_news[uid] = img["url"]
enriched = [] enriched = []
for a in articles: for a in articles:
+6 -5
View File
@@ -2,8 +2,8 @@ import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table from devplacepy.database import get_table, db
from devplacepy.templating import templates from devplacepy.templating import templates, clear_unread_cache
from devplacepy.utils import require_user, time_ago from devplacepy.utils import require_user, time_ago
from devplacepy.seo import base_seo_context from devplacepy.seo import base_seo_context
@@ -93,13 +93,14 @@ async def mark_read(request: Request, notification_uid: str):
n = notifications_table.find_one(uid=notification_uid) n = notifications_table.find_one(uid=notification_uid)
if n and n["user_uid"] == user["uid"]: if n and n["user_uid"] == user["uid"]:
notifications_table.update({"id": n["id"], "read": True}, ["id"]) notifications_table.update({"id": n["id"], "read": True}, ["id"])
clear_unread_cache(user["uid"])
return RedirectResponse(url="/notifications", status_code=302) return RedirectResponse(url="/notifications", status_code=302)
@router.post("/mark-all-read") @router.post("/mark-all-read")
async def mark_all_read(request: Request): async def mark_all_read(request: Request):
user = require_user(request) user = require_user(request)
notifications_table = get_table("notifications") with db:
for n in notifications_table.find(user_uid=user["uid"], read=False): db.query("UPDATE notifications SET read = 1 WHERE user_uid = :u AND read = 0", u=user["uid"])
notifications_table.update({"id": n["id"], "read": True}, ["id"]) clear_unread_cache(user["uid"])
return RedirectResponse(url="/notifications", status_code=302) return RedirectResponse(url="/notifications", status_code=302)
+3 -2
View File
@@ -5,7 +5,7 @@ from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db from devplacepy.database import get_table, db
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago from devplacepy.utils import get_current_user, require_user, time_ago, clear_user_cache
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, profile_page_schema, combine from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, profile_page_schema, combine
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -58,7 +58,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
gists = [] gists = []
for g in gists_raw: for g in gists_raw:
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])}) gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
posts_count = len(posts) or len(list(get_table("posts").find(user_uid=profile_user["uid"]))) posts_count = len(posts) or get_table("posts").count(user_uid=profile_user["uid"])
activities = [] activities = []
if tab == "activity": if tab == "activity":
@@ -124,6 +124,7 @@ async def update_profile(request: Request, data: Annotated[ProfileForm, Form()])
"git_link": data.git_link.strip(), "git_link": data.git_link.strip(),
"website": data.website.strip(), "website": data.website.strip(),
}, ["uid"]) }, ["uid"])
clear_user_cache(user["uid"])
logger.info(f"Profile updated for {user['username']}") logger.info(f"Profile updated for {user['username']}")
return RedirectResponse(url=f"/profile/{user['username']}", status_code=302) return RedirectResponse(url=f"/profile/{user['username']}", status_code=302)
+16 -27
View File
@@ -1,10 +1,11 @@
import logging import logging
from typing import Annotated from typing import Annotated
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import or_
from fastapi import APIRouter, Request, HTTPException, Form from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import ProjectForm from devplacepy.models import ProjectForm
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_vote_counts, load_comments, resolve_by_slug from devplacepy.database import get_table, get_vote_counts, load_comments, resolve_by_slug, get_users_by_uids, get_site_stats
from devplacepy.attachments import link_attachments, get_attachments, delete_target_attachments from devplacepy.attachments import link_attachments, get_attachments, delete_target_attachments
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
@@ -16,39 +17,29 @@ router = APIRouter()
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None): def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None):
projects = get_table("projects") projects = get_table("projects")
all_projects = list(projects.all())
filters = {}
if user_uid: if user_uid:
all_projects = [p for p in all_projects if p["user_uid"] == user_uid] filters["user_uid"] = user_uid
if project_type: if project_type:
all_projects = [p for p in all_projects if p.get("project_type") == project_type] filters["project_type"] = project_type
if tab == "released":
filters["status"] = "Released"
if search: clauses = []
search_lower = search.lower() if search and projects.exists:
all_projects = [ like = f"%{search}%"
p for p in all_projects clauses.append(or_(projects.table.columns.title.ilike(like), projects.table.columns.description.ilike(like)))
if search_lower in p.get("title", "").lower()
or search_lower in p.get("description", "").lower() order = ["-stars", "-created_at"] if tab == "popular" else ["-created_at"]
] all_projects = list(projects.find(*clauses, **filters, order_by=order))
if all_projects: if all_projects:
from devplacepy.database import get_users_by_uids users_map = get_users_by_uids([p["user_uid"] for p in all_projects])
uids = [p["user_uid"] for p in all_projects]
users_map = get_users_by_uids(uids)
for p in all_projects: for p in all_projects:
author = users_map.get(p["user_uid"]) author = users_map.get(p["user_uid"])
p["author_name"] = author["username"] if author else "Unknown" 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"]
elif tab == "popular":
all_projects.sort(key=lambda p: int(p.get("stars", 0)), reverse=True)
elif tab == "new":
all_projects.sort(key=lambda p: p.get("created_at", ""), reverse=True)
else:
all_projects.sort(key=lambda p: p.get("created_at", ""), reverse=True)
return all_projects return all_projects
@@ -62,8 +53,7 @@ async def projects_page(
): ):
user = get_current_user(request) user = get_current_user(request)
projects = get_projects_list(tab, search, user_uid, project_type) projects = get_projects_list(tab, search, user_uid, project_type)
users = get_table("users") total_members = get_site_stats()["total_members"]
total_members = len(list(users.all()))
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
@@ -97,7 +87,6 @@ async def project_detail(request: Request, project_slug: str):
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
from devplacepy.database import get_users_by_uids
users_map = get_users_by_uids([project["user_uid"]]) users_map = get_users_by_uids([project["user_uid"]])
author = users_map.get(project["user_uid"]) author = users_map.get(project["user_uid"])
+2 -2
View File
@@ -2,7 +2,7 @@ import logging
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from devplacepy.database import get_table, get_setting from devplacepy.database import get_table, get_setting, get_int_setting
from devplacepy.utils import require_user from devplacepy.utils import require_user
from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES
@@ -40,7 +40,7 @@ async def upload_file(request: Request):
result = store_attachment(content, file.filename, user["uid"]) result = store_attachment(content, file.filename, user["uid"])
if result is None: if result is None:
max_size_mb = int(get_setting("max_upload_size_mb", "10")) max_size_mb = get_int_setting("max_upload_size_mb", 10)
return JSONResponse({"error": f"File exceeds {max_size_mb}MB limit"}, status_code=413) return JSONResponse({"error": f"File exceeds {max_size_mb}MB limit"}, status_code=413)
logger.info(f"File uploaded: {file.filename} ({len(content)} bytes) -> {result['url']}") logger.info(f"File uploaded: {file.filename} ({len(content)} bytes) -> {result['url']}")
+2 -2
View File
@@ -35,8 +35,8 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
"created_at": datetime.now(timezone.utc).isoformat(), "created_at": datetime.now(timezone.utc).isoformat(),
}) })
up_count = len(list(votes.find(target_uid=target_uid, value=1))) up_count = votes.count(target_uid=target_uid, value=1)
down_count = len(list(votes.find(target_uid=target_uid, value=-1))) down_count = votes.count(target_uid=target_uid, value=-1)
net = up_count - down_count net = up_count - down_count
if target_type == "post": if target_type == "post":
+14 -25
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
import httpx import httpx
from devplacepy.database import get_table from devplacepy.database import get_table, get_setting
from devplacepy.services.base import BaseService from devplacepy.services.base import BaseService
from devplacepy.utils import generate_uid, make_combined_slug, strip_html from devplacepy.utils import generate_uid, make_combined_slug, strip_html
@@ -17,22 +17,13 @@ AI_MODEL_DEFAULT = "molodetz"
GRADE_THRESHOLD_DEFAULT = 7 GRADE_THRESHOLD_DEFAULT = 7
def _get_setting(key: str, default: str) -> str:
table = get_table("site_settings")
row = table.find_one(key=key)
if row is None:
return default
return row.get("value", default)
def _get_ai_key() -> str: def _get_ai_key() -> str:
key = os.environ.get("NEWS_AI_KEY") key = os.environ.get("NEWS_AI_KEY")
if key: if key:
return key return key
table = get_table("site_settings") key = get_setting("news_ai_key", "")
row = table.find_one(key="news_ai_key") if key:
if row: return key
return row.get("value", "")
key = os.environ.get("OPENROUTER_API_KEY") key = os.environ.get("OPENROUTER_API_KEY")
if key: if key:
return key return key
@@ -51,10 +42,9 @@ def _extract_grade(text: str) -> int | None:
return None return None
async def _get_article_images(url: str) -> list[dict]: async def _get_article_images(url: str, client: httpx.AsyncClient) -> list[dict]:
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(url, timeout=10.0)
resp = await client.get(url)
resp.raise_for_status() resp.raise_for_status()
html = resp.text html = resp.text
pattern = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE) pattern = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
@@ -77,10 +67,10 @@ class NewsService(BaseService):
super().__init__(name="news", interval_seconds=3600) super().__init__(name="news", interval_seconds=3600)
async def run_once(self) -> None: async def run_once(self) -> None:
api_url = _get_setting("news_api_url", NEWS_API_URL_DEFAULT) api_url = get_setting("news_api_url", NEWS_API_URL_DEFAULT)
ai_url = _get_setting("news_ai_url", AI_URL_DEFAULT) ai_url = get_setting("news_ai_url", AI_URL_DEFAULT)
ai_model = _get_setting("news_ai_model", AI_MODEL_DEFAULT) ai_model = get_setting("news_ai_model", AI_MODEL_DEFAULT)
threshold = int(_get_setting("news_grade_threshold", str(GRADE_THRESHOLD_DEFAULT))) threshold = int(get_setting("news_grade_threshold", str(GRADE_THRESHOLD_DEFAULT)))
self.log(f"Fetching news from {api_url}") self.log(f"Fetching news from {api_url}")
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
@@ -118,7 +108,7 @@ class NewsService(BaseService):
skipped_count += 1 skipped_count += 1
continue continue
grade = await self._grade_article(article, ai_url, ai_model) grade = await self._grade_article(article, ai_url, ai_model, client)
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
if grade is None: if grade is None:
@@ -198,7 +188,7 @@ class NewsService(BaseService):
link = article.get("link", "") link = article.get("link", "")
if link: if link:
fresh_images = await _get_article_images(link) fresh_images = await _get_article_images(link, client)
for img in fresh_images: for img in fresh_images:
images_table.insert({ images_table.insert({
"uid": generate_uid(), "uid": generate_uid(),
@@ -210,7 +200,7 @@ class NewsService(BaseService):
self.log(f"New {new_count}, updated {updated_count}, draft {draft_count}, " self.log(f"New {new_count}, updated {updated_count}, draft {draft_count}, "
f"grading failed {failed_count}, skipped {skipped_count}") f"grading failed {failed_count}, skipped {skipped_count}")
async def _grade_article(self, article: dict, ai_url: str, ai_model: str) -> int | None: async def _grade_article(self, article: dict, ai_url: str, ai_model: str, client: httpx.AsyncClient) -> int | None:
title = (article.get("title", "") or "")[:500] title = (article.get("title", "") or "")[:500]
description = strip_html(article.get("description", "") or "")[:1000] description = strip_html(article.get("description", "") or "")[:1000]
content = strip_html(article.get("content", "") or "")[:1500] content = strip_html(article.get("content", "") or "")[:1500]
@@ -238,8 +228,7 @@ class NewsService(BaseService):
headers["Authorization"] = f"Bearer {ai_key}" headers["Authorization"] = f"Bearer {ai_key}"
try: try:
async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.post(ai_url, json=payload, headers=headers, timeout=15.0)
resp = await client.post(ai_url, json=payload, headers=headers)
if resp.status_code != 200: if resp.status_code != 200:
self.log(f"AI grading returned {resp.status_code}: {resp.text[:200]}") self.log(f"AI grading returned {resp.status_code}: {resp.text[:200]}")
resp.raise_for_status() resp.raise_for_status()
+11 -15
View File
@@ -1,5 +1,5 @@
import time
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from devplacepy.cache import TTLCache
from devplacepy.config import TEMPLATES_DIR from devplacepy.config import TEMPLATES_DIR
from devplacepy.constants import TOPICS from devplacepy.constants import TOPICS
from devplacepy.database import get_table from devplacepy.database import get_table
@@ -13,23 +13,19 @@ from devplacepy.seo import (
) )
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = {} _unread_cache = TTLCache(ttl=60)
_UNREAD_CACHE_TTL = 60
def clear_unread_cache(user_uid: str) -> None: def clear_unread_cache(user_uid: str) -> None:
_unread_cache.pop(user_uid, None) _unread_cache.pop(user_uid)
def jinja_unread_count(user_uid: str) -> int: def jinja_unread_count(user_uid: str) -> int:
cached = _unread_cache.get(user_uid) cached = _unread_cache.get(user_uid)
if cached: if cached is not None:
entry, expiry = cached return cached
if time.time() < expiry:
return entry
_unread_cache.pop(user_uid, None)
notifs = get_table("notifications") notifs = get_table("notifications")
count = len(list(notifs.find(user_uid=user_uid, read=False))) count = notifs.count(user_uid=user_uid, read=False)
_unread_cache[user_uid] = (count, time.time() + _UNREAD_CACHE_TTL) _unread_cache.set(user_uid, count)
return count return count
def jinja_user_projects(user_uid: str) -> list: def jinja_user_projects(user_uid: str) -> list:
@@ -42,12 +38,12 @@ templates.env.globals["avatar_url"] = avatar_url
templates.env.globals["format_date"] = _format_date templates.env.globals["format_date"] = _format_date
templates.env.globals["TOPICS"] = TOPICS templates.env.globals["TOPICS"] = TOPICS
def jinja_max_upload_size_mb(): def jinja_max_upload_size_mb():
from devplacepy.database import get_setting from devplacepy.database import get_int_setting
return int(get_setting("max_upload_size_mb", "10")) return get_int_setting("max_upload_size_mb", 10)
def jinja_max_attachments(): def jinja_max_attachments():
from devplacepy.database import get_setting from devplacepy.database import get_int_setting
return int(get_setting("max_attachments_per_resource", "10")) return get_int_setting("max_attachments_per_resource", 10)
def jinja_allowed_file_types(): def jinja_allowed_file_types():
from devplacepy.database import get_setting from devplacepy.database import get_setting
+12 -10
View File
@@ -1,11 +1,11 @@
import html import html
import re import re
import secrets import secrets
import time
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from passlib.hash import pbkdf2_sha256 from passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status from fastapi import Request, HTTPException, status
from devplacepy.cache import TTLCache
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.config import SECRET_KEY, SESSION_MAX_AGE from devplacepy.config import SECRET_KEY, SESSION_MAX_AGE
@@ -33,8 +33,13 @@ def create_session(user_uid: str) -> str:
return token return token
_user_cache = {} _user_cache = TTLCache(ttl=300)
_USER_CACHE_TTL = 300
def clear_user_cache(user_uid: str) -> None:
for token, user in _user_cache.items():
if user.get("uid") == user_uid:
_user_cache.pop(token)
def get_current_user(request: Request): def get_current_user(request: Request):
@@ -43,11 +48,8 @@ def get_current_user(request: Request):
return None return None
cached = _user_cache.get(token) cached = _user_cache.get(token)
if cached: if cached is not None:
entry, expiry = cached return cached
if time.time() < expiry:
return entry
_user_cache.pop(token, None)
sessions = get_table("sessions") sessions = get_table("sessions")
session = sessions.find_one(session_token=token) session = sessions.find_one(session_token=token)
@@ -63,10 +65,10 @@ def get_current_user(request: Request):
user = users.find_one(uid=session["user_uid"]) user = users.find_one(uid=session["user_uid"])
if user and not user.get("is_active", True): if user and not user.get("is_active", True):
sessions.delete(id=session["id"]) sessions.delete(id=session["id"])
_user_cache.pop(token, None) _user_cache.pop(token)
return None return None
if user: if user:
_user_cache[token] = (user, time.time() + _USER_CACHE_TTL) _user_cache.set(token, user)
return user return user
+419 -9
View File
@@ -17,11 +17,19 @@ USER_UIDS = []
POST_UIDS = [] POST_UIDS = []
POST_SLUGS = [] POST_SLUGS = []
PROJECT_UIDS = [] PROJECT_UIDS = []
PROJECT_SLUGS = []
COMMENT_UIDS = [] COMMENT_UIDS = []
NEWS_UIDS = [] NEWS_UIDS = []
NEWS_SLUGS = [] NEWS_SLUGS = []
GIST_SLUGS = []
GIST_UIDS = []
NOTIFICATION_UIDS = []
ADMIN_USER = {}
ADMIN_TARGETS = {}
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random"] TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random"]
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"] PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
GIST_LANGUAGES = ["python", "javascript", "go", "rust", "bash", "sql", "json"]
UPLOAD_FILE = ("load_test.txt", b"locust upload payload", "text/plain")
AVATAR_STYLES = [ AVATAR_STYLES = [
"adventurer", "adventurer-neutral", "avataaars", "bottts", "identicon", "adventurer", "adventurer-neutral", "avataaars", "bottts", "identicon",
"initials", "lorelei", "micah", "open-peeps", "pixel-art", "shapes", "initials", "lorelei", "micah", "open-peeps", "pixel-art", "shapes",
@@ -111,9 +119,8 @@ def seed_data(environment, **kwargs):
m = re.search(r"/posts/(\S+)", resp.url) m = re.search(r"/posts/(\S+)", resp.url)
if m and m.group(1) not in POST_SLUGS: if m and m.group(1) not in POST_SLUGS:
POST_SLUGS.append(m.group(1)) POST_SLUGS.append(m.group(1))
# extract full UUID from the response HTML
html = resp.read().decode("utf-8", errors="replace") html = resp.read().decode("utf-8", errors="replace")
m2 = re.search(rf'name="post_uid"\s+value="({UUID_RE})"', html) m2 = re.search(rf'/votes/post/({UUID_RE})', html)
if m2 and m2.group(1) not in POST_UIDS: if m2 and m2.group(1) not in POST_UIDS:
POST_UIDS.append(m2.group(1)) POST_UIDS.append(m2.group(1))
except Exception as e: except Exception as e:
@@ -150,6 +157,31 @@ def seed_data(environment, **kwargs):
except Exception as e: except Exception as e:
logger.warning(f"Create seed project failed: {e}") logger.warning(f"Create seed project failed: {e}")
for seed in SEED_USERS[:2]:
try:
opener = logged_in_opener(seed["email"])
body = urllib.parse.urlencode({
"title": f"Gist {uuid.uuid4().hex[:6]}",
"description": "Seed gist for load testing.",
"source_code": "print('hello')\n" * 5,
"language": random.choice(GIST_LANGUAGES),
}).encode()
resp = opener.open(
urllib.request.Request(f"{host}/gists/create", data=body),
timeout=10,
)
m = re.search(r"/gists/(\S+)", resp.url)
if m and m.group(1) not in GIST_SLUGS:
GIST_SLUGS.append(m.group(1))
html = resp.read().decode("utf-8", errors="replace")
m2 = re.search(rf'/votes/gist/({UUID_RE})', html)
if m2 and m2.group(1) not in GIST_UIDS:
GIST_UIDS.append(m2.group(1))
except Exception as e:
logger.warning(f"Create seed gist failed: {e}")
logger.info(f"Created {len(GIST_SLUGS)} seed gists")
# ── Seed news articles (direct DB insert) ─────────────────── # ── Seed news articles (direct DB insert) ───────────────────
try: try:
from devplacepy.database import get_table, init_db from devplacepy.database import get_table, init_db
@@ -169,9 +201,61 @@ def seed_data(environment, **kwargs):
}) })
NEWS_UIDS.append(article_uid) NEWS_UIDS.append(article_uid)
NEWS_SLUGS.append(slug) NEWS_SLUGS.append(slug)
admin_news_uid = str(uuid.uuid4())
admin_news_title = f"Admin target news {uuid.uuid4().hex[:4]}"
news_table.insert({
"uid": admin_news_uid,
"slug": make_combined_slug(admin_news_title, admin_news_uid),
"title": admin_news_title,
"external_id": f"locust_admin_target_{uuid.uuid4().hex[:6]}",
"grade": 5, "status": "published", "show_on_landing": 0,
"source_name": "Locust", "synced_at": "2026-01-01T00:00:00",
"description": "Dedicated target for admin news mutations.",
})
ADMIN_TARGETS["news_uid"] = admin_news_uid
except Exception as e: except Exception as e:
logger.warning(f"Seed news articles failed: {e}") logger.warning(f"Seed news articles failed: {e}")
# ── Seed an admin user (direct DB insert) ───────────────────
try:
from datetime import datetime, timezone
from devplacepy.database import get_table, init_db
from devplacepy.utils import hash_password, generate_uid
init_db()
username = f"lu_admin_{uuid.uuid4().hex[:6]}"
email = f"{username}@locust.devplace"
get_table("users").insert({
"uid": generate_uid(),
"username": username,
"email": email,
"password_hash": hash_password(PASSWORD),
"bio": "", "location": "", "git_link": "", "website": "",
"role": "Admin", "is_active": True,
"level": 1, "xp": 0, "stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
ADMIN_USER["username"] = username
ADMIN_USER["email"] = email
ALL_USERNAMES.append(username)
disposable_uid = generate_uid()
disposable_name = f"lu_target_{uuid.uuid4().hex[:6]}"
get_table("users").insert({
"uid": disposable_uid,
"username": disposable_name,
"email": f"{disposable_name}@locust.devplace",
"password_hash": hash_password(PASSWORD),
"bio": "", "location": "", "git_link": "", "website": "",
"role": "Member", "is_active": True,
"level": 1, "xp": 0, "stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
ADMIN_TARGETS["disposable_uid"] = disposable_uid
logger.info(f"Created admin user {username} and disposable target")
except Exception as e:
logger.warning(f"Seed admin user failed: {e}")
# ── Harvest UIDs from HTML responses ────────────────────── # ── Harvest UIDs from HTML responses ──────────────────────
# projects sidebar: /projects?user_uid={uuid} # projects sidebar: /projects?user_uid={uuid}
# project cards: /votes/project/{uuid} # project cards: /votes/project/{uuid}
@@ -193,6 +277,11 @@ def seed_data(environment, **kwargs):
PROJECT_UIDS.extend( PROJECT_UIDS.extend(
p for p in proj_matches if p not in PROJECT_UIDS p for p in proj_matches if p not in PROJECT_UIDS
) )
slug_matches = re.findall(r'href="/projects/([^"/?#]+)"', html)
PROJECT_SLUGS.extend(
s for s in slug_matches
if s not in PROJECT_SLUGS and s not in ("create",)
)
except Exception as e: except Exception as e:
logger.warning(f"Harvest from {seed['username']} failed: {e}") logger.warning(f"Harvest from {seed['username']} failed: {e}")
@@ -211,15 +300,20 @@ def seed_data(environment, **kwargs):
logger.info( logger.info(
f"Seed complete: {len(SEED_USERS)} users, {len(POST_UIDS)} posts, " f"Seed complete: {len(SEED_USERS)} users, {len(POST_UIDS)} posts, "
f"{len(PROJECT_UIDS)} projects, {len(COMMENT_UIDS)} comments, " f"{len(PROJECT_UIDS)} projects ({len(PROJECT_SLUGS)} slugs), "
f"{len(GIST_UIDS)} gists, {len(COMMENT_UIDS)} comments, "
f"{len(USER_UIDS)} uids" f"{len(USER_UIDS)} uids"
) )
class DevPlaceUser(HttpUser): class DevPlaceUser(HttpUser):
weight = 20
wait_time = between(1, 5) wait_time = between(1, 5)
def on_start(self): def on_start(self):
self.own_post_slugs = []
self.own_gist_slugs = []
self.own_project_slugs = []
if random.random() < 0.3 and SEED_USERS: if random.random() < 0.3 and SEED_USERS:
seed = random.choice(SEED_USERS) seed = random.choice(SEED_USERS)
do_login(self.client, seed["email"]) do_login(self.client, seed["email"])
@@ -292,6 +386,50 @@ class DevPlaceUser(HttpUser):
def view_bugs(self): def view_bugs(self):
self.client.get("/bugs", name="bugs") self.client.get("/bugs", name="bugs")
@task(3)
def view_project_detail(self):
if not PROJECT_SLUGS:
return
self.client.get(
f"/projects/{random.choice(PROJECT_SLUGS)}", name="projects/[slug]"
)
@task(2)
def view_gists(self):
self.client.get("/gists", name="gists")
@task(2)
def view_gist_detail(self):
if not GIST_SLUGS:
return
self.client.get(
f"/gists/{random.choice(GIST_SLUGS)}", name="gists/[slug]"
)
@task(1)
def search_profiles(self):
term = random.choice(ALL_USERNAMES)[:5] if ALL_USERNAMES else "lu"
self.client.get(
"/profile/search", params={"q": term}, name="profile/search"
)
@task(1)
def search_messages(self):
term = random.choice(ALL_USERNAMES)[:5] if ALL_USERNAMES else "lu"
self.client.get(
"/messages/search", params={"q": term}, name="messages/search"
)
@task(1)
def view_seo(self):
self.client.get(
random.choice(["/robots.txt", "/sitemap.xml"]), name="seo"
)
@task(1)
def view_static(self):
self.client.get("/static/css/base.css", name="static")
# ── auth flows ────────────────────────────────────────────── # ── auth flows ──────────────────────────────────────────────
@task(1) @task(1)
@@ -307,6 +445,30 @@ class DevPlaceUser(HttpUser):
if SEED_USERS: if SEED_USERS:
do_login(self.client, random.choice(SEED_USERS)["email"]) do_login(self.client, random.choice(SEED_USERS)["email"])
@task(1)
def view_forgot_password(self):
self.client.get("/auth/forgot-password", name="auth/forgot-password")
@task(1)
def request_password_reset(self):
email = random.choice(SEED_USERS)["email"] if SEED_USERS else "x@locust.devplace"
self.client.post(
"/auth/forgot-password", data={"email": email},
name="auth/forgot-password/post",
)
@task(1)
def reset_password_flow(self):
token = uuid.uuid4().hex + uuid.uuid4().hex
self.client.get(
f"/auth/reset-password/{token}", name="auth/reset-password"
)
self.client.post(
f"/auth/reset-password/{token}",
data={"password": PASSWORD, "confirm_password": PASSWORD},
name="auth/reset-password/post",
)
# ── content creation ──────────────────────────────────────── # ── content creation ────────────────────────────────────────
@task(2) @task(2)
@@ -321,9 +483,12 @@ class DevPlaceUser(HttpUser):
name="posts/create") as resp: name="posts/create") as resp:
if resp.status_code == 200 and resp.history: if resp.status_code == 200 and resp.history:
m = re.search(r"/posts/(\S+)", resp.url) m = re.search(r"/posts/(\S+)", resp.url)
if m and m.group(1) not in POST_SLUGS: if m:
POST_SLUGS.append(m.group(1)) slug = m.group(1)
m2 = re.search(rf'name="post_uid"\s+value="({UUID_RE})"', resp.text) if slug not in POST_SLUGS:
POST_SLUGS.append(slug)
self.own_post_slugs.append(slug)
m2 = re.search(rf'/votes/post/({UUID_RE})', resp.text)
if m2 and m2.group(1) not in POST_UIDS: if m2 and m2.group(1) not in POST_UIDS:
POST_UIDS.append(m2.group(1)) POST_UIDS.append(m2.group(1))
resp.success() resp.success()
@@ -341,11 +506,48 @@ class DevPlaceUser(HttpUser):
@task(1) @task(1)
def create_project(self): def create_project(self):
self.client.post("/projects/create", data={ with self.client.post("/projects/create", data={
"title": f"Project {uuid.uuid4().hex[:6]}", "title": f"Project {uuid.uuid4().hex[:6]}",
"description": "y " * 50, "description": "y " * 50,
"project_type": random.choice(PROJECT_TYPES), "project_type": random.choice(PROJECT_TYPES),
}, name="projects/create") }, catch_response=True, name="projects/create") as resp:
m = re.search(r"/projects/(\S+)", resp.url)
if resp.status_code == 200 and m:
slug = m.group(1)
if slug not in PROJECT_SLUGS:
PROJECT_SLUGS.append(slug)
self.own_project_slugs.append(slug)
resp.success()
else:
resp.failure("project not created")
@task(1)
def create_gist(self):
with self.client.post("/gists/create", data={
"title": f"Gist {uuid.uuid4().hex[:6]}",
"description": "Load test gist.",
"source_code": "x = 1\n" * 20,
"language": random.choice(GIST_LANGUAGES),
}, catch_response=True, name="gists/create") as resp:
m = re.search(r"/gists/(\S+)", resp.url)
if resp.status_code == 200 and m:
slug = m.group(1)
if slug not in GIST_SLUGS:
GIST_SLUGS.append(slug)
self.own_gist_slugs.append(slug)
m2 = re.search(rf'/votes/gist/({UUID_RE})', resp.text)
if m2 and m2.group(1) not in GIST_UIDS:
GIST_UIDS.append(m2.group(1))
resp.success()
else:
resp.failure("gist not created")
@task(1)
def create_bug(self):
self.client.post("/bugs/create", data={
"title": f"Bug {uuid.uuid4().hex[:6]}",
"description": "Load test bug report description.",
}, name="bugs/create")
# ── social interaction ────────────────────────────────────── # ── social interaction ──────────────────────────────────────
@@ -369,6 +571,26 @@ class DevPlaceUser(HttpUser):
name="votes/project", name="votes/project",
) )
@task(1)
def vote_on_comment(self):
if not COMMENT_UIDS:
return
self.client.post(
f"/votes/comment/{random.choice(COMMENT_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/comment",
)
@task(1)
def vote_on_gist(self):
if not GIST_UIDS:
return
self.client.post(
f"/votes/gist/{random.choice(GIST_UIDS)}",
data={"value": random.choice([1, -1])},
name="votes/gist",
)
@task(1) @task(1)
def follow_user(self): def follow_user(self):
targets = [u for u in ALL_USERNAMES if u != self.username] targets = [u for u in ALL_USERNAMES if u != self.username]
@@ -412,12 +634,25 @@ class DevPlaceUser(HttpUser):
@task(1) @task(1)
def view_notifications(self): def view_notifications(self):
self.client.get("/notifications", name="notifications") resp = self.client.get("/notifications", name="notifications")
matches = re.findall(rf'/notifications/mark-read/({UUID_RE})', resp.text)
NOTIFICATION_UIDS.extend(
m for m in matches if m not in NOTIFICATION_UIDS
)
@task(1) @task(1)
def mark_all_notifications_read(self): def mark_all_notifications_read(self):
self.client.post("/notifications/mark-all-read", name="notifications/mark-all-read") self.client.post("/notifications/mark-all-read", name="notifications/mark-all-read")
@task(1)
def mark_one_notification_read(self):
if not NOTIFICATION_UIDS:
return
self.client.post(
f"/notifications/mark-read/{random.choice(NOTIFICATION_UIDS)}",
name="notifications/mark-read",
)
@task(1) @task(1)
def delete_comment(self): def delete_comment(self):
if not COMMENT_UIDS: if not COMMENT_UIDS:
@@ -427,6 +662,85 @@ class DevPlaceUser(HttpUser):
name="comments/delete", name="comments/delete",
) )
# ── owner-scoped mutations ──────────────────────────────────
@task(1)
def edit_post(self):
if not self.own_post_slugs:
return
self.client.post(
f"/posts/edit/{random.choice(self.own_post_slugs)}",
data={
"content": f"Edited {uuid.uuid4().hex} " * 3,
"title": "Edited post",
"topic": random.choice(TOPICS),
},
name="posts/edit",
)
@task(1)
def delete_post(self):
if not self.own_post_slugs:
return
slug = self.own_post_slugs.pop()
if slug in POST_SLUGS:
POST_SLUGS.remove(slug)
self.client.post(f"/posts/delete/{slug}", name="posts/delete")
@task(1)
def edit_gist(self):
if not self.own_gist_slugs:
return
self.client.post(
f"/gists/edit/{random.choice(self.own_gist_slugs)}",
data={
"title": "Edited gist",
"description": "Edited description.",
"source_code": "y = 2\n" * 20,
"language": random.choice(GIST_LANGUAGES),
},
name="gists/edit",
)
@task(1)
def delete_gist(self):
if not self.own_gist_slugs:
return
slug = self.own_gist_slugs.pop()
if slug in GIST_SLUGS:
GIST_SLUGS.remove(slug)
self.client.post(f"/gists/delete/{slug}", name="gists/delete")
@task(1)
def delete_project(self):
if not self.own_project_slugs:
return
slug = self.own_project_slugs.pop()
if slug in PROJECT_SLUGS:
PROJECT_SLUGS.remove(slug)
self.client.post(f"/projects/delete/{slug}", name="projects/delete")
@task(1)
def upload_file(self):
name, content, mime = UPLOAD_FILE
with self.client.post(
"/uploads/upload",
files={"file": (name, content, mime)},
catch_response=True, name="uploads/upload",
) as resp:
if resp.status_code == 201:
resp.success()
try:
uid = resp.json().get("uid")
except ValueError:
uid = None
if uid:
self.client.delete(
f"/uploads/delete/{uid}", name="uploads/delete"
)
else:
resp.failure(f"upload failed: status={resp.status_code}")
# ── static / media ────────────────────────────────────────── # ── static / media ──────────────────────────────────────────
@task(2) @task(2)
@@ -445,3 +759,99 @@ class DevPlaceUser(HttpUser):
"target_uid": random.choice(NEWS_UIDS), "target_uid": random.choice(NEWS_UIDS),
"target_type": "news", "target_type": "news",
}, name="comments/news") }, name="comments/news")
class AdminUser(HttpUser):
weight = 1
wait_time = between(2, 6)
def on_start(self):
if ADMIN_USER:
do_login(self.client, ADMIN_USER["email"])
@task(2)
def view_admin_index(self):
self.client.get("/admin", name="admin")
@task(3)
def view_admin_users(self):
self.client.get("/admin/users", name="admin/users")
@task(2)
def view_admin_settings(self):
self.client.get("/admin/settings", name="admin/settings")
@task(2)
def view_admin_news(self):
self.client.get("/admin/news", name="admin/news")
@task(2)
def view_admin_services(self):
self.client.get("/admin/services", name="admin/services")
@task(2)
def view_admin_services_data(self):
self.client.get("/admin/services/data", name="admin/services/data")
@task(1)
def save_settings(self):
self.client.post("/admin/settings", data={
"max_upload_size_mb": "10",
}, name="admin/settings/save")
@task(1)
def news_toggle(self):
uid = ADMIN_TARGETS.get("news_uid")
if not uid:
return
self.client.post(f"/admin/news/{uid}/toggle", name="admin/news/toggle")
@task(1)
def news_publish(self):
uid = ADMIN_TARGETS.get("news_uid")
if not uid:
return
self.client.post(f"/admin/news/{uid}/publish", name="admin/news/publish")
@task(1)
def news_landing(self):
uid = ADMIN_TARGETS.get("news_uid")
if not uid:
return
self.client.post(f"/admin/news/{uid}/landing", name="admin/news/landing")
@task(1)
def news_delete(self):
uid = ADMIN_TARGETS.get("news_uid")
if not uid:
return
self.client.post(f"/admin/news/{uid}/delete", name="admin/news/delete")
@task(1)
def user_role(self):
uid = ADMIN_TARGETS.get("disposable_uid")
if not uid:
return
self.client.post(
f"/admin/users/{uid}/role", data={"role": "Member"},
name="admin/users/role",
)
@task(1)
def user_password(self):
uid = ADMIN_TARGETS.get("disposable_uid")
if not uid:
return
self.client.post(
f"/admin/users/{uid}/password", data={"password": PASSWORD},
name="admin/users/password",
)
@task(1)
def user_toggle(self):
uid = ADMIN_TARGETS.get("disposable_uid")
if not uid:
return
self.client.post(
f"/admin/users/{uid}/toggle", name="admin/users/toggle"
)