Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49853e079b | ||
|
|
2afb2038e5 | ||
|
|
18c9f20090 | ||
|
|
28bc8c1b74 | ||
|
|
8778d60c21 | ||
|
|
9c88d2120c |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
svg = generate_avatar_svg(seed)
|
if request.headers.get("if-none-match") == etag:
|
||||||
_cache[cache_key] = svg
|
return Response(status_code=304, headers=headers)
|
||||||
return Response(content=svg, media_type="image/svg+xml")
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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']}")
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
+122
-133
@@ -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,12 +42,11 @@ 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)
|
||||||
matches = pattern.findall(html)
|
matches = pattern.findall(html)
|
||||||
images = []
|
images = []
|
||||||
@@ -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:
|
||||||
@@ -92,125 +82,125 @@ class NewsService(BaseService):
|
|||||||
self.log(f"Failed to fetch news API: {e}")
|
self.log(f"Failed to fetch news API: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
articles = data.get("articles", [])
|
articles = data.get("articles", [])
|
||||||
self.log(f"Received {len(articles)} articles")
|
self.log(f"Received {len(articles)} articles")
|
||||||
|
|
||||||
news_table = get_table("news")
|
news_table = get_table("news")
|
||||||
images_table = get_table("news_images")
|
images_table = get_table("news_images")
|
||||||
sync_table = get_table("news_sync")
|
sync_table = get_table("news_sync")
|
||||||
|
|
||||||
synced_ids = set()
|
synced_ids = set()
|
||||||
for entry in sync_table.find():
|
for entry in sync_table.find():
|
||||||
synced_ids.add(entry["external_id"])
|
synced_ids.add(entry["external_id"])
|
||||||
|
|
||||||
new_count = 0
|
new_count = 0
|
||||||
updated_count = 0
|
updated_count = 0
|
||||||
draft_count = 0
|
draft_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
|
|
||||||
for article in articles:
|
for article in articles:
|
||||||
external_id = article.get("guid", "")
|
external_id = article.get("guid", "")
|
||||||
if not external_id:
|
if not external_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if external_id in synced_ids:
|
if external_id in synced_ids:
|
||||||
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:
|
||||||
grade_val = 0
|
grade_val = 0
|
||||||
auto_published = False
|
auto_published = False
|
||||||
failed_count += 1
|
failed_count += 1
|
||||||
sync_status = "grading_failed"
|
sync_status = "grading_failed"
|
||||||
elif grade < threshold:
|
elif grade < threshold:
|
||||||
grade_val = grade
|
grade_val = grade
|
||||||
auto_published = False
|
auto_published = False
|
||||||
draft_count += 1
|
draft_count += 1
|
||||||
sync_status = "graded"
|
sync_status = "graded"
|
||||||
else:
|
else:
|
||||||
grade_val = grade
|
grade_val = grade
|
||||||
auto_published = True
|
auto_published = True
|
||||||
sync_status = "graded"
|
sync_status = "graded"
|
||||||
|
|
||||||
is_published = "published" if auto_published else "draft"
|
is_published = "published" if auto_published else "draft"
|
||||||
existing = news_table.find_one(external_id=external_id)
|
existing = news_table.find_one(external_id=external_id)
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
existing_slug = existing.get("slug", "")
|
existing_slug = existing.get("slug", "")
|
||||||
news_table.update({
|
news_table.update({
|
||||||
"id": existing["id"],
|
"id": existing["id"],
|
||||||
"grade": grade_val,
|
"grade": grade_val,
|
||||||
"status": is_published,
|
"status": is_published,
|
||||||
"title": article.get("title", ""),
|
"title": article.get("title", ""),
|
||||||
"slug": existing_slug or make_combined_slug(article.get("title", "") or "news", existing["uid"]),
|
"slug": existing_slug or make_combined_slug(article.get("title", "") or "news", existing["uid"]),
|
||||||
"description": strip_html(article.get("description", "") or "")[:5000],
|
"description": strip_html(article.get("description", "") or "")[:5000],
|
||||||
"url": article.get("link", ""),
|
"url": article.get("link", ""),
|
||||||
"source_name": article.get("feed_name", ""),
|
"source_name": article.get("feed_name", ""),
|
||||||
"content": strip_html(article.get("content", "") or "")[:10000],
|
"content": strip_html(article.get("content", "") or "")[:10000],
|
||||||
"author": article.get("author", ""),
|
"author": article.get("author", ""),
|
||||||
"article_published": article.get("published", ""),
|
"article_published": article.get("published", ""),
|
||||||
"synced_at": now,
|
"synced_at": now,
|
||||||
}, ["id"])
|
}, ["id"])
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
images_table.delete(news_uid=existing["uid"])
|
images_table.delete(news_uid=existing["uid"])
|
||||||
article_uid = existing["uid"]
|
article_uid = existing["uid"]
|
||||||
else:
|
else:
|
||||||
article_uid = generate_uid()
|
article_uid = generate_uid()
|
||||||
article_slug = make_combined_slug(article.get("title", "") or "news", article_uid)
|
article_slug = make_combined_slug(article.get("title", "") or "news", article_uid)
|
||||||
news_table.insert({
|
news_table.insert({
|
||||||
"uid": article_uid,
|
"uid": article_uid,
|
||||||
"slug": article_slug,
|
"slug": article_slug,
|
||||||
"external_id": external_id,
|
"external_id": external_id,
|
||||||
"title": article.get("title", ""),
|
"title": article.get("title", ""),
|
||||||
"description": strip_html(article.get("description", "") or "")[:5000],
|
"description": strip_html(article.get("description", "") or "")[:5000],
|
||||||
"url": article.get("link", ""),
|
"url": article.get("link", ""),
|
||||||
"image_url": "",
|
"image_url": "",
|
||||||
"source_name": article.get("feed_name", ""),
|
"source_name": article.get("feed_name", ""),
|
||||||
"grade": grade_val,
|
"grade": grade_val,
|
||||||
"status": is_published,
|
"status": is_published,
|
||||||
"content": strip_html(article.get("content", "") or "")[:10000],
|
"content": strip_html(article.get("content", "") or "")[:10000],
|
||||||
"author": article.get("author", ""),
|
"author": article.get("author", ""),
|
||||||
"article_published": article.get("published", ""),
|
"article_published": article.get("published", ""),
|
||||||
"synced_at": now,
|
"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", ""),
|
|
||||||
})
|
})
|
||||||
|
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}, "
|
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,12 +228,11 @@ 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()
|
result = resp.json()
|
||||||
result = resp.json()
|
|
||||||
text = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
text = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
if not text:
|
if not text:
|
||||||
self.log(f"AI grading returned empty content for: {title[:60]}")
|
self.log(f"AI grading returned empty content for: {title[:60]}")
|
||||||
|
|||||||
+11
-15
@@ -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
@@ -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
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user