mirror of https://gitlab.com/tildes/tildes.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
253 lines
8.6 KiB
253 lines
8.6 KiB
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
"""Contains the MessageConversation and MessageReply classes.
|
|
|
|
Note the difference between these two classes - MessageConversation represents both the
|
|
overall conversation and the initial message in a particular message
|
|
conversation/thread. Subsequent replies (if any) inside that same conversation are
|
|
represented by MessageReply.
|
|
|
|
This might feel a bit unusual since it splits "all messages" across two tables/classes,
|
|
but it simplifies a lot of things when organizing them into threads.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Any, List, Optional, Sequence, Tuple
|
|
|
|
from pyramid.security import ALL_PERMISSIONS, Allow, DENY_ALL
|
|
from sqlalchemy import (
|
|
CheckConstraint,
|
|
Column,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
Text,
|
|
TIMESTAMP,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import ARRAY
|
|
from sqlalchemy.orm import deferred, relationship
|
|
from sqlalchemy.sql.expression import text
|
|
|
|
from tildes.lib.id import id_to_id36
|
|
from tildes.lib.markdown import convert_markdown_to_safe_html
|
|
from tildes.metrics import incr_counter
|
|
from tildes.models import DatabaseModel
|
|
from tildes.models.user import User
|
|
from tildes.schemas.message import (
|
|
MessageConversationSchema,
|
|
MessageReplySchema,
|
|
SUBJECT_MAX_LENGTH,
|
|
)
|
|
|
|
|
|
class MessageConversation(DatabaseModel):
|
|
"""Model for a message conversation (and the initial message).
|
|
|
|
Trigger behavior:
|
|
Incoming:
|
|
- num_replies, last_reply_time, and unread_user_ids are updated when a new
|
|
message_replies row is inserted for the conversation.
|
|
- num_replies and last_reply_time will be updated if a message_replies row is
|
|
deleted.
|
|
Outgoing:
|
|
- Inserting or updating unread_user_ids will update num_unread_messages for all
|
|
relevant users.
|
|
"""
|
|
|
|
schema_class = MessageConversationSchema
|
|
|
|
__tablename__ = "message_conversations"
|
|
|
|
conversation_id: int = Column(Integer, primary_key=True)
|
|
sender_id: int = Column(
|
|
Integer, ForeignKey("users.user_id"), nullable=False, index=True
|
|
)
|
|
recipient_id: int = Column(
|
|
Integer, ForeignKey("users.user_id"), nullable=False, index=True
|
|
)
|
|
created_time: datetime = Column(
|
|
TIMESTAMP(timezone=True),
|
|
nullable=False,
|
|
index=True,
|
|
server_default=text("NOW()"),
|
|
)
|
|
subject: str = Column(
|
|
Text,
|
|
CheckConstraint(
|
|
f"LENGTH(subject) <= {SUBJECT_MAX_LENGTH}", name="subject_length"
|
|
),
|
|
nullable=False,
|
|
)
|
|
markdown: str = deferred(Column(Text, nullable=False))
|
|
rendered_html: str = Column(Text, nullable=False)
|
|
num_replies: int = Column(Integer, nullable=False, server_default="0")
|
|
last_reply_time: Optional[datetime] = Column(TIMESTAMP(timezone=True), index=True)
|
|
unread_user_ids: List[int] = Column(
|
|
ARRAY(Integer), nullable=False, server_default="{}"
|
|
)
|
|
|
|
sender: User = relationship(
|
|
"User", lazy=False, innerjoin=True, foreign_keys=[sender_id]
|
|
)
|
|
recipient: User = relationship(
|
|
"User", lazy=False, innerjoin=True, foreign_keys=[recipient_id]
|
|
)
|
|
replies: Sequence["MessageReply"] = relationship(
|
|
"MessageReply", order_by="MessageReply.created_time"
|
|
)
|
|
|
|
# Create a GIN index on the unread_user_ids column using the gin__int_ops operator
|
|
# class supplied by the intarray module. This should be the best index for "array
|
|
# contains" queries.
|
|
__table_args__ = (
|
|
Index(
|
|
"ix_message_conversations_unread_user_ids_gin",
|
|
unread_user_ids,
|
|
postgresql_using="gin",
|
|
postgresql_ops={"unread_user_ids": "gin__int_ops"},
|
|
),
|
|
)
|
|
|
|
def __init__(self, sender: User, recipient: User, subject: str, markdown: str):
|
|
"""Create a new message conversation between two users."""
|
|
self.sender = sender
|
|
self.recipient = recipient
|
|
self.unread_user_ids = [self.recipient.user_id]
|
|
self.subject = subject
|
|
self.markdown = markdown
|
|
self.rendered_html = convert_markdown_to_safe_html(markdown)
|
|
|
|
def _update_creation_metric(self) -> None:
|
|
incr_counter("messages", type="conversation")
|
|
|
|
def __acl__(self) -> Sequence[Tuple[str, Any, str]]:
|
|
"""Pyramid security ACL."""
|
|
acl = [
|
|
(Allow, self.sender_id, ALL_PERMISSIONS),
|
|
(Allow, self.recipient_id, ALL_PERMISSIONS),
|
|
]
|
|
|
|
acl.append(DENY_ALL)
|
|
|
|
return acl
|
|
|
|
@property
|
|
def conversation_id36(self) -> str:
|
|
"""Return the conversation's ID in ID36 format."""
|
|
return id_to_id36(self.conversation_id)
|
|
|
|
@property
|
|
def last_activity_time(self) -> datetime:
|
|
"""Return the last time a message was sent in this conversation."""
|
|
if self.last_reply_time:
|
|
return self.last_reply_time
|
|
|
|
return self.created_time
|
|
|
|
def is_participant(self, user: User) -> bool:
|
|
"""Return whether the user is a participant in the conversation."""
|
|
return user in (self.sender, self.recipient)
|
|
|
|
def other_user(self, viewer: User) -> User:
|
|
"""Return the conversation's other user from viewer's perspective.
|
|
|
|
That is, if the viewer is the sender, this will be the recipient, and vice
|
|
versa.
|
|
"""
|
|
if not self.is_participant(viewer):
|
|
raise ValueError("User is not a participant in this conversation.")
|
|
|
|
if viewer == self.sender:
|
|
return self.recipient
|
|
|
|
return self.sender
|
|
|
|
def is_unread_by_user(self, user: User) -> bool:
|
|
"""Return whether the conversation is unread by the specified user."""
|
|
if not self.is_participant(user):
|
|
raise ValueError("User is not a participant in this conversation.")
|
|
|
|
return user.user_id in self.unread_user_ids
|
|
|
|
def mark_unread_for_user(self, user: User) -> None:
|
|
"""Mark the conversation unread for the specified user.
|
|
|
|
Uses the postgresql intarray union operator `|`, so there's no need to worry
|
|
about duplicate values, race conditions, etc.
|
|
"""
|
|
if not self.is_participant(user):
|
|
raise ValueError("User is not a participant in this conversation.")
|
|
|
|
union = MessageConversation.unread_user_ids.op("|") # type: ignore
|
|
self.unread_user_ids = union(user.user_id)
|
|
|
|
def mark_read_for_user(self, user: User) -> None:
|
|
"""Mark the conversation read for the specified user.
|
|
|
|
Uses the postgresql intarray "remove element from array" operation, so there's
|
|
no need to worry about whether the value is present or not, race conditions,
|
|
etc.
|
|
"""
|
|
if not self.is_participant(user):
|
|
raise ValueError("User is not a participant in this conversation.")
|
|
|
|
user_id = user.user_id
|
|
self.unread_user_ids = ( # type: ignore
|
|
MessageConversation.unread_user_ids - user_id # type: ignore
|
|
)
|
|
|
|
|
|
class MessageReply(DatabaseModel):
|
|
"""Model for the replies sent to a MessageConversation.
|
|
|
|
Trigger behavior:
|
|
Outgoing:
|
|
- Inserting will update num_replies, last_reply_time, and unread_user_ids for
|
|
the relevant conversation.
|
|
- Deleting will update num_replies and last_reply_time for the relevant
|
|
conversation.
|
|
"""
|
|
|
|
schema_class = MessageReplySchema
|
|
|
|
__tablename__ = "message_replies"
|
|
|
|
reply_id: int = Column(Integer, primary_key=True)
|
|
conversation_id: int = Column(
|
|
Integer,
|
|
ForeignKey("message_conversations.conversation_id"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
sender_id: int = Column(
|
|
Integer, ForeignKey("users.user_id"), nullable=False, index=True
|
|
)
|
|
created_time: datetime = Column(
|
|
TIMESTAMP(timezone=True),
|
|
nullable=False,
|
|
index=True,
|
|
server_default=text("NOW()"),
|
|
)
|
|
markdown: str = deferred(Column(Text, nullable=False))
|
|
rendered_html: str = Column(Text, nullable=False)
|
|
|
|
conversation: MessageConversation = relationship(
|
|
"MessageConversation", innerjoin=True
|
|
)
|
|
sender: User = relationship("User", lazy=False, innerjoin=True)
|
|
|
|
def __init__(self, conversation: MessageConversation, sender: User, markdown: str):
|
|
"""Add a new reply to a message conversation."""
|
|
self.conversation = conversation
|
|
self.sender = sender
|
|
self.markdown = markdown
|
|
self.rendered_html = convert_markdown_to_safe_html(markdown)
|
|
|
|
def _update_creation_metric(self) -> None:
|
|
incr_counter("messages", type="reply")
|
|
|
|
@property
|
|
def reply_id36(self) -> str:
|
|
"""Return the reply's ID in ID36 format."""
|
|
return id_to_id36(self.reply_id)
|