|
# services/forum.py
|
|
from snek.system.service import BaseService
|
|
import re
|
|
import uuid
|
|
from collections import defaultdict
|
|
from typing import Any, Awaitable, Callable, Dict, List
|
|
from snek.system.model import now
|
|
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
|
|
class BaseForumService(BaseService):
|
|
"""
|
|
Base mix-in that gives a service `add_notification_listener`,
|
|
an internal `_dispatch_event` helper, and a public `notify` method.
|
|
"""
|
|
def get_timestamp(self):
|
|
return now()
|
|
def generate_uid(self):
|
|
return str(uuid.uuid4())
|
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
# Map event name -> list of listener callables
|
|
self._listeners: Dict[str, List[EventListener]] = defaultdict(list)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def add_notification_listener(
|
|
self, event_name: str, listener: EventListener
|
|
) -> None:
|
|
"""
|
|
Register a callback to be fired when `event_name` happens.
|
|
|
|
Parameters
|
|
----------
|
|
event_name : str
|
|
The name of the domain event, e.g. "post_created".
|
|
listener : Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
|
|
Your handler; can be async or sync.
|
|
"""
|
|
if not callable(listener):
|
|
raise TypeError("listener must be callable")
|
|
|
|
self._listeners[event_name].append(listener)
|
|
|
|
async def _dispatch_event(self, event_name: str, data: Any) -> None:
|
|
"""Invoke every listener for the given event."""
|
|
for listener in self._listeners.get(event_name, []):
|
|
if hasattr(listener, "__await__"): # async function or coro
|
|
await listener(event_name, data)
|
|
else: # plain sync function
|
|
listener(event_name, data)
|
|
|
|
async def notify(self, event_name: str, data: Any) -> None:
|
|
"""
|
|
Public method to trigger notification to all listeners of an event.
|
|
|
|
Parameters
|
|
----------
|
|
event_name : str
|
|
The name of the event to notify listeners about.
|
|
data : Any
|
|
The data to pass to the listeners.
|
|
"""
|
|
await self._dispatch_event(event_name, data)
|
|
|
|
|
|
class ForumService(BaseForumService):
|
|
mapper_name = "forum"
|
|
|
|
async def create_forum(self, name, description, created_by_uid, slug=None, icon=None):
|
|
if not slug:
|
|
slug = self.generate_slug(name)
|
|
|
|
# Check if slug exists
|
|
existing = await self.get(slug=slug)
|
|
if existing:
|
|
slug = f"{slug}-{self.generate_uid()[:8]}"
|
|
|
|
model = await self.new()
|
|
model["name"] = name
|
|
model["description"] = description
|
|
model["slug"] = slug
|
|
model["created_by_uid"] = created_by_uid
|
|
if icon:
|
|
model["icon"] = icon
|
|
|
|
if await self.save(model):
|
|
await self.notify("forum_created", model)
|
|
return model
|
|
raise Exception(f"Failed to create forum: {model.errors}")
|
|
|
|
def generate_slug(self, text):
|
|
# Convert to lowercase and replace spaces with hyphens
|
|
slug = text.lower().strip()
|
|
slug = re.sub(r'[^\w\s-]', '', slug)
|
|
slug = re.sub(r'[-\s]+', '-', slug)
|
|
return slug
|
|
|
|
async def get_active_forums(self):
|
|
async for forum in self.find(is_active=True, order_by="position"):
|
|
yield forum
|
|
|
|
async def update_last_post(self, forum_uid, thread_uid):
|
|
forum = await self.get(uid=forum_uid)
|
|
if forum:
|
|
forum["last_post_at"] = self.get_timestamp()
|
|
forum["last_thread_uid"] = thread_uid
|
|
await self.save(forum)
|
|
|
|
|
|
# services/thread.py
|
|
class ThreadService(BaseForumService):
|
|
mapper_name = "thread"
|
|
|
|
|
|
|
|
async def create_thread(self, forum_uid, title, content, created_by_uid):
|
|
# Generate slug
|
|
slug = self.services.forum.generate_slug(title)
|
|
|
|
# Check if slug exists in this forum
|
|
existing = await self.get(forum_uid=forum_uid, slug=slug)
|
|
if existing:
|
|
slug = f"{slug}-{self.generate_uid()[:8]}"
|
|
|
|
# Create thread
|
|
thread = await self.new()
|
|
thread["forum_uid"] = forum_uid
|
|
thread["title"] = title
|
|
thread["slug"] = slug
|
|
thread["created_by_uid"] = created_by_uid
|
|
thread["last_post_at"] = self.get_timestamp()
|
|
thread["last_post_by_uid"] = created_by_uid
|
|
|
|
if await self.save(thread):
|
|
# Create first post
|
|
post = await self.services.post.create_post(
|
|
thread_uid=thread["uid"],
|
|
forum_uid=forum_uid,
|
|
content=content,
|
|
created_by_uid=created_by_uid,
|
|
is_first_post=True
|
|
)
|
|
|
|
# Update forum counters
|
|
forum = await self.services.forum.get(uid=forum_uid)
|
|
await forum.increment_thread_count()
|
|
await self.services.forum.update_last_post(forum_uid, thread["uid"])
|
|
|
|
await self.notify("thread_created", {
|
|
"thread": thread,
|
|
"forum_uid": forum_uid
|
|
})
|
|
|
|
return thread, post
|
|
raise Exception(f"Failed to create thread: {thread.errors}")
|
|
|
|
async def toggle_pin(self, thread_uid, user_uid):
|
|
thread = await self.get(uid=thread_uid)
|
|
if not thread:
|
|
return None
|
|
|
|
# Check if user is admin
|
|
user = await self.services.user.get(uid=user_uid)
|
|
if not user.get("is_admin"):
|
|
return None
|
|
|
|
thread["is_pinned"] = not thread["is_pinned"]
|
|
await self.save(thread)
|
|
return thread
|
|
|
|
async def toggle_lock(self, thread_uid, user_uid):
|
|
thread = await self.get(uid=thread_uid)
|
|
if not thread:
|
|
return None
|
|
|
|
# Check if user is admin or thread creator
|
|
user = await self.services.user.get(uid=user_uid)
|
|
if not user.get("is_admin") and thread["created_by_uid"] != user_uid:
|
|
return None
|
|
|
|
thread["is_locked"] = not thread["is_locked"]
|
|
await self.save(thread)
|
|
return thread
|
|
|
|
|
|
# services/post.py
|
|
class PostService(BaseForumService):
|
|
mapper_name = "post"
|
|
|
|
async def create_post(self, thread_uid, forum_uid, content, created_by_uid, is_first_post=False):
|
|
# Check if thread is locked
|
|
thread = await self.services.thread.get(uid=thread_uid)
|
|
if thread["is_locked"] and not is_first_post:
|
|
raise Exception("Thread is locked")
|
|
|
|
post = await self.new()
|
|
post["thread_uid"] = thread_uid
|
|
post["forum_uid"] = forum_uid
|
|
post["content"] = content
|
|
post["created_by_uid"] = created_by_uid
|
|
post["is_first_post"] = is_first_post
|
|
|
|
if await self.save(post):
|
|
# Update thread counters
|
|
if not is_first_post:
|
|
thread["post_count"] += 1
|
|
thread["last_post_at"] = self.get_timestamp()
|
|
thread["last_post_by_uid"] = created_by_uid
|
|
await self.services.thread.save(thread)
|
|
|
|
# Update forum counters
|
|
forum = await self.services.forum.get(uid=forum_uid)
|
|
await forum.increment_post_count()
|
|
await self.services.forum.update_last_post(forum_uid, thread_uid)
|
|
|
|
await self.notify("post_created", {
|
|
"post": post,
|
|
"thread_uid": thread_uid,
|
|
"forum_uid": forum_uid
|
|
})
|
|
|
|
return post
|
|
raise Exception(f"Failed to create post: {post.errors}")
|
|
|
|
async def edit_post(self, post_uid, content, user_uid):
|
|
post = await self.get(uid=post_uid)
|
|
if not post:
|
|
return None
|
|
|
|
# Check permissions
|
|
user = await self.services.user.get(uid=user_uid)
|
|
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
|
|
return None
|
|
|
|
post["content"] = content
|
|
post["edited_at"] = self.get_timestamp()
|
|
post["edited_by_uid"] = user_uid
|
|
|
|
if await self.save(post):
|
|
await self.notify("post_edited", post)
|
|
return post
|
|
return None
|
|
|
|
async def delete_post(self, post_uid, user_uid):
|
|
post = await self.get(uid=post_uid)
|
|
if not post:
|
|
return False
|
|
|
|
# Check permissions
|
|
user = await self.services.user.get(uid=user_uid)
|
|
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
|
|
return False
|
|
|
|
# Don't allow deleting first post
|
|
if post["is_first_post"]:
|
|
return False
|
|
|
|
post["deleted_at"] = self.get_timestamp()
|
|
if await self.save(post):
|
|
await self.notify("post_deleted", post)
|
|
return True
|
|
return False
|
|
|
|
|
|
# services/post_like.py
|
|
class PostLikeService(BaseForumService):
|
|
mapper_name = "post_like"
|
|
|
|
async def toggle_like(self, post_uid, user_uid):
|
|
# Check if already liked
|
|
existing = await self.get(post_uid=post_uid, user_uid=user_uid)
|
|
|
|
if existing:
|
|
# Unlike
|
|
await self.delete(uid=existing["uid"])
|
|
|
|
# Update post like count
|
|
post = await self.services.post.get(uid=post_uid)
|
|
post["like_count"] = max(0, post["like_count"] - 1)
|
|
await self.services.post.save(post)
|
|
|
|
await self.notify("post_unliked", {
|
|
"post_uid": post_uid,
|
|
"user_uid": user_uid
|
|
})
|
|
|
|
return False
|
|
else:
|
|
# Like
|
|
like = await self.new()
|
|
like["post_uid"] = post_uid
|
|
like["user_uid"] = user_uid
|
|
|
|
if await self.save(like):
|
|
# Update post like count
|
|
post = await self.services.post.get(uid=post_uid)
|
|
post["like_count"] += 1
|
|
await self.services.post.save(post)
|
|
|
|
await self.notify("post_liked", {
|
|
"post_uid": post_uid,
|
|
"user_uid": user_uid
|
|
})
|
|
|
|
return True
|
|
return None
|