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:
name: failure-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_SPAWN_RATE ?= 5
LOCUST_RUN_TIME ?= 120s
DEVPLACE_RATE_LIMIT ?= 1000000
.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
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:
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); \
mkdir -p $(LOCUST_DB_DIR); \
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 & \
@@ -39,6 +41,7 @@ locust:
locust-headless:
export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \
export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \
mkdir -p $(LOCUST_DB_DIR); \
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 & \
@@ -70,3 +73,8 @@ docker-logs:
docker-clean:
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
for table in ("news", "news_images", "news_sync"):
if table in db.tables:
count = len(list(db[table].all()))
count = db[table].count()
db[table].delete()
print(f"Deleted {count} rows from '{table}'")
else:
+62 -2
View File
@@ -1,6 +1,7 @@
import dataset
import logging
from datetime import datetime
from datetime import datetime, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import DATABASE_URL
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}
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):
if not target_uids or "votes" not in db.tables:
return {}, {}
@@ -218,6 +228,17 @@ def get_attachments_by_type(resource_type: str, resource_uids: list) -> dict:
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:
if "attachments" not in db.tables:
return
@@ -252,11 +273,50 @@ def _delete_attachment_file(storage_path: str) -> None:
logger.warning(f"Failed to delete attachment file {storage_path}: {e}")
_settings_cache = TTLCache(ttl=60)
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:
return default
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):
+3 -8
View File
@@ -8,7 +8,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
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.utils import get_current_user, time_ago
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:
news_table = get_table("news")
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:
image_url = ""
if images_table:
img = images_table.find_one(news_uid=a["uid"])
if img:
image_url = img["url"]
landing_articles.append({
"uid": a["uid"],
"slug": a.get("slug", ""),
@@ -180,7 +175,7 @@ async def landing(request: Request):
"grade": a.get("grade", 0),
"synced_at": a.get("synced_at", "") or "",
"time_ago": time_ago(a["synced_at"]),
"image_url": image_url,
"image_url": images_by_news.get(a["uid"], ""),
})
landing_posts = []
+13 -11
View File
@@ -4,9 +4,9 @@ from datetime import datetime
from fastapi import APIRouter, Request, Form
from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm
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.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
logger = logging.getLogger(__name__)
@@ -23,13 +23,13 @@ async def admin_index(request: Request):
async def admin_users(request: Request, page: int = 1):
admin = require_admin(request)
users_table = get_table("users")
total = len(list(users_table.all()))
total = users_table.count()
pagination = build_pagination(page, total)
offset = (pagination["page"] - 1) * pagination["per_page"]
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:
posts_count = len(list(get_table("posts").find(user_uid=u["uid"])))
u["posts_count"] = posts_count
u["posts_count"] = post_counts.get(u["uid"], 0)
base = site_url(request)
seo_ctx = base_seo_context(
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)
users = get_table("users")
users.update({"uid": uid, "role": role}, ["uid"])
clear_user_cache(uid)
logger.info(f"Admin {admin['username']} set user {uid} role to {role}")
return RedirectResponse(url="/admin/users", status_code=302)
@@ -83,6 +84,7 @@ async def admin_user_toggle(request: Request, uid: str):
if user:
new_state = not user.get("is_active", True)
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}")
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):
admin = require_admin(request)
news_table = get_table("news")
total = len(list(news_table.all()))
total = news_table.count()
pagination = build_pagination(page, total)
offset = (pagination["page"] - 1) * pagination["per_page"]
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 = []
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({
"article": a,
"time_ago": time_ago(a["synced_at"]),
"synced_at": a.get("synced_at", ""),
"grade": a.get("grade", 0),
"has_image": has_image,
"has_image": a["uid"] in images_by_news,
})
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():
existing = settings.find_one(key=key)
if existing:
if value == "":
continue
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])
else:
settings.insert({"uid": generate_uid(), "key": key, "value": value})
clear_settings_cache()
logger.info(f"Admin {admin['username']} updated settings")
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()
is_first = len(list(users.all())) == 0
is_first = users.count() == 0
users.insert({
"uid": uid,
"username": username,
+14 -6
View File
@@ -1,20 +1,28 @@
import hashlib
import logging
from fastapi import APIRouter, Request
from fastapi.responses import Response
from devplacepy.avatar import generate_avatar_svg
from devplacepy.cache import TTLCache
logger = logging.getLogger(__name__)
router = APIRouter()
_cache = {}
_cache = TTLCache(ttl=86400, max_size=4096)
_CACHE_CONTROL = "public, max-age=86400, immutable"
@router.get("/{style}/{seed}")
async def avatar_proxy(request: Request, style: str, seed: str, size: int = 128):
cache_key = f"{seed}:{size}"
if cache_key in _cache:
return Response(content=_cache[cache_key], media_type="image/svg+xml")
etag = '"' + hashlib.md5(cache_key.encode("utf-8")).hexdigest() + '"'
headers = {"ETag": etag, "Cache-Control": _CACHE_CONTROL}
svg = generate_avatar_svg(seed)
_cache[cache_key] = svg
return Response(content=svg, media_type="image/svg+xml")
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)
_cache.set(cache_key, svg)
return Response(content=svg, media_type="image/svg+xml", headers=headers)
+6 -12
View File
@@ -1,8 +1,7 @@
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Request
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.templating import templates
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)
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")
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()))
stats = get_site_stats()
top_authors = list(users_table.find(stars={">": 0}, order_by=["-stars"], _limit=5))
daily_topic = get_daily_topic()
@@ -99,10 +93,10 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
"posts": posts,
"current_tab": tab,
"current_topic": topic,
"total_members": total_members,
"posts_today": posts_today,
"total_projects": total_projects,
"total_gists": total_gists,
"total_members": stats["total_members"],
"posts_today": stats["posts_today"],
"total_projects": stats["total_projects"],
"total_gists": stats["total_gists"],
"top_authors": top_authors,
"daily_topic": daily_topic,
"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.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"])
with db:
db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid)
from devplacepy.database import get_users_by_uids
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 fastapi import APIRouter, Request
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.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
@@ -26,14 +26,7 @@ async def news_page(request: Request):
))
article_uids = [a["uid"] for a in articles]
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"]
images_by_news = get_news_images_by_uids(article_uids)
enriched = []
for a in articles:
+6 -5
View File
@@ -2,8 +2,8 @@ import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.database import get_table, db
from devplacepy.templating import templates, clear_unread_cache
from devplacepy.utils import require_user, time_ago
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)
if n and n["user_uid"] == user["uid"]:
notifications_table.update({"id": n["id"], "read": True}, ["id"])
clear_unread_cache(user["uid"])
return RedirectResponse(url="/notifications", status_code=302)
@router.post("/mark-all-read")
async def mark_all_read(request: Request):
user = require_user(request)
notifications_table = get_table("notifications")
for n in notifications_table.find(user_uid=user["uid"], read=False):
notifications_table.update({"id": n["id"], "read": True}, ["id"])
with db:
db.query("UPDATE notifications SET read = 1 WHERE user_uid = :u AND read = 0", u=user["uid"])
clear_unread_cache(user["uid"])
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 devplacepy.database import get_table, db
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
logger = logging.getLogger(__name__)
@@ -58,7 +58,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
gists = []
for g in gists_raw:
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 = []
if tab == "activity":
@@ -124,6 +124,7 @@ async def update_profile(request: Request, data: Annotated[ProfileForm, Form()])
"git_link": data.git_link.strip(),
"website": data.website.strip(),
}, ["uid"])
clear_user_cache(user["uid"])
logger.info(f"Profile updated for {user['username']}")
return RedirectResponse(url=f"/profile/{user['username']}", status_code=302)
+16 -27
View File
@@ -1,10 +1,11 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from sqlalchemy import or_
from fastapi import APIRouter, Request, HTTPException, Form
from devplacepy.models import ProjectForm
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.templating import templates
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):
projects = get_table("projects")
all_projects = list(projects.all())
filters = {}
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:
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:
search_lower = search.lower()
all_projects = [
p for p in all_projects
if search_lower in p.get("title", "").lower()
or search_lower in p.get("description", "").lower()
]
clauses = []
if search and projects.exists:
like = f"%{search}%"
clauses.append(or_(projects.table.columns.title.ilike(like), projects.table.columns.description.ilike(like)))
order = ["-stars", "-created_at"] if tab == "popular" else ["-created_at"]
all_projects = list(projects.find(*clauses, **filters, order_by=order))
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)
users_map = get_users_by_uids([p["user_uid"] for p in all_projects])
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"]
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
@@ -62,8 +53,7 @@ async def projects_page(
):
user = get_current_user(request)
projects = get_projects_list(tab, search, user_uid, project_type)
users = get_table("users")
total_members = len(list(users.all()))
total_members = get_site_stats()["total_members"]
base = site_url(request)
seo_ctx = base_seo_context(
@@ -97,7 +87,6 @@ async def project_detail(request: Request, project_slug: str):
if not project:
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"]])
author = users_map.get(project["user_uid"])
+2 -2
View File
@@ -2,7 +2,7 @@ import logging
from pathlib import Path
from fastapi import APIRouter, Request
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.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"])
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)
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(),
})
up_count = len(list(votes.find(target_uid=target_uid, value=1)))
down_count = len(list(votes.find(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)
net = up_count - down_count
if target_type == "post":
+122 -133
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
import httpx
from devplacepy.database import get_table
from devplacepy.database import get_table, get_setting
from devplacepy.services.base import BaseService
from devplacepy.utils import generate_uid, make_combined_slug, strip_html
@@ -17,22 +17,13 @@ AI_MODEL_DEFAULT = "molodetz"
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:
key = os.environ.get("NEWS_AI_KEY")
if key:
return key
table = get_table("site_settings")
row = table.find_one(key="news_ai_key")
if row:
return row.get("value", "")
key = get_setting("news_ai_key", "")
if key:
return key
key = os.environ.get("OPENROUTER_API_KEY")
if key:
return key
@@ -51,12 +42,11 @@ def _extract_grade(text: str) -> int | 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:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url)
resp.raise_for_status()
html = resp.text
resp = await client.get(url, timeout=10.0)
resp.raise_for_status()
html = resp.text
pattern = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
matches = pattern.findall(html)
images = []
@@ -77,10 +67,10 @@ class NewsService(BaseService):
super().__init__(name="news", interval_seconds=3600)
async def run_once(self) -> None:
api_url = _get_setting("news_api_url", NEWS_API_URL_DEFAULT)
ai_url = _get_setting("news_ai_url", AI_URL_DEFAULT)
ai_model = _get_setting("news_ai_model", AI_MODEL_DEFAULT)
threshold = int(_get_setting("news_grade_threshold", str(GRADE_THRESHOLD_DEFAULT)))
api_url = get_setting("news_api_url", NEWS_API_URL_DEFAULT)
ai_url = get_setting("news_ai_url", AI_URL_DEFAULT)
ai_model = get_setting("news_ai_model", AI_MODEL_DEFAULT)
threshold = int(get_setting("news_grade_threshold", str(GRADE_THRESHOLD_DEFAULT)))
self.log(f"Fetching news from {api_url}")
async with httpx.AsyncClient(timeout=30.0) as client:
@@ -92,125 +82,125 @@ class NewsService(BaseService):
self.log(f"Failed to fetch news API: {e}")
return
articles = data.get("articles", [])
self.log(f"Received {len(articles)} articles")
articles = data.get("articles", [])
self.log(f"Received {len(articles)} articles")
news_table = get_table("news")
images_table = get_table("news_images")
sync_table = get_table("news_sync")
news_table = get_table("news")
images_table = get_table("news_images")
sync_table = get_table("news_sync")
synced_ids = set()
for entry in sync_table.find():
synced_ids.add(entry["external_id"])
synced_ids = set()
for entry in sync_table.find():
synced_ids.add(entry["external_id"])
new_count = 0
updated_count = 0
draft_count = 0
failed_count = 0
skipped_count = 0
new_count = 0
updated_count = 0
draft_count = 0
failed_count = 0
skipped_count = 0
for article in articles:
external_id = article.get("guid", "")
if not external_id:
continue
for article in articles:
external_id = article.get("guid", "")
if not external_id:
continue
if external_id in synced_ids:
skipped_count += 1
continue
if external_id in synced_ids:
skipped_count += 1
continue
grade = await self._grade_article(article, ai_url, ai_model)
now = datetime.now(timezone.utc).isoformat()
grade = await self._grade_article(article, ai_url, ai_model, client)
now = datetime.now(timezone.utc).isoformat()
if grade is None:
grade_val = 0
auto_published = False
failed_count += 1
sync_status = "grading_failed"
elif grade < threshold:
grade_val = grade
auto_published = False
draft_count += 1
sync_status = "graded"
else:
grade_val = grade
auto_published = True
sync_status = "graded"
if grade is None:
grade_val = 0
auto_published = False
failed_count += 1
sync_status = "grading_failed"
elif grade < threshold:
grade_val = grade
auto_published = False
draft_count += 1
sync_status = "graded"
else:
grade_val = grade
auto_published = True
sync_status = "graded"
is_published = "published" if auto_published else "draft"
existing = news_table.find_one(external_id=external_id)
is_published = "published" if auto_published else "draft"
existing = news_table.find_one(external_id=external_id)
if existing:
existing_slug = existing.get("slug", "")
news_table.update({
"id": existing["id"],
"grade": grade_val,
"status": is_published,
"title": article.get("title", ""),
"slug": existing_slug or make_combined_slug(article.get("title", "") or "news", existing["uid"]),
"description": strip_html(article.get("description", "") or "")[:5000],
"url": article.get("link", ""),
"source_name": article.get("feed_name", ""),
"content": strip_html(article.get("content", "") or "")[:10000],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
}, ["id"])
updated_count += 1
images_table.delete(news_uid=existing["uid"])
article_uid = existing["uid"]
else:
article_uid = generate_uid()
article_slug = make_combined_slug(article.get("title", "") or "news", article_uid)
news_table.insert({
"uid": article_uid,
"slug": article_slug,
"external_id": external_id,
"title": article.get("title", ""),
"description": strip_html(article.get("description", "") or "")[:5000],
"url": article.get("link", ""),
"image_url": "",
"source_name": article.get("feed_name", ""),
"grade": grade_val,
"status": is_published,
"content": strip_html(article.get("content", "") or "")[:10000],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
})
new_count += 1
existing_sync = sync_table.find_one(external_id=external_id)
if existing_sync:
sync_table.update({
"id": existing_sync["id"],
"status": sync_status,
"synced_at": now,
}, ["id"])
else:
sync_table.insert({
"uid": generate_uid(),
"external_id": external_id,
"status": sync_status,
"synced_at": now,
})
synced_ids.add(external_id)
link = article.get("link", "")
if link:
fresh_images = await _get_article_images(link)
for img in fresh_images:
images_table.insert({
"uid": generate_uid(),
"news_uid": article_uid,
"url": img["url"],
"alt_text": img.get("alt_text", ""),
if existing:
existing_slug = existing.get("slug", "")
news_table.update({
"id": existing["id"],
"grade": grade_val,
"status": is_published,
"title": article.get("title", ""),
"slug": existing_slug or make_combined_slug(article.get("title", "") or "news", existing["uid"]),
"description": strip_html(article.get("description", "") or "")[:5000],
"url": article.get("link", ""),
"source_name": article.get("feed_name", ""),
"content": strip_html(article.get("content", "") or "")[:10000],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
}, ["id"])
updated_count += 1
images_table.delete(news_uid=existing["uid"])
article_uid = existing["uid"]
else:
article_uid = generate_uid()
article_slug = make_combined_slug(article.get("title", "") or "news", article_uid)
news_table.insert({
"uid": article_uid,
"slug": article_slug,
"external_id": external_id,
"title": article.get("title", ""),
"description": strip_html(article.get("description", "") or "")[:5000],
"url": article.get("link", ""),
"image_url": "",
"source_name": article.get("feed_name", ""),
"grade": grade_val,
"status": is_published,
"content": strip_html(article.get("content", "") or "")[:10000],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
})
new_count += 1
existing_sync = sync_table.find_one(external_id=external_id)
if existing_sync:
sync_table.update({
"id": existing_sync["id"],
"status": sync_status,
"synced_at": now,
}, ["id"])
else:
sync_table.insert({
"uid": generate_uid(),
"external_id": external_id,
"status": sync_status,
"synced_at": now,
})
synced_ids.add(external_id)
link = article.get("link", "")
if link:
fresh_images = await _get_article_images(link, client)
for img in fresh_images:
images_table.insert({
"uid": generate_uid(),
"news_uid": article_uid,
"url": img["url"],
"alt_text": img.get("alt_text", ""),
})
self.log(f"New {new_count}, updated {updated_count}, draft {draft_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]
description = strip_html(article.get("description", "") or "")[:1000]
content = strip_html(article.get("content", "") or "")[:1500]
@@ -238,12 +228,11 @@ class NewsService(BaseService):
headers["Authorization"] = f"Bearer {ai_key}"
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(ai_url, json=payload, headers=headers)
if resp.status_code != 200:
self.log(f"AI grading returned {resp.status_code}: {resp.text[:200]}")
resp.raise_for_status()
result = resp.json()
resp = await client.post(ai_url, json=payload, headers=headers, timeout=15.0)
if resp.status_code != 200:
self.log(f"AI grading returned {resp.status_code}: {resp.text[:200]}")
resp.raise_for_status()
result = resp.json()
text = result.get("choices", [{}])[0].get("message", {}).get("content", "")
if not text:
self.log(f"AI grading returned empty content for: {title[:60]}")
+11 -15
View File
@@ -1,5 +1,5 @@
import time
from fastapi.templating import Jinja2Templates
from devplacepy.cache import TTLCache
from devplacepy.config import TEMPLATES_DIR
from devplacepy.constants import TOPICS
from devplacepy.database import get_table
@@ -13,23 +13,19 @@ from devplacepy.seo import (
)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = {}
_UNREAD_CACHE_TTL = 60
_unread_cache = TTLCache(ttl=60)
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:
cached = _unread_cache.get(user_uid)
if cached:
entry, expiry = cached
if time.time() < expiry:
return entry
_unread_cache.pop(user_uid, None)
if cached is not None:
return cached
notifs = get_table("notifications")
count = len(list(notifs.find(user_uid=user_uid, read=False)))
_unread_cache[user_uid] = (count, time.time() + _UNREAD_CACHE_TTL)
count = notifs.count(user_uid=user_uid, read=False)
_unread_cache.set(user_uid, count)
return count
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["TOPICS"] = TOPICS
def jinja_max_upload_size_mb():
from devplacepy.database import get_setting
return int(get_setting("max_upload_size_mb", "10"))
from devplacepy.database import get_int_setting
return get_int_setting("max_upload_size_mb", 10)
def jinja_max_attachments():
from devplacepy.database import get_setting
return int(get_setting("max_attachments_per_resource", "10"))
from devplacepy.database import get_int_setting
return get_int_setting("max_attachments_per_resource", 10)
def jinja_allowed_file_types():
from devplacepy.database import get_setting
+12 -10
View File
@@ -1,11 +1,11 @@
import html
import re
import secrets
import time
import logging
from datetime import datetime, timedelta, timezone
from passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status
from devplacepy.cache import TTLCache
from devplacepy.database import get_table
from devplacepy.config import SECRET_KEY, SESSION_MAX_AGE
@@ -33,8 +33,13 @@ def create_session(user_uid: str) -> str:
return token
_user_cache = {}
_USER_CACHE_TTL = 300
_user_cache = TTLCache(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):
@@ -43,11 +48,8 @@ def get_current_user(request: Request):
return None
cached = _user_cache.get(token)
if cached:
entry, expiry = cached
if time.time() < expiry:
return entry
_user_cache.pop(token, None)
if cached is not None:
return cached
sessions = get_table("sessions")
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"])
if user and not user.get("is_active", True):
sessions.delete(id=session["id"])
_user_cache.pop(token, None)
_user_cache.pop(token)
return None
if user:
_user_cache[token] = (user, time.time() + _USER_CACHE_TTL)
_user_cache.set(token, user)
return user
+419 -9
View File
@@ -17,11 +17,19 @@ USER_UIDS = []
POST_UIDS = []
POST_SLUGS = []
PROJECT_UIDS = []
PROJECT_SLUGS = []
COMMENT_UIDS = []
NEWS_UIDS = []
NEWS_SLUGS = []
GIST_SLUGS = []
GIST_UIDS = []
NOTIFICATION_UIDS = []
ADMIN_USER = {}
ADMIN_TARGETS = {}
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random"]
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 = [
"adventurer", "adventurer-neutral", "avataaars", "bottts", "identicon",
"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)
if m and m.group(1) not in POST_SLUGS:
POST_SLUGS.append(m.group(1))
# extract full UUID from the response HTML
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:
POST_UIDS.append(m2.group(1))
except Exception as e:
@@ -150,6 +157,31 @@ def seed_data(environment, **kwargs):
except Exception as 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) ───────────────────
try:
from devplacepy.database import get_table, init_db
@@ -169,9 +201,61 @@ def seed_data(environment, **kwargs):
})
NEWS_UIDS.append(article_uid)
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:
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 ──────────────────────
# projects sidebar: /projects?user_uid={uuid}
# project cards: /votes/project/{uuid}
@@ -193,6 +277,11 @@ def seed_data(environment, **kwargs):
PROJECT_UIDS.extend(
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:
logger.warning(f"Harvest from {seed['username']} failed: {e}")
@@ -211,15 +300,20 @@ def seed_data(environment, **kwargs):
logger.info(
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"
)
class DevPlaceUser(HttpUser):
weight = 20
wait_time = between(1, 5)
def on_start(self):
self.own_post_slugs = []
self.own_gist_slugs = []
self.own_project_slugs = []
if random.random() < 0.3 and SEED_USERS:
seed = random.choice(SEED_USERS)
do_login(self.client, seed["email"])
@@ -292,6 +386,50 @@ class DevPlaceUser(HttpUser):
def view_bugs(self):
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 ──────────────────────────────────────────────
@task(1)
@@ -307,6 +445,30 @@ class DevPlaceUser(HttpUser):
if SEED_USERS:
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 ────────────────────────────────────────
@task(2)
@@ -321,9 +483,12 @@ class DevPlaceUser(HttpUser):
name="posts/create") as resp:
if resp.status_code == 200 and resp.history:
m = re.search(r"/posts/(\S+)", resp.url)
if m and m.group(1) not in POST_SLUGS:
POST_SLUGS.append(m.group(1))
m2 = re.search(rf'name="post_uid"\s+value="({UUID_RE})"', resp.text)
if m:
slug = m.group(1)
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:
POST_UIDS.append(m2.group(1))
resp.success()
@@ -341,11 +506,48 @@ class DevPlaceUser(HttpUser):
@task(1)
def create_project(self):
self.client.post("/projects/create", data={
with self.client.post("/projects/create", data={
"title": f"Project {uuid.uuid4().hex[:6]}",
"description": "y " * 50,
"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 ──────────────────────────────────────
@@ -369,6 +571,26 @@ class DevPlaceUser(HttpUser):
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)
def follow_user(self):
targets = [u for u in ALL_USERNAMES if u != self.username]
@@ -412,12 +634,25 @@ class DevPlaceUser(HttpUser):
@task(1)
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)
def mark_all_notifications_read(self):
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)
def delete_comment(self):
if not COMMENT_UIDS:
@@ -427,6 +662,85 @@ class DevPlaceUser(HttpUser):
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 ──────────────────────────────────────────
@task(2)
@@ -445,3 +759,99 @@ class DevPlaceUser(HttpUser):
"target_uid": random.choice(NEWS_UIDS),
"target_type": "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"
)