Skip to content
Open
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
168 changes: 118 additions & 50 deletions backend/data/blooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,129 @@
from data.connection import db_cursor
from data.users import User

from psycopg2.errors import UniqueViolation


class AlreadyRebloomedError(Exception):
pass


@dataclass
class Bloom:
id: int
sender: User
content: str
sent_timestamp: datetime.datetime


def add_bloom(*, sender: User, content: str) -> Bloom:
original_bloom_id: Optional[int] = None
original_sender: Optional[str] = None
original_sent_timestamp: Optional[datetime.datetime] = None
Comment on lines +22 to +24

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than having three Optional properties here which are always either set or not, I'd recommend extracting a RebloomDetails class and having a single Optional[RebloomDetails] property.

That way you don't need to worry in code about the (hopefully impossible!) possibility that original_bloom_id is set where original_sender isn't - we make clear through the types the properties are grouped and are all present or all absent.

rebloom_count: int = 0
rebloomed_by_current_user: bool = False


# Shared by every read query below: resolves original-bloom attribution and
# rebloom stats via the self-referencing original_bloom_id column, rather than
# a separate table or a stored counter.
_SELECT_FIELDS = """
b.id, u.username, b.content, b.send_timestamp,
b.original_bloom_id, orig_user.username, orig.send_timestamp,
(
SELECT COUNT(*) FROM blooms rb
WHERE rb.original_bloom_id = COALESCE(b.original_bloom_id, b.id)
),
EXISTS(
SELECT 1 FROM blooms rb2
WHERE rb2.sender_id = %(current_user_id)s

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this clause, will users who aren't logged in still be able to see blooms?

AND rb2.original_bloom_id = COALESCE(b.original_bloom_id, b.id)
)
"""

_JOIN_CLAUSE = """
blooms b
INNER JOIN users u ON u.id = b.sender_id
LEFT JOIN blooms orig ON orig.id = b.original_bloom_id
LEFT JOIN users orig_user ON orig_user.id = orig.sender_id
"""


def _bloom_from_row(row) -> Bloom:
(
bloom_id,
sender_username,
content,
timestamp,
original_bloom_id,
original_sender,
original_sent_timestamp,
rebloom_count,
rebloomed_by_current_user,
) = row
return Bloom(
id=bloom_id,
sender=sender_username,
content=content,
sent_timestamp=timestamp,
original_bloom_id=original_bloom_id,
original_sender=original_sender,
original_sent_timestamp=original_sent_timestamp,
rebloom_count=rebloom_count,
rebloomed_by_current_user=rebloomed_by_current_user,
)


def add_bloom(
*, sender_id: int, content: str, original_bloom_id: Optional[int] = None
) -> int:
hashtags = [word[1:] for word in content.split(" ") if word.startswith("#")]

now = datetime.datetime.now(tz=datetime.UTC)
bloom_id = int(now.timestamp() * 1000000)
with db_cursor() as cur:
cur.execute(
"INSERT INTO blooms (id, sender_id, content, send_timestamp) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s)",
"INSERT INTO blooms (id, sender_id, content, send_timestamp, original_bloom_id) VALUES (%(bloom_id)s, %(sender_id)s, %(content)s, %(timestamp)s, %(original_bloom_id)s)",
dict(
bloom_id=bloom_id,
sender_id=sender.id,
sender_id=sender_id,
content=content,
timestamp=datetime.datetime.now(datetime.UTC),
timestamp=now,
original_bloom_id=original_bloom_id,
),
)
for hashtag in hashtags:
cur.execute(
"INSERT INTO hashtags (hashtag, bloom_id) VALUES (%(hashtag)s, %(bloom_id)s)",
dict(hashtag=hashtag, bloom_id=bloom_id),
)
return bloom_id


def add_rebloom(*, sender_id: int, original_bloom_id: int, content: str) -> int:
try:
return add_bloom(
sender_id=sender_id,
content=content,
original_bloom_id=original_bloom_id,
)
except UniqueViolation:
raise AlreadyRebloomedError(
f"User {sender_id} has already reblооmed bloom {original_bloom_id}"
)


def get_blooms_for_user(
username: str, *, before: Optional[int] = None, limit: Optional[int] = None
username: str,
*,
current_user_id: Optional[int] = None,
before: Optional[int] = None,
limit: Optional[int] = None,
) -> List[Bloom]:
with db_cursor() as cur:
kwargs = {
"sender_username": username,
"current_user_id": current_user_id,
}
if before is not None:
before_clause = "AND send_timestamp < %(before_limit)s"
before_clause = "AND b.send_timestamp < %(before_limit)s"
kwargs["before_limit"] = before
else:
before_clause = ""
Expand All @@ -54,83 +137,68 @@ def get_blooms_for_user(

cur.execute(
f"""SELECT
blooms.id, users.username, content, send_timestamp
{_SELECT_FIELDS}
FROM
blooms INNER JOIN users ON users.id = blooms.sender_id
{_JOIN_CLAUSE}
WHERE
username = %(sender_username)s
u.username = %(sender_username)s
{before_clause}
ORDER BY send_timestamp DESC
ORDER BY b.send_timestamp DESC
{limit_clause}
""",
kwargs,
)
rows = cur.fetchall()
blooms = []
for row in rows:
bloom_id, sender_username, content, timestamp = row
blooms.append(
Bloom(
id=bloom_id,
sender=sender_username,
content=content,
sent_timestamp=timestamp,
)
)
return blooms
return [_bloom_from_row(row) for row in rows]


def get_bloom(bloom_id: int) -> Optional[Bloom]:
def get_bloom(
bloom_id: int, *, current_user_id: Optional[int] = None
) -> Optional[Bloom]:
with db_cursor() as cur:
cur.execute(
"SELECT blooms.id, users.username, content, send_timestamp FROM blooms INNER JOIN users ON users.id = blooms.sender_id WHERE blooms.id = %s",
(bloom_id,),
f"""SELECT
{_SELECT_FIELDS}
FROM
{_JOIN_CLAUSE}
WHERE
b.id = %(bloom_id)s
""",
{"bloom_id": bloom_id, "current_user_id": current_user_id},
)
row = cur.fetchone()
if row is None:
return None
bloom_id, sender_username, content, timestamp = row
return Bloom(
id=bloom_id,
sender=sender_username,
content=content,
sent_timestamp=timestamp,
)
return _bloom_from_row(row)


def get_blooms_with_hashtag(
hashtag_without_leading_hash: str, *, limit: int = None
hashtag_without_leading_hash: str,
*,
current_user_id: Optional[int] = None,
limit: int = None,
) -> List[Bloom]:
kwargs = {
"hashtag_without_leading_hash": hashtag_without_leading_hash,
"current_user_id": current_user_id,
}
limit_clause = make_limit_clause(limit, kwargs)
with db_cursor() as cur:
cur.execute(
f"""SELECT
blooms.id, users.username, content, send_timestamp
{_SELECT_FIELDS}
FROM
blooms INNER JOIN hashtags ON blooms.id = hashtags.bloom_id INNER JOIN users ON blooms.sender_id = users.id
{_JOIN_CLAUSE}
INNER JOIN hashtags ON b.id = hashtags.bloom_id
WHERE
hashtag = %(hashtag_without_leading_hash)s
ORDER BY send_timestamp DESC
ORDER BY b.send_timestamp DESC
{limit_clause}
""",
kwargs,
)
rows = cur.fetchall()
blooms = []
for row in rows:
bloom_id, sender_username, content, timestamp = row
blooms.append(
Bloom(
id=bloom_id,
sender=sender_username,
content=content,
sent_timestamp=timestamp,
)
)
return blooms
return [_bloom_from_row(row) for row in rows]


def make_limit_clause(limit: Optional[int], kwargs: Dict[Any, Any]) -> str:
Expand Down
81 changes: 72 additions & 9 deletions backend/endpoints.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Dict, Union
from data import blooms
from data.blooms import AlreadyRebloomedError
from data.follows import follow, get_followed_usernames, get_inverse_followed_usernames
from data.users import (
UserRegistrationError,
Expand Down Expand Up @@ -110,7 +111,10 @@ def other_profile(profile_username):
current_user = get_current_user()

followers = get_inverse_followed_usernames(profile_user)
all_blooms = blooms.get_blooms_for_user(profile_username)
all_blooms = blooms.get_blooms_for_user(
profile_username,
current_user_id=current_user.id if current_user else None,
)
all_blooms.reverse()
return jsonify(
{
Expand Down Expand Up @@ -158,7 +162,7 @@ def send_bloom():

user = get_current_user()

blooms.add_bloom(sender=user, content=request.json["content"])
blooms.add_bloom(sender_id=user.id, content=request.json["content"])

return jsonify(
{
Expand All @@ -167,33 +171,81 @@ def send_bloom():
)


@jwt_required(optional=True)
def get_bloom(id_str):
try:
id_int = int(id_str)
except ValueError:
return make_response((f"Invalid bloom id", 400))
bloom = blooms.get_bloom(id_int)
return make_response(jsonify({"success": False, "message": "Invalid bloom id"}), 400)
current_user = get_current_user()
bloom = blooms.get_bloom(
id_int, current_user_id=current_user.id if current_user else None
)
if bloom is None:
return make_response((f"Bloom not found", 404))
return make_response(jsonify({"success": False, "message": "Bloom not found"}), 404)
return jsonify(bloom)


@jwt_required()
def do_rebloom(id_str):
try:
id_int = int(id_str)
except ValueError:
return make_response(jsonify({"success": False, "message": "Invalid bloom id"}), 400)

current_user = get_current_user()

target_bloom = blooms.get_bloom(id_int)
if target_bloom is None:
return make_response(jsonify({"success": False, "message": "Bloom not found"}), 404)

# Reblooming a rebloom points at its original, so reblooms never chain.
root_bloom_id = target_bloom.original_bloom_id or target_bloom.id

try:
blooms.add_rebloom(
sender_id=current_user.id,
original_bloom_id=root_bloom_id,
content=target_bloom.content,
)
except AlreadyRebloomedError:
return make_response(
jsonify(
{
"success": False,
"message": "You have already reblооmed this bloom",
}
),
409,
)

return jsonify(
{
"success": True,
}
)


@jwt_required()
def home_timeline():
current_user = get_current_user()

# Get blooms from followed users
followed_users = get_followed_usernames(current_user)
nested_user_blooms = [
blooms.get_blooms_for_user(followed_user, limit=50)
blooms.get_blooms_for_user(
followed_user, current_user_id=current_user.id, limit=50
)
for followed_user in followed_users
]

# Flatten list of blooms from followed users
followed_blooms = [bloom for blooms in nested_user_blooms for bloom in blooms]

# Get the current user's own blooms
own_blooms = blooms.get_blooms_for_user(current_user.username, limit=50)
own_blooms = blooms.get_blooms_for_user(
current_user.username, current_user_id=current_user.id, limit=50
)

# Combine own blooms with followed blooms
all_blooms = followed_blooms + own_blooms
Expand All @@ -206,8 +258,13 @@ def home_timeline():
return jsonify(sorted_blooms)


@jwt_required(optional=True)
def user_blooms(profile_username):
user_blooms = blooms.get_blooms_for_user(profile_username)
current_user = get_current_user()
user_blooms = blooms.get_blooms_for_user(
profile_username,
current_user_id=current_user.id if current_user else None,
)
user_blooms.reverse()
return jsonify(user_blooms)

Expand All @@ -228,8 +285,14 @@ def suggested_follows(limit_str):
return jsonify(suggestions)


@jwt_required(optional=True)
def hashtag(hashtag):
return jsonify(blooms.get_blooms_with_hashtag(hashtag))
current_user = get_current_user()
return jsonify(
blooms.get_blooms_with_hashtag(
hashtag, current_user_id=current_user.id if current_user else None
)
)


def verify_request_fields(names_to_types: Dict[str, type]) -> Union[Response, None]:
Expand Down
Loading