Skip to content

Commit

Permalink
Merge pull request #20 from keul/read-from-calendars
Browse files Browse the repository at this point in the history
Read from calendars
  • Loading branch information
keul authored Apr 9, 2024
2 parents 9438be7 + 569849f commit 93e7af9
Show file tree
Hide file tree
Showing 9 changed files with 339 additions and 24 deletions.
8 changes: 7 additions & 1 deletion HISTORY.rst
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
History
=======

0.6.1 (unreleased)
0.7.1 (unreleased)
------------------

- Nothing changed yet.


0.7.0 (2024-04-09)
------------------

- Added ``read`` option to ``--execute``


0.6.0 (2023-03-31)
------------------

Expand Down
37 changes: 30 additions & 7 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ B-Open Haunts
What it does
============

Fill Google Calendars with events taken from a Google Spreadsheet.
Fill Google Calendars with events taken from a Google Spreadsheet. Or the other way around.

How to install
==============
Expand Down Expand Up @@ -90,6 +90,18 @@ To just report overtime entries in the set:
haunts --execute report --day=2021-05-24 --day=2021-05-25 --day=2021-05-28 --project="Project X" --overtime May
To *read* today events from all configured calendar and write them on your "May" sheet for the current:

.. code-block:: bash
haunts --execute read May
To *read* events for a specific date from all configured calendar and write them on your "May" sheet for the current:

.. code-block:: bash
haunts --execute read -d 2023-05-15 May
How it works
------------

Expand All @@ -98,8 +110,14 @@ What haunts does depends on the ``--execute`` parameter.
In its default configuration (if ``--execute`` is omitted, or equal to ``sync``), the command will try to access a Google Spreatsheet you must have access to (write access required), specifically: it will read a single sheet at time inside that spreadsheet.
Every row inside this sheet is an event that will be also created on a Google Calendar.

Alternatively you can provide ``--execute report``.
In this case it just access the Google Spreadsheet to collect data.
Alternatively you can provide:

- ``--execute report``.

In this case it just access the Google Spreadsheet to collect data.
- ``--execute read``.

In this case it fills the Google Spreadsheet for you, by *reading* you calendars.

Sheet definition
----------------
Expand Down Expand Up @@ -164,18 +182,23 @@ Every sheet should contains following headers:
Configuring projects
~~~~~~~~~~~~~~~~~~~~

The spreadsheet must also contains a *configuration sheet* (default name is ``config``, can be changed in the .ini) where you must put two columns (with headers):
The spreadsheet must also contains a *configuration sheet* (default name is ``config``, can be changed in the .ini) where you must put at least two columns (with same headers as follows):

**id**
The id of a Google Calendar associated to this project.
You must have write access to this calendar.

**name**
The name of the project, like an alias to the calendar
The name of the project, like a human readable name for a calendar.
A project name can be associated to the same calendar id multiple times (this way you can have aliases).

**read_from** (optional)
User only for ``--execute read``.

A project name can be associated to the same calendar id multiple times.
Read events from this (optional) calendar id instead of the main one.
This makes possible to *read* events from a calendar, but store them in another ones.

Values in the ``name`` column are the only valid values for the ``Project`` column introduced above
Values in the ``name`` column are valid values for the ``Project`` column introduced above.

How events will be filled
-------------------------
Expand Down
21 changes: 18 additions & 3 deletions haunts/calendars.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ def create_event(config_dir, calendar, date, summary, details, length, from_time

from_time = from_time or get("START_TIME", "09:00")
start = datetime.datetime.strptime(
f"{date.strftime('%Y-%m-%d')}T{from_time}:00{LOCAL_TIMEZONE}",
"%Y-%m-%dT%H:%M:%S%z",
f"{date.strftime('%Y-%m-%d')}T{from_time}:00Z",
"%Y-%m-%dT%H:%M:%SZ",
)
print(start)

startParams = None
endParams = None
Expand All @@ -49,19 +50,27 @@ def create_event(config_dir, calendar, date, summary, details, length, from_time
delta = datetime.timedelta(hours=0)
end = start + delta

print(start.isoformat() + "Z")

if haveLength:
# Event with a duration
startParams = {
"dateTime": start.isoformat(),
"timeZone": get("TIMEZONE", "Etc/GMT"),
}
endParams = {
"dateTime": end.isoformat(),
"timeZone": get("TIMEZONE", "Etc/GMT"),
}
else:
# Full day event
startParams = {
"date": start.isoformat()[:10],
"timeZone": get("TIMEZONE", "Etc/GMT"),
}
endParams = {
"date": (end + datetime.timedelta(days=1)).isoformat()[:10],
"timeZone": get("TIMEZONE", "Etc/GMT"),
}

event_body = {
Expand All @@ -73,7 +82,13 @@ def create_event(config_dir, calendar, date, summary, details, length, from_time

def execute_creation():
LOGGER.debug(calendar, date, summary, details, length, event_body, from_time)
event = service.events().insert(calendarId=calendar, body=event_body).execute()
try:
event = (
service.events().insert(calendarId=calendar, body=event_body).execute()
)
except HttpError as err:
LOGGER.error(f"Cannot create the event: {err.status_code}")
raise
return event

try:
Expand Down
9 changes: 8 additions & 1 deletion haunts/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .spreadsheet import sync_report
from .report import report
from . import actions
from .download import extract_events


@click.command()
Expand All @@ -35,7 +36,7 @@
@click.option(
"--execute",
"-e",
type=click.Choice(["sync", "report"], case_sensitive=False),
type=click.Choice(["sync", "report", "read"], case_sensitive=False),
help="select which action to execute.",
show_default=True,
default="sync",
Expand Down Expand Up @@ -144,6 +145,12 @@ def main(
)
elif execute == "report":
report(config_dir, sheet, days=day, projects=project, overtime=overtime)
elif execute == "read":
extract_events(
config_dir,
sheet,
day=day[0] if day else datetime.date.today().strftime("%Y-%m-%d"),
)
return 0


Expand Down
115 changes: 115 additions & 0 deletions haunts/download.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from googleapiclient.discovery import build
from datetime import datetime, timedelta
import click

from .ini import get
from .credentials import get_credentials
from .spreadsheet import (
append_line,
get_calendars_names,
get_calendars,
)
from .spreadsheet import SCOPES as SPREADSHEET_SCOPES
from .calendars import SCOPES as CALENDAR_SCOPES


def filter_my_event(events):
"""
Take a list of Google Calendar events and returns events created by USER_EMAIL
or events that have USER_EMAIL in the attendees list.
"""
USER_EMAIL = get("USER_EMAIL")
if USER_EMAIL is None:
raise KeyError("USER_EMAIL not set in configuration")
for event in events:
if event.get("creator", {}).get("email") == USER_EMAIL:
yield event
elif USER_EMAIL in [
attendee.get("email") for attendee in event.get("attendees", [])
]:
yield event


def get_events_at(events_service, calendar_id, date):
"""Get all events from a calendar in a specific date."""
start_datetime = datetime.combine(date, datetime.min.time()).isoformat() + "Z"
end_datetime = (
datetime.combine(date, datetime.min.time())
+ timedelta(days=1)
- timedelta(seconds=1)
).isoformat() + "Z"
events_result = events_service.list(
calendarId=calendar_id,
timeMin=start_datetime,
timeMax=end_datetime,
singleEvents=True,
orderBy="startTime",
timeZone=get("TIMEZONE", "Etc/GMT"),
).execute()
events = events_result.get("items", [])
# Enrich events with calendar_id
return [{**e, "calendar_id": calendar_id} for e in events]


def extract_events(config_dir, sheet, day):
"""Public module entry point.
Extract events from Google Calendar and copy them to proper Google Sheet.
"""
calendar_credentials = get_credentials(
config_dir, CALENDAR_SCOPES, "calendars-token.json"
)
spreadsheeet_credentials = get_credentials(
config_dir, SPREADSHEET_SCOPES, "sheets-token.json"
)
calendar_service = build("calendar", "v3", credentials=calendar_credentials)
spreadsheet_service = build("sheets", "v4", credentials=spreadsheeet_credentials)

date_to_check = datetime.strptime(
day, "%Y-%m-%d"
).date() # Replace with the desired date

events_service = calendar_service.events()
sheet_service = spreadsheet_service.spreadsheets()

configured_calendars = get_calendars(
sheet_service, ignore_alias=True, use_read_col=True
)
all_events = []
# Get "my events" from all configured calendars in the selected date
already_added_events = set()
for calendar_id in configured_calendars.values():
events = get_events_at(events_service, calendar_id, date_to_check)
new_events = [
e for e in filter_my_event(events) if e["id"] not in already_added_events
]
already_added_events.update([e["id"] for e in new_events])
all_events.extend(new_events)

# Get calendar configurations
calendar_names = get_calendars_names(sheet_service)

# Main operation loop
for event in all_events:
event_summary = event.get("summary", "No summary")
start = event["start"].get("dateTime", event["start"].get("date"))
end = event["end"].get("dateTime", event["end"].get("date"))
project = calendar_names[event["calendar_id"]]

start_date = datetime.fromisoformat(start).date()
start_time = datetime.fromisoformat(start).time()
duration = datetime.fromisoformat(end) - datetime.fromisoformat(start)
click.echo(f"Adding new event {event_summary} ({project}) to selected sheet")
append_line(
sheet_service,
sheet,
date_col=start_date,
time_col=start_time,
duration_col=duration,
project_col=project,
activity_col=event_summary,
details_col=event.get("description", ""),
event_id_col=event["id"],
link_col=event.get("htmlLink", ""),
action_col="I",
)
8 changes: 8 additions & 0 deletions haunts/ini.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@
# Overtime start date in HH:MM format
# Default is empty: no overtime
# OVERTIME_FROM=20:00
# User email.
# Required for `--execute read`
# USER_EMAIL=<your email here>
# Preferred timezone
# Default is GMT
# TIMEZONE=Europe/Rom
"""

parser = configparser.RawConfigParser(allow_no_value=True)
Expand Down
Loading

0 comments on commit 93e7af9

Please sign in to comment.