Skip to content

Commit 28e460d

Browse files
authored
feat: Sync Google (#136)
1 parent 2b8df37 commit 28e460d

15 files changed

+3756
-8
lines changed

.gitignore

+4-1
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,14 @@ dmypy.json
145145
# Pyre type checker
146146
.pyre/
147147

148+
# mac env
149+
bin
148150

149151
# register stuff
150152
run.txt
153+
151154
# VScode
152-
.vscode/
155+
.vscode
153156
app/.vscode/
154157

155158
app/routers/stam

app/config.py.example

+5
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ WEBSITE_LANGUAGE = "en"
3636
ASTRONOMY_API_KEY = os.getenv('ASTRONOMY_API_KEY')
3737
WEATHER_API_KEY = os.getenv('WEATHER_API_KEY')
3838

39+
# https://developers.google.com/calendar/quickstart/python -
40+
# follow instracions and make an env variable with the path to the file.
41+
CLIENT_SECRET_FILE = os.environ.get('CLIENT_SECRET')
42+
43+
3944
# EXPORT
4045
ICAL_VERSION = '2.0'
4146
PRODUCT_ID = '-//Our product id//'

app/database/models.py

+21-1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ class User(Base):
4646
)
4747
comments = relationship("Comment", back_populates="user")
4848

49+
oauth_credentials = relationship(
50+
"OAuthCredentials", cascade="all, delete", back_populates="owner",
51+
uselist=False)
52+
4953
def __repr__(self):
5054
return f'<User {self.id}>'
5155

@@ -65,11 +69,12 @@ class Event(Base):
6569
end = Column(DateTime, nullable=False)
6670
content = Column(String)
6771
location = Column(String)
72+
is_google_event = Column(Boolean)
6873
vc_link = Column(String)
6974
color = Column(String, nullable=True)
70-
availability = Column(Boolean, default=True, nullable=False)
7175
invitees = Column(String)
7276
emotion = Column(String, nullable=True)
77+
availability = Column(Boolean, default=True, nullable=False)
7378

7479
owner_id = Column(Integer, ForeignKey("users.id"))
7580
category_id = Column(Integer, ForeignKey("categories.id"))
@@ -187,6 +192,21 @@ def __repr__(self):
187192
)
188193

189194

195+
class OAuthCredentials(Base):
196+
__tablename__ = "oauth_credentials"
197+
198+
id = Column(Integer, primary_key=True, index=True)
199+
token = Column(String)
200+
refresh_token = Column(String)
201+
token_uri = Column(String)
202+
client_id = Column(String)
203+
client_secret = Column(String)
204+
expiry = Column(DateTime)
205+
206+
user_id = Column(Integer, ForeignKey("users.id"))
207+
owner = relationship("User", back_populates=__tablename__, uselist=False)
208+
209+
190210
class SalarySettings(Base):
191211
# Code revision required after categories feature is added
192212
# Code revision required after holiday times feature is added

app/dependencies.py

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from app.database import SessionLocal
99
from app.internal.logger_customizer import LoggerCustomizer
1010

11+
GOOGLE_ERROR = config.CLIENT_SECRET_FILE is None
1112
APP_PATH = os.path.dirname(os.path.realpath(__file__))
1213
MEDIA_PATH = os.path.join(APP_PATH, config.MEDIA_DIRECTORY)
1314
STATIC_PATH = os.path.join(APP_PATH, "static")

app/internal/google_connect.py

+213
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
from datetime import datetime
2+
from fastapi import Depends
3+
4+
from google.auth.transport.requests import Request as google_request
5+
from google.oauth2.credentials import Credentials
6+
from google_auth_oauthlib.flow import InstalledAppFlow
7+
from googleapiclient.discovery import build
8+
9+
from app.database.models import Event, User, OAuthCredentials, UserEvent
10+
from app.dependencies import get_db, SessionLocal
11+
from app.config import CLIENT_SECRET_FILE
12+
from app.routers.event import create_event
13+
14+
15+
SCOPES = ['https://www.googleapis.com/auth/calendar']
16+
17+
18+
def get_credentials(user: User,
19+
session: SessionLocal = Depends(get_db)) -> Credentials:
20+
credentials = get_credentials_from_db(user)
21+
22+
if credentials is not None:
23+
credentials = refresh_token(credentials, session, user)
24+
else:
25+
credentials = get_credentials_from_consent_screen(
26+
user=user, session=session)
27+
28+
return credentials
29+
30+
31+
def fetch_save_events(credentials: Credentials, user: User,
32+
session: SessionLocal = Depends(get_db)) -> None:
33+
if credentials is not None:
34+
events = get_current_year_events(credentials, user, session)
35+
push_events_to_db(events, user, session)
36+
37+
38+
def clean_up_old_credentials_from_db(
39+
session: SessionLocal = Depends(get_db)
40+
) -> None:
41+
session.query(OAuthCredentials).filter_by(user_id=None).delete()
42+
session.commit()
43+
44+
45+
def get_credentials_from_consent_screen(user: User,
46+
session: SessionLocal = Depends(get_db)
47+
) -> Credentials:
48+
credentials = None
49+
50+
if not is_client_secret_none(): # if there is no client_secrets.json
51+
flow = InstalledAppFlow.from_client_secrets_file(
52+
client_secrets_file=CLIENT_SECRET_FILE,
53+
scopes=SCOPES
54+
)
55+
56+
flow.run_local_server(prompt='consent')
57+
credentials = flow.credentials
58+
59+
push_credentials_to_db(
60+
credentials=credentials, user=user, session=session
61+
)
62+
63+
clean_up_old_credentials_from_db(session=session)
64+
65+
return credentials
66+
67+
68+
def push_credentials_to_db(credentials: Credentials, user: User,
69+
session: SessionLocal = Depends(get_db)
70+
) -> OAuthCredentials:
71+
72+
oauth_credentials = OAuthCredentials(
73+
owner=user,
74+
token=credentials.token,
75+
refresh_token=credentials.refresh_token,
76+
token_uri=credentials.token_uri,
77+
client_id=credentials.client_id,
78+
client_secret=credentials.client_secret,
79+
expiry=credentials.expiry
80+
)
81+
82+
session.add(oauth_credentials)
83+
session.commit()
84+
return credentials
85+
86+
87+
def is_client_secret_none() -> bool:
88+
return CLIENT_SECRET_FILE is None
89+
90+
91+
def get_current_year_events(
92+
credentials: Credentials, user: User,
93+
session: SessionLocal = Depends(get_db)) -> list:
94+
'''Getting user events from google calendar'''
95+
96+
current_year = datetime.now().year
97+
start = datetime(current_year, 1, 1).isoformat() + 'Z'
98+
end = datetime(current_year + 1, 1, 1).isoformat() + 'Z'
99+
100+
service = build('calendar', 'v3', credentials=credentials)
101+
events_result = service.events().list(
102+
calendarId='primary',
103+
timeMin=start,
104+
timeMax=end,
105+
singleEvents=True,
106+
orderBy='startTime'
107+
).execute()
108+
109+
events = events_result.get('items', [])
110+
return events
111+
112+
113+
def push_events_to_db(events: list, user: User,
114+
session: SessionLocal = Depends(get_db)) -> bool:
115+
'''Adding google events to db'''
116+
cleanup_user_google_calendar_events(user, session)
117+
118+
for event in events:
119+
# Running over the events that have come from the API
120+
title = event.get('summary') # The Google event title
121+
122+
# support for all day events
123+
if 'dateTime' in event['start']:
124+
# This case handles part time events (not all day events)
125+
start = datetime.fromisoformat(event['start']['dateTime'])
126+
end = datetime.fromisoformat(event['end']['dateTime'])
127+
else:
128+
# This case handles all day events
129+
start_in_str = event['start']['date']
130+
start = datetime.strptime(start_in_str, '%Y-%m-%d')
131+
132+
end_in_str = event['end']['date']
133+
end = datetime.strptime(end_in_str, '%Y-%m-%d')
134+
135+
# if Google Event has a location attached
136+
location = event.get('location')
137+
138+
create_google_event(title, start, end, user, location, session)
139+
return True
140+
141+
142+
def create_google_event(title: str, start: datetime,
143+
end: datetime, user: User, location: str,
144+
session: SessionLocal = Depends(get_db)) -> Event:
145+
return create_event(
146+
# creating an event obj and pushing it into the db
147+
db=session,
148+
title=title,
149+
start=start,
150+
end=end,
151+
owner_id=user.id,
152+
location=location,
153+
is_google_event=True
154+
)
155+
156+
157+
def cleanup_user_google_calendar_events(
158+
user: User, session: SessionLocal = Depends(get_db)
159+
) -> bool:
160+
'''removing all user google events so the next time will be syncronized'''
161+
162+
for user_event in user.events:
163+
user_event_id = user_event.id
164+
event = user_event.events
165+
if event.is_google_event:
166+
session.query(Event).filter_by(id=event.id).delete()
167+
session.query(UserEvent).filter_by(id=user_event_id).delete()
168+
session.commit()
169+
170+
return True
171+
172+
173+
def get_credentials_from_db(user: User) -> Credentials:
174+
'''bring user credential to use with google calendar api
175+
and save the credential in the db'''
176+
177+
credentials = None
178+
179+
if user.oauth_credentials is not None:
180+
db_credentials = user.oauth_credentials
181+
credentials = Credentials(
182+
token=db_credentials.token,
183+
refresh_token=db_credentials.refresh_token,
184+
token_uri=db_credentials.token_uri,
185+
client_id=db_credentials.client_id,
186+
client_secret=db_credentials.client_secret,
187+
expiry=db_credentials.expiry
188+
)
189+
190+
return credentials
191+
192+
193+
def refresh_token(credentials: Credentials,
194+
user: User, session: SessionLocal = Depends(get_db)
195+
) -> Credentials:
196+
197+
refreshed_credentials = credentials
198+
if credentials.expired:
199+
credentials.refresh(google_request())
200+
refreshed_credentials = OAuthCredentials(
201+
owner=user,
202+
token=credentials.token,
203+
refresh_token=credentials.refresh_token,
204+
token_uri=credentials.token_uri,
205+
client_id=credentials.client_id,
206+
client_secret=credentials.client_secret,
207+
expiry=credentials.expiry
208+
)
209+
210+
session.add(refreshed_credentials)
211+
session.commit()
212+
213+
return refreshed_credentials

app/main.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ def create_tables(engine, psql_environment):
4343

4444
from app.routers import ( # noqa: E402
4545
agenda, calendar, categories, celebrity, currency, dayview,
46-
email, event, export, four_o_four, invitation, login, logout, profile,
46+
email, event, export, four_o_four, google_connect,
47+
invitation, login, logout, profile,
4748
register, search, telegram, user, weekview, whatsapp,
4849
)
4950

@@ -78,6 +79,7 @@ async def swagger_ui_redirect():
7879
event.router,
7980
export.router,
8081
four_o_four.router,
82+
google_connect.router,
8183
invitation.router,
8284
login.router,
8385
logout.router,

app/routers/event.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,10 @@ async def create_new_event(request: Request,
100100
if vc_link is not None:
101101
raise_if_zoom_link_invalid(vc_link)
102102

103-
event = create_event(session, title, start, end, owner_id, content,
104-
location, vc_link, invitees=invited_emails,
103+
event = create_event(db=session, title=title, start=start, end=end,
104+
owner_id=owner_id, content=content,
105+
location=location, vc_link=vc_link,
106+
invitees=invited_emails,
105107
category_id=category_id,
106108
availability=availability)
107109

@@ -263,6 +265,7 @@ def create_event(db: Session, title: str, start, end, owner_id: int,
263265
invitees: List[str] = None,
264266
category_id: Optional[int] = None,
265267
availability: bool = True,
268+
is_google_event: bool = False,
266269
):
267270
"""Creates an event and an association."""
268271

@@ -282,6 +285,7 @@ def create_event(db: Session, title: str, start, end, owner_id: int,
282285
invitees=invitees_concatenated,
283286
category_id=category_id,
284287
availability=availability,
288+
is_google_event=is_google_event
285289
)
286290
create_model(
287291
db, UserEvent,

app/routers/google_connect.py

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from fastapi import Depends, APIRouter, Request
2+
from starlette.responses import RedirectResponse
3+
from loguru import logger
4+
5+
from app.internal.utils import get_current_user
6+
from app.dependencies import get_db
7+
from app.internal.google_connect import get_credentials, fetch_save_events
8+
from app.routers.profile import router as profile
9+
10+
router = APIRouter(
11+
prefix="/google",
12+
tags=["sync"],
13+
responses={404: {"description": "Not found"}},
14+
)
15+
16+
17+
@router.get("/sync")
18+
async def google_sync(request: Request,
19+
session=Depends(get_db)) -> RedirectResponse:
20+
'''Sync with Google - if user never synced with google this funcion will take
21+
the user to a consent screen to use his google calendar data with the app.
22+
'''
23+
24+
user = get_current_user(session) # getting active user
25+
26+
# getting valid credentials
27+
credentials = get_credentials(user=user, session=session)
28+
29+
if credentials is None:
30+
# in case credentials is none, this is mean there isn't a client_secret
31+
logger.error("GoogleSync isn't available - missing client_secret.json")
32+
33+
# fetch and save the events com from Google Calendar
34+
fetch_save_events(credentials=credentials, user=user, session=session)
35+
36+
url = profile.url_path_for('profile')
37+
return RedirectResponse(url=url)

app/routers/profile.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from app import config
1111
from app.database.models import User
12-
from app.dependencies import get_db, MEDIA_PATH, templates
12+
from app.dependencies import get_db, MEDIA_PATH, templates, GOOGLE_ERROR
1313
from app.internal.on_this_day_events import get_on_this_day_events
1414
from app.internal.import_holidays import (get_holidays_from_file,
1515
save_holidays_to_db)
@@ -58,6 +58,7 @@ async def profile(
5858
"user": user,
5959
"events": upcoming_events,
6060
"signs": signs,
61+
'google_error': GOOGLE_ERROR,
6162
"on_this_day_data": on_this_day_data,
6263
})
6364

0 commit comments

Comments
 (0)