Skip to content

Commit

Permalink
Merge pull request #1 from RedRem95/custom_ical
Browse files Browse the repository at this point in the history
Custom ical implementation
  • Loading branch information
RedRem95 authored Mar 27, 2024
2 parents 9fa5a3a + b1c05a6 commit 32f17f4
Show file tree
Hide file tree
Showing 8 changed files with 188 additions and 43 deletions.
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,13 @@ For this endpoint you can use a combination of header and query parameters to cu
| Authorization | yes | One of the authorization keys you generated and put in the auth.json file |

### Parameters
| Key | Required | Description |
|---------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| token | yes | One of the authorization keys you generated and put in the auth.json file |
| date_types | no | Comma seperated list of the dates in the events you want to have in your calendar. For example `Due Date`. If you want to include a date from a custom field put two underscores infront of the name of the custom field, for example `__your_custom_field_with_a_date`. Defaults to all dates found in all events |
| only_assigned | no | Allowed values are `true` and `false`. If false all dates will be added to your calendar. If not only the one for the user associated with the access key in auth.json are published into the calendar. Default is `true` |

| Key | Required | Description |
|----------------|----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| token | yes | One of the authorization keys you generated and put in the auth.json file |
| date_types | no | Comma seperated list of the dates in the events you want to have in your calendar. For example `Due Date`. If you want to include a date from a custom field put two underscores infront of the name of the custom field, for example `__your_custom_field_with_a_date`. Defaults to all dates found in all events |
| only_assigned | no | Allowed values are `true` and `false`. If false all dates will be added to your calendar. If not only the one for the user associated with the access key in auth.json are published into the calendar. Default is `true` |
| include_closed | no | Allowed values are `true` and `false`. If true closed tasks will be included. Default is `false` |

An example query could look like this:
```http request
Expand Down
2 changes: 1 addition & 1 deletion clickup_to_ical/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.0.4"
__version__ = "0.0.5"
9 changes: 5 additions & 4 deletions clickup_to_ical/clickup/data.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timezone
from typing import Optional, List as tList, Any, Union, Dict


Expand Down Expand Up @@ -175,12 +175,13 @@ def get_assignees(self) -> tList[Member]:
def get_all_dates(self) -> Dict[str, datetime]:
ret = {}
if self.due_date is not None:
ret["Due Date"] = datetime.utcfromtimestamp(float(self.due_date)/1000)
ret["Due Date"] = datetime.fromtimestamp(float(self.due_date) / 1000, tz=timezone.utc)
if self.start_date is not None:
ret["Start Date"] = datetime.utcfromtimestamp(float(self.start_date)/1000)
ret["Start Date"] = datetime.fromtimestamp(float(self.start_date) / 1000, tz=timezone.utc)
for custom_field in self.custom_fields:
if custom_field.type == "date" and custom_field.value is not None:
ret[f"__{custom_field.name}"] = datetime.utcfromtimestamp(float(custom_field.value)/1000)
ret[f"__{custom_field.name}"] = datetime.fromtimestamp(float(custom_field.value) / 1000,
tz=timezone.utc)
return ret

def get_members(self) -> tList[Member]:
Expand Down
4 changes: 4 additions & 0 deletions clickup_to_ical/ical/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from clickup_to_ical.ical.calendar import Calendar
from clickup_to_ical.ical.event import Event, User

__all__ = ['Calendar', 'Event', 'User']
50 changes: 50 additions & 0 deletions clickup_to_ical/ical/calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from dataclasses import dataclass, field
from datetime import timedelta
from typing import List

import pytz

from clickup_to_ical.ical.event import Event


def _time_to_duration(td: timedelta) -> str:
days = td.days
hours, remainder = divmod(td.seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"P{days if days > 0 else ''}T{f'{hours}H' if hours > 0 else ''}{f'{minutes}M' if minutes > 0 else ''}{f'{seconds}S' if seconds > 0 else ''}"


@dataclass(frozen=True)
class Calendar:
version: str
prodid: str
method: str = None
calscale: str = None
calendar_name: str = None
calendar_description: str = None
calendar_timezone = None
calendar_ttl: timedelta = None
events: List[Event] = field(default_factory=list)

def __str__(self):
ret = [
f"VERSION:{self.version}",
f"PRODID:{self.prodid}",
]
if self.method is not None:
ret.append(f"METHOD:{self.method}")
if self.calscale is not None:
ret.append(f"CALSCALE:{self.calscale}")
ret.append(f"X-MICROSOFT-CALSCALE:{self.calscale}")
if self.calendar_name is not None:
ret.append(f"X-WR-CALNAME:{self.calendar_name}")
if self.calendar_description is not None:
ret.append(f"X-WR-CALDESC:{self.calendar_description}")
if self.calendar_timezone is not None and self.calendar_timezone != pytz.UTC:
ret.append(f"X-WR-TIMEZONE:{str(self.calendar_timezone)}")
if self.calendar_ttl is not None:
ret.append(f"X-PUBLISHED-TTL:{_time_to_duration(self.calendar_ttl)}")

ret.extend(str(event) for event in self.events)

return "BEGIN:VCALENDAR\n{}\nEND:VCALENDAR".format('\n'.join(ret))
79 changes: 79 additions & 0 deletions clickup_to_ical/ical/event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List

import pytz


def _datetime_to_str(dt_name: str, dt: datetime) -> str:
base = f"{dt.year:04d}{dt.month:02d}{dt.day:02d}T{dt.hour:02d}{dt.minute:02d}{dt.second:02d}"
if dt.tzinfo is None:
return f"{dt_name}:{base}"
elif dt.tzinfo in (pytz.UTC, timezone.utc, pytz.timezone("UTC")):
return f"{dt_name}:{base}Z"
else:
return f"{dt_name};TZID={dt.tzinfo};VALUE=DATE:{base}"


@dataclass(frozen=True)
class User:
uri: str
name: str = None

def __str__(self, user_type: str = "ATTENDEE"):
return f"{user_type}{'' if self.name is None else f';CN={self.name}'}:{self.uri}"


@dataclass(frozen=True)
class Event:
uid: str
dtstamp: datetime
dtstart: datetime
dtend: datetime = None
created: datetime = None
last_modified: datetime = None
url: str = None
location: str = None
summary: str = None
description: str = None
sequence: int = None
transp: str = None
status: str = None
priority: int = None
organizer: User = None
attendees: List[User] = field(default_factory=list)

def __str__(self):
ret = [
f"UID:{self.uid}",
_datetime_to_str("DTSTAMP", self.dtstamp),
_datetime_to_str("DTSTART", self.dtstart)
]

if self.dtend is not None:
ret.append(_datetime_to_str("DTEND", self.dtend))
if self.created is not None:
ret.append(_datetime_to_str("CREATED", self.created))
if self.last_modified is not None:
ret.append(_datetime_to_str("LAST-MODIFIED", self.created))
if self.url is not None:
ret.append(f"URL:{self.url}")
if self.location is not None:
ret.append(f"LOCATION:{self.location}")
if self.summary is not None:
ret.append(f"SUMMARY:{self.summary}")
if self.description is not None:
ret.append(f"DESCRIPTION:{self.description}")
if self.sequence is not None:
ret.append(f"SEQUENCE:{self.sequence}")
if self.transp is not None:
ret.append(f"TRANSP:{self.transp}")
if self.status is not None:
ret.append(f"STATUS:{self.status}")
if self.priority is not None:
ret.append(f"PRIORITY:{self.priority}")
if self.organizer is not None:
ret.append(self.organizer.__str__(user_type="ORGANIZER"))
ret.extend(x.__str__(user_type="ATTENDEE") for x in self.attendees)

return "BEGIN:VEVENT\n{}\nEND:VEVENT".format("\n".join(ret))
73 changes: 41 additions & 32 deletions clickup_to_ical/web/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import timedelta
from logging import getLogger

import pytz
from flask import Flask, request, Response

from clickup_to_ical import __version__
Expand All @@ -9,16 +10,14 @@
from clickup_to_ical.web.utils import ThreadingDict

_LOGGER = getLogger("clickup_to_ical")
app = Flask("clickup_to_ical")
app = Flask(f"clickup_to_ical-{__version__}")
_LOGGER.info(f"Creating flask app for clickup_to_ical-{__version__}")


@app.route("/api/1.0/calendar", methods=["GET"])
def get_calendar():
import uuid
from ical.calendar import Calendar
from ical.event import Event
from ical.types import CalAddress
from ical.calendar_stream import IcsCalendarStream
from clickup_to_ical.ical import Calendar, Event, User
from datetime import datetime
request_from = request.environ.get('HTTP_X_REAL_IP', request.remote_addr)
auth_key = request.headers.get("Authorization", request.args.get("token", None))
Expand Down Expand Up @@ -48,51 +47,61 @@ def date_type_filter(_dt_name) -> bool:

if request.args.get("only_assigned", "true").lower() in TRUE_VALUES:
log["assignees"] = clickup_user_id
assigned_tasks = tasks[clickup_user_id]
assigned_tasks = tasks.get(clickup_user_id, [])
else:
log["assignees"] = "all"
assigned_tasks = tasks[None]
assigned_tasks = tasks.get(None, [])

include_closed = request.args.get("include_closed", "false") in TRUE_VALUES
log["closed included"] = include_closed

_LOGGER.info(f"Request from {request_from}: <{'; '.join(f'{x}: {y}' for x, y in log.items())}>")
calendar = Calendar()
calendar.prodid = f'-//CLICKUP-TO-ICAL-CONVERTER//github.com/RedRem95/clickup_to_ical//{__version__}//'
calendar.version = '2.0'
calendar = Calendar(
version='2.0',
prodid=f'-//CLICKUP-TO-ICAL-CONVERTER//github.com/RedRem95/clickup_to_ical//{__version__}//',
calendar_name="ClickupToIcal Calendar",
calendar_description="ClickupToIcal Calendar\n{}".format("\n".join(f"{x}: {y}" for x, y in log.items())),
)

for task in (x for x in assigned_tasks if (include_closed or x.is_open())):
members = {x.id: x for x in task.get_members()}
if task.markdown_description is not None and len(task.markdown_description) > 0:
from markdown import markdown
from bs4 import BeautifulSoup
desc = BeautifulSoup(markdown(task.markdown_description), features='html.parser').get_text()
task_description = desc
else:
task_description = None

if task.creator in members and members[task.creator].email:
task_organizer = User(
uri=f"mailto:{members[task.creator].email}",
name=members[task.creator].username
)
else:
task_organizer = None

task_attendees = []
for assignee in task.get_assignees():
task_attendees.append(User(
uri=f"mailto:{assignee.email}",
name=assignee.username
))

for dt_name, dt in ((x, y) for x, y in task.get_all_dates().items() if date_type_filter(_dt_name=x)):
real_name = dt_name[2 if dt_name.startswith('__') else 0:]
event = Event(
uid=str(uuid.uuid3(uuid.NAMESPACE_X500, f"{task.id}-{dt_name}")),
dtstamp=datetime.utcfromtimestamp(float(task.date_created) / 1000),
dtstamp=datetime.fromtimestamp(float(task.date_created) / 1000, tz=pytz.UTC),
url=task.url,
summary=f"{task.get_name()} - {real_name}",
dtstart=dt,
dtend=dt + timedelta(seconds=default_event_length.get(real_name, 0)),
priority=task.priority,
organizer=task_organizer,
attendees=task_attendees,
description=task_description,
)
if task.priority is not None:
event.priority = task.priority
if task.markdown_description is not None and len(task.markdown_description) > 0:
from markdown import markdown
from bs4 import BeautifulSoup
desc = BeautifulSoup(markdown(task.markdown_description), features='html.parser').get_text()
event.description = desc

if task.creator in members and members[task.creator].email:
event.organizer = CalAddress(
uri=f"mailto:{members[task.creator].email}",
common_name=members[task.creator].username
)

for assignee in task.get_assignees():
event.attendees.append(CalAddress(
uri=f"mailto:{assignee.email}",
common_name=assignee.username
))

calendar.events.append(event)

return Response(IcsCalendarStream.calendar_to_ics(calendar), mimetype="text/calendar", status=200)
return Response(str(calendar), mimetype="text/calendar", status=200)
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
},
packages=setuptools.find_packages(include=['clickup_to_ical', 'clickup_to_ical.*']),
python_requires=">=3.7",
install_requires=["requests", "ical", "Flask", "requests-ratelimiter", "markdown", "beautifulsoup4"],
install_requires=["requests", "Flask", "requests-ratelimiter", "markdown", "beautifulsoup4"],
include_package_data=True,
zip_safe=False,
)

0 comments on commit 32f17f4

Please sign in to comment.