Twitterクローンを作ります #16 リツイート機能(1)


# DB

リツイートについても、ベースとしたudemyの講座では以下のような仕組みを採用していました。

- Post.retweetUser に投稿をリツイートしたユーザーのIDを配列で保持
- リツイートをした際は Post.retweetData にリツイートした投稿のIDをもち、本文が空の Post を保存する
- User.retweets にユーザーがリツイートした投稿のIDを配列で保持

いいねと同様に、Post.retweetUser と User.retweets は廃止したいので Retweet のテーブルを作成しましょう。

- あるユーザーがリツイートした投稿を、リツイートした日時があたらしい順に表示する
→ posts.retweet_data が空でないものを検索すれば良い
- ある投稿をリツイートしたユーザーを、リツイートした日時があたらしい順に表示する
→ retweets を (post, retweeted_at) で検索
- タイムラインに表示する投稿それぞれにリツイートされた件数を表示する。
→ posts.retweet_count 
- タイムラインに表示する投稿のうち、ログインユーザーがリツイートした投稿はボタンをハイライトする。
→ retweets を (retweeted_by, posted_at) で検索


```python:server/models/retweet.py
from bson.objectid import ObjectId
from database import db

from datetime import datetime


class Retweet:
    _collection = db['retweets']

    def __init__(self,
                post: str,
                posted_at: datetime,
                retweeted_by: str,
                retweeted_at: datetime = None):
        self.post = post
        self.posted_at = posted_at
        self.retweeted_by = retweeted_by
        if not retweeted_at:
            retweeted_at = datetime.now()
        self.retweeted_at = retweeted_at

    def create(self):
        data = vars(self)
        data['post'] = ObjectId(data['post'])
        data['retweeted_by'] = ObjectId(data['retweeted_by'])
        return self._collection.insert_one(data)
```

こちらも一応インデックスを貼っておきましょう。

```
$ python

>>> from models.retweet import Retweet
>>> import pymongo

>>> Retweet._collection.create_index([('post', pymongo.ASCENDING), ('retweeted_at', pymongo.DESCENDING)], name='post__retweeted_at')

>>> Retweet._collection.create_index([('retweeted_by', pymongo.ASCENDING), ('posted_at', pymongo.DESCENDING)], name='retweeted_by__posted_at')

>>> Retweet._collection.create_index([('post', pymongo.ASCENDING), ('retweeted_by', pymongo.DESCENDING)], name='post__retweeted_by', unique=True)
>>> Retweet._collection.index_information()
{'_id_': {'v': 2, 'key': [('_id', 1)]}, 'post__retweeted_at': {'v': 2, 'key': [('post', 1), ('retweeted_at', -1)]}, 'post__retweeted_by': {'v': 2, 'key': [('post', 1), ('retweeted_by', -1)], 'unique': True}, 'retweeted_by__posted_at': {'v': 2, 'key': [('retweeted_by', 1), ('posted_at', -1)]}}
```

# APIの実装

いいねの実装を参考にリツイート処理を実装しようと思いましたが、少し困ったことがでてきました。
自分がいいねしたかどうかをAPIのlikingというフィールドで表現していましたがこれはDBに保存している値ではなくAPIで設定している値なのでretweetのAPIで取得するのが面倒だという点です。
POSTとDELETEの/api/posts/<post_id>/retweet では liking を返さなければ、storeで行っているObject.assignでlikingを上書きしないので一旦その作戦で進めましょう。

実装はいいねとほぼ同じです。

```python:server/models/post.py
from bson.objectid import ObjectId
from database import db

from datetime import datetime


class Post:
    _collection = db['posts']

    MAX_CONTENT_LENGTH = 140

    def __init__(self,
                content: str,
                posted_by: str,
                retweeted_post: str = None,
                like_count: int = 0,
                retweet_count: int=0,
                created_at: datetime = None,
                updated_at: datetime = None):
        self.content = content
        self.posted_by = posted_by
        self.retweeted_post = retweeted_post
        self.like_count = like_count
        self.retweet_count = retweet_count
        if not created_at:
            created_at = datetime.now()
        self.created_at = created_at
        if not updated_at:
            updated_at = datetime.now()
        self.updated_at = updated_at

    def create(self):
        data = vars(self)
        data['posted_by'] = ObjectId(data['posted_by'])
        if 'retweeted_post' in data and data['retweeted_post']:
            data['retweeted_post'] = ObjectId(data['retweeted_post'])
        return self._collection.insert_one(data)
...
```

```python:server/apis/posts.py
from flask import jsonify, request, session
from bson.objectid import ObjectId
import pymongo
from models.like import Like
from models.post import Post
from models.retweet import Retweet
from models.user import User
from main import app
from apis import login_required

...


@app.route('/api/posts', methods=['GET'])
@login_required()
def list_posts():
    results = []
    for post in Post._collection.find(sort=[('created_at', pymongo.DESCENDING)]):
        results.append(_populate(post))
    
    if results:
        user_id = ObjectId(session['user']['_id'])
        term = {
            '$gte': results[-1]['created_at'],
            '$lte': results[0]['created_at']
        }

        liked_posts = { str(like['post']) for like in Like._collection.find({'liked_by': user_id, 'posted_at': term}) }
        retweeted_posts = { str(retweet['post']) for retweet in Retweet._collection.find({'retweeted_by': user_id, 'posted_at': term}) }
        for result in results:
            result['liking'] = result['_id'] in liked_posts
            result['retweeting'] = result['_id'] in retweeted_posts

    return jsonify(results)

...


@app.route('/api/posts/<post_id>/retweet', methods=['POST'])
@login_required()
def retweet_post(post_id):
    post = Post._collection.find_one({'_id': ObjectId(post_id)})
    if not post:
        return jsonify({'error': { 'message': '投稿が存在しません' }}), 404
    user_id = session['user']['_id']
    retweet = Retweet._collection.find_one({'post': ObjectId(post_id), 'retweeted_by': ObjectId(user_id)})
    if retweet:
        post['retweeting'] = True
        return jsonify(_populate(post))
    else:
        retweet = Retweet(post=post_id,
                          posted_at=post['created_at'],
                          retweeted_by=user_id)
        try:
            retweet.create()
            retweeting_post = Post(content=None,
                                   posted_by=user_id,
                                   retweeted_post=post_id)
            retweeting_post.create()
        except pymongo.errors.DuplicateKeyError:
            pass
        retweet_count = Retweet._collection.count_documents({'post': ObjectId(post_id)})
        post = Post._collection.find_one_and_update(
            {'_id': ObjectId(post_id)}, 
            {'$set': {'retweet_count': retweet_count}},
            return_document=pymongo.ReturnDocument.AFTER)
        post['retweeting'] = True

        return jsonify(_populate(post))


@app.route('/api/posts/<post_id>/retweet', methods=['DELETE'])
@login_required()
def unretweet_post(post_id):
    post = Post._collection.find_one({'_id': ObjectId(post_id)})
    if not post:
        return jsonify({'error': { 'message': '投稿が存在しません' }}), 404
    user_id = session['user']['_id']
    retweet = Retweet._collection.find_one_and_delete({'post': ObjectId(post_id), 'retweeted_by': ObjectId(user_id)})
    Post._collection.find_one_and_delete({'posted_by': ObjectId(user_id), 'retweeted_post': ObjectId(post_id)})
    if not retweet:
        post['retweeting'] = False
        return jsonify(_populate(post))
    else:
        retweet_count = Retweet._collection.count_documents({'post': ObjectId(post_id)})
        post = Post._collection.find_one_and_update(
            {'_id': ObjectId(post_id)}, 
            {'$set': {'retweet_count': retweet_count}},
            return_document=pymongo.ReturnDocument.AFTER)
        post['retweeting'] = False

        return jsonify(_populate(post))


def _populate(post):
    if not post:
        return post
    post['_id'] = str(post['_id'])
    posted_by = User._collection.find_one({'_id': post['posted_by']})
    if posted_by:
        posted_by['_id'] = str(posted_by['_id'])
        post['posted_by'] = posted_by
    if 'retweeted_post' in post and post['retweeted_post']:
        post['retweeted_post'] = _populate(Post._collection.find_one({'_id': post['retweeted_post']}))
    return post
```

# 画面

アクションや呼び出し部分はいいねとほぼ同じです。

```javascript:client/src/store.js
import { createStore } from 'vuex'
import router from '@/router';

import axios from 'axios';
import UIkit from 'uikit';

export default createStore({
  state: {
    userLoggedIn: undefined,
    posts: []
  },
  getters: {
  },
  mutations: {
...
  },
  actions: {
...
    },
    retweetPost ({ commit }, post) {
      axios.post(`/api/posts/${post._id}/retweet`).then(resp => {
        commit('updatePost', resp.data);
      })
      .catch(error => {
        if (error.response.status === 401) {
          router.push('/login');
        } else {
          UIkit.notification(error.response.data.error.message, {status: 'danger'});
        }
      });
    },
    unretweetPost ({ commit }, post) {
      axios.delete(`/api/posts/${post._id}/retweet`).then(resp => {
        commit('updatePost', resp.data);
      })
      .catch(error => {
        if (error.response.status === 401) {
          router.push('/login');
        } else {
          UIkit.notification(error.response.data.error.message, {status: 'danger'});
        }
      });
    }
  },
  modules: {
  }
})
```

ひとまずリツイート自体はできるようになりましたが、リツイートすると空っぽの投稿が表示されるようになってしまいました。
次回はここを修正していきましょう。

![スクリーンショット 2022-03-17 16.06.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/1ddf5558-a1f2-ae74-89a7-75bb0f1246d2.png)