Compare commits
1 commit
Author | SHA1 | Date | |
---|---|---|---|
f47cfa6614 |
12 changed files with 106 additions and 475 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,4 +1 @@
|
||||||
__pycache__
|
__pycache__
|
||||||
wp-cal-integration
|
|
||||||
*.c
|
|
||||||
*.o
|
|
||||||
|
|
12
Dockerfile
12
Dockerfile
|
@ -1,12 +0,0 @@
|
||||||
FROM alpine
|
|
||||||
|
|
||||||
RUN mkdir -p /opt/wp-cal-integration \
|
|
||||||
&& apk --no-cache add python3 py3-pip
|
|
||||||
COPY main.py /opt/wp-cal-integration/
|
|
||||||
COPY adapters /opt/wp-cal-integration/adapters
|
|
||||||
COPY requirements.txt /opt/wp-cal-integration/
|
|
||||||
RUN python3 -m pip install -r /opt/wp-cal-integration/requirements.txt
|
|
||||||
VOLUME /etc/wp-cal-integration/
|
|
||||||
|
|
||||||
ENTRYPOINT [ "/opt/wp-cal-integration/main.py" ]
|
|
||||||
CMD [ "-c", "/etc/wp-cal-integration/config.yaml" ]
|
|
21
Makefile
21
Makefile
|
@ -1,21 +0,0 @@
|
||||||
CC ?= cc
|
|
||||||
CFLAGS ?= $(shell python-config --embed --cflags)
|
|
||||||
LDFLAGS ?= $(shell python-config --embed --ldflags)
|
|
||||||
|
|
||||||
SRC := main.py adapters/*.py
|
|
||||||
OBJ := $(SRC:%.py=%.o)
|
|
||||||
|
|
||||||
wp-cal-integration: $(OBJ)
|
|
||||||
$(CC) $(LDFLAGS) -o $@ $^
|
|
||||||
|
|
||||||
clean:
|
|
||||||
$(RM) $(OBJ)
|
|
||||||
|
|
||||||
main.c: main.py
|
|
||||||
cython -3 --embed -o $@ $<
|
|
||||||
%.c: %.py
|
|
||||||
cython -3 -o $@ $<
|
|
||||||
|
|
||||||
%.o: %.c
|
|
||||||
$(CC) -c $(CFLAGS) -o $@ $<
|
|
||||||
|
|
12
README.md
12
README.md
|
@ -1,17 +1,5 @@
|
||||||
# Wordpress WP Booking System/Google Calendar integration
|
# Wordpress WP Booking System/Google Calendar integration
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
1. Create a google service account which has permission to read the desired calendars (add it to the calendars).
|
|
||||||
2. Add a key to the service account (choose the json option) and save it somewhere secure.
|
|
||||||
3. Run this program once `./main.py --config config.yaml` it will generate a example configuration file.
|
|
||||||
4. Add the json contents into the example file under `.google.credentials` and fill in the rest of the options.
|
|
||||||
5. To synchronize once run `./main.py --config config.yaml`.
|
|
||||||
|
|
||||||
## CI
|
|
||||||
|
|
||||||
There is a concourse pipeline which runs once every hour to synchronize all events. It reads the configuration
|
|
||||||
file from the concourse vault and passes it to the script.
|
|
||||||
|
|
||||||
## Known issues
|
## Known issues
|
||||||
|
|
||||||
|
|
|
@ -1,28 +1,11 @@
|
||||||
|
|
||||||
|
|
||||||
from .google import Google
|
from .google import Google
|
||||||
from .wordpress import Wordpress
|
from .wordpress import Wordpress
|
||||||
from .abc import Source, Sink, Adapter
|
from .wordpress import CalendarMetadata
|
||||||
|
|
||||||
_ADAPTERS: set[type[Adapter]] = {
|
|
||||||
Google,
|
|
||||||
Wordpress,
|
|
||||||
}
|
|
||||||
_SOURCES: set[type[Source]] = {
|
|
||||||
Google,
|
|
||||||
}
|
|
||||||
_SINKS: set[type[Sink]] = {
|
|
||||||
Wordpress,
|
|
||||||
}
|
|
||||||
ADAPTERS: dict[str, type[Adapter]] = {
|
|
||||||
str(x.__name__).lower(): x for x in _ADAPTERS
|
|
||||||
}
|
|
||||||
SOURCES: dict[str, type[Source]] = {
|
|
||||||
str(x.__name__).lower(): x for x in _SOURCES
|
|
||||||
}
|
|
||||||
SINKS: dict[str, type[Sink]] = {
|
|
||||||
str(x.__name__).lower(): x for x in _SINKS
|
|
||||||
}
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Google',
|
'Google',
|
||||||
'Wordpress',
|
'Wordpress',
|
||||||
|
'CalendarMetadata',
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,85 +0,0 @@
|
||||||
import abc
|
|
||||||
import typing
|
|
||||||
import datetime
|
|
||||||
import copy
|
|
||||||
|
|
||||||
import schema
|
|
||||||
import dateutil.rrule
|
|
||||||
|
|
||||||
class Adapter(abc.ABC):
|
|
||||||
schema: schema.Schema
|
|
||||||
|
|
||||||
def __init__(self, config, *args, **kwargs):
|
|
||||||
_ = args, kwargs
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def new(cls, config: dict, *args, **kwargs):
|
|
||||||
return cls(cls.schema.validate(config) , *args, **kwargs)
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def login(self) -> bool:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
class Source(abc.ABC):
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def get_events(
|
|
||||||
self,
|
|
||||||
start: datetime.datetime | None=None,
|
|
||||||
until: datetime.timedelta | None=None,
|
|
||||||
limit=None,
|
|
||||||
) -> typing.Iterable[dict]:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def get_events_resolved(
|
|
||||||
self,
|
|
||||||
start: datetime.datetime | None=None,
|
|
||||||
until: datetime.timedelta | None=None,
|
|
||||||
limit=None,
|
|
||||||
) -> typing.Iterable[dict]:
|
|
||||||
until = until if until else datetime.timedelta(days=365)
|
|
||||||
now = start if start else datetime.datetime.utcnow().astimezone()
|
|
||||||
now_365 = now + until
|
|
||||||
for e in self.get_events(
|
|
||||||
start=start,
|
|
||||||
until=until,
|
|
||||||
limit=limit,
|
|
||||||
):
|
|
||||||
if 'recurrence' not in e:
|
|
||||||
yield e
|
|
||||||
continue
|
|
||||||
r = e.pop('recurrence')
|
|
||||||
r = dateutil.rrule.rrulestr(
|
|
||||||
'\n'.join(r),
|
|
||||||
unfold=True,
|
|
||||||
ignoretz=True,
|
|
||||||
dtstart=datetime.datetime.fromisoformat(
|
|
||||||
e['start']['dateTime']
|
|
||||||
if 'dateTime' in e['start'] else
|
|
||||||
e['start']['date']
|
|
||||||
).replace(tzinfo=None)
|
|
||||||
)
|
|
||||||
for t_ in r.between(now.replace(tzinfo=None), now_365.replace(tzinfo=None)):
|
|
||||||
e_ = copy.deepcopy(e)
|
|
||||||
if 'dateTime' in e['start']:
|
|
||||||
e_['start']['dateTime'] = datetime.datetime.combine(t_.date(), datetime.datetime.fromisoformat(e['start']['dateTime']).time()).isoformat()
|
|
||||||
elif 'date' in e['start']:
|
|
||||||
e_['start']['date'] = datetime.datetime.combine(t_.date(), datetime.time())
|
|
||||||
if 'dateTime' in e['end']:
|
|
||||||
e_['end']['dateTime'] = datetime.datetime.combine(t_.date(), datetime.datetime.fromisoformat(e['end']['dateTime']).time()).isoformat()
|
|
||||||
elif 'date' in e['end']:
|
|
||||||
e_['end']['date'] = datetime.datetime.combine(t_.date(), datetime.time())
|
|
||||||
yield e_
|
|
||||||
yield e
|
|
||||||
|
|
||||||
class Sink(abc.ABC):
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def post_events(
|
|
||||||
self,
|
|
||||||
events,
|
|
||||||
start: datetime.datetime | None=None,
|
|
||||||
until: datetime.timedelta | None=None,
|
|
||||||
) -> bool:
|
|
||||||
raise NotImplementedError()
|
|
|
@ -1,55 +1,52 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import tempfile
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import os.path
|
||||||
import os
|
|
||||||
|
|
||||||
|
import json
|
||||||
from google.auth.transport.requests import Request
|
from google.auth.transport.requests import Request
|
||||||
#from google.oauth2.credentials import Credentials
|
from google.oauth2.credentials import Credentials
|
||||||
from google.oauth2.service_account import Credentials
|
|
||||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
from googleapiclient.errors import HttpError
|
from googleapiclient.errors import HttpError
|
||||||
from schema import Use, Optional, Schema
|
|
||||||
|
|
||||||
logger = logging.getLogger(f"wp_cal.{__name__}")
|
|
||||||
|
|
||||||
from .abc import Source, Adapter
|
|
||||||
|
|
||||||
# If modifying these scopes, delete the file token.json.
|
# If modifying these scopes, delete the file token.json.
|
||||||
SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']
|
SCOPES = ['https://www.googleapis.com/auth/calendar']
|
||||||
|
|
||||||
|
|
||||||
class Google(Source, Adapter):
|
class Google():
|
||||||
|
|
||||||
schema = Schema({
|
def __init__(self, calendar_id, *, credentials, token_file):
|
||||||
'calendar_id': Use(str),
|
_, credentials_file = tempfile.mkstemp()
|
||||||
'credentials': Use(dict),
|
with open(credentials_file, 'w') as fp:
|
||||||
Optional(
|
json.dump(credentials, fp)
|
||||||
'token_file',
|
|
||||||
default=os.path.join(
|
|
||||||
os.environ["HOME"],
|
|
||||||
'.wp-cal-google-token',
|
|
||||||
),
|
|
||||||
): Use(str),
|
|
||||||
})
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
self.calendar_id = calendar_id
|
||||||
super().__init__(*args, **kwargs)
|
self.credentials_file = credentials_file
|
||||||
self.calendar_id = self.config['calendar_id']
|
self.token_file = token_file
|
||||||
self.credentials_info = self.config['credentials']
|
|
||||||
self.token_file = self.config['token_file']
|
|
||||||
|
|
||||||
# runtime data
|
|
||||||
self.credentials = None
|
self.credentials = None
|
||||||
|
|
||||||
def login(self) -> bool:
|
def login(self):
|
||||||
self.credentials = Credentials.from_service_account_info(self.credentials_info, scopes=SCOPES)
|
print('token_file =', self.token_file)
|
||||||
if not self.credentials.valid:
|
print('exists?', os.path.exists(self.token_file))
|
||||||
self.credentials.refresh(Request())
|
if os.path.exists(self.token_file):
|
||||||
logger.debug('credentials.valid = %s', repr(self.credentials.valid))
|
self.credentials = Credentials.from_authorized_user_file(self.token_file, SCOPES)
|
||||||
return self.credentials.valid
|
print('valid?', self.credentials.valid)
|
||||||
|
if self.credentials is None or not self.credentials.valid:
|
||||||
|
if self.credentials is not None \
|
||||||
|
and self.credentials.expired \
|
||||||
|
and self.credentials.refresh_token:
|
||||||
|
self.credentials.refresh(Request())
|
||||||
|
else:
|
||||||
|
flow = InstalledAppFlow.from_client_secrets_file(
|
||||||
|
self.credentials_file,
|
||||||
|
SCOPES,
|
||||||
|
)
|
||||||
|
self.credentials = flow.run_local_server(port=0)
|
||||||
|
with open(self.token_file, 'w') as token:
|
||||||
|
token.write(self.credentials.to_json())
|
||||||
|
|
||||||
def get_events(self, start: datetime.datetime | None=None, until: datetime.timedelta | None=None, limit=None):
|
def get_events(self, start: datetime.datetime | None=None, until: datetime.timedelta | None=None, limit=None):
|
||||||
until = until if until else datetime.timedelta(days=365)
|
until = until if until else datetime.timedelta(days=365)
|
||||||
|
@ -65,7 +62,7 @@ class Google(Source, Adapter):
|
||||||
maxResults=limit,
|
maxResults=limit,
|
||||||
).execute()
|
).execute()
|
||||||
except HttpError:
|
except HttpError:
|
||||||
logger.exception("a http error occured")
|
print('an error occured')
|
||||||
raise
|
raise
|
||||||
events = events.get('items', [])
|
events = events.get('items', [])
|
||||||
return events
|
return events
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(f"wp_cal.{__name__}")
|
|
||||||
|
|
||||||
def dict_merge(d0, d1):
|
def dict_merge(d0, d1):
|
||||||
for k, v in d1.items():
|
for k, v in d1.items():
|
||||||
if (k in d0 and isinstance(d0[k], dict) and isinstance(d1[k], dict)):
|
if (k in d0 and isinstance(d0[k], dict) and isinstance(d1[k], dict)):
|
||||||
|
|
|
@ -5,15 +5,10 @@ import requests
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import json
|
import json
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
|
||||||
|
|
||||||
from schema import Use, Schema
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
from . import utils
|
from . import utils
|
||||||
from .abc import Sink, Adapter
|
|
||||||
|
|
||||||
logger = logging.getLogger(f"wp_cal.{__name__}")
|
|
||||||
|
|
||||||
class CalendarMetadata():
|
class CalendarMetadata():
|
||||||
|
|
||||||
|
@ -37,29 +32,19 @@ class CalendarMetadata():
|
||||||
**translations,
|
**translations,
|
||||||
}
|
}
|
||||||
|
|
||||||
class Wordpress(Sink, Adapter):
|
class Wordpress():
|
||||||
|
|
||||||
schema = Schema({
|
def __init__(
|
||||||
'url': Use(str),
|
self,
|
||||||
'calendar': {
|
base_url: str,
|
||||||
'id': Use(str),
|
*,
|
||||||
'name': Use(str),
|
calendar_metadata: CalendarMetadata,
|
||||||
'translations': Use(dict),
|
credentials: dict,
|
||||||
},
|
):
|
||||||
'credentials': {
|
self.base_url = base_url
|
||||||
'user': Use(str),
|
|
||||||
'password': Use(str),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.base_url = self.config['url']
|
|
||||||
self.credentials = self.config['credentials']
|
|
||||||
self.calendar_metadata = CalendarMetadata(**self.config['calendar'])
|
|
||||||
|
|
||||||
# runtime data
|
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
|
self.credentials = credentials
|
||||||
|
self.calendar_meadata = calendar_metadata
|
||||||
|
|
||||||
def login(self):
|
def login(self):
|
||||||
login_request = self.session.post(
|
login_request = self.session.post(
|
||||||
|
@ -70,15 +55,9 @@ class Wordpress(Sink, Adapter):
|
||||||
'wp-submit': 'Anmelden',
|
'wp-submit': 'Anmelden',
|
||||||
'redirect_to': f'{self.base_url}/wp-admin/',
|
'redirect_to': f'{self.base_url}/wp-admin/',
|
||||||
'testcookie': 1,
|
'testcookie': 1,
|
||||||
},
|
}
|
||||||
allow_redirects=False,
|
|
||||||
)
|
)
|
||||||
logger.debug('login request return headers = %s', login_request.headers)
|
return login_request
|
||||||
login_request_cookies = [x.strip() for x in login_request.headers['Set-Cookie'].split(',')]
|
|
||||||
if len(login_request_cookies) > 1:
|
|
||||||
logger.debug('login seems to be ok, cookies = %s', login_request_cookies)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _datestr_to_date(self, s, tz=None):
|
def _datestr_to_date(self, s, tz=None):
|
||||||
if s.endswith('Z'):
|
if s.endswith('Z'):
|
||||||
|
@ -138,8 +117,8 @@ class Wordpress(Sink, Adapter):
|
||||||
start: datetime.datetime | None=None,
|
start: datetime.datetime | None=None,
|
||||||
until: datetime.timedelta | None=None
|
until: datetime.timedelta | None=None
|
||||||
):
|
):
|
||||||
start = start if start else datetime.datetime.utcnow().astimezone()
|
|
||||||
until = until if until else datetime.timedelta(days=365)
|
until = until if until else datetime.timedelta(days=365)
|
||||||
|
start = start if start else datetime.datetime.utcnow().astimezone()
|
||||||
|
|
||||||
final_dict = {}
|
final_dict = {}
|
||||||
for event in events:
|
for event in events:
|
||||||
|
@ -152,17 +131,9 @@ class Wordpress(Sink, Adapter):
|
||||||
)
|
)
|
||||||
return final_dict
|
return final_dict
|
||||||
|
|
||||||
def get_nonce(self):
|
def post_events(self, events):
|
||||||
r = self.session.get(
|
metadata = self.calendar_meadata.to_dict()
|
||||||
f'{self.base_url}/wp-admin/admin.php?page=wpbs-calendars&subpage=edit-calendar&calendar_id=1',
|
data = self._generate_data(events)
|
||||||
)
|
|
||||||
soup = BeautifulSoup(r.text, 'html.parser')
|
|
||||||
nonce = soup.find_all('input', {'id': 'wpbs_token'})[0]
|
|
||||||
return nonce['value']
|
|
||||||
|
|
||||||
def post_events(self, events, start: datetime.datetime | None=None, until: datetime.timedelta | None=None):
|
|
||||||
metadata = self.calendar_metadata.to_dict()
|
|
||||||
data = self._generate_data(events, start=start, until=until)
|
|
||||||
update_request = self.session.post(
|
update_request = self.session.post(
|
||||||
f'{self.base_url}/wp-admin/admin-ajax.php',
|
f'{self.base_url}/wp-admin/admin-ajax.php',
|
||||||
auth=(self.credentials['user'], self.credentials['password']),
|
auth=(self.credentials['user'], self.credentials['password']),
|
||||||
|
@ -170,11 +141,7 @@ class Wordpress(Sink, Adapter):
|
||||||
'action': 'wpbs_save_calendar_data',
|
'action': 'wpbs_save_calendar_data',
|
||||||
'form_data': urllib.parse.urlencode(metadata),
|
'form_data': urllib.parse.urlencode(metadata),
|
||||||
'calendar_data': json.dumps(data),
|
'calendar_data': json.dumps(data),
|
||||||
'wpbs_token': self.get_nonce(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
r = 'wpbs_message=calendar_update_success' in update_request.text
|
return update_request
|
||||||
if not r:
|
|
||||||
raise Exception(f'failed to post events, got answer {update_request.text}')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,41 +0,0 @@
|
||||||
---
|
|
||||||
resources:
|
|
||||||
- name: periodic
|
|
||||||
type: time
|
|
||||||
source:
|
|
||||||
interval: 24h
|
|
||||||
|
|
||||||
- name: script
|
|
||||||
type: git
|
|
||||||
source:
|
|
||||||
uri: https://gitea.redxef.at/redxef/wp-cal-integration
|
|
||||||
branch: master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
- name: update-calendar
|
|
||||||
plan:
|
|
||||||
- get: periodic
|
|
||||||
trigger: true
|
|
||||||
- get: script
|
|
||||||
- task: run-update
|
|
||||||
config:
|
|
||||||
platform: linux
|
|
||||||
image_resource:
|
|
||||||
type: registry-image
|
|
||||||
source:
|
|
||||||
repository: alpine
|
|
||||||
tag: latest
|
|
||||||
inputs:
|
|
||||||
- name: script
|
|
||||||
path: .
|
|
||||||
params:
|
|
||||||
configuration_json: ((configuration))
|
|
||||||
run:
|
|
||||||
path: sh
|
|
||||||
args:
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
#!/usr/bin/env sh
|
|
||||||
apk --no-cache --update add libcap jq python3 py3-pip
|
|
||||||
python3 -m pip install --break-system-packages --requirement requirements.txt
|
|
||||||
echo "$configuration_json" | ./main.py -l :verbose -c -
|
|
230
main.py
230
main.py
|
@ -1,197 +1,67 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import logging
|
|
||||||
import datetime
|
|
||||||
import yaml
|
import yaml
|
||||||
from schema import Schema, And, Or, Use, Optional, Regex
|
try:
|
||||||
|
from yaml import CLoader as Loader
|
||||||
|
except ImportError:
|
||||||
|
from yaml import Loader
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
import adapters
|
|
||||||
from adapters import *
|
from adapters import *
|
||||||
|
|
||||||
logging.VERBOSE = (logging.DEBUG + logging.INFO) // 2
|
class Config():
|
||||||
|
|
||||||
logger = logging.getLogger(f'wp_cal')
|
"""
|
||||||
|
The default configuration.
|
||||||
|
|
||||||
def range_str_to_timedelta(value):
|
|
||||||
valid_units = 'smhdw'
|
|
||||||
current_int = 0
|
|
||||||
values = {}
|
|
||||||
for c in value:
|
|
||||||
if c in '0123456789':
|
|
||||||
current_int *= 10
|
|
||||||
current_int += int(c)
|
|
||||||
elif c in valid_units:
|
|
||||||
if c in values:
|
|
||||||
logger.warning('unit %s already in values, overwriting', c)
|
|
||||||
values[c] = current_int
|
|
||||||
current_int = 0
|
|
||||||
c = 's'
|
|
||||||
if current_int != 0:
|
|
||||||
if c in values:
|
|
||||||
logger.warning('unit %s already in values, overwriting', c)
|
|
||||||
values['s'] = current_int
|
|
||||||
for valid_unit in valid_units:
|
|
||||||
values.setdefault(valid_unit, 0)
|
|
||||||
return datetime.timedelta(
|
|
||||||
seconds=values['s'],
|
|
||||||
minutes=values['m'],
|
|
||||||
hours=values['h'],
|
|
||||||
days=values['d'],
|
|
||||||
weeks=values['w']
|
|
||||||
)
|
|
||||||
|
|
||||||
def multiple_timedeltas(value):
|
Keys:
|
||||||
mappings = {}
|
.google.calendar_id: the id of the calendar to sync
|
||||||
for v in value.split(','):
|
.google.credentials: the json of the obtained credentials file
|
||||||
kv = v.split(':')
|
.google.token_file: where to save the login token
|
||||||
if len(kv) == 1:
|
.wordpress.url: the base wordpress url
|
||||||
mappings[-1] = range_str_to_timedelta(kv[0])
|
.wordpress.calendar.id: the id of the (wp-booking-system) calendar
|
||||||
continue
|
.wordpress.calendar.name: the name of the calendar
|
||||||
mappings[int(kv[0])] = range_str_to_timedelta(kv[1])
|
.wordpress.calendar.translations: a dictionary of language <-> translation pairs (example: {"en": "Reservations"})
|
||||||
return mappings
|
.wordpress.credentials.user: the user as which to log into wordpress
|
||||||
|
.wordpress.credentials.password: the users password
|
||||||
|
"""
|
||||||
|
|
||||||
config_schema = Schema({
|
def __init__(self, file):
|
||||||
'sources': [dict],
|
self.file = file
|
||||||
'sinks': [dict],
|
self.config: dict | None = None
|
||||||
Optional('logging', default={'level': ':WARNING,wp_cal:INFO'}): {
|
|
||||||
Optional('level', default=':WARNING,wp_cal:INFO'): Use(str),
|
|
||||||
},
|
|
||||||
Optional('range', default='365d'): Or(
|
|
||||||
dict,
|
|
||||||
And(
|
|
||||||
Use(str),
|
|
||||||
Regex(r'(([0-9]+:)?[0-9]+[smhdw],?)*'),
|
|
||||||
Use(multiple_timedeltas),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
def load_config(file):
|
def load(self):
|
||||||
if file == '-':
|
if self.file == '-':
|
||||||
config = yaml.safe_load(sys.stdin)
|
config = yaml.load(sys.stdin, Loader=Loader)
|
||||||
else:
|
|
||||||
if os.stat(file).st_mode & 0o777 & ~0o600:
|
|
||||||
raise Exception('refusing to load insecure configuration file, file must have permission 0o600')
|
|
||||||
with open(file) as fp:
|
|
||||||
config = yaml.safe_load(fp)
|
|
||||||
return config_schema.validate(config)
|
|
||||||
|
|
||||||
def init_logging():
|
|
||||||
logging.getLogger().addHandler(
|
|
||||||
logging.StreamHandler(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_logging_level(level: str):
|
|
||||||
levels = {
|
|
||||||
'NOTSET': logging.NOTSET,
|
|
||||||
'DEBUG': logging.DEBUG,
|
|
||||||
'VERBOSE': logging.VERBOSE,
|
|
||||||
'INFO': logging.INFO,
|
|
||||||
'WARNING': logging.WARNING,
|
|
||||||
'WARN': logging.WARNING,
|
|
||||||
'ERROR': logging.ERROR,
|
|
||||||
'CRITICAL': logging.CRITICAL,
|
|
||||||
}
|
|
||||||
|
|
||||||
log_levels = [x.split(':') for x in level.split(',')]
|
|
||||||
for module, level in log_levels:
|
|
||||||
module = module.strip() if module else None
|
|
||||||
level = level.strip().upper()
|
|
||||||
if level not in levels:
|
|
||||||
raise ValueError(f'invalid log level, allowed values: {repr(set(levels.keys()))}')
|
|
||||||
logging.getLogger(module).setLevel(levels[level])
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option('--config', '-c', envvar='WP_CAL_CONFIG', default='-', help='The configuration file')
|
|
||||||
@click.option('--dryrun', '-d', envvar='WP_CAL_DRYRUN', is_flag=True, help="Don't actually post any data, just show it")
|
|
||||||
@click.option('--level', '-l', envvar='WP_CAL_LEVEL', default=None, help='The log level for the application')
|
|
||||||
@click.option('--range', '-r', envvar='WP_CAL_RANGE', default=None, help='The time range from start to start + range to synchronize events')
|
|
||||||
def main(config, dryrun, level, range):
|
|
||||||
init_logging()
|
|
||||||
set_logging_level(':DEBUG')
|
|
||||||
|
|
||||||
config = load_config(config)
|
|
||||||
if level:
|
|
||||||
config['logging']['level'] = level
|
|
||||||
if range:
|
|
||||||
config['range'] = range
|
|
||||||
config = config_schema.validate(config)
|
|
||||||
set_logging_level(config['logging']['level'])
|
|
||||||
|
|
||||||
sources = set()
|
|
||||||
sinks = set()
|
|
||||||
for source in config['sources']:
|
|
||||||
assert len(source.keys()) == 1
|
|
||||||
for aname, a in adapters.ADAPTERS.items():
|
|
||||||
if aname in source:
|
|
||||||
sources |= {a.new(source[aname])}
|
|
||||||
break
|
|
||||||
else:
|
else:
|
||||||
logger.error('couldn\'t find valid adapter for source configuration %s', source)
|
with open(self.file) as fp:
|
||||||
sys.exit(1)
|
config = yaml.load(fp, Loader=Loader)
|
||||||
for sink in config['sinks']:
|
self.config = config
|
||||||
assert len(sink.keys()) == 1
|
|
||||||
for aname, a in adapters.ADAPTERS.items():
|
|
||||||
if aname in sink:
|
|
||||||
sinks |= {a.new(sink[aname])}
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
logger.error('couldn\'t find valid adapter for sink configuration %s', sink)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if not all([isinstance(x, adapters.Source) for x in sources]):
|
def __getitem__(self, name):
|
||||||
logger.error('one or more source configurations do not implement being a source')
|
assert self.config is not None
|
||||||
sys.exit(1)
|
return self.config[name]
|
||||||
if not all([isinstance(x, adapters.Sink) for x in sinks]):
|
|
||||||
logger.error('one or more sink configurations do not implement being a sink')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# log in
|
|
||||||
if not all([x.login() for x in sources | sinks]):
|
|
||||||
logger.error('failed to log into one or more sinks or sources')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# gather events
|
|
||||||
events = []
|
|
||||||
source_results = []
|
|
||||||
for i, source in enumerate(sources):
|
|
||||||
try:
|
|
||||||
events += source.get_events_resolved(until=config['range'].get(i, config['range'][-1]))
|
|
||||||
source_results += [True]
|
|
||||||
except Exception:
|
|
||||||
logger.exception('failed to get events from source %s', source)
|
|
||||||
source_results += [False]
|
|
||||||
if not any(source_results):
|
|
||||||
logger.error('event get failed for all sources')
|
|
||||||
sys.exit(1)
|
|
||||||
# filter cancelled events
|
|
||||||
logger.info('found %d events', len(events))
|
|
||||||
logger.info('not syncing cancelled events')
|
|
||||||
events = [e for e in events if e['status'] != 'cancelled']
|
|
||||||
logger.info('syncing %d events', len(events))
|
|
||||||
logger.log(logging.VERBOSE, 'events are: %s', [f"{e.get('summary', '<NO SUMMARY PROVIDED>')} ({e.get('start')})" for e in events])
|
|
||||||
|
|
||||||
# post events
|
|
||||||
if not dryrun:
|
|
||||||
sink_results = []
|
|
||||||
for sink in sinks:
|
|
||||||
try:
|
|
||||||
sink.post_events(events, until=max(config['range'].values()))
|
|
||||||
sink_results += [True]
|
|
||||||
except Exception:
|
|
||||||
logger.exception('failed to post to sink %s', sink)
|
|
||||||
sink_results += [False]
|
|
||||||
if not any(sink_results):
|
|
||||||
logger.error('event post failed for all sinks')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
logger.info("done")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
config = Config('-')
|
||||||
|
config.load()
|
||||||
|
g = Google(
|
||||||
|
config['google']['calendar_id'],
|
||||||
|
credentials=config['google']['credentials'],
|
||||||
|
token_file=config['google'].get('token_file', '~/.wp-cal-integration-google-token.json')
|
||||||
|
)
|
||||||
|
w = Wordpress(
|
||||||
|
config['wordpress']['url'],
|
||||||
|
calendar_metadata=CalendarMetadata(
|
||||||
|
id=config['wordpress']['calendar']['id'],
|
||||||
|
name=config['wordpress']['calendar']['name'],
|
||||||
|
translations=config['wordpress']['calendar']['translations'],
|
||||||
|
),
|
||||||
|
credentials=config['wordpress']['credentials'],
|
||||||
|
)
|
||||||
|
g.login()
|
||||||
|
events = g.get_events()
|
||||||
|
w.login()
|
||||||
|
w.post_events(events)
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
pyyaml
|
|
||||||
schema
|
|
||||||
google-api-python-client
|
|
||||||
google-auth-httplib2
|
|
||||||
google-auth-oauthlib
|
|
||||||
click
|
|
||||||
bs4
|
|
||||||
python-dateutil
|
|
Loading…
Reference in a new issue