Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions api/messages/messages_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
get_all_praises,
get_praises_about_user,
save_praise,
admin_create_news,
admin_update_news,
admin_delete_news,
admin_list_news,
)
from services.feedback_service import save_feedback, get_user_feedback
from services.giveaway_service import save_giveaway, get_user_giveaway, get_all_giveaways
Expand Down Expand Up @@ -511,6 +515,65 @@ def get_single_news(id):
return vars(get_news(news_limit=1,news_id=id))


# -------------------- Admin Blog routes begin here --------------------------- #
# These power the /admin/blog UI. The original public POST /news above keeps its
# X-Api-Key auth (Slack integration relies on it). Admin CRUD lives here under
# /admin/news to keep the auth model unambiguous.

def _actor_from_request():
try:
return {
"propel_user_id": auth_user.user_id if auth_user else None,
"email": getattr(auth_user, "email", None) if auth_user else None,
}
except Exception:
return None


@bp.route("/admin/news", methods=["GET"])
@auth.require_user
@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId)
def admin_get_all_news():
logger.info("GET /admin/news called")
limit_arg = request.args.get("limit")
status_filter = request.args.get("status")
limit = 500
if limit_arg:
try:
limit = max(1, min(2000, int(limit_arg)))
except ValueError:
pass
return vars(admin_list_news(limit=limit, status_filter=status_filter))


@bp.route("/admin/news", methods=["POST"])
@auth.require_user
@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId)
def admin_post_news():
logger.info("POST /admin/news called")
msg, status_code = admin_create_news(request.get_json(), _actor_from_request())
return vars(msg), status_code


@bp.route("/admin/news/<news_id>", methods=["PATCH"])
@auth.require_user
@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId)
def admin_patch_news(news_id):
logger.info(f"PATCH /admin/news/{news_id} called")
msg, status_code = admin_update_news(news_id, request.get_json(), _actor_from_request())
return vars(msg), status_code


@bp.route("/admin/news/<news_id>", methods=["DELETE"])
@auth.require_user
@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId)
def admin_delete_news_route(news_id):
logger.info(f"DELETE /admin/news/{news_id} called")
msg, status_code = admin_delete_news(news_id)
return vars(msg), status_code
# -------------------- Admin Blog routes end here --------------------------- #


@bp.route("/lead", methods=["POST"])
async def store_lead():
logger.info("POST /lead called")
Expand Down
50 changes: 50 additions & 0 deletions common/utils/firebase.py
Original file line number Diff line number Diff line change
Expand Up @@ -1186,6 +1186,56 @@ def upsert_news(news):
_, doc_ref = db.collection("news").add(news)
return doc_ref.id


def update_news_partial(news_id, patch):
"""Partial update of a news doc. Returns True if the doc existed and was updated."""
db = get_db()
doc_ref = db.collection("news").document(news_id)
snap = doc_ref.get()
if not snap.exists:
logger.warning(f"update_news_partial: news_id={news_id} not found")
return False
if "title" in patch and isinstance(patch["title"], str):
patch["title"] = patch["title"].replace('"', "").replace("'", "").replace("\\", "")
patch["last_updated"] = datetime.datetime.now().isoformat()
doc_ref.set(patch, merge=True)
logger.info(f"update_news_partial: updated news_id={news_id} with keys={list(patch.keys())}")
return True


def delete_news(news_id):
"""Hard delete of a news doc. Returns True if the doc existed and was removed."""
db = get_db()
doc_ref = db.collection("news").document(news_id)
snap = doc_ref.get()
if not snap.exists:
logger.warning(f"delete_news: news_id={news_id} not found")
return False
doc_ref.delete()
logger.info(f"delete_news: removed news_id={news_id}")
return True


def get_all_news_admin(limit=500, status_filter=None):
"""Admin list of news including drafts/archived. Ordered by slack_ts desc.

status_filter: None | "draft" | "published" | "archived" | "all" (treated like None).
"""
db = get_db()
query = db.collection("news").order_by("slack_ts", direction=firestore.Query.DESCENDING).limit(limit)
docs = query.stream()
results = []
for doc in docs:
d = doc.to_dict()
d["id"] = doc.id
if status_filter and status_filter != "all":
doc_status = d.get("status", "published")
if doc_status != status_filter:
continue
results.append(d)
return results


def upsert_praise(praise):
db = get_db() # this connects to our Firestore database
logger.info(f"Adding praise {praise}")
Expand Down
145 changes: 139 additions & 6 deletions services/news_service.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import os
import time
from datetime import datetime

import pytz
from cachetools import cached, TTLCache
from ratelimit import limits

from common.log import get_logger
from common.utils.firebase import upsert_news, upsert_praise, get_user_by_user_id, get_recent_praises, get_praises_by_user_id
from common.utils.firebase import (
upsert_news,
upsert_praise,
get_user_by_user_id,
get_recent_praises,
get_praises_by_user_id,
update_news_partial,
delete_news as delete_news_doc,
get_all_news_admin,
)
from common.utils.openai_api import generate_and_save_image_to_cdn
from common.utils.slack import get_user_info
from common.utils.firestore_helpers import doc_to_json
Expand Down Expand Up @@ -43,6 +53,114 @@ def save_news(json):
return msg


_ADMIN_ALLOWED_KEYS = {
"title",
"description",
"content_markdown",
"content_format",
"image",
"featured_image",
"author",
"tags",
"slug",
"status",
"published_at",
"seo",
"links",
"slack_permalink",
"slack_channel",
"last_updated_by",
}


def _filter_admin_payload(json_in):
return {k: v for k, v in (json_in or {}).items() if k in _ADMIN_ALLOWED_KEYS}


def admin_create_news(json_in, actor):
"""Create a news doc via the admin UI.

Differences vs. save_news (the Slack-integration path):
- Does NOT auto-generate an OpenAI image when featured_image is supplied
- Defaults status="draft" so nothing surprises the public
- Stamps slack_ts to time.time() if missing so existing ordering works
- Records last_updated_by from actor
"""
payload = _filter_admin_payload(json_in)

if not payload.get("title"):
return Message("Missing title"), 400

payload.setdefault("description", "")
payload.setdefault("content_format", "markdown")
payload.setdefault("status", "draft")
payload.setdefault("tags", [])
payload.setdefault("links", [])
payload.setdefault("slack_permalink", "")
payload.setdefault("slack_channel", "admin-blog")
payload["slack_ts"] = str(time.time())

featured = payload.get("featured_image") or payload.get("image")
if featured:
payload["image"] = featured
payload["featured_image"] = featured
else:
cdn_dir = "ohack.dev/news"
try:
news_image = generate_and_save_image_to_cdn(cdn_dir, payload["title"])
payload["image"] = f"{CDN_SERVER}/{cdn_dir}/{news_image}"
except Exception as e:
logger.warning(f"admin_create_news: image generation failed, continuing without ({e})")

payload["last_updated"] = datetime.now().isoformat()
if actor:
payload["last_updated_by"] = actor
payload.setdefault("created_by", actor)

news_id = upsert_news(payload)
get_news.cache_clear()

msg = Message("Created news")
msg.id = news_id
return msg, 201


def admin_update_news(news_id, json_in, actor):
"""Partial update of a news doc."""
patch = _filter_admin_payload(json_in)
if not patch:
return Message("No allowed fields in payload"), 400

if "featured_image" in patch and patch["featured_image"]:
patch["image"] = patch["featured_image"]

if actor:
patch["last_updated_by"] = actor

ok = update_news_partial(news_id, patch)
if not ok:
return Message("Not found"), 404
get_news.cache_clear()
msg = Message("Updated news")
msg.id = news_id
return msg, 200


def admin_delete_news(news_id):
"""Hard delete a news doc."""
ok = delete_news_doc(news_id)
if not ok:
return Message("Not found"), 404
get_news.cache_clear()
return Message("Deleted news"), 200


def admin_list_news(limit=500, status_filter=None):
"""Admin list — includes drafts/archived posts."""
results = get_all_news_admin(limit=limit, status_filter=status_filter)
return Message(results)


def save_praise(json):
check_fields = ["praise_receiver", "praise_channel", "praise_message"]
for field in check_fields:
Expand Down Expand Up @@ -129,6 +247,12 @@ def get_praises_about_user(user_id):
return Message(results)


def _is_publicly_visible(doc_dict):
"""A post is public unless explicitly marked as draft or archived."""
status = (doc_dict or {}).get("status", "published")
return status not in ("draft", "archived")


@cached(cache=TTLCache(maxsize=100, ttl=32600), key=lambda news_limit, news_id: f"{news_limit}-{news_id}")
def get_news(news_limit=3, news_id=None):
logger.debug("Get News")
Expand All @@ -137,17 +261,26 @@ def get_news(news_limit=3, news_id=None):
logger.info(f"Getting single news item for news_id={news_id}")
collection = db.collection('news')
doc = collection.document(news_id).get()
if doc is None:
if doc is None or not doc.exists:
return Message({})
else:
return Message(doc.to_dict())
doc_dict = doc.to_dict()
if not _is_publicly_visible(doc_dict):
# Public route: don't leak drafts / archived posts
return Message({})
return Message(doc_dict)
else:
# Over-fetch a bit so status filtering doesn't return fewer than asked
fetch_limit = max(news_limit * 3, news_limit + 10)
collection = db.collection('news')
docs = collection.order_by("slack_ts", direction=firestore.Query.DESCENDING).limit(news_limit).stream()
docs = collection.order_by("slack_ts", direction=firestore.Query.DESCENDING).limit(fetch_limit).stream()
results = []
for doc in docs:
doc_json = doc.to_dict()
if not _is_publicly_visible(doc_json):
continue
doc_json["id"] = doc.id
results.append(doc_json)
logger.debug(f"Get News Result: {results}")
if len(results) >= news_limit:
break
logger.debug(f"Get News Result: {len(results)} items")
return Message(results)
Loading