diff --git a/app/api/alpha/routes.py b/app/api/alpha/routes.py index 2e046237..bc87a4eb 100644 --- a/app/api/alpha/routes.py +++ b/app/api/alpha/routes.py @@ -3,10 +3,10 @@ from app.api.alpha.utils import get_site, post_site_block, \ get_search, \ get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe, post_post, \ put_post, post_post_delete, post_post_report, post_post_lock, post_post_feature, post_post_remove, \ - get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, \ + get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, post_reply_mark_as_read, \ post_reply_delete, post_reply_report, post_reply_remove, \ get_community_list, get_community, post_community_follow, post_community_block, \ - get_user, post_user_block + get_user, post_user_block, get_user_unread_count, get_user_replies from app.shared.auth import log_user_in from flask import current_app, jsonify, request @@ -354,6 +354,18 @@ def post_alpha_comment_remove(): return jsonify({"error": str(ex)}), 400 +@bp.route('/api/alpha/comment/mark_as_read', methods=['POST']) +def post_alpha_comment_mark_as_read(): + if not enable_api(): + return jsonify({'error': 'alpha api is not enabled'}) + try: + auth = request.headers.get('Authorization') + data = request.get_json(force=True) or {} + return jsonify(post_reply_mark_as_read(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + # User @bp.route('/api/alpha/user', methods=['GET']) def get_alpha_user(): @@ -379,6 +391,29 @@ def post_alpha_user_login(): return jsonify({"error": str(ex)}), 400 +@bp.route('/api/alpha/user/unread_count', methods=['GET']) +def get_alpha_user_unread_count(): + if not enable_api(): + return jsonify({'error': 'alpha api is not enabled'}) + try: + auth = request.headers.get('Authorization') + return jsonify(get_user_unread_count(auth)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + +@bp.route('/api/alpha/user/replies', methods=['GET']) +def get_alpha_user_replies(): + if not enable_api(): + return jsonify({'error': 'alpha api is not enabled'}) + try: + auth = request.headers.get('Authorization') + data = request.args.to_dict() or None + return jsonify(get_user_replies(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + @bp.route('/api/alpha/user/block', methods=['POST']) def post_alpha_user_block(): if not enable_api(): @@ -426,8 +461,7 @@ def alpha_post(): return jsonify({"error": "not_yet_implemented"}), 400 # Reply - not yet implemented -@bp.route('/api/alpha/comment', methods=['GET']) # Stage 1 if needed for search -@bp.route('/api/alpha/comment/mark_as_read', methods=['POST']) # No DB support +@bp.route('/api/alpha/comment', methods=['GET']) # Stage 2 @bp.route('/api/alpha/comment/distinguish', methods=['POST']) # Not really used @bp.route('/api/alpha/comment/report/resolve', methods=['PUT']) # Stage 2 @bp.route('/api/alpha/comment/report/list', methods=['GET']) # Stage 2 @@ -451,7 +485,6 @@ def alpha_chat(): @bp.route('/api/alpha/user/get_captcha', methods=['GET']) # Not available in app @bp.route('/api/alpha/user/mention', methods=['GET']) # No DB support @bp.route('/api/alpha/user/mention/mark_as_read', methods=['POST']) # No DB support -@bp.route('/api/alpha/user/replies', methods=['GET']) # Stage 1 @bp.route('/api/alpha/user/ban', methods=['POST']) # Admin function. No plans to implement @bp.route('/api/alpha/user/banned', methods=['GET']) # Admin function. No plans to implement @bp.route('/api/alpha/user/delete_account', methods=['POST']) # Not available in app @@ -461,7 +494,6 @@ def alpha_chat(): @bp.route('/api/alpha/user/save_user_settings', methods=['PUT']) # Not available in app @bp.route('/api/alpha/user/change_password', methods=['PUT']) # Not available in app @bp.route('/api/alpha/user/report_count', methods=['GET']) # Stage 2 -@bp.route('/api/alpha/user/unread_count', methods=['GET']) # Stage 1 @bp.route('/api/alpha/user/verify_email', methods=['POST']) # Admin function. No plans to implement @bp.route('/api/alpha/user/leave_admin', methods=['POST']) # Admin function. No plans to implement @bp.route('/api/alpha/user/totp/generate', methods=['POST']) # Not available in app diff --git a/app/api/alpha/utils/__init__.py b/app/api/alpha/utils/__init__.py index 8b015d27..a2dd5aee 100644 --- a/app/api/alpha/utils/__init__.py +++ b/app/api/alpha/utils/__init__.py @@ -1,8 +1,8 @@ from app.api.alpha.utils.site import get_site, post_site_block from app.api.alpha.utils.misc import get_search from app.api.alpha.utils.post import get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe, post_post, put_post, post_post_delete, post_post_report, post_post_lock, post_post_feature, post_post_remove -from app.api.alpha.utils.reply import get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, post_reply_delete, post_reply_report, post_reply_remove +from app.api.alpha.utils.reply import get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, post_reply_delete, post_reply_report, post_reply_remove, post_reply_mark_as_read from app.api.alpha.utils.community import get_community, get_community_list, post_community_follow, post_community_block -from app.api.alpha.utils.user import get_user, post_user_block +from app.api.alpha.utils.user import get_user, post_user_block, get_user_unread_count, get_user_replies diff --git a/app/api/alpha/utils/community.py b/app/api/alpha/utils/community.py index 1a410446..8cc89405 100644 --- a/app/api/alpha/utils/community.py +++ b/app/api/alpha/utils/community.py @@ -72,7 +72,7 @@ def get_community_list(auth, data): def get_community(auth, data): if not data or ('id' not in data and 'name' not in data): - raise Exception('missing_parameters') + raise Exception('missing parameters for community') if 'id' in data: community = int(data['id']) elif 'name' in data: diff --git a/app/api/alpha/utils/misc.py b/app/api/alpha/utils/misc.py index 1d9f86e5..0f800669 100644 --- a/app/api/alpha/utils/misc.py +++ b/app/api/alpha/utils/misc.py @@ -6,7 +6,7 @@ from app.api.alpha.views import search_view def get_search(auth, data): if not data or ('q' not in data and 'type_' not in data): - raise Exception('missing_parameters') + raise Exception('missing parameters for search') type = data['type_'] listing_type = data['listing_type'] if 'listing_type' in data else 'Local' diff --git a/app/api/alpha/utils/post.py b/app/api/alpha/utils/post.py index ce168e4c..c9b5542d 100644 --- a/app/api/alpha/utils/post.py +++ b/app/api/alpha/utils/post.py @@ -35,7 +35,7 @@ def cached_post_list(type, sort, user_id, community_id, community_name, person_i # change when polls are supported posts = posts.filter(Post.type != POST_TYPE_POLL) - if user_id is not None: + if user_id and user_id != person_id: blocked_person_ids = blocked_users(user_id) if blocked_person_ids: posts = posts.filter(Post.user_id.not_in(blocked_person_ids)) @@ -104,7 +104,7 @@ def get_post_list(auth, data, user_id=None, search_type='Posts'): def get_post(auth, data): if not data or 'id' not in data: - raise Exception('missing_parameters') + raise Exception('missing parameters for post') id = int(data['id']) diff --git a/app/api/alpha/utils/reply.py b/app/api/alpha/utils/reply.py index ea598269..ac9cb545 100644 --- a/app/api/alpha/utils/reply.py +++ b/app/api/alpha/utils/reply.py @@ -1,12 +1,12 @@ -from app import cache +from app import cache, db from app.api.alpha.utils.validators import required, integer_expected, boolean_expected, string_expected from app.api.alpha.views import reply_view, reply_report_view -from app.models import PostReply, Post +from app.models import Notification, PostReply, Post from app.shared.reply import vote_for_reply, bookmark_the_post_reply, remove_the_bookmark_from_post_reply, toggle_post_reply_notification, make_reply, edit_reply, \ delete_reply, restore_reply, report_reply, mod_remove_reply, mod_restore_reply from app.utils import authorise_api_user, blocked_users, blocked_instances -from sqlalchemy import desc +from sqlalchemy import desc, or_ # person_id param: the author of the reply; user_id param: the current logged-in user @cache.memoize(timeout=3) @@ -16,7 +16,7 @@ def cached_reply_list(post_id, person_id, sort, max_depth, user_id): if person_id: replies = PostReply.query.filter_by(user_id=person_id) - if user_id is not None: + if user_id and user_id != person_id: blocked_person_ids = blocked_users(user_id) if blocked_person_ids: replies = replies.filter(PostReply.user_id.not_in(blocked_person_ids)) @@ -43,7 +43,7 @@ def get_reply_list(auth, data, user_id=None): person_id = data['person_id'] if data and 'person_id' in data else None if data and not post_id and not person_id: - raise Exception('missing_parameters') + raise Exception('missing parameters for reply') else: if auth: user_id = authorise_api_user(auth) @@ -62,6 +62,7 @@ def get_reply_list(auth, data, user_id=None): replylist.append(reply_view(reply=reply, variant=2, user_id=user_id)) except: continue + break list_json = { "comments": replylist } @@ -210,3 +211,33 @@ def post_reply_remove(auth, data): reply_json = reply_view(reply=reply, variant=4, user_id=user_id) return reply_json + + +def post_reply_mark_as_read(auth, data): + required(['comment_reply_id', 'read'], data) + integer_expected(['comment_reply_id'], data) + boolean_expected(['read'], data) + + reply_id = data['comment_reply_id'] + read = data['read'] + + user_id = authorise_api_user(auth) + + # no real support for this. Just marking the Notification for the reply really + # notification has its own id, which would be handy, but reply_view is currently just returning the reply.id for that + reply = PostReply.query.filter_by(id=reply_id).one() + + reply_url = '#comment_' + str(reply.id) + mention_url = '/comment/' + str(reply.id) + notification = Notification.query.filter(Notification.user_id == user_id, or_(Notification.url.ilike(f"%{reply_url}%"), Notification.url.ilike(f"%{mention_url}%"))).first() + if notification: + notification.read = read + db.session.commit() + + reply_json = {'comment_reply_view': reply_view(reply=reply, variant=5, user_id=user_id, read=True)} + return reply_json + + + + + diff --git a/app/api/alpha/utils/user.py b/app/api/alpha/utils/user.py index 39e668d8..fc429f42 100644 --- a/app/api/alpha/utils/user.py +++ b/app/api/alpha/utils/user.py @@ -1,11 +1,14 @@ -from app.api.alpha.views import user_view +from app import db +from app.api.alpha.views import user_view, reply_view from app.utils import authorise_api_user from app.api.alpha.utils.post import get_post_list from app.api.alpha.utils.reply import get_reply_list from app.api.alpha.utils.validators import required, integer_expected, boolean_expected -from app.models import User +from app.models import PostReply, User from app.shared.user import block_another_user, unblock_another_user +from sqlalchemy import text, desc + def get_user(auth, data): if not data or ('person_id' not in data and 'username' not in data): @@ -27,8 +30,9 @@ def get_user(auth, data): auth = None # avoid authenticating user again in get_post_list and get_reply_list # bit unusual. have to help construct the json here rather than in views, to avoid circular dependencies - post_list = get_post_list(auth, data, user_id) - reply_list = get_reply_list(auth, data, user_id) + # lists are empty when viewing own account, to deal with a bug I've yet to identify + post_list = get_post_list(auth, data, user_id) if not user_id == person_id else {'posts': []} + reply_list = get_reply_list(auth, data, user_id) if not user_id == person_id else {'comments': []} user_json = user_view(user=person_id, variant=3) user_json['posts'] = post_list['posts'] @@ -81,3 +85,50 @@ def post_user_block(auth, data): user_id = block_another_user(person_id, SRC_API, auth) if block else unblock_another_user(person_id, SRC_API, auth) user_json = user_view(user=person_id, variant=4, user_id=user_id) return user_json + + +def get_user_unread_count(auth): + user_id = authorise_api_user(auth) + + # Mentions are just included in replies + + unread_notifications = db.session.execute(text("SELECT COUNT(id) as c FROM notification WHERE user_id = :user_id AND read = false"), {'user_id': user_id}).scalar() + unread_messages = db.session.execute(text("SELECT * from chat_message AS cm INNER JOIN conversation c ON cm.conversation_id =c.id WHERE c.read = false AND cm.recipient_id = :user_id"), {'user_id': user_id}).scalar() + if not unread_messages: + unread_messages = 0 + + unread_count = { + "replies": unread_notifications - unread_messages, + "mentions": 0, + "private_messages": unread_messages + } + + return unread_count + + +def get_user_replies(auth, data): + page = int(data['page']) if data and 'page' in data else 1 + limit = int(data['limit']) if data and 'limit' in data else 10 + + user_id = authorise_api_user(auth) + + unread_urls = db.session.execute(text("select url from notification where user_id = :user_id and read = false and url ilike '%comment%'"), {'user_id': user_id}).scalars() + unread_ids = [] + for url in unread_urls: + if '#comment_' in url: # reply format + unread_ids.append(url.rpartition('_')[-1]) + elif '/comment/' in url: # mention format + unread_ids.append(url.rpartition('/')[-1]) + + replies = PostReply.query.filter(PostReply.id.in_(unread_ids)).order_by(desc(PostReply.posted_at)).paginate(page=page, per_page=limit, error_out=False) + + reply_list = [] + for reply in replies: + reply_list.append(reply_view(reply=reply, variant=5, user_id=user_id)) + list_json = { + "replies": reply_list + } + + return list_json + + diff --git a/app/api/alpha/views.py b/app/api/alpha/views.py index ce2d301d..7ddb73c6 100644 --- a/app/api/alpha/views.py +++ b/app/api/alpha/views.py @@ -262,7 +262,7 @@ def calculate_if_has_children(reply): # result used as True / False return db.session.execute(text('SELECT COUNT(id) AS c FROM "post_reply" WHERE parent_id = :id'), {'id': reply.id}).scalar() -def reply_view(reply: PostReply | int, variant, user_id=None, my_vote=0): +def reply_view(reply: PostReply | int, variant, user_id=None, my_vote=0, read=False): if isinstance(reply, int): reply = PostReply.query.filter_by(id=reply).one() @@ -330,6 +330,38 @@ def reply_view(reply: PostReply | int, variant, user_id=None, my_vote=0): return v4 + # Variant 5 - views/comment_reply_view.dart - /user/replies api endpoint + if variant == 5: + bookmarked = db.session.execute(text('SELECT user_id FROM "post_reply_bookmark" WHERE post_reply_id = :post_reply_id and user_id = :user_id'), {'post_reply_id': reply.id, 'user_id': user_id}).scalar() + reply_sub = db.session.execute(text('SELECT user_id FROM "notification_subscription" WHERE type = :type and entity_id = :entity_id and user_id = :user_id'), {'type': NOTIF_REPLY, 'entity_id': reply.id, 'user_id': user_id}).scalar() + banned = db.session.execute(text('SELECT user_id FROM "community_ban" WHERE user_id = :user_id and community_id = :community_id'), {'user_id': reply.user_id, 'community_id': reply.community_id}).scalar() + moderator = db.session.execute(text('SELECT is_moderator FROM "community_member" WHERE user_id = :user_id and community_id = :community_id'), {'user_id': reply.user_id, 'community_id': reply.community_id}).scalar() + admin = db.session.execute(text('SELECT user_id FROM "user_role" WHERE user_id = :user_id and role_id = 4'), {'user_id': reply.user_id}).scalar() + + saved = True if bookmarked else False + activity_alert = True if reply_sub else False + creator_banned_from_community = True if banned else False + creator_is_moderator = True if moderator else False + creator_is_admin = True if admin else False + + v5 = {'comment_reply': {'id': reply.id, 'recipient_id': user_id, 'comment_id': reply.id, 'read': read, 'published': reply.posted_at.isoformat() + 'Z'}, + 'comment': reply_view(reply=reply, variant=1), + 'creator': user_view(user=reply.author, variant=1), + 'post': post_view(post=reply.post, variant=1), + 'community': community_view(community=reply.community, variant=1), + 'recipient': user_view(user=user_id, variant=1), + 'counts': {'comment_id': reply.id, 'score': reply.score, 'upvotes': reply.up_votes, 'downvotes': reply.down_votes, 'published': reply.posted_at.isoformat() + 'Z', 'child_count': 0}, + 'activity_alert': activity_alert, + 'creator_banned_from_community': creator_banned_from_community, + 'creator_is_moderator': creator_is_moderator, + 'creator_is_admin': creator_is_admin, + 'subscribed': 'NotSubscribed', + 'saved': saved, + 'creator_blocked': False + } + + return v5 + def reply_report_view(report, reply_id, user_id): # views/comment_report_view.dart - /comment/report api endpoint