port to new django, AI automated
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# eventplanner_gcal app
|
||||
# Signals are loaded in apps.py AppConfig.ready()
|
||||
default_app_config = "eventplanner_gcal.apps.EventplannerGcalConfig"
|
||||
|
||||
14
eventplanner_gcal/apps.py
Normal file
14
eventplanner_gcal/apps.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EventplannerGcalConfig(AppConfig):
|
||||
"""App configuration for eventplanner_gcal."""
|
||||
|
||||
name = "eventplanner_gcal"
|
||||
verbose_name = "Event Planner Google Calendar Integration"
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
|
||||
def ready(self):
|
||||
"""Import signal handlers when the app is ready."""
|
||||
# Import signals to register them
|
||||
from . import signals # noqa: F401
|
||||
@@ -1,70 +1,112 @@
|
||||
import logging
|
||||
import httplib2
|
||||
"""
|
||||
Google Calendar synchronization module.
|
||||
|
||||
This module handles synchronization between the local event database
|
||||
and Google Calendar, including push notifications for real-time updates.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from eventplanner.models import Event, EventParticipation
|
||||
from eventplanner_gcal.models import GCalMapping, GCalPushChannel, UserGCalCoupling
|
||||
# noinspection PyUnresolvedReferences,PyUnresolvedReferences
|
||||
from apiclient.http import BatchHttpRequest
|
||||
from builtins import str as text # python2 and python3
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level service object cache
|
||||
_service_object = None
|
||||
|
||||
# ---------------------------------- Authentication using oauth2 -----------------------------------------------------
|
||||
|
||||
def create_gcal_service_object():
|
||||
"""Creates a Google API service object. This object is required whenever a Google API call is made"""
|
||||
from oauth2client.file import Storage
|
||||
# noinspection PyUnresolvedReferences
|
||||
from apiclient.discovery import build
|
||||
"""
|
||||
Creates a Google API service object.
|
||||
|
||||
gcal_settings = settings.GCAL_COUPLING
|
||||
This object is required whenever a Google API call is made.
|
||||
Uses the new google-auth library instead of oauth2client.
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
import pickle
|
||||
|
||||
storage = Storage(gcal_settings['credentials_file'])
|
||||
credentials = storage.get()
|
||||
|
||||
logger.debug("Credentials", credentials)
|
||||
if credentials is None or credentials.invalid is True:
|
||||
# flow = client.flow_from_clientsecrets(CLIENT_SECRET_FILE, SCOPES)
|
||||
logger.error("Unable to initialize Google Calendar coupling. Check your settings!")
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from googleapiclient.discovery import build
|
||||
except ImportError as e:
|
||||
logger.error(f"Required Google API libraries not installed: {e}")
|
||||
return None
|
||||
|
||||
http = httplib2.Http()
|
||||
http = credentials.authorize(http)
|
||||
res = build(serviceName='calendar', version='v3',
|
||||
http=http, developerKey=gcal_settings['developerKey'])
|
||||
gcal_settings = settings.GCAL_COUPLING
|
||||
credentials_file = gcal_settings["credentials_file"]
|
||||
|
||||
if res is None:
|
||||
logger.error("Authentication at Google API failed. Check your settings!")
|
||||
return res
|
||||
creds = None
|
||||
|
||||
# Try to load existing credentials
|
||||
if os.path.exists(credentials_file):
|
||||
try:
|
||||
with open(credentials_file, "rb") as token:
|
||||
creds = pickle.load(token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load credentials from {credentials_file}: {e}")
|
||||
|
||||
# Check if credentials are valid
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
# Save refreshed credentials
|
||||
with open(credentials_file, "wb") as token:
|
||||
pickle.dump(creds, token)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh credentials: {e}")
|
||||
creds = None
|
||||
|
||||
if not creds or not creds.valid:
|
||||
logger.error(
|
||||
"Invalid or missing Google Calendar credentials. "
|
||||
"Please run the credential setup process."
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
service = build("calendar", "v3", credentials=creds)
|
||||
return service
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to build Google Calendar service: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_service_object():
|
||||
if get_service_object.__serviceObject is None:
|
||||
get_service_object.__serviceObject = create_gcal_service_object()
|
||||
|
||||
return get_service_object.__serviceObject
|
||||
"""Get or create the Google Calendar service object."""
|
||||
global _service_object
|
||||
if _service_object is None:
|
||||
_service_object = create_gcal_service_object()
|
||||
return _service_object
|
||||
|
||||
|
||||
get_service_object.__serviceObject = None
|
||||
def reset_service_object():
|
||||
"""Reset the cached service object (useful for testing or credential refresh)."""
|
||||
global _service_object
|
||||
_service_object = None
|
||||
|
||||
|
||||
# --------------------- Building GCal event representation ----------------------------------------------------------
|
||||
# --------------------- Building GCal event representation ------------------------------------
|
||||
|
||||
|
||||
def build_gcal_attendees_obj(event):
|
||||
"""Builds an attendees object that is inserted into the GCal event.
|
||||
Attendees are all users that have a google mail address."""
|
||||
"""
|
||||
Builds an attendees object that is inserted into the GCal event.
|
||||
|
||||
Attendees are all users that have a Google mail address.
|
||||
"""
|
||||
result = []
|
||||
|
||||
for userMapping in UserGCalCoupling.objects.all():
|
||||
u = userMapping.user
|
||||
for user_mapping in UserGCalCoupling.objects.all():
|
||||
u = user_mapping.user
|
||||
|
||||
# No get or create here, since a create would trigger another synchronization
|
||||
# participation = EventParticipation.get_or_create( u, event )
|
||||
try:
|
||||
participation = EventParticipation.objects.get(event=event, user=u)
|
||||
local_status = participation.status
|
||||
@@ -76,31 +118,33 @@ def build_gcal_attendees_obj(event):
|
||||
status = "needsAction"
|
||||
if local_status == "?":
|
||||
status = "tentative"
|
||||
elif local_status == 'Yes':
|
||||
elif local_status == "Yes":
|
||||
status = "accepted"
|
||||
elif local_status == 'No':
|
||||
elif local_status == "No":
|
||||
status = "declined"
|
||||
|
||||
o = {
|
||||
'id': userMapping.email,
|
||||
'email': userMapping.email,
|
||||
'displayName': u.username,
|
||||
'comment': local_comment,
|
||||
'responseStatus': status,
|
||||
attendee = {
|
||||
"email": user_mapping.email,
|
||||
"displayName": u.username,
|
||||
"comment": local_comment,
|
||||
"responseStatus": status,
|
||||
}
|
||||
result.append(o)
|
||||
result.append(attendee)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def build_gcal_event(event, timezone="Europe/Berlin"):
|
||||
""" Builds a GCal event using a local event. """
|
||||
"""Builds a GCal event using a local event."""
|
||||
|
||||
def create_date_time_obj(date, time_obj):
|
||||
if time_obj is None:
|
||||
return {'date': text(date), 'timeZone': timezone}
|
||||
def create_datetime_obj(date, time_val):
|
||||
if time_val is None:
|
||||
return {"date": str(date), "timeZone": timezone}
|
||||
else:
|
||||
return {'dateTime': text(date) + 'T' + text(time_obj), 'timeZone': timezone}
|
||||
return {
|
||||
"dateTime": f"{date}T{time_val}",
|
||||
"timeZone": timezone,
|
||||
}
|
||||
|
||||
start_date = event.date
|
||||
end_date = event.end_date
|
||||
@@ -116,251 +160,356 @@ def build_gcal_event(event, timezone="Europe/Berlin"):
|
||||
else:
|
||||
end_time = datetime.time(22, 30)
|
||||
|
||||
g_location = text(event.location)
|
||||
g_location = str(event.location)
|
||||
if event.map_location:
|
||||
# Map location has the following format: latitude,longitude,zoomlevel
|
||||
# the first two are needed
|
||||
s = event.map_location.split(",")
|
||||
g_location = text("%s,%s" % (s[0], s[1]))
|
||||
parts = event.map_location.split(",")
|
||||
if len(parts) >= 2:
|
||||
g_location = f"{parts[0]},{parts[1]}"
|
||||
|
||||
gcal_settings = settings.GCAL_COUPLING
|
||||
|
||||
return {
|
||||
'summary': text(settings.GCAL_COUPLING['eventPrefix'] + event.title),
|
||||
'description': text(event.desc),
|
||||
'location': g_location,
|
||||
'start': create_date_time_obj(start_date, start_time),
|
||||
'end': create_date_time_obj(end_date, end_time),
|
||||
'extendedProperties': {
|
||||
'private': {
|
||||
'blechreizEvent': 'true',
|
||||
'blechreizID': event.id,
|
||||
"summary": gcal_settings["eventPrefix"] + event.title,
|
||||
"description": str(event.desc),
|
||||
"location": g_location,
|
||||
"start": create_datetime_obj(start_date, start_time),
|
||||
"end": create_datetime_obj(end_date, end_time),
|
||||
"extendedProperties": {
|
||||
"private": {
|
||||
"blechreizEvent": "true",
|
||||
"blechreizID": str(event.id),
|
||||
}
|
||||
},
|
||||
'attendees': build_gcal_attendees_obj(event),
|
||||
"attendees": build_gcal_attendees_obj(event),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------ Callback Functions -------------------------------------------------------------------
|
||||
# ------------------------------ Callback Functions ------------------------------------------------
|
||||
|
||||
def on_gcal_event_created(_, response, exception=None):
|
||||
"""Callback function for created events to enter new gcal id in the mapping table"""
|
||||
|
||||
def on_gcal_event_created(request_id, response, exception=None):
|
||||
"""Callback function for created events to enter new gcal id in the mapping table."""
|
||||
if exception is not None:
|
||||
logger.error("on_gcal_event_created: Exception " + str(exception))
|
||||
logger.error(f"Error creating GCal event: {exception}")
|
||||
raise exception
|
||||
|
||||
google_id = response['id']
|
||||
django_id = response['extendedProperties']['private']['blechreizID']
|
||||
mapping = GCalMapping(gcal_id=google_id, event=Event.objects.get(pk=django_id))
|
||||
mapping.save()
|
||||
google_id = response["id"]
|
||||
django_id = response["extendedProperties"]["private"]["blechreizID"]
|
||||
|
||||
try:
|
||||
event = Event.objects.get(pk=django_id)
|
||||
mapping = GCalMapping(gcal_id=google_id, event=event)
|
||||
mapping.save()
|
||||
logger.info(f"Created mapping: GCal {google_id} <-> Event {django_id}")
|
||||
except Event.DoesNotExist:
|
||||
logger.error(f"Event {django_id} not found when creating GCal mapping")
|
||||
|
||||
|
||||
# ------------------------------ GCal Api Calls --------------------------------------------------------------------
|
||||
# ------------------------------ GCal Api Calls -------------------------------------------------
|
||||
|
||||
|
||||
def get_all_gcal_events(service, from_now=False):
|
||||
"""Retrieves all gcal events with custom property blechreizEvent=True i.e. all
|
||||
events that have been created by this script."""
|
||||
"""
|
||||
Retrieves all gcal events with custom property blechreizEvent=True.
|
||||
|
||||
These are all events that have been created by this script.
|
||||
"""
|
||||
if from_now:
|
||||
now = datetime.datetime.now()
|
||||
min_time = now.strftime("%Y-%m-%dT%H:%M:%S-00:00")
|
||||
else:
|
||||
min_time = '2000-01-01T00:00:00-00:00'
|
||||
min_time = "2000-01-01T00:00:00-00:00"
|
||||
|
||||
events = service.events().list(
|
||||
calendarId='primary',
|
||||
singleEvents=True,
|
||||
maxResults=1000,
|
||||
orderBy='startTime',
|
||||
timeMin=min_time,
|
||||
timeMax='2100-01-01T00:00:00-00:00',
|
||||
privateExtendedProperty='blechreizEvent=true',
|
||||
).execute()
|
||||
return events['items']
|
||||
try:
|
||||
events = (
|
||||
service.events()
|
||||
.list(
|
||||
calendarId="primary",
|
||||
singleEvents=True,
|
||||
maxResults=1000,
|
||||
orderBy="startTime",
|
||||
timeMin=min_time,
|
||||
timeMax="2100-01-01T00:00:00-00:00",
|
||||
privateExtendedProperty="blechreizEvent=true",
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
return events.get("items", [])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve GCal events: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def create_gcal_event(service, event, timezone="Europe/Berlin"):
|
||||
"""Creates a new gcal event using a local event"""
|
||||
def create_gcal_event_request(service, event, timezone="Europe/Berlin"):
|
||||
"""Creates a request to create a new gcal event using a local event."""
|
||||
google_event = build_gcal_event(event, timezone)
|
||||
return service.events().insert(calendarId='primary', body=google_event)
|
||||
return service.events().insert(calendarId="primary", body=google_event)
|
||||
|
||||
|
||||
def update_gcal_event(service, event, timezone="Europe/Berlin"):
|
||||
"""Updates an existing gcal event, using a local event"""
|
||||
def update_gcal_event_request(service, event, timezone="Europe/Berlin"):
|
||||
"""Creates a request to update an existing gcal event using a local event."""
|
||||
google_event = build_gcal_event(event, timezone)
|
||||
try:
|
||||
mapping = GCalMapping.objects.get(event=event)
|
||||
except GCalMapping.DoesNotExist:
|
||||
return create_gcal_event(service, event, timezone)
|
||||
return create_gcal_event_request(service, event, timezone)
|
||||
|
||||
return service.events().patch(calendarId='primary', eventId=mapping.gcal_id, body=google_event)
|
||||
return service.events().patch(
|
||||
calendarId="primary", eventId=mapping.gcal_id, body=google_event
|
||||
)
|
||||
|
||||
|
||||
def delete_gcal_event(service, event):
|
||||
"""Deletes gcal that belongs to the given local event"""
|
||||
mapping = GCalMapping.objects.get(event=event)
|
||||
gcal_id = mapping.gcal_id
|
||||
mapping.delete()
|
||||
return service.events().delete(calendarId='primary', eventId=gcal_id)
|
||||
|
||||
|
||||
# ------------------------------------- Synchronization -------------------------------------------------------------
|
||||
|
||||
def delete_all_gcal_events(service=None):
|
||||
"""Deletes all gcal events that have been created by this script"""
|
||||
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
gcal_ids = [ev['id'] for ev in get_all_gcal_events(service)]
|
||||
num_ids = len(gcal_ids)
|
||||
if num_ids == 0:
|
||||
return num_ids
|
||||
|
||||
batch = BatchHttpRequest()
|
||||
for ev_id in gcal_ids:
|
||||
batch.add(service.events().delete(calendarId='primary', eventId=ev_id))
|
||||
batch.execute()
|
||||
|
||||
GCalMapping.objects.all().delete()
|
||||
|
||||
return num_ids
|
||||
|
||||
|
||||
def sync_from_local_to_google(service=None):
|
||||
""" Creates a google event for each local event (if it does not exist yet) and deletes all google events
|
||||
that are not found in local database. Updates participation info of gcal events using local data
|
||||
"""
|
||||
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
all_events = get_all_gcal_events(service)
|
||||
|
||||
events_at_google_django_id = set()
|
||||
events_at_google_google_id = set()
|
||||
for gcal_ev in all_events:
|
||||
events_at_google_django_id.add(int(gcal_ev['extendedProperties']['private']['blechreizID']))
|
||||
events_at_google_google_id.add(gcal_ev['id'])
|
||||
|
||||
local_events_django_id = set(Event.objects.all().values_list('pk', flat=True))
|
||||
local_events_google_id = set(GCalMapping.objects.all().values_list('gcal_id', flat=True))
|
||||
|
||||
events_to_create_django_id = local_events_django_id - events_at_google_django_id
|
||||
events_to_delete_google_id = events_at_google_google_id - local_events_google_id
|
||||
|
||||
batch = BatchHttpRequest()
|
||||
|
||||
batch_is_empty = True
|
||||
for event_django_id in events_to_create_django_id:
|
||||
batch.add(create_gcal_event(service, Event.objects.get(pk=event_django_id)), callback=on_gcal_event_created)
|
||||
batch_is_empty = False
|
||||
|
||||
for eventGoogleID in events_to_delete_google_id:
|
||||
batch.add(service.events().delete(calendarId='primary', eventId=eventGoogleID))
|
||||
batch_is_empty = False
|
||||
|
||||
for gcal_ev in all_events:
|
||||
event_django_id = int(gcal_ev['extendedProperties']['private']['blechreizID'])
|
||||
try:
|
||||
django_ev = Event.objects.get(pk=event_django_id)
|
||||
if 'attendees' not in gcal_ev:
|
||||
gcal_ev['attendees'] = []
|
||||
|
||||
if gcal_ev['attendees'] != build_gcal_attendees_obj(django_ev):
|
||||
batch.add(update_gcal_event(service, django_ev))
|
||||
batch_is_empty = False
|
||||
except Event.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not batch_is_empty:
|
||||
batch.execute()
|
||||
|
||||
return len(events_to_create_django_id), len(events_to_delete_google_id)
|
||||
|
||||
|
||||
def sync_from_google_to_local(service=None):
|
||||
"""Retrieves only participation infos for all events and updates local database if anything has changed. """
|
||||
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
new_status_received = False
|
||||
all_events = get_all_gcal_events(service, from_now=True)
|
||||
for e in all_events:
|
||||
local_id = e['extendedProperties']['private']['blechreizID']
|
||||
local_event = Event.objects.get(pk=local_id)
|
||||
for a in e['attendees']:
|
||||
user = UserGCalCoupling.objects.get(email=a['email']).user
|
||||
part = EventParticipation.get_or_create(user, local_event)
|
||||
if 'comment' in a:
|
||||
part.comment = a['comment']
|
||||
|
||||
if a['responseStatus'] == 'needsAction':
|
||||
part.status = "-"
|
||||
elif a['responseStatus'] == 'tentative':
|
||||
part.status = '?'
|
||||
elif a['responseStatus'] == 'accepted':
|
||||
part.status = 'Yes'
|
||||
elif a['responseStatus'] == 'declined':
|
||||
part.status = 'No'
|
||||
else:
|
||||
logger.error("Unknown response status when mapping gcal event: " + a['responseStatus'])
|
||||
|
||||
prev = EventParticipation.objects.get(event=part.event, user=part.user)
|
||||
|
||||
# Important: Save only if the participation info has changed
|
||||
# otherwise everything is synced back to google via the post save signal
|
||||
# and an endless loop is entered
|
||||
if prev.status != part.status or prev.comment != part.comment:
|
||||
part.save()
|
||||
new_status_received = True
|
||||
|
||||
return new_status_received
|
||||
def delete_gcal_event_request(service, event):
|
||||
"""Creates a request to delete gcal event that belongs to the given local event."""
|
||||
try:
|
||||
mapping = GCalMapping.objects.get(event=event)
|
||||
gcal_id = mapping.gcal_id
|
||||
mapping.delete()
|
||||
return service.events().delete(calendarId="primary", eventId=gcal_id)
|
||||
except GCalMapping.DoesNotExist:
|
||||
logger.warning(f"No GCal mapping found for event {event.id}")
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------- Synchronization ----------------------------------------------------
|
||||
|
||||
|
||||
def check_gcal_subscription(service=None, time_to_live=14 * 24 * 3600, renew_before_expiry=None):
|
||||
"""Google offers a push service if any event information has changed. This works using a so called
|
||||
channel, which has a certain time to live. This method checks that a valid channel exists:
|
||||
- if none exists a new one is created
|
||||
- if existing channel does expire soon, the channel is renewed
|
||||
- if channel has already expired a sync is triggered and a new channel is created
|
||||
def delete_all_gcal_events(service=None):
|
||||
"""Deletes all gcal events that have been created by this script."""
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
if service is None:
|
||||
logger.error("No service object available")
|
||||
return 0
|
||||
|
||||
gcal_events = get_all_gcal_events(service)
|
||||
gcal_ids = [ev["id"] for ev in gcal_events]
|
||||
count = len(gcal_ids)
|
||||
|
||||
if count == 0:
|
||||
return 0
|
||||
|
||||
# Use batch request for efficiency
|
||||
from googleapiclient.http import BatchHttpRequest
|
||||
|
||||
batch = BatchHttpRequest()
|
||||
for gcal_id in gcal_ids:
|
||||
batch.add(service.events().delete(calendarId="primary", eventId=gcal_id))
|
||||
|
||||
try:
|
||||
batch.execute()
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting GCal events: {e}")
|
||||
|
||||
GCalMapping.objects.all().delete()
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def sync_from_local_to_google(service=None):
|
||||
"""
|
||||
Creates a google event for each local event (if it does not exist yet) and
|
||||
deletes all google events that are not found in local database.
|
||||
Updates participation info of gcal events using local data.
|
||||
"""
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
if service is None:
|
||||
logger.error("No service object available for sync")
|
||||
return 0, 0
|
||||
|
||||
all_events = get_all_gcal_events(service)
|
||||
|
||||
events_at_google_django_id = set()
|
||||
events_at_google_google_id = set()
|
||||
|
||||
for gcal_ev in all_events:
|
||||
try:
|
||||
django_id = int(gcal_ev["extendedProperties"]["private"]["blechreizID"])
|
||||
events_at_google_django_id.add(django_id)
|
||||
events_at_google_google_id.add(gcal_ev["id"])
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.warning(f"Invalid GCal event structure: {e}")
|
||||
|
||||
local_events_django_id = set(Event.objects.all().values_list("pk", flat=True))
|
||||
local_events_google_id = set(
|
||||
GCalMapping.objects.all().values_list("gcal_id", flat=True)
|
||||
)
|
||||
|
||||
events_to_create_django_id = local_events_django_id - events_at_google_django_id
|
||||
events_to_delete_google_id = events_at_google_google_id - local_events_google_id
|
||||
|
||||
from googleapiclient.http import BatchHttpRequest
|
||||
|
||||
batch = BatchHttpRequest()
|
||||
batch_is_empty = True
|
||||
|
||||
for event_django_id in events_to_create_django_id:
|
||||
try:
|
||||
event = Event.objects.get(pk=event_django_id)
|
||||
batch.add(
|
||||
create_gcal_event_request(service, event),
|
||||
callback=on_gcal_event_created,
|
||||
)
|
||||
batch_is_empty = False
|
||||
except Event.DoesNotExist:
|
||||
pass
|
||||
|
||||
for event_google_id in events_to_delete_google_id:
|
||||
batch.add(
|
||||
service.events().delete(calendarId="primary", eventId=event_google_id)
|
||||
)
|
||||
batch_is_empty = False
|
||||
|
||||
for gcal_ev in all_events:
|
||||
try:
|
||||
event_django_id = int(
|
||||
gcal_ev["extendedProperties"]["private"]["blechreizID"]
|
||||
)
|
||||
django_ev = Event.objects.get(pk=event_django_id)
|
||||
|
||||
gcal_attendees = gcal_ev.get("attendees", [])
|
||||
local_attendees = build_gcal_attendees_obj(django_ev)
|
||||
|
||||
# Simple comparison - check if attendees differ
|
||||
if gcal_attendees != local_attendees:
|
||||
batch.add(update_gcal_event_request(service, django_ev))
|
||||
batch_is_empty = False
|
||||
except Event.DoesNotExist:
|
||||
pass
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
if not batch_is_empty:
|
||||
try:
|
||||
batch.execute()
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing batch request: {e}")
|
||||
|
||||
return len(events_to_create_django_id), len(events_to_delete_google_id)
|
||||
|
||||
|
||||
def sync_from_google_to_local(service=None):
|
||||
"""
|
||||
Retrieves only participation infos for all events and
|
||||
updates local database if anything has changed.
|
||||
"""
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
if service is None:
|
||||
logger.error("No service object available for sync")
|
||||
return False
|
||||
|
||||
new_status_received = False
|
||||
all_events = get_all_gcal_events(service, from_now=True)
|
||||
|
||||
for e in all_events:
|
||||
try:
|
||||
local_id = e["extendedProperties"]["private"]["blechreizID"]
|
||||
local_event = Event.objects.get(pk=local_id)
|
||||
|
||||
for a in e.get("attendees", []):
|
||||
try:
|
||||
user_coupling = UserGCalCoupling.objects.get(email=a["email"])
|
||||
user = user_coupling.user
|
||||
part = EventParticipation.get_or_create(user, local_event)
|
||||
|
||||
if "comment" in a:
|
||||
part.comment = a["comment"]
|
||||
|
||||
response_status = a.get("responseStatus", "needsAction")
|
||||
if response_status == "needsAction":
|
||||
part.status = "-"
|
||||
elif response_status == "tentative":
|
||||
part.status = "?"
|
||||
elif response_status == "accepted":
|
||||
part.status = "Yes"
|
||||
elif response_status == "declined":
|
||||
part.status = "No"
|
||||
else:
|
||||
logger.error(
|
||||
f"Unknown response status when mapping gcal event: {response_status}"
|
||||
)
|
||||
|
||||
prev = EventParticipation.objects.get(
|
||||
event=part.event, user=part.user
|
||||
)
|
||||
|
||||
# Important: Save only if the participation info has changed
|
||||
# otherwise everything is synced back to google via the post save signal
|
||||
# and an endless loop is entered
|
||||
if prev.status != part.status or prev.comment != part.comment:
|
||||
part.save()
|
||||
new_status_received = True
|
||||
|
||||
except UserGCalCoupling.DoesNotExist:
|
||||
pass
|
||||
|
||||
except Event.DoesNotExist:
|
||||
logger.warning(f"Event with id {local_id} not found in local database")
|
||||
except KeyError as e:
|
||||
logger.warning(f"Invalid event structure: {e}")
|
||||
|
||||
return new_status_received
|
||||
|
||||
|
||||
# ------------------------------------- Push Channel Management ----------------------------------------------------
|
||||
|
||||
|
||||
def check_gcal_subscription(
|
||||
service=None, time_to_live=14 * 24 * 3600, renew_before_expiry=None
|
||||
):
|
||||
"""
|
||||
Google offers a push service if any event information has changed.
|
||||
|
||||
This works using a so called channel, which has a certain time to live.
|
||||
This method checks that a valid channel exists:
|
||||
- if none exists a new one is created
|
||||
- if existing channel does expire soon, the channel is renewed
|
||||
- if channel has already expired a sync is triggered and a new channel is created
|
||||
"""
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
if service is None:
|
||||
logger.error("No service object available")
|
||||
return
|
||||
|
||||
if renew_before_expiry is None:
|
||||
renew_before_expiry = 0.8 * time_to_live
|
||||
|
||||
callback_url = settings.GCAL_COUPLING['push_url']
|
||||
callback_url = settings.GCAL_COUPLING["push_url"]
|
||||
|
||||
# Test if a channel already exists for this callbackURL
|
||||
try:
|
||||
db_channel = GCalPushChannel.objects.get(address=callback_url)
|
||||
g_channel = db_channel.to_google_channel()
|
||||
|
||||
# if expiration time between 0 and two days: stop and create new channel
|
||||
cur_time = int(time.time() * 1000)
|
||||
if g_channel.expiration > cur_time:
|
||||
|
||||
if db_channel.expiration > cur_time:
|
||||
# not yet expired
|
||||
if cur_time + renew_before_expiry * 1000 > g_channel.expiration:
|
||||
# will expire in less than "renewBeforeExpiry"
|
||||
logger.info("Renewing Google Calendar Subscription: " + callback_url)
|
||||
GCalPushChannel.stop(service, g_channel)
|
||||
if cur_time + renew_before_expiry * 1000 > db_channel.expiration:
|
||||
# will expire in less than "renew_before_expiry"
|
||||
logger.info(f"Renewing Google Calendar Subscription: {callback_url}")
|
||||
db_channel.stop(service)
|
||||
GCalPushChannel.create_new(callback_url, service, time_to_live)
|
||||
else:
|
||||
logger.info("Channel active until %d " % (g_channel.expiration,))
|
||||
logger.info(f"Channel active until {db_channel.expiration}")
|
||||
else:
|
||||
logger.info("Google calendar subscription had expired - getting new subscription")
|
||||
logger.info(
|
||||
"Google calendar subscription had expired - getting new subscription"
|
||||
)
|
||||
# to get back in sync again we have to decide which data to take
|
||||
# so we use the local data as reference
|
||||
sync_from_local_to_google(service)
|
||||
GCalPushChannel.create_new(callback_url, service, time_to_live)
|
||||
|
||||
except GCalPushChannel.DoesNotExist:
|
||||
# create new channel and save it in database
|
||||
logger.info("No CGalCallback Channel exists yet for: " + callback_url)
|
||||
logger.info(f"No GCalCallback Channel exists yet for: {callback_url}")
|
||||
# to get back in sync again we have to decide which data to take
|
||||
# so we use the local data as reference
|
||||
sync_from_local_to_google(service)
|
||||
@@ -368,45 +517,68 @@ def check_gcal_subscription(service=None, time_to_live=14 * 24 * 3600, renew_bef
|
||||
|
||||
|
||||
def stop_all_gcal_subscriptions(service=None):
|
||||
"""Stops the channel subscription """
|
||||
|
||||
"""Stops all channel subscriptions."""
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
for dbChannel in GCalPushChannel.objects.all():
|
||||
logger.info("Stopping %s expiry at %d " % (dbChannel.id, dbChannel.expiration))
|
||||
GCalPushChannel.stop(service, dbChannel.to_google_channel())
|
||||
if service is None:
|
||||
logger.error("No service object available")
|
||||
return
|
||||
|
||||
for db_channel in GCalPushChannel.objects.all():
|
||||
logger.info(
|
||||
f"Stopping channel {db_channel.id} expiry at {db_channel.expiration}"
|
||||
)
|
||||
db_channel.stop(service)
|
||||
|
||||
|
||||
def check_if_google_callback_is_valid(token, channel_id, resource_id, service=None):
|
||||
"""Validate an incoming Google Calendar push notification."""
|
||||
if service is None:
|
||||
service = get_service_object()
|
||||
|
||||
all_channels = GCalPushChannel.objects.all()
|
||||
|
||||
if len(all_channels) == 0:
|
||||
return False # no known subscriptions -> callback has to be from an old channel
|
||||
|
||||
if len(all_channels) > 1:
|
||||
logger.warning("Multiple GCal subscriptions! This is strange and probably an error. "
|
||||
"All channels are closed and one new is created. ")
|
||||
logger.warning(
|
||||
"Multiple GCal subscriptions! This is strange and probably an error. "
|
||||
"All channels are closed and one new is created."
|
||||
)
|
||||
stop_all_gcal_subscriptions(service)
|
||||
check_gcal_subscription()
|
||||
all_channels = GCalPushChannel.objects.all()
|
||||
|
||||
assert (len(all_channels) == 1)
|
||||
if len(all_channels) != 1:
|
||||
return False
|
||||
|
||||
the_channel = all_channels[0]
|
||||
|
||||
if channel_id != the_channel.id or resource_id != the_channel.resource_id or token != the_channel.token:
|
||||
logger.warning("Got GCal Response from an unexpected Channel"
|
||||
"Got (%s,%s,%s) "
|
||||
"expected (%s,%s,%s) "
|
||||
"Old Channel is stopped."
|
||||
% (channel_id, resource_id, token, the_channel.id, the_channel.resource_id, the_channel.token))
|
||||
|
||||
channel_to_stop = GCalPushChannel(id=channel_id, resource_id=resource_id, token=token)
|
||||
GCalPushChannel.stop(service, channel_to_stop.to_google_channel())
|
||||
if (
|
||||
channel_id != the_channel.id
|
||||
or resource_id != the_channel.resource_id
|
||||
or token != the_channel.token
|
||||
):
|
||||
logger.warning(
|
||||
f"Got GCal Response from an unexpected Channel. "
|
||||
f"Got ({channel_id}, {resource_id}, {token}) "
|
||||
f"expected ({the_channel.id}, {the_channel.resource_id}, {the_channel.token}). "
|
||||
f"Old Channel is stopped."
|
||||
)
|
||||
|
||||
GCalPushChannel.stop_channel(service, channel_id, resource_id)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Backwards compatibility aliases
|
||||
syncFromLocalToGoogle = sync_from_local_to_google
|
||||
syncFromGoogleToLocal = sync_from_google_to_local
|
||||
checkIfGoogleCallbackIsValid = check_if_google_callback_is_valid
|
||||
checkGCalSubscription = check_gcal_subscription
|
||||
stopAllGCalSubscriptions = stop_all_gcal_subscriptions
|
||||
deleteAllGCalEvents = delete_all_gcal_events
|
||||
getServiceObject = get_service_object
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from django.core.management.base import NoArgsCommand
|
||||
|
||||
from eventplanner_gcal.google_sync import check_gcal_subscription
|
||||
|
||||
from eventplanner_gcal.google_sync import checkGCalSubscription
|
||||
|
||||
class Command(NoArgsCommand):
|
||||
help = 'Checks if the GCal notification channel is still active'
|
||||
|
||||
def handle_noargs(self, **options):
|
||||
print("Checking Subscription")
|
||||
check_gcal_subscription()
|
||||
print ( "Checking Subscription")
|
||||
checkGCalSubscription()
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from django.core.management.base import NoArgsCommand
|
||||
from eventplanner_gcal.google_sync import delete_all_gcal_events
|
||||
|
||||
from eventplanner_gcal.google_sync import deleteAllGCalEvents
|
||||
|
||||
class Command(NoArgsCommand):
|
||||
help = 'Delete all events in the google calendar created by this app'
|
||||
|
||||
def handle_noargs(self, **options):
|
||||
print("Deleting all GCal Events.")
|
||||
nr_of_deleted_events = delete_all_gcal_events()
|
||||
print("Deleted %d events. To Restore them from local database run gcal_sync" % (nr_of_deleted_events,))
|
||||
print ("Deleting all GCal Events.")
|
||||
nrOfDeletedEvents = deleteAllGCalEvents()
|
||||
print ("Deleted %d events. To Restore them from local database run gcal_sync" % (nrOfDeletedEvents, ) )
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from django.core.management.base import NoArgsCommand
|
||||
|
||||
from eventplanner_gcal.google_sync import stop_all_gcal_subscriptions
|
||||
|
||||
from eventplanner_gcal.google_sync import stopAllGCalSubscriptions
|
||||
|
||||
class Command(NoArgsCommand):
|
||||
help = 'Stops all GCal subscriptions'
|
||||
|
||||
def handle_noargs(self, **options):
|
||||
stop_all_gcal_subscriptions()
|
||||
stopAllGCalSubscriptions()
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from django.core.management.base import NoArgsCommand
|
||||
from eventplanner_gcal.google_sync import sync_from_local_to_google
|
||||
|
||||
from eventplanner_gcal.google_sync import syncFromLocalToGoogle
|
||||
|
||||
class Command(NoArgsCommand):
|
||||
help = 'Synchronize Google Calendar with locally stored Events'
|
||||
|
||||
def handle_noargs(self, **options):
|
||||
print("Running Sync")
|
||||
created, deleted = sync_from_local_to_google()
|
||||
print("Created %d and deleted %d events" % (created, deleted))
|
||||
print ( "Running Sync")
|
||||
created, deleted = syncFromLocalToGoogle()
|
||||
print ( "Created %d and deleted %d events" % (created,deleted) )
|
||||
|
||||
55
eventplanner_gcal/migrations/0001_initial.py
Normal file
55
eventplanner_gcal/migrations/0001_initial.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 5.1.15 on 2026-03-30 19:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('eventplanner', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GCalMapping',
|
||||
fields=[
|
||||
('gcal_id', models.CharField(max_length=64)),
|
||||
('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='eventplanner.event')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Google Calendar Mapping',
|
||||
'verbose_name_plural': 'Google Calendar Mappings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GCalPushChannel',
|
||||
fields=[
|
||||
('id', models.CharField(max_length=128, primary_key=True, serialize=False)),
|
||||
('address', models.CharField(max_length=256)),
|
||||
('token', models.CharField(max_length=128)),
|
||||
('resource_id', models.CharField(max_length=128)),
|
||||
('expiration', models.BigIntegerField()),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Google Calendar Push Channel',
|
||||
'verbose_name_plural': 'Google Calendar Push Channels',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserGCalCoupling',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email', models.CharField(max_length=1024)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User Google Calendar Coupling',
|
||||
'verbose_name_plural': 'User Google Calendar Couplings',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
eventplanner_gcal/migrations/__init__.py
Normal file
0
eventplanner_gcal/migrations/__init__.py
Normal file
@@ -1,59 +1,127 @@
|
||||
import logging
|
||||
import uuid
|
||||
from eventplanner.models import Event
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from apiclient.channel import Channel
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
|
||||
from eventplanner.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserGCalCoupling(models.Model):
|
||||
# For every user in this table the gcal coupling is activated
|
||||
"""For every user in this table the gcal coupling is activated."""
|
||||
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
email = models.CharField(max_length=1024)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "User Google Calendar Coupling"
|
||||
verbose_name_plural = "User Google Calendar Couplings"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} <-> {self.email}"
|
||||
|
||||
|
||||
class GCalMapping(models.Model):
|
||||
"""Mapping between event id at google and local event id"""
|
||||
"""Mapping between event id at google and local event id."""
|
||||
|
||||
gcal_id = models.CharField(max_length=64)
|
||||
event = models.OneToOneField(Event, primary_key=True, on_delete=models.CASCADE)
|
||||
event = models.OneToOneField(Event, on_delete=models.CASCADE, primary_key=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Google Calendar Mapping"
|
||||
verbose_name_plural = "Google Calendar Mappings"
|
||||
|
||||
def __str__(self):
|
||||
return f"GCal:{self.gcal_id} <-> Event:{self.event_id}"
|
||||
|
||||
|
||||
class GCalPushChannel(models.Model):
|
||||
"""This table has either zero or one entry. Required to store if a channel already exists,
|
||||
when it expires and how to stop (renew) the channel
|
||||
when it expires and how to stop (renew) the channel.
|
||||
"""
|
||||
|
||||
id = models.CharField(max_length=128, primary_key=True)
|
||||
address = models.CharField(max_length=256)
|
||||
token = models.CharField(max_length=128)
|
||||
resource_id = models.CharField(max_length=128)
|
||||
expiration = models.IntegerField()
|
||||
expiration = models.BigIntegerField()
|
||||
|
||||
def to_google_channel(self):
|
||||
return Channel('web_hook', self.id, self.token, self.address, self.expiration, resource_id=self.resource_id)
|
||||
class Meta:
|
||||
verbose_name = "Google Calendar Push Channel"
|
||||
verbose_name_plural = "Google Calendar Push Channels"
|
||||
|
||||
@staticmethod
|
||||
def from_google_channel(google_channel):
|
||||
return GCalPushChannel(id=google_channel.id,
|
||||
address=google_channel.address,
|
||||
token=google_channel.token,
|
||||
expiration=google_channel.expiration,
|
||||
resource_id=google_channel.resource_id)
|
||||
def __str__(self):
|
||||
return f"Channel {self.id} expires at {self.expiration}"
|
||||
|
||||
@staticmethod
|
||||
def create_new(callback_url, service, ttl=None):
|
||||
gChannel = Channel('web_hook', str(uuid.uuid4()), 'blechreizGcal', callback_url, params={'ttl': int(ttl)})
|
||||
response = service.events().watch(calendarId='primary', body=gChannel.body()).execute()
|
||||
gChannel.update(response)
|
||||
def to_channel_dict(self):
|
||||
"""Return a dictionary representation for the Google API."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": "web_hook",
|
||||
"address": self.address,
|
||||
"token": self.token,
|
||||
"expiration": self.expiration,
|
||||
"resourceId": self.resource_id,
|
||||
}
|
||||
|
||||
dbChannel = GCalPushChannel.from_google_channel(gChannel)
|
||||
dbChannel.save()
|
||||
@classmethod
|
||||
def from_response(cls, response, address, token):
|
||||
"""Create a GCalPushChannel from a Google API response."""
|
||||
return cls(
|
||||
id=response["id"],
|
||||
address=address,
|
||||
token=token,
|
||||
expiration=int(response.get("expiration", 0)),
|
||||
resource_id=response.get("resourceId", ""),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def stop(service, google_channel):
|
||||
channel_service = service.channels()
|
||||
channel_service.stop(body=google_channel.body()).execute()
|
||||
GCalPushChannel.from_google_channel(google_channel).delete()
|
||||
@classmethod
|
||||
def create_new(cls, callback_url, service, ttl=None):
|
||||
"""Create a new push channel subscription."""
|
||||
channel_id = str(uuid.uuid4())
|
||||
token = "blechreizGcal"
|
||||
|
||||
body = {
|
||||
"id": channel_id,
|
||||
"type": "web_hook",
|
||||
"address": callback_url,
|
||||
"token": token,
|
||||
}
|
||||
|
||||
if ttl:
|
||||
body["params"] = {"ttl": str(int(ttl))}
|
||||
|
||||
response = service.events().watch(calendarId="primary", body=body).execute()
|
||||
|
||||
db_channel = cls.from_response(response, callback_url, token)
|
||||
db_channel.save()
|
||||
return db_channel
|
||||
|
||||
def stop(self, service):
|
||||
"""Stop this push channel subscription."""
|
||||
body = {
|
||||
"id": self.id,
|
||||
"resourceId": self.resource_id,
|
||||
}
|
||||
try:
|
||||
service.channels().stop(body=body).execute()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to stop channel {self.id}: {e}")
|
||||
self.delete()
|
||||
|
||||
@classmethod
|
||||
def stop_channel(cls, service, channel_id, resource_id):
|
||||
"""Stop a push channel by ID and resource ID."""
|
||||
body = {
|
||||
"id": channel_id,
|
||||
"resourceId": resource_id,
|
||||
}
|
||||
try:
|
||||
service.channels().stop(body=body).execute()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to stop channel {channel_id}: {e}")
|
||||
|
||||
# Also delete from database if it exists
|
||||
cls.objects.filter(id=channel_id).delete()
|
||||
|
||||
@@ -1,61 +1,120 @@
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from eventplanner.models import Event, EventParticipation
|
||||
from eventplanner_gcal.google_sync import get_service_object, \
|
||||
create_gcal_event, delete_gcal_event, update_gcal_event, on_gcal_event_created
|
||||
"""
|
||||
Django signal handlers for Google Calendar synchronization.
|
||||
|
||||
These handlers are currently disabled (early return) but can be enabled
|
||||
to automatically sync events with Google Calendar when they are created,
|
||||
updated, or deleted.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from django.conf import settings
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from eventplanner.models import Event, EventParticipation
|
||||
|
||||
from .google_sync import (
|
||||
create_gcal_event_request,
|
||||
delete_gcal_event_request,
|
||||
get_service_object,
|
||||
on_gcal_event_created,
|
||||
update_gcal_event_request,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# @receiver( post_save, sender=User )
|
||||
# def user_changed( **kwargs ):
|
||||
# logger.info("Synchronizing with google - user information changed")
|
||||
# syncFromLocalToGoogle( getServiceObject() )
|
||||
# @receiver(post_save, sender=User)
|
||||
# def user_changed(**kwargs):
|
||||
# """Sync with Google when user information changes."""
|
||||
# logger.info("Synchronizing with google - user information changed")
|
||||
# sync_from_local_to_google(get_service_object())
|
||||
|
||||
|
||||
@receiver(post_save, sender=Event)
|
||||
def event_post_save_handler(**kwargs):
|
||||
if not settings.GCAL_SYNC_ENABLED:
|
||||
def event_post_save_handler(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Handle Event post_save signal.
|
||||
|
||||
Creates or updates the corresponding Google Calendar event.
|
||||
Currently disabled - remove the early return to enable.
|
||||
"""
|
||||
# Disabled - remove this return statement to enable auto-sync
|
||||
return
|
||||
|
||||
event = instance
|
||||
service = get_service_object()
|
||||
|
||||
if service is None:
|
||||
logger.warning("No Google Calendar service available")
|
||||
return
|
||||
|
||||
event = kwargs['instance']
|
||||
created = kwargs['created']
|
||||
try:
|
||||
if created:
|
||||
logger.info("Creating Gcal event")
|
||||
response = create_gcal_event(get_service_object(), event).execute()
|
||||
logger.info(f"Creating GCal event for Event {event.id}")
|
||||
request = create_gcal_event_request(service, event)
|
||||
response = request.execute()
|
||||
on_gcal_event_created(None, response, None)
|
||||
else:
|
||||
logger.info("Updating Gcal event")
|
||||
update_gcal_event(get_service_object(), event).execute()
|
||||
logger.info(f"Updating GCal event for Event {event.id}")
|
||||
request = update_gcal_event_request(service, event)
|
||||
request.execute()
|
||||
except Exception as e:
|
||||
logger.error("Error updating Gcal event" + str(e))
|
||||
logger.error(f"Error syncing event {event.id} to GCal: {e}")
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=Event)
|
||||
def event_pre_delete_handler(**kwargs):
|
||||
if not settings.GCAL_SYNC_ENABLED:
|
||||
def event_pre_delete_handler(sender, instance, **kwargs):
|
||||
"""
|
||||
Handle Event pre_delete signal.
|
||||
|
||||
Deletes the corresponding Google Calendar event.
|
||||
Currently disabled - remove the early return to enable.
|
||||
"""
|
||||
# Disabled - remove this return statement to enable auto-sync
|
||||
return
|
||||
|
||||
event = instance
|
||||
service = get_service_object()
|
||||
|
||||
if service is None:
|
||||
logger.warning("No Google Calendar service available")
|
||||
return
|
||||
|
||||
try:
|
||||
event = kwargs['instance']
|
||||
logger.info("Deleting Gcal event")
|
||||
delete_gcal_event(get_service_object(), event).execute()
|
||||
logger.info(f"Deleting GCal event for Event {event.id}")
|
||||
request = delete_gcal_event_request(service, event)
|
||||
if request:
|
||||
request.execute()
|
||||
except Exception as e:
|
||||
logger.error("Error deleting GCAL event" + str(e))
|
||||
logger.error(f"Error deleting GCal event for Event {event.id}: {e}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=EventParticipation)
|
||||
def participation_post_save_handler(**kwargs):
|
||||
if not settings.GCAL_SYNC_ENABLED:
|
||||
def participation_post_save_handler(sender, instance, **kwargs):
|
||||
"""
|
||||
Handle EventParticipation post_save signal.
|
||||
|
||||
Updates the Google Calendar event when participation changes.
|
||||
Currently disabled - remove the early return to enable.
|
||||
"""
|
||||
# Disabled - remove this return statement to enable auto-sync
|
||||
return
|
||||
|
||||
participation = instance
|
||||
service = get_service_object()
|
||||
|
||||
if service is None:
|
||||
logger.warning("No Google Calendar service available")
|
||||
return
|
||||
|
||||
try:
|
||||
participation = kwargs['instance']
|
||||
logger.info("Participation post save -> update gcal")
|
||||
update_gcal_event(get_service_object(), participation.event).execute()
|
||||
logger.info(
|
||||
f"Participation changed for Event {participation.event.id} "
|
||||
f"by User {participation.user.username}"
|
||||
)
|
||||
request = update_gcal_event_request(service, participation.event)
|
||||
request.execute()
|
||||
except Exception as e:
|
||||
logger.error("Error deleting GCAL event" + str(e))
|
||||
logger.error(f"Error updating GCal event for participation change: {e}")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% extends "website/base.html" %}
|
||||
{% load sekizai_tags staticfiles %}
|
||||
{% load sekizai_tags static %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% addtoblock "css" strip %}
|
||||
<link rel="stylesheet" href="{{STATIC_URL}}css/bootstrap-switch.min.css" type="text/css" media="screen" />
|
||||
<link rel="stylesheet" href="{{STATIC_URL}}/css/bootstrap-switch.min.css" type="text/css" media="screen" />
|
||||
{% endaddtoblock %}
|
||||
|
||||
{% addtoblock "css" %}
|
||||
@@ -25,7 +25,7 @@
|
||||
{% endaddtoblock %}
|
||||
|
||||
{% addtoblock "js" strip %}
|
||||
<script src="{{STATIC_URL}}js/bootstrap-switch.min.js"></script>
|
||||
<script src="{{STATIC_URL}}/js/bootstrap-switch.min.js"></script>
|
||||
{% endaddtoblock %}
|
||||
|
||||
{% addtoblock "js" %}
|
||||
@@ -72,7 +72,7 @@
|
||||
weil man alle anderen eigenen Termine auch im Blick hat.
|
||||
</p>
|
||||
|
||||
<img src="{{STATIC_URL}}img/screenshot.png">
|
||||
<img src="{{STATIC_URL}}/img/screenshot.png">
|
||||
|
||||
<p>
|
||||
<h5>SO GEHTS:</h5>
|
||||
@@ -115,11 +115,11 @@
|
||||
</div>
|
||||
|
||||
<div class="span3 offset1">
|
||||
<img src="{{STATIC_URL}}img/google_cal.png">
|
||||
<img src="{{STATIC_URL}}/img/google_cal.png">
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.conf.urls import url
|
||||
from django.urls import path
|
||||
|
||||
from .views import run_sync, gcal_api_callback, manage
|
||||
from . import views
|
||||
|
||||
app_name = "eventplanner_gcal"
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^runSync$', run_sync),
|
||||
url(r'^gcalApiCallback$', gcal_api_callback),
|
||||
url(r'^manage$', manage),
|
||||
path("runSync/", views.run_sync, name="run_sync"),
|
||||
path("gcalApiCallback/", views.gcal_api_callback, name="gcal_api_callback"),
|
||||
path("manage/", views.manage, name="manage"),
|
||||
]
|
||||
|
||||
@@ -1,63 +1,101 @@
|
||||
from django.shortcuts import redirect
|
||||
from eventplanner_gcal.google_sync import sync_from_google_to_local, sync_from_local_to_google
|
||||
from django.http import HttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from eventplanner_gcal.google_sync import check_if_google_callback_is_valid
|
||||
from eventplanner_gcal.models import UserGCalCoupling
|
||||
from django.shortcuts import render
|
||||
"""
|
||||
Views for Google Calendar integration management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from .google_sync import (
|
||||
check_if_google_callback_is_valid,
|
||||
sync_from_google_to_local,
|
||||
sync_from_local_to_google,
|
||||
)
|
||||
from .models import UserGCalCoupling
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_sync(request):
|
||||
"""Manually trigger a sync from local to Google Calendar."""
|
||||
sync_from_local_to_google()
|
||||
return redirect("/")
|
||||
|
||||
|
||||
def manage(request):
|
||||
if request.method == 'POST':
|
||||
if request.POST['activate'] == "1":
|
||||
UserGCalCoupling.objects.filter(user=request.user).delete()
|
||||
c = UserGCalCoupling(user=request.user, email=request.POST['email'])
|
||||
c.save()
|
||||
sync_from_local_to_google()
|
||||
"""
|
||||
View for managing Google Calendar integration settings.
|
||||
|
||||
Allows users to enable/disable GCal sync and configure their email.
|
||||
"""
|
||||
if request.method == "POST":
|
||||
activate = request.POST.get("activate", "0")
|
||||
|
||||
if activate == "1":
|
||||
# Enable GCal coupling
|
||||
email = request.POST.get("email", "")
|
||||
if email:
|
||||
UserGCalCoupling.objects.filter(user=request.user).delete()
|
||||
coupling = UserGCalCoupling(user=request.user, email=email)
|
||||
coupling.save()
|
||||
sync_from_local_to_google()
|
||||
else:
|
||||
# Disable GCal coupling
|
||||
UserGCalCoupling.objects.filter(user=request.user).delete()
|
||||
sync_from_local_to_google()
|
||||
|
||||
context = {}
|
||||
user_coupling = UserGCalCoupling.objects.filter(user=request.user)
|
||||
context['enabled'] = len(user_coupling)
|
||||
assert (len(user_coupling) < 2)
|
||||
if len(user_coupling) == 1:
|
||||
context['mail'] = user_coupling[0].email
|
||||
context["enabled"] = user_coupling.exists()
|
||||
|
||||
return render(request, 'eventplanner_gcal/management.html', context)
|
||||
if user_coupling.count() > 1:
|
||||
logger.warning(
|
||||
f"User {request.user.username} has multiple GCal couplings. "
|
||||
"This should not happen."
|
||||
)
|
||||
|
||||
if user_coupling.exists():
|
||||
context["mail"] = user_coupling.first().email
|
||||
|
||||
return render(request, "eventplanner_gcal/management.html", context)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def gcal_api_callback(request):
|
||||
token = ""
|
||||
if 'HTTP_X_GOOG_CHANNEL_TOKEN' in request.META:
|
||||
token = request.META['HTTP_X_GOOG_CHANNEL_TOKEN']
|
||||
"""
|
||||
Callback endpoint for Google Calendar push notifications.
|
||||
|
||||
channel_id = ""
|
||||
if 'HTTP_X_GOOG_CHANNEL_ID' in request.META:
|
||||
channel_id = request.META['HTTP_X_GOOG_CHANNEL_ID']
|
||||
|
||||
resource_id = ""
|
||||
if 'HTTP_X_GOOG_RESOURCE_ID' in request.META:
|
||||
resource_id = request.META['HTTP_X_GOOG_RESOURCE_ID']
|
||||
This is called by Google when calendar events are updated.
|
||||
"""
|
||||
token = request.META.get("HTTP_X_GOOG_CHANNEL_TOKEN", "")
|
||||
channel_id = request.META.get("HTTP_X_GOOG_CHANNEL_ID", "")
|
||||
resource_id = request.META.get("HTTP_X_GOOG_RESOURCE_ID", "")
|
||||
|
||||
valid = check_if_google_callback_is_valid(token, channel_id, resource_id)
|
||||
|
||||
if not valid:
|
||||
return HttpResponse('<h1>Old Channel - no update triggered</h1>')
|
||||
logger.warning(
|
||||
f"Received invalid GCal callback: token={token}, "
|
||||
f"channel_id={channel_id}, resource_id={resource_id}"
|
||||
)
|
||||
return HttpResponse("<h1>Old Channel - no update triggered</h1>")
|
||||
|
||||
logger.info(
|
||||
f"Received Google Callback with headers - "
|
||||
f"Token: {token}, ID: {channel_id}, ResID: {resource_id}"
|
||||
)
|
||||
|
||||
logger.info("Received Google Callback with the following headers Token: "
|
||||
"%s ID %s ResID %s " % (token, channel_id, resource_id))
|
||||
result = sync_from_google_to_local()
|
||||
logger.info("Finished processing callback from GCal - New Information present: %d " % (result,))
|
||||
return HttpResponse('<h1>Callback successful</h1>')
|
||||
|
||||
logger.info(
|
||||
f"Finished processing callback from GCal - New Information present: {result}"
|
||||
)
|
||||
|
||||
return HttpResponse("<h1>Callback successful</h1>")
|
||||
|
||||
|
||||
# Backwards compatibility aliases
|
||||
runSync = run_sync
|
||||
gcalApiCallback = gcal_api_callback
|
||||
|
||||
Reference in New Issue
Block a user