【Python3】学校の休講情報のGoogleカレンダーからクラスを仕分けて予定に登録したい

# はじめに
動かしてます。詳細は記事へ。
[【Python3】学校の休講情報のGoogleカレンダーからクラスを仕分けて予定に登録するやつを定期的に動かした](https://qiita.com/Hiroya_W/private/8e6fd5b410aa8709228c)

某高専、学校の休講情報がGoogleカレンダーに登録されています。
こんな感じに。

![休講情報 - Google Chrome_001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336583/3aa9a739-abe9-e23f-1b10-fb6997c05100.png)

これを自分のGoogleカレンダーに追加(右下のプラスから追加できるのは5年間知らなかった)
しておけば、すぐに休講情報を確認できるわけですが、

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336583/1237810f-520a-90c1-6a0a-606f2494d850.png" width=40%>

**全クラス分入ってて見づらい！！！**

なんとかできないんでしょうか。
自分のクラスだけ追加して見たい、というのが本題です。やっていきます。

# Googleカレンダー APIの準備
[Google Calenderの予定をPython3から取得する](https://qiita.com/lobmto/items/c1a220a12ec9c1fad560)
こちらを参考に、認証用のJSONファイルを取得しておきます。

# サンプルを動かしてみる
[Python Quickstart](https://developers.google.com/calendar/quickstart/python)に記載されていた
サンプルプログラムを動かしてみます。

pipでライブラリをインストールし、取得した認証用JSONファイルを配置します。
サンプルプログラム内のファイルパスも変更しておきます。

実行すると、ブラウザが立ち上がり、ログインを求められました。
アプリ名は、認証用のJSONファイルを取得する際に入力した名前が表示されていますね。

![範囲を選択_004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336583/decb04f6-430d-dc25-ae06-521e4cf3e2aa.png)

不安になる表示が出ますが、自分のアプリなので問題なし。続行します。

![範囲を選択_005.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336583/0622f0cf-bdba-7e2d-4f8d-7b61a5b1f2e1.png)

権限を付与します。

![範囲を選択_006.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336583/551d5306-0733-caca-deb0-3e4a83920e4b.png)

すると、私の場合ではこんな感じに表示されました。
これは、私のカレンダーに登録されている予定です。

```
$ python src/get_info.py
Getting the upcoming 10 events
2019-08-13T10:00:00+09:00 基本情報 申込締切
2019-10-20T09:00:00+09:00 基本情報 試験日
```

# 休講情報のカレンダーID
ここからは、以下のサイトを参考に進めていきます。
[[Python] GoogleカレンダーAPIを使って、日本の祝日を取得する](https://www.yoheim.net/blog.php?q=20160501)

カレンダーにはGoogleカレンダーIDというものがつけられています。
載せてもいいものなのか分からないので、一応伏せておきますが、
Googleカレンダーから、カレンダーの設定を開くと確認できます。
![範囲を選択_003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336583/b9ac1fdc-61e2-7ee6-a151-3df83f9ae1cf.png)

このカレンダーから予定を取得します。

# カレンダーIDを指定して予定を取得する
サンプルプログラムにおいて、APIを実行する部分はこの部分になります。
それより前は認証の手続き、それより後は情報の整形といった感じになっています。

```python
events_result = service.events().list(calendarId='primary', timeMin=now,
                                    maxResults=10, singleEvents=True,
                                    orderBy='startTime').execute()
```

この`calendarId`という引数に休講情報のカレンダーIDを指定して実行してみます。

```python
    # カレンダーID
    calendar_id = (
        "CALENDAR_ID@group.calendar.google.com" # 休講情報のカレンダーIDをここ
    )
    # Call the Calendar API
    now = datetime.datetime.utcnow().isoformat() + "Z"  # 'Z' indicates UTC time
    print("Getting the upcoming 10 events")
    events_result = (
        service.events()
        .list(
            calendarId=calendar_id,
            timeMin=now,
            maxResults=10,
            singleEvents=True,
            orderBy="startTime",
        )
        .execute()
    )
```

```
$ python src/get_info.py
Getting the upcoming 10 events
2019-09-30 専１  3～8時限 特別実験（ESｺｰｽB班） 金山教員
```

え、超簡単。

これからクラスを仕分け、そのクラス用のGoogleカレンダーに登録するという方針で進めていきます。

# アプリの権限の変更
ここまではカレンダーの情報を読むだけの権限で十分でしたが、
ここからはカレンダーへの書き込みの権限が必要になってきます。

>しかし一度サンプルスクリプトを実行しGoogleアカウント側でreadonly権限を承認した場合、Googleアカウント側から権限を削除したのち、read/write権限に変更後のスクリプトを実行し再度権限を承認する必要がある。

[[Python]スクレイピングで情報取得→googleカレンダーAPIでイベント登録を自動化](https://qiita.com/torahack/items/fbe7416f5571344060ea#_reference-693cbfb6861cc72b89c8)より引用。

ということですので、参考記事に従って権限を削除し承認しなおします。
[他のアプリに許可したアクセス権を取り消す](https://www.howtonote.jp/google-account/security/index4.html)

Googleアカウントのページを開き、左側の`セキュリティ`をクリック。
`アカウントにアクセスできるサードパーティアプリ`の`サードパーティによるアクセスを管理`から、
`HIROアプリ`のアクセス権を削除します。

また、Python側のプログラムでtokenが存在して読み込んだけど期限切れだよって言われて、
自動的にリフレッシュ(ブラウザを起動して承認画面を表示)してくれませんでした。
ワークスペースに存在する`token.pickle`を削除するか、
無理やり、`pickle.load(token)`をコメントアウトして目標は達成。

# カレンダーへ予定を追加するサンプルプログラム
情報の整形前に、どのようなパラメータを渡して予定を登録するのか確認します。

以下は[GoogleカレンダーAPIリファレンス](https://developers.google.com/calendar/v3/reference/events/insert)に記載されていたものに少し調整を加えたものです。

```python
body = {
    "summary": "Google I/O 2015",
    "location": "800 Howard St., San Francisco, CA 94103",
    "description": "A chance to hear more about Google's developer products.",
    "start": {"dateTime": "2019-08-09T09:00:00+09:00", "timeZone": "Asia/Tokyo"},
    "end": {"dateTime": "2019-08-09T17:00:00+09:00", "timeZone": "Asia/Tokyo"},
    "recurrence": ["RRULE:FREQ=DAILY;COUNT=2"],
    "attendees": [{"email": "lpage@example.com"}, {"email": "sbrin@example.com"}],
    "reminders": {
        "useDefault": False,
        "overrides": [
            {"method": "email", "minutes": 24 * 60},
            {"method": "popup", "minutes": 10},
        ],
    },
    "colorId": "5",
}

event = service.events().insert(calendarId="primary", body=body).execute()
print("Event created: %s" % (event.get("htmlLink")))
```

実行すると、こんな感じ。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/336583/556ade44-f59d-7a70-ee31-e4bd5bdb7129.png" width=40%>

さすがGoogle APIといったところでしょうか、全部簡単に触れちゃいますね。

# 取得できるevent情報
登録の仕方はわかりましたので、どういった情報が得られているのか確認します。
適当にeventをprintして表示し、整形と少し手を加えたものを以下に記載します。

```json
{
    "kind": "calendar#event",
    "etag": "01234567890", 
    "id": "abc01234567890",
    "status": "confirmed",
    "htmlLink": "https://www.google.com/calendar/event?eid=EVENTID",
    "created": "2019-08-08T06:03:09.000Z",
    "updated": "2019-08-08T06:04:15.669Z",
    "summary": "専１  3～8時限 特別実験（ESｺｰｽB班） 金山教員",
    "creator": {
        "email": "EMAIL@ADDRESS.com"},
        "organizer": {
            "email": "ORGANIZER@group.calendar.google.com",
            "displayName": "休講情報",
            "self": "True"
        }, 
    "start": {
        "date": "2019-09-30"
    },
    "end": {
        "date": "2019-10-01"
    },
    "transparency": "transparent",
    "iCalUID": "iCalUID@google.com",
    "sequence": 0,
    "reminders": {
        "useDefault": "True"
    }
}
```

これを抜き出して、カレンダーに追加します。
結局、必要なのは以下の情報だけです。

- id
- summary
- start.date
- end.date

# 予定の削除
予定の削除には、取得できるイベント情報の`id`を用います。

```python
service.events().delete(calendarId='primary', eventId='eventId').execute()
```

# CSVへ書き込む
クラス分けする前に取得した情報を一旦CSVへ書き込むことにします。

## 休講情報

```python:get_info_create_csv.py
    # columnを指定してDataFrameを作成
    df = pd.DataFrame(columns=["id", "summary", "start_date", "end_date"])
    if not events:
        print("No upcoming events found.")
    for event in events:
        # 1行分のデータを作成
        sr = pd.Series(
            [
                event["id"],
                event["summary"],
                event["start"].get("dateTime", event["start"].get("date")),
                event["end"].get("dateTime", event["end"].get("date")),
            ],
            index=df.columns,
        )
        # DataFrameに追加
        df = df.append(sr, ignore_index=True)

    # CSVに書き出し
    df.to_csv("event.csv", index=False)
```

## 自分のカレンダー情報
追加するための休講情報カレンダーを新たに用意し、そのカレンダーに登録されている情報を取得します。
カレンダーIDを変更するだけ。

# 差分を取得
- 新しく追加された休講情報
- 消された休講情報

を取得したいと思います。
そのために前回取得した休講情報を記録しておき、新しく取得した休講情報を比較します。すると、

- 新しく取得した情報にしかなければ**カレンダーに追加する**
- 前回取得した情報にしかなければ**カレンダーから削除する**

とすれば良い気がしてきます。
もちろん、新しく取得する休講情報は取得日を含み、それより未来の予定を取得しますが、
前回取得した休講情報には、それより過去の日付を含むため、それを取り除く必要があります。

とすると、プログラムは以下のようになります。

```python:get_diff.py
import pandas as pd


def main():
    df_new = pd.read_csv("cancel_new.csv")

    df_old = pd.read_csv("cancel_old.csv")
    # object型からdatetime型へ変換
    df_old["start_date"] = pd.to_datetime(df_old["start_date"])
    # 今日の日付を含む未来の予定のみ取り出す
    df_old = df_old[df_old["start_date"] >= pd.Timestamp.now(tz="Asia/Tokyo").date()]

    # 新しく取得した情報にしかないもの
    df_create = df_new[~df_new.id.isin((df_old.id))]
    # 前回取得した情報にしかないもの
    df_delete = df_old[~df_old.id.isin((df_new.id))]
    print(df_create)
    print(df_delete)


if __name__ == "__main__":
    main()

```


# 予定の追加と削除
今までのやつをガッチャンコします。
summaryは同じでも、日付が異なることがありえるので、どちらも一致するかを調べ、
一致すればIDを取得し、IDを用いて削除します。
また、summaryに含まれるクラス情報を調べて、自分のクラスであれば登録するようにしておきます。
ここでは、クラス名`5E`が含まれるかをチェックします。


```python
from __future__ import print_function
import datetime
import pickle
import os.path
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
import pandas as pd

# If modifying these scopes, delete the file token.pickle.
SCOPES = ["https://www.googleapis.com/auth/calendar"]


def get_diff():
    df_new = pd.read_csv("cancel_new.csv")

    df_old = pd.read_csv("cancel_old.csv")
    df_old["start_date"] = pd.to_datetime(df_old["start_date"])
    df_old = df_old[df_old["start_date"] >= pd.Timestamp.now().date()]

    df_create = df_new[~df_new.id.isin((df_old.id))]
    df_delete = df_old[~df_old.id.isin((df_new.id))]
    print(df_create)
    print(df_delete)
    return df_create, df_delete


def main():
    # 差分を取得
    df_create, df_delete = get_diff()

    """Shows basic usage of the Google Calendar API.
    Prints the start and name of the next 10 events on the user's calendar.
    """
    creds = None
    # The file token.pickle stores the user's access and refresh tokens, and is
    # created automatically when the authorization flow completes for the first
    # time.
    if os.path.exists("token.pickle"):
        with open("token.pickle", "rb") as token:
            creds = pickle.load(token)
            # print("refresh")
    # If there are no (valid) credentials available, let the user log in.
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file(
                "secret/client_secret.json", SCOPES
            )
            creds = flow.run_local_server(port=0)
        # Save the credentials for the next run
        with open("token.pickle", "wb") as token:
            pickle.dump(creds, token)

    service = build("calendar", "v3", credentials=creds)

    calendar_id = (
        "CALENDAR_ID@group.calendar.google.com"
    )

    # create
    for index, row in df_create.iterrows():
        if "5E" in row["summary"]:
            body = {
                "summary": row["summary"],
                "start": {"date": row["start_date"], "timeZone": "Asia/Tokyo"},
                "end": {"date": row["end_date"], "timeZone": "Asia/Tokyo"},
                "colorId": "5",
            }

            event = service.events().insert(calendarId=calendar_id, body=body).execute()
            print("Event created: %s" % (event.get("htmlLink")))

    # delete
    df_my = pd.read_csv("my_info.csv")
    for index, delete_row in df_delete.iterrows():
        if "5E" in delete_row["summary"]:
            for index, myinfo_row in df_my.iterrows():
                # fmt: off
                if (
                    delete_row["summary"] == myinfo_row["summary"]
                    and delete_row["start_date"] == pd.to_datetime(myinfo_row["start_date"])
                ):
                    # fmt: on
                    service.events().delete(
                        calendarId=calendar_id, eventId=myinfo_row["id"]
                    ).execute()
                    print("Event deleted")


if __name__ == "__main__":
    main()

```

これで`5E`の休講情報であれば、予定に追加したり、予定から削除したりできるようになりました。
後は、定期的に実行されるようにしておけば、完成です

# 全クラス対応
1つ仕分ければ、全クラスにも対応できるな、ということでやってみようとしたのですが、

一度に作成できるカレンダーの上限に引っかかってしまった。
日付が変わるまで待機して再度作成してみることにします。

また、クラス名の数字に`全角数字`、`半角数字`が入り混じってるので修正する必要がありました。

他にも専攻科1年って書いたり、専1って書いたり...。
表記を統一してくださいお願いします。
(柔軟に対応できるようにプログラムも書いてみたいが、スパゲッティしてきた...。)

# おわりに
それぞれのクラス用のカレンダーを用意して、振り分ければ学校全員に使ってもらえそうですね。
やってみたいと思います。