port to new django, AI automated

This commit is contained in:
2026-03-30 22:35:36 +02:00
parent e2d166e437
commit 372da3caa9
215 changed files with 9283 additions and 2981 deletions

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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()

View File

@@ -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, ) )

View File

@@ -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()

View File

@@ -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) )

View 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',
},
),
]

View File

View 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()

View File

@@ -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}")

View File

@@ -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 %}

View File

@@ -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"),
]

View File

@@ -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