diff --git a/src/snek/app.py b/src/snek/app.py index 4bae513..a46137b 100644 --- a/src/snek/app.py +++ b/src/snek/app.py @@ -76,6 +76,7 @@ from snek.view.upload import UploadView from snek.view.user import UserView from snek.view.web import WebView from snek.webdav import WebdavApplication +from snek.forum import setup_forum SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34" from snek.system.template import whitelist_attributes @@ -152,7 +153,7 @@ class Application(BaseApplication): self.ssh_host = "0.0.0.0" self.ssh_port = 2242 - self.setup_router() + self.forum = None self.ssh_server = None self.sync_service = None self.executor = None @@ -161,6 +162,8 @@ class Application(BaseApplication): self.mappers = get_mappers(app=self) self.broadcast_service = None self.user_availability_service_task = None + + self.setup_router() base_path = pathlib.Path(__file__).parent self.ip2location = IP2Location.IP2Location( base_path.joinpath("IP2LOCATION-LITE-DB11.BIN") @@ -333,9 +336,10 @@ class Application(BaseApplication): ) self.webdav = WebdavApplication(self) self.git = GitApplication(self) + self.add_subapp("/webdav", self.webdav) self.add_subapp("/git", self.git) - + setup_forum(self) # self.router.add_get("/{file_path:.*}", self.static_handler) async def handle_test(self, request): diff --git a/src/snek/forum.py b/src/snek/forum.py new file mode 100644 index 0000000..a0b0d2d --- /dev/null +++ b/src/snek/forum.py @@ -0,0 +1,115 @@ +# forum_app.py +import aiohttp.web +from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView + + +class ForumApplication(aiohttp.web.Application): + def __init__(self, parent, *args, **kwargs): + super().__init__(*args, **kwargs) + self.parent = parent + self.render_template = self.parent.render_template + # Set up routes + self.setup_routes() + + # Set up notification listeners + self.setup_notifications() + + @property + def db(self): + return self.parent.db + + @property + def services(self): + return self.parent.services + + def setup_routes(self): + """Set up all forum routes""" + # API routes + self.router.add_view("/index.html", ForumIndexView) + self.router.add_route("GET", "/api/forums", ForumView.get_forums) + self.router.add_route("GET", "/api/forums/{slug}", ForumView.get_forum) + self.router.add_route("POST", "/api/forums/{slug}/threads", ForumView.create_thread) + self.router.add_route("GET", "/api/threads/{thread_slug}", ForumView.get_thread) + self.router.add_route("POST", "/api/threads/{thread_uid}/posts", ForumView.create_post) + self.router.add_route("PUT", "/api/posts/{post_uid}", ForumView.edit_post) + self.router.add_route("DELETE", "/api/posts/{post_uid}", ForumView.delete_post) + self.router.add_route("POST", "/api/posts/{post_uid}/like", ForumView.toggle_like) + self.router.add_route("POST", "/api/threads/{thread_uid}/pin", ForumView.toggle_pin) + self.router.add_route("POST", "/api/threads/{thread_uid}/lock", ForumView.toggle_lock) + + # WebSocket route + self.router.add_view("/ws", ForumWebSocketView) + + # Static HTML route + self.router.add_route("GET", "/{path:.*}", self.serve_forum_html) + + def setup_notifications(self): + """Set up notification listeners for WebSocket broadcasting""" + # Forum notifications + self.services.forum.add_notification_listener("forum_created", self.on_forum_event) + + # Thread notifications + self.services.thread.add_notification_listener("thread_created", self.on_thread_event) + + # Post notifications + self.services.post.add_notification_listener("post_created", self.on_post_event) + self.services.post.add_notification_listener("post_edited", self.on_post_event) + self.services.post.add_notification_listener("post_deleted", self.on_post_event) + + # Like notifications + self.services.post_like.add_notification_listener("post_liked", self.on_like_event) + self.services.post_like.add_notification_listener("post_unliked", self.on_like_event) + + async def on_forum_event(self, event_type, data): + """Handle forum events""" + await ForumWebSocketView.broadcast_update(self, event_type, data) + + async def on_thread_event(self, event_type, data): + """Handle thread events""" + await ForumWebSocketView.broadcast_update(self, event_type, data) + + async def on_post_event(self, event_type, data): + """Handle post events""" + await ForumWebSocketView.broadcast_update(self, event_type, data) + + async def on_like_event(self, event_type, data): + """Handle like events""" + await ForumWebSocketView.broadcast_update(self, event_type, data) + + async def serve_forum_html(self, request): + """Serve the forum HTML with the web component""" + html = """ + + + + + Forum + + + + + + +""" + return await self.parent.render_template("forum.html", request) + + + #return aiohttp.web.Response(text=html, content_type="text/html") + + +# Integration with main app +def setup_forum(app): + """Set up forum sub-application""" + forum_app = ForumApplication(app) + app.add_subapp("/forum", forum_app) + app.forum_app = forum_app + # Register models and services if needed + # This would typically be done in your main app initialization + + return forum_app diff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py index 062553a..be01022 100644 --- a/src/snek/mapper/__init__.py +++ b/src/snek/mapper/__init__.py @@ -12,6 +12,7 @@ from snek.mapper.push import PushMapper from snek.mapper.repository import RepositoryMapper from snek.mapper.user import UserMapper from snek.mapper.user_property import UserPropertyMapper +from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper from snek.system.object import Object @@ -31,9 +32,14 @@ def get_mappers(app=None): "channel_attachment": ChannelAttachmentMapper(app=app), "container": ContainerMapper(app=app), "push": PushMapper(app=app), + "forum": ForumMapper(app=app), + "thread": ThreadMapper(app=app), + "post": PostMapper(app=app), + "post_like": PostLikeMapper(app=app), } ) def get_mapper(name, app=None): return get_mappers(app=app)[name] + diff --git a/src/snek/mapper/forum.py b/src/snek/mapper/forum.py new file mode 100644 index 0000000..ec258a2 --- /dev/null +++ b/src/snek/mapper/forum.py @@ -0,0 +1,23 @@ +# mapper/forum.py +from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel +from snek.system.mapper import BaseMapper + + +class ForumMapper(BaseMapper): + table_name = "forum" + model_class = ForumModel + + +class ThreadMapper(BaseMapper): + table_name = "thread" + model_class = ThreadModel + + +class PostMapper(BaseMapper): + table_name = "post" + model_class = PostModel + + +class PostLikeMapper(BaseMapper): + table_name = "post_like" + model_class = PostLikeModel diff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py index 187ea4b..13ba5b2 100644 --- a/src/snek/model/__init__.py +++ b/src/snek/model/__init__.py @@ -14,6 +14,7 @@ from snek.model.push_registration import PushRegistrationModel from snek.model.repository import RepositoryModel from snek.model.user import UserModel from snek.model.user_property import UserPropertyModel +from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel from snek.system.object import Object @@ -33,9 +34,15 @@ def get_models(): "channel_attachment": ChannelAttachmentModel, "container": Container, "push_registration": PushRegistrationModel, + "forum": ForumModel, + "thread": ThreadModel, + "post": PostModel, + "post_like": PostLikeModel, } ) def get_model(name): return get_models()[name] + + diff --git a/src/snek/model/forum.py b/src/snek/model/forum.py new file mode 100644 index 0000000..59ef23a --- /dev/null +++ b/src/snek/model/forum.py @@ -0,0 +1,96 @@ +# models/forum.py +from snek.system.model import BaseModel, ModelField + + +class ForumModel(BaseModel): + """Forum categories""" + name = ModelField(name="name", required=True, kind=str, min_length=3, max_length=100) + description = ModelField(name="description", required=False, kind=str, max_length=500) + slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$", unique=True) + icon = ModelField(name="icon", required=False, kind=str) + position = ModelField(name="position", required=True, kind=int, value=0) + is_active = ModelField(name="is_active", required=True, kind=bool, value=True) + created_by_uid = ModelField(name="created_by_uid", required=True, kind=str) + thread_count = ModelField(name="thread_count", required=True, kind=int, value=0) + post_count = ModelField(name="post_count", required=True, kind=int, value=0) + last_post_at = ModelField(name="last_post_at", required=False, kind=str) + last_thread_uid = ModelField(name="last_thread_uid", required=False, kind=str) + + async def get_threads(self, limit=50, offset=0): + return await self.app.services.thread.find( + forum_uid=self["uid"], + deleted_at=None, + _limit=limit, + _offset=offset, + _order_by="is_pinned DESC, last_post_at DESC" + ) + + async def increment_thread_count(self): + self["thread_count"] += 1 + await self.save() + + async def increment_post_count(self): + self["post_count"] += 1 + await self.save() + + +# models/thread.py +class ThreadModel(BaseModel): + """Forum threads""" + forum_uid = ModelField(name="forum_uid", required=True, kind=str) + title = ModelField(name="title", required=True, kind=str, min_length=5, max_length=200) + slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$") + created_by_uid = ModelField(name="created_by_uid", required=True, kind=str) + is_pinned = ModelField(name="is_pinned", required=True, kind=bool, value=False) + is_locked = ModelField(name="is_locked", required=True, kind=bool, value=False) + view_count = ModelField(name="view_count", required=True, kind=int, value=0) + post_count = ModelField(name="post_count", required=True, kind=int, value=0) + last_post_at = ModelField(name="last_post_at", required=False, kind=str) + last_post_by_uid = ModelField(name="last_post_by_uid", required=False, kind=str) + + async def get_posts(self, limit=50, offset=0): + return await self.app.services.post.find( + thread_uid=self["uid"], + deleted_at=None, + _limit=limit, + _offset=offset, + _order_by="created_at ASC" + ) + + async def increment_view_count(self): + self["view_count"] += 1 + await self.save() + + async def increment_post_count(self): + self["post_count"] += 1 + self["last_post_at"] = self.app.services.get_timestamp() + await self.save() + + +# models/post.py +class PostModel(BaseModel): + """Forum posts""" + thread_uid = ModelField(name="thread_uid", required=True, kind=str) + forum_uid = ModelField(name="forum_uid", required=True, kind=str) + content = ModelField(name="content", required=True, kind=str, min_length=1, max_length=10000) + created_by_uid = ModelField(name="created_by_uid", required=True, kind=str) + edited_at = ModelField(name="edited_at", required=False, kind=str) + edited_by_uid = ModelField(name="edited_by_uid", required=False, kind=str) + is_first_post = ModelField(name="is_first_post", required=True, kind=bool, value=False) + like_count = ModelField(name="like_count", required=True, kind=int, value=0) + + async def get_author(self): + return await self.app.services.user.get(uid=self["created_by_uid"]) + + async def is_liked_by(self, user_uid): + return await self.app.services.post_like.exists( + post_uid=self["uid"], + user_uid=user_uid + ) + + +# models/post_like.py +class PostLikeModel(BaseModel): + """Post likes""" + post_uid = ModelField(name="post_uid", required=True, kind=str) + user_uid = ModelField(name="user_uid", required=True, kind=str) diff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py index 2f6e85b..6e9e58f 100644 --- a/src/snek/service/__init__.py +++ b/src/snek/service/__init__.py @@ -17,32 +17,49 @@ from snek.service.user import UserService from snek.service.user_property import UserPropertyService from snek.service.util import UtilService from snek.system.object import Object -from snek.service.statistics import StatisticsService +from snek.service.statistics import StatisticsService +from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService +_service_registry = {} + +def register_service(name, service_cls): + _service_registry[name] = service_cls + +register = register_service @functools.cache def get_services(app): - return Object( + result = Object( **{ - "user": UserService(app=app), - "channel_member": ChannelMemberService(app=app), - "channel": ChannelService(app=app), - "channel_message": ChannelMessageService(app=app), - "chat": ChatService(app=app), - "socket": SocketService(app=app), - "notification": NotificationService(app=app), - "util": UtilService(app=app), - "drive": DriveService(app=app), - "drive_item": DriveItemService(app=app), - "user_property": UserPropertyService(app=app), - "repository": RepositoryService(app=app), - "db": DBService(app=app), - "channel_attachment": ChannelAttachmentService(app=app), - "container": ContainerService(app=app), - "push": PushService(app=app), - "statistics": StatisticsService(app=app), + name: service_cls(app=app) + for name, service_cls in _service_registry.items() } ) - + result.register = register_service + return result def get_service(name, app=None): return get_services(app=app)[name] + +# Registering all services +register_service("user", UserService) +register_service("channel_member", ChannelMemberService) +register_service("channel", ChannelService) +register_service("channel_message", ChannelMessageService) +register_service("chat", ChatService) +register_service("socket", SocketService) +register_service("notification", NotificationService) +register_service("util", UtilService) +register_service("drive", DriveService) +register_service("drive_item", DriveItemService) +register_service("user_property", UserPropertyService) +register_service("repository", RepositoryService) +register_service("db", DBService) +register_service("channel_attachment", ChannelAttachmentService) +register_service("container", ContainerService) +register_service("push", PushService) +register_service("statistics", StatisticsService) +register_service("forum", ForumService) +register_service("thread", ThreadService) +register_service("post", PostService) +register_service("post_like", PostLikeService) + diff --git a/src/snek/service/forum.py b/src/snek/service/forum.py new file mode 100644 index 0000000..6e241c2 --- /dev/null +++ b/src/snek/service/forum.py @@ -0,0 +1,293 @@ +# services/forum.py +from snek.system.service import BaseService +import re + +from collections import defaultdict +from typing import Any, Awaitable, Callable, Dict, List + +EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None] + + +class BaseForumService(BaseService): + """ + Base mix-in that gives a service `add_notification_listener` + and an internal `_dispatch_event` helper. + """ + + 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) + + # ----------------------------------------------------------------- + # The piece below is optional but makes the service fully usable. + # Call `_dispatch_event` whenever the service actually performs an + # action that should notify listeners. + # ----------------------------------------------------------------- + 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) + + + + +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.find_one(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 ASC, created_at ASC"): + 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.find_one(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.find_one(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 diff --git a/src/snek/templates/forum.html b/src/snek/templates/forum.html new file mode 100644 index 0000000..4930417 --- /dev/null +++ b/src/snek/templates/forum.html @@ -0,0 +1,829 @@ + + + + + + Forum Component + + + + + + + diff --git a/src/snek/view/forum.py b/src/snek/view/forum.py new file mode 100644 index 0000000..fd0c462 --- /dev/null +++ b/src/snek/view/forum.py @@ -0,0 +1,533 @@ +# views/forum.py +from snek.system.view import BaseView +from aiohttp import web +import json + + + +from aiohttp import web + +from snek.system.view import BaseView + + +class ForumIndexView(BaseView): + + login_required = True + + async def get(self): + if self.login_required and not self.session.get("logged_in"): + return web.HTTPFound("/") + channel = await self.services.channel.get( + uid=self.request.match_info.get("channel") + ) + if not channel: + user = await self.services.user.get( + uid=self.request.match_info.get("channel") + ) + if user: + channel = await self.services.channel.get_dm( + self.session.get("uid"), user["uid"] + ) + if channel: + return web.HTTPFound("/channel/{}.html".format(channel["uid"])) + if not channel: + return web.HTTPNotFound() + + channel_member = await self.app.services.channel_member.get( + user_uid=self.session.get("uid"), channel_uid=channel["uid"] + ) + if not channel_member: + if not channel["is_private"]: + channel_member = await self.app.services.channel_member.create( + channel_uid=channel["uid"], + user_uid=self.session.get("uid"), + is_moderator=False, + is_read_only=False, + is_muted=False, + is_banned=False, + ) + + return web.HTTPNotFound() + + channel_member["new_count"] = 0 + await self.app.services.channel_member.save(channel_member) + + user = await self.services.user.get(uid=self.session.get("uid")) + + messages = [ + await self.app.services.channel_message.to_extended_dict(message) + for message in await self.app.services.channel_message.offset( + channel["uid"] + ) + ] + for message in messages: + await self.app.services.notification.mark_as_read( + self.session.get("uid"), message["uid"] + ) + name = await channel_member.get_name() + return await self.render_template( + "forum.html", + {"name": name, "channel": channel, "user": user, "messages": messages}, + ) + + + + + +class ForumView(BaseView): + """REST API endpoints for forum""" + + login_required = True + + async def get_forums(self): + request = self + self = request.app + """GET /forum/api/forums - Get all active forums""" + forums = [] + async for forum in self.services.forum.get_active_forums(): + forums.append({ + "uid": forum["uid"], + "name": forum["name"], + "description": forum["description"], + "slug": forum["slug"], + "icon": forum["icon"], + "thread_count": forum["thread_count"], + "post_count": forum["post_count"], + "last_post_at": forum["last_post_at"], + "last_thread_uid": forum["last_thread_uid"] + }) + return web.json_response({"forums": forums}) + + async def get_forum(self): + request = self + self = request.app + """GET /forum/api/forums/:slug - Get forum by slug""" + slug = self.request.match_info["slug"] + forum = await self.services.forum.find_one(slug=slug, is_active=True) + + if not forum: + return web.json_response({"error": "Forum not found"}, status=404) + + # Get threads + threads = [] + page = int(self.request.query.get("page", 1)) + limit = 50 + offset = (page - 1) * limit + + async for thread in forum.get_threads(limit=limit, offset=offset): + # Get author info + author = await self.services.user.get(uid=thread["created_by_uid"]) + last_post_author = None + if thread["last_post_by_uid"]: + last_post_author = await self.services.user.get(uid=thread["last_post_by_uid"]) + + threads.append({ + "uid": thread["uid"], + "title": thread["title"], + "slug": thread["slug"], + "is_pinned": thread["is_pinned"], + "is_locked": thread["is_locked"], + "view_count": thread["view_count"], + "post_count": thread["post_count"], + "created_at": thread["created_at"], + "last_post_at": thread["last_post_at"], + "author": { + "uid": author["uid"], + "username": author["username"], + "nick": author["nick"], + "color": author["color"] + }, + "last_post_author": { + "uid": last_post_author["uid"], + "username": last_post_author["username"], + "nick": last_post_author["nick"], + "color": last_post_author["color"] + } if last_post_author else None + }) + + return web.json_response({ + "forum": { + "uid": forum["uid"], + "name": forum["name"], + "description": forum["description"], + "slug": forum["slug"], + "icon": forum["icon"], + "thread_count": forum["thread_count"], + "post_count": forum["post_count"] + }, + "threads": threads, + "page": page, + "hasMore": len(threads) == limit + }) + + async def create_thread(self): + request = self + self = request.app + """POST /forum/api/forums/:slug/threads - Create new thread""" + if not self.request.session.get("logged_in"): + return web.json_response({"error": "Unauthorized"}, status=401) + + slug = self.request.match_info["slug"] + forum = await self.services.forum.find_one(slug=slug, is_active=True) + + if not forum: + return web.json_response({"error": "Forum not found"}, status=404) + + data = await self.request.json() + title = data.get("title", "").strip() + content = data.get("content", "").strip() + + if not title or not content: + return web.json_response({"error": "Title and content required"}, status=400) + + try: + thread, post = await self.services.thread.create_thread( + forum_uid=forum["uid"], + title=title, + content=content, + created_by_uid=self.request.session["uid"] + ) + + return web.json_response({ + "thread": { + "uid": thread["uid"], + "slug": thread["slug"], + "forum_slug": forum["slug"] + } + }) + except Exception as e: + return web.json_response({"error": str(e)}, status=400) + + async def get_thread(self): + request = self + self = request.app + """GET /forum/api/threads/:thread_slug - Get thread with posts""" + thread_slug = self.request.match_info["thread_slug"] + thread = await self.services.thread.find_one(slug=thread_slug) + + if not thread: + return web.json_response({"error": "Thread not found"}, status=404) + + # Increment view count + await thread.increment_view_count() + + # Get forum + forum = await self.services.forum.get(uid=thread["forum_uid"]) + + # Get posts + posts = [] + page = int(self.request.query.get("page", 1)) + limit = 50 + offset = (page - 1) * limit + + current_user_uid = self.request.session.get("uid") + + async for post in thread.get_posts(limit=limit, offset=offset): + author = await post.get_author() + is_liked = False + if current_user_uid: + is_liked = await post.is_liked_by(current_user_uid) + + posts.append({ + "uid": post["uid"], + "content": post["content"], + "created_at": post["created_at"], + "edited_at": post["edited_at"], + "is_first_post": post["is_first_post"], + "like_count": post["like_count"], + "is_liked": is_liked, + "author": { + "uid": author["uid"], + "username": author["username"], + "nick": author["nick"], + "color": author["color"] + } + }) + + # Get thread author + thread_author = await self.services.user.get(uid=thread["created_by_uid"]) + + return web.json_response({ + "thread": { + "uid": thread["uid"], + "title": thread["title"], + "slug": thread["slug"], + "is_pinned": thread["is_pinned"], + "is_locked": thread["is_locked"], + "view_count": thread["view_count"], + "post_count": thread["post_count"], + "created_at": thread["created_at"], + "author": { + "uid": thread_author["uid"], + "username": thread_author["username"], + "nick": thread_author["nick"], + "color": thread_author["color"] + } + }, + "forum": { + "uid": forum["uid"], + "name": forum["name"], + "slug": forum["slug"] + }, + "posts": posts, + "page": page, + "hasMore": len(posts) == limit + }) + + async def create_post(self): + request = self + self = request.app + """POST /forum/api/threads/:thread_uid/posts - Create new post""" + if not self.request.session.get("logged_in"): + return web.json_response({"error": "Unauthorized"}, status=401) + + thread_uid = self.request.match_info["thread_uid"] + thread = await self.services.thread.get(uid=thread_uid) + + if not thread: + return web.json_response({"error": "Thread not found"}, status=404) + + data = await self.request.json() + content = data.get("content", "").strip() + + if not content: + return web.json_response({"error": "Content required"}, status=400) + + try: + post = await self.services.post.create_post( + thread_uid=thread["uid"], + forum_uid=thread["forum_uid"], + content=content, + created_by_uid=self.request.session["uid"] + ) + + author = await post.get_author() + + return web.json_response({ + "post": { + "uid": post["uid"], + "content": post["content"], + "created_at": post["created_at"], + "like_count": post["like_count"], + "is_liked": False, + "author": { + "uid": author["uid"], + "username": author["username"], + "nick": author["nick"], + "color": author["color"] + } + } + }) + except Exception as e: + return web.json_response({"error": str(e)}, status=400) + + async def edit_post(self): + request = self + self = request.app + """PUT /forum/api/posts/:post_uid - Edit post""" + if not self.request.session.get("logged_in"): + return web.json_response({"error": "Unauthorized"}, status=401) + + post_uid = self.request.match_info["post_uid"] + data = await self.request.json() + content = data.get("content", "").strip() + + if not content: + return web.json_response({"error": "Content required"}, status=400) + + post = await self.services.post.edit_post( + post_uid=post_uid, + content=content, + user_uid=self.request.session["uid"] + ) + + if not post: + return web.json_response({"error": "Cannot edit post"}, status=403) + + return web.json_response({ + "post": { + "uid": post["uid"], + "content": post["content"], + "edited_at": post["edited_at"] + } + }) + + async def delete_post(self): + request = self + self = request.app + """DELETE /forum/api/posts/:post_uid - Delete post""" + if not self.request.session.get("logged_in"): + return web.json_response({"error": "Unauthorized"}, status=401) + + post_uid = self.request.match_info["post_uid"] + + success = await self.services.post.delete_post( + post_uid=post_uid, + user_uid=self.request.session["uid"] + ) + + if not success: + return web.json_response({"error": "Cannot delete post"}, status=403) + + return web.json_response({"success": True}) + + async def toggle_like(self): + request = self + self = request.app + """POST /forum/api/posts/:post_uid/like - Toggle post like""" + if not self.request.session.get("logged_in"): + return web.json_response({"error": "Unauthorized"}, status=401) + + post_uid = self.request.match_info["post_uid"] + + is_liked = await self.services.post_like.toggle_like( + post_uid=post_uid, + user_uid=self.request.session["uid"] + ) + + if is_liked is None: + return web.json_response({"error": "Failed to toggle like"}, status=400) + + # Get updated post + post = await self.services.post.get(uid=post_uid) + + return web.json_response({ + "is_liked": is_liked, + "like_count": post["like_count"] + }) + + async def toggle_pin(self): + request = self + self = request.app + """POST /forum/api/threads/:thread_uid/pin - Toggle thread pin""" + if not self.request.session.get("logged_in"): + return web.json_response({"error": "Unauthorized"}, status=401) + + thread_uid = self.request.match_info["thread_uid"] + + thread = await self.services.thread.toggle_pin( + thread_uid=thread_uid, + user_uid=self.request.session["uid"] + ) + + if not thread: + return web.json_response({"error": "Cannot toggle pin"}, status=403) + + return web.json_response({"is_pinned": thread["is_pinned"]}) + + async def toggle_lock(self): + request = self + self = request.app + """POST /forum/api/threads/:thread_uid/lock - Toggle thread lock""" + if not self.request.session.get("logged_in"): + return web.json_response({"error": "Unauthorized"}, status=401) + + thread_uid = self.request.match_info["thread_uid"] + + thread = await self.services.thread.toggle_lock( + thread_uid=thread_uid, + user_uid=self.request.session["uid"] + ) + + if not thread: + return web.json_response({"error": "Cannot toggle lock"}, status=403) + + return web.json_response({"is_locked": thread["is_locked"]}) + + +# views/forum_websocket.py +class ForumWebSocketView(BaseView): + """WebSocket view for real-time forum updates""" + + async def websocket_handler(self): + ws = web.WebSocketResponse() + await ws.prepare(self.request) + + # Store WebSocket connection + ws_id = self.services.generate_uid() + if not hasattr(self.app, 'forum_websockets'): + self.app.forum_websockets = {} + + user_uid = self.request.session.get("uid") + self.app.forum_websockets[ws_id] = { + "ws": ws, + "user_uid": user_uid, + "subscriptions": set() + } + + try: + async for msg in ws: + if msg.type == web.WSMsgType.TEXT: + data = json.loads(msg.data) + await self.handle_ws_message(ws_id, data) + elif msg.type == web.WSMsgType.ERROR: + break + finally: + # Clean up + if ws_id in self.app.forum_websockets: + del self.app.forum_websockets[ws_id] + + return ws + + async def handle_ws_message(self, ws_id, data): + """Handle incoming WebSocket messages""" + action = data.get("action") + + if action == "subscribe": + # Subscribe to forum/thread updates + target_type = data.get("type") # "forum" or "thread" + target_id = data.get("id") + + if target_type and target_id: + subscription = f"{target_type}:{target_id}" + self.app.forum_websockets[ws_id]["subscriptions"].add(subscription) + + # Send confirmation + ws = self.app.forum_websockets[ws_id]["ws"] + await ws.send_str(json.dumps({ + "type": "subscribed", + "subscription": subscription + })) + + elif action == "unsubscribe": + target_type = data.get("type") + target_id = data.get("id") + + if target_type and target_id: + subscription = f"{target_type}:{target_id}" + self.app.forum_websockets[ws_id]["subscriptions"].discard(subscription) + + # Send confirmation + ws = self.app.forum_websockets[ws_id]["ws"] + await ws.send_str(json.dumps({ + "type": "unsubscribed", + "subscription": subscription + })) + + @staticmethod + async def broadcast_update(app, event_type, data): + """Broadcast updates to subscribed WebSocket clients""" + if not hasattr(app, 'forum_websockets'): + return + + # Determine subscription targets based on event + targets = set() + + if event_type in ["thread_created", "post_created", "post_edited", "post_deleted"]: + if "forum_uid" in data: + targets.add(f"forum:{data['forum_uid']}") + + if event_type in ["post_created", "post_edited", "post_deleted", "post_liked", "post_unliked"]: + if "thread_uid" in data: + targets.add(f"thread:{data['thread_uid']}") + + # Send to subscribed clients + for ws_id, ws_data in app.forum_websockets.items(): + if ws_data["subscriptions"] & targets: + try: + await ws_data["ws"].send_str(json.dumps({ + "type": event_type, + "data": data + })) + except Exception: + pass