Commit 1023b45a authored by Robin Sonnabend's avatar Robin Sonnabend
Browse files

Pushing to CalDAV calendars

parent 607b9aae
from datetime import datetime, timedelta
import random
import quopri
from caldav import DAVClient, Principal, Calendar, Event
from vobject.base import ContentLine
import config
class CalendarException(Exception):
pass
class Client:
def __init__(self, calendar=None, url=None):
self.url = url if url is not None else config.CALENDAR_URL
self.client = DAVClient(self.url)
self.principal = self.client.principal()
if calendar is not None:
self.calendar = self.get_calendar(calendar)
else:
self.calendar = calendar
def get_calendars(self):
return [
calendar.name
for calendar in self.principal.calendars()
]
def get_calendar(self, calendar_name):
candidates = self.principal.calendars()
for calendar in candidates:
if calendar.name == calendar_name:
return calendar
raise CalendarException("No calendar named {}.".format(calendar_name))
def set_event_at(self, begin, name, description):
candidates = [
Event.from_raw_event(raw_event)
for raw_event in self.calendar.date_search(begin)
]
candidates = [event for event in candidates if event.name == name]
event = None
if len(candidates) == 0:
event = Event(None, name, description, begin,
begin + timedelta(hours=config.CALENDAR_DEFAULT_DURATION))
vevent = self.calendar.add_event(event.to_vcal())
event.vevent = vevent
else:
event = candidates[0]
event.set_description(description)
event.vevent.save()
NAME_KEY = "summary"
DESCRIPTION_KEY = "description"
BEGIN_KEY = "dtstart"
END_KEY = "dtend"
def _get_item(content, key):
if key in content:
return content[key][0].value
return None
class Event:
def __init__(self, vevent, name, description, begin, end):
self.vevent = vevent
self.name = name
self.description = description
self.begin = begin
self.end = end
@staticmethod
def from_raw_event(vevent):
raw_event = vevent.instance.contents["vevent"][0]
content = raw_event.contents
name = _get_item(content, NAME_KEY)
description = _get_item(content, DESCRIPTION_KEY)
begin = _get_item(content, BEGIN_KEY)
end = _get_item(content, END_KEY)
return Event(vevent=vevent, name=name, description=description,
begin=begin, end=end)
def set_description(self, description):
raw_event = self.vevent.instance.contents["vevent"][0]
self.description = description
encoded = encode_quopri(description)
if DESCRIPTION_KEY not in raw_event.contents:
raw_event.contents[DESCRIPTION_KEY] = [ContentLine(DESCRIPTION_KEY, {"ENCODING": ["QUOTED-PRINTABLE"]}, encoded)]
else:
content_line = raw_event.contents[DESCRIPTION_KEY][0]
content_line.value = encoded
content_line.params["ENCODING"] = ["QUOTED-PRINTABLE"]
def __repr__(self):
return "<Event(name='{}', description='{}', begin={}, end={})>".format(
self.name, self.description, self.begin, self.end)
def to_vcal(self):
offset = get_timezone_offset()
return """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//FSMPI Protokollsystem//CalDAV Client//EN
BEGIN:VEVENT
UID:{uid}
DTSTAMP:{now}
DTSTART:{begin}
DTEND:{end}
SUMMARY:{summary}
DESCRIPTION;ENCODING=QUOTED-PRINTABLE:{description}
END:VEVENT
END:VCALENDAR""".format(
uid=create_uid(), now=date_format(datetime.now()-offset),
begin=date_format(self.begin-offset), end=date_format(self.end-offset),
summary=self.name,
description=encode_quopri(self.description))
def create_uid():
return str(random.randint(0, 1e10)).rjust(10, "0")
def date_format(dt):
return dt.strftime("%Y%m%dT%H%M%SZ")
def get_timezone_offset():
difference = datetime.now() - datetime.utcnow()
return timedelta(hours=round(difference.seconds / 3600 + difference.days * 24))
def encode_quopri(text):
return quopri.encodestring(text.encode("utf-8")).replace(b"\n", b"=0D=0A").decode("utf-8")
def main():
client = Client("Protokolltest")
client.set_event_at(datetime(2017, 2, 27, 19, 0), "FSS", "Tagesordnung\nTOP 1")
if __name__ == "__main__":
if config.CALENDAR_ACTIVE:
main()
SQLALCHEMY_DATABASE_URI = "postgresql://proto3:@/proto3" # change
# (local) database
SQLALCHEMY_DATABASE_URI = "postgresql://user:password@host/database" # change this
SQLALCHEMY_TRACK_MODIFICATIONS = False # do not change
SECRET_KEY = "something random"
SECRET_KEY = "something random" # change this
DEBUG = False
# mailserver (optional)
MAIL_ACTIVE = True
MAIL_FROM = "protokolle@example.com"
MAIL_HOST = "mail.example.com:465"
MAIL_USER = "user"
MAIL_PASSWORD = "password"
MAIL_PREFIX = "protokolle"
# (local) message queue (necessary)
CELERY_BROKER_URL = "redis://localhost:6379/0"
CELERY_TASK_SERIALIZER = "pickle" # do not change
CELERY_ACCEPT_CONTENT = ["pickle"] # do not change
# this websites address
URL_ROOT = "protokolle.example.com"
URL_PROTO = "https"
URL_PATH = "/"
URL_PARAMS = ""
# ldap server (necessary)
LDAP_PROVIDER_URL = "ldaps://auth.example.com:389"
LDAP_BASE = "dc=example,dc=example,dc=com"
LDAP_PROTOCOL_VERSION = 3 # do not change
# CUPS printserver (optional)
PRINTING_ACTIVE = True
PRINTING_SERVER = "printsrv.example.com:631"
PRINTING_USER = "protocols"
......@@ -33,7 +38,9 @@ PRINTING_PRINTERS = [
"other_printer": ["list", "of", "options"]
]
ETHERPAD_URL = "https://fachschaften.rwth-aachen.de/etherpad"
# etherpad (optional)
ETHERPAD_ACTIVE = True
ETHERPAD_URL = "https://example.com/etherpad"
EMPTY_ETHERPAD = """Welcome to Etherpad!
This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!
......@@ -42,6 +49,7 @@ Get involved with Etherpad at http://etherpad.org
""" # do not change
# wiki (optional)
WIKI_ACTIVE = True
WIKI_API_URL = "https://wiki.example.com/wiki/api.php"
WIKI_ANONYMOUS = False
......@@ -49,17 +57,38 @@ WIKI_USER = "user"
WIKI_PASSWORD = "password"
WIKI_DOMAIN = "domain" # set to None if not necessary
SESSION_PROTECTION = "strong"
# CalDAV calendar (optional)
CALENDAR_ACTIVE = True
CALENDAR_URL = "https://user:password@calendar.example.com/dav/"
CALENDAR_DEFAULT_DURATION = 3 # default meeting length in hours
SECURITY_KEY = "some other random string"
SESSION_PROTECTION = "strong" # do not change
SECURITY_KEY = "some other random string" # change this
# lines of error description
ERROR_CONTEXT_LINES = 3
# pagination
PAGE_LENGTH = 20
PAGE_DIFF = 3
# upcoming meetings within this number of days from today are shown on the index page
MAX_INDEX_DAYS = 14
# mail to contact in case of complex errors
ADMIN_MAIL = "admin@example.com"
# accept protocols even with some errors
# useful for importing old protocols
# not recommended for regular operation
PARSER_LAZY = False
# minimum similarity (0-100) todos need to have to be considered equal
FUZZY_MIN_SCORE = 50
# choose something nice from fc-list
# Nimbus Sans looks very much like Computer Modern
FONTS = {
"main": {
"regular": "Nimbus Sans",
......@@ -87,7 +116,9 @@ FONTS = {
}
}
# local filesystem path to save documents
DOCUMENTS_PATH = "documents"
# keywords indicating private protocol parts
PRIVATE_KEYWORDS = ["private", "internal", "privat", "intern"]
"""empty message
Revision ID: 0555db125011
Revises: d543c6a2ea6e
Create Date: 2017-02-28 00:46:16.624939
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0555db125011'
down_revision = 'd543c6a2ea6e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('protocoltypes', sa.Column('calendar', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('protocoltypes', 'calendar')
# ### end Alembic commands ###
......@@ -34,6 +34,7 @@ class ProtocolType(db.Model):
wiki_category = db.Column(db.String)
wiki_only_public = db.Column(db.Boolean)
printer = db.Column(db.String)
calendar = db.Column(db.String)
protocols = relationship("Protocol", backref=backref("protocoltype"), cascade="all, delete-orphan", order_by="Protocol.id")
default_tops = relationship("DefaultTOP", backref=backref("protocoltype"), cascade="all, delete-orphan", order_by="DefaultTOP.number")
......@@ -56,16 +57,19 @@ class ProtocolType(db.Model):
self.wiki_category = wiki_category
self.wiki_only_public = wiki_only_public
self.printer = printer
self.calendar = calendar
def __repr__(self):
return ("<ProtocolType(id={}, short_name={}, name={}, "
"organization={}, is_public={}, private_group={}, "
"public_group={}, use_wiki={}, wiki_category='{}', "
"wiki_only_public={}, printer={}, usual_time={})>".format(
"wiki_only_public={}, printer={}, usual_time={}, "
"calendar='{}')>".format(
self.id, self.short_name, self.name,
self.organization, self.is_public, self.private_group,
self.public_group, self.use_wiki, self.wiki_category,
self.wiki_only_public, self.printer, self.usual_time))
self.wiki_only_public, self.printer, self.usual_time,
self.calendar))
def get_latest_protocol(self):
candidates = sorted([protocol for protocol in self.protocols if protocol.is_done()], key=lambda p: p.date, reverse=True)
......@@ -205,6 +209,9 @@ class Protocol(db.Model):
return ""
return get_etherpad_url(self.get_identifier())
def get_datetime(self):
return datetime(self.date.year, self.date.month, self.date.day, self.protocoltype.usual_time.hour, self.protocoltype.usual_time.minute)
def has_nonplanned_tops(self):
return len([top for top in self.tops if not top.planned]) > 0
......
......@@ -7,8 +7,10 @@ billiard==3.5.0.2
blessings==1.6
blinker==1.4
bpython==0.16
caldav==0.5.0
celery==4.0.2
click==6.7
coverage==4.3.4
curtsies==0.2.11
Flask==0.12
Flask-Migrate==2.0.3
......@@ -21,14 +23,17 @@ greenlet==0.4.12
itsdangerous==0.24
Jinja2==2.9.5
kombu==4.0.2
lxml==3.7.3
Mako==1.0.6
MarkupSafe==0.23
nose==1.3.7
packaging==16.8
pathtools==0.1.2
psycopg2==2.6.2
Pygments==2.2.0
pyldap==2.4.28
pyparsing==2.1.10
python-dateutil==2.6.0
python-editor==1.0.3
python-engineio==1.2.2
python-Levenshtein==0.12.0
......@@ -42,6 +47,7 @@ six==1.10.0
SQLAlchemy==1.1.5
tzlocal==1.3
vine==1.1.3
vobject==0.9.4.1
watchdog==0.8.3
wcwidth==0.1.7
Werkzeug==0.11.15
......
......@@ -451,6 +451,7 @@ def new_protocol():
protocol = Protocol(protocoltype.id, form.date.data)
db.session.add(protocol)
db.session.commit()
tasks.push_tops_to_calendar(protocol)
return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=protocol.id))
type_id = request.args.get("type_id")
if type_id is not None:
......@@ -645,6 +646,7 @@ def update_protocol(protocol_id):
if edit_form.validate_on_submit():
edit_form.populate_obj(protocol)
db.session.commit()
tasks.push_tops_to_calendar(protocol)
return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=protocol.id))
return render_template("protocol-update.html", upload_form=upload_form, edit_form=edit_form, protocol=protocol)
......@@ -677,6 +679,7 @@ def new_top(protocol_id):
top = TOP(protocol_id=protocol.id, name=form.name.data, number=form.number.data, planned=True)
db.session.add(top)
db.session.commit()
tasks.push_tops_to_calendar(top.protocol)
return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=protocol.id))
else:
current_numbers = list(map(lambda t: t.number, protocol.tops))
......@@ -696,6 +699,7 @@ def edit_top(top_id):
if form.validate_on_submit():
form.populate_obj(top)
db.session.commit()
tasks.push_tops_to_calendar(top.protocol)
return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=top.protocol.id))
return render_template("top-edit.html", form=form, top=top)
......@@ -708,11 +712,12 @@ def delete_top(top_id):
flash("Invalider TOP oder keine Berechtigung.", "alert-error")
return redirect(request.args.get("next") or url_for("index"))
name = top.name
protocol_id = top.protocol.id
protocol = top.protocol
db.session.delete(top)
db.session.commit()
tasks.push_tops_to_calendar(protocol)
flash("Der TOP {} wurde gelöscht.".format(name), "alert-success")
return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=protocol_id))
return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=protocol.id))
@app.route("/protocol/top/move/<int:top_id>/<diff>")
@login_required
......@@ -725,6 +730,7 @@ def move_top(top_id, diff):
try:
top.number += int(diff)
db.session.commit()
tasks.push_tops_to_calendar(top.protocol)
except ValueError:
flash("Die angegebene Differenz ist keine Zahl.", "alert-error")
return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=top.protocol.id))
......
......@@ -13,6 +13,7 @@ from shared import db, escape_tex, unhyphen, date_filter, datetime_filter, date_
from utils import mail_manager, url_manager, encode_kwargs, decode_kwargs
from parser import parse, ParserException, Element, Content, Text, Tag, Remark, Fork, RenderType
from wiki import WikiClient, WikiException
from calendarpush import Client as CalendarClient, CalendarException
from legacy import lookup_todo_id, import_old_todos
import config
......@@ -439,3 +440,24 @@ def send_mail_async(protocol_id, to_addr, subject, content, appendix):
db.session.add(error)
db.session.commit()
def push_tops_to_calendar(protocol):
push_tops_to_calendar_async.delay(protocol.id)
@celery.task
def push_tops_to_calendar_async(protocol_id):
if not config.CALENDAR_ACTIVE:
return
with app.app_context():
protocol = Protocol.query.filter_by(id=protocol_id).first()
if protocol.protocoltype.calendar == "":
return
description = render_template("calendar-tops.txt", protocol=protocol)
try:
client = CalendarClient(protocol.protocoltype.calendar)
client.set_event_at(begin=protocol.get_datetime(),
name=protocol.protocoltype.short_name, description=description)
except CalendarException as exc:
error = Protocol.create_error("Calendar",
"Pushing TOPs to Calendar failed", str(exc))
db.session.add(error)
db.session.commit()
{% if not protocol.has_nonplanned_tops() %}
{% for default_top in protocol.protocoltype.default_tops %}
{% if not default_top.is_at_end() %}
{{default_top.name}}
{% endif %}
{% endfor %}
{% endif %}
{% for top in protocol.tops %}
{{top.name}}
{% endfor %}
{% if not protocol.has_nonplanned_tops() %}
{% for default_top in protocol.protocoltype.default_tops %}
{% if default_top.is_at_end() %}
{{default_top.name}}
{% endif %}
{% endfor %}
{% endif %}
......@@ -4,6 +4,7 @@ from wtforms.validators import InputRequired, Optional
from models.database import TodoState
from validators import CheckTodoDateByState
from calendarpush import Client as CalendarClient
import config
......@@ -19,6 +20,12 @@ def get_todostate_choices():
for state in TodoState
]
def get_calendar_choices():
return [
(calendar, calendar)
for calendar in CalendarClient().get_calendars()
]
class LoginForm(FlaskForm):
username = StringField("Benutzer", validators=[InputRequired("Bitte gib deinen Benutzernamen ein.")])
password = PasswordField("Passwort", validators=[InputRequired("Bitte gib dein Passwort ein.")])
......@@ -37,6 +44,7 @@ class ProtocolTypeForm(FlaskForm):
use_wiki = BooleanField("Wiki benutzen")
wiki_only_public = BooleanField("Wiki ist öffentlich")
printer = SelectField("Drucker", choices=list(zip(config.PRINTING_PRINTERS, config.PRINTING_PRINTERS)))
calendar = SelectField("Kalender", choices=get_calendar_choices())
class DefaultTopForm(FlaskForm):
name = StringField("Name", validators=[InputRequired("Du musst einen Namen angeben.")])
......
......@@ -118,7 +118,11 @@ class ProtocolTypeTable(SingleValueTable):
wiki_headers.append("Wiki-Kategorie")
if not config.WIKI_ACTIVE:
wiki_headers = []
return general_headers + mail_headers + printing_headers + wiki_headers
calendar_headers = ["Kalender"]
if not config.CALENDAR_ACTIVE:
calendar_headers = []
return (general_headers + mail_headers + printing_headers
+ wiki_headers + calendar_headers)
def row(self):
general_part = [
......@@ -146,7 +150,10 @@ class ProtocolTypeTable(SingleValueTable):
wiki_part.append(self.value.wiki_category)
if not config.WIKI_ACTIVE:
wiki_part = []
return general_part + mail_part + printing_part + wiki_part
calendar_part = [self.value.calendar if self.value.calendar is not None else ""]
if not config.CALENDAR_ACTIVE:
calendar_part = []
return general_part + mail_part + printing_part + wiki_part + calendar_part
class DefaultTOPsTable(Table):
def __init__(self, tops, protocoltype=None):
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment