diff --git a/calendarpush.py b/calendarpush.py new file mode 100644 index 0000000000000000000000000000000000000000..260aece3ccf9b3ceb39004122df8f023122278d5 --- /dev/null +++ b/calendarpush.py @@ -0,0 +1,135 @@ +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() diff --git a/config.py.example b/config.py.example index 5c443f4da4d37864609fbb8607c3b80271f9ef63..0f0399a6032489a2236ccaa4893868925c889490 100644 --- a/config.py.example +++ b/config.py.example @@ -1,30 +1,35 @@ -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"] diff --git a/migrations/versions/0555db125011_.py b/migrations/versions/0555db125011_.py new file mode 100644 index 0000000000000000000000000000000000000000..f3c0bfdffdafe8cfcf0df70a2f2cdb2625999d32 --- /dev/null +++ b/migrations/versions/0555db125011_.py @@ -0,0 +1,28 @@ +"""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 ### diff --git a/models/database.py b/models/database.py index 55a0f5da20a199e10d1a7f9be3fa046ad351abff..07c4e4fccd5eef0c46b780e87753ac8e7996f1a9 100644 --- a/models/database.py +++ b/models/database.py @@ -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 diff --git a/requirements.txt b/requirements.txt index 218d809d2873d46c835358172d46e6ca057a48f7..499eb51498e3c0c923bd48fcc5ab4269c23f7c4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/server.py b/server.py index e1a8ac59fdc2c01325e694f5bc48590819cfa706..8036f3b958449caf2866ed45ca4f2d7092234194 100755 --- a/server.py +++ b/server.py @@ -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)) diff --git a/tasks.py b/tasks.py index 658f81dc2cd7d24156f39b1751d22c5136440189..bea4291cf8b07d29b1101ad9b36116c663a28b48 100644 --- a/tasks.py +++ b/tasks.py @@ -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() diff --git a/templates/calendar-tops.txt b/templates/calendar-tops.txt new file mode 100644 index 0000000000000000000000000000000000000000..a9d8da6e991061d1021a5c9a37cfe311ce1842c4 --- /dev/null +++ b/templates/calendar-tops.txt @@ -0,0 +1,17 @@ +{% 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 %} diff --git a/views/forms.py b/views/forms.py index d910205efdebd65cdc52f786d902ed1dd48a45df..e917996fba8b82626c5b775a933ab2504afdc169 100644 --- a/views/forms.py +++ b/views/forms.py @@ -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.")]) diff --git a/views/tables.py b/views/tables.py index 643fb67c5566f984c0d1281a4aea7ddcdc5a91ef..89d2c0323300c85f02af1c94275a4e0ee1a87739 100644 --- a/views/tables.py +++ b/views/tables.py @@ -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):