diff --git a/migrations/versions/515d261a624b_.py b/migrations/versions/515d261a624b_.py new file mode 100644 index 0000000000000000000000000000000000000000..2b5f42eb6e83a5937ea39f00a29557c2e77fcf62 --- /dev/null +++ b/migrations/versions/515d261a624b_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 515d261a624b +Revises: 77bf71eef07f +Create Date: 2017-02-26 00:33:13.555804 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '515d261a624b' +down_revision = '77bf71eef07f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('protocoltypes', sa.Column('usual_time', sa.Time(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('protocoltypes', 'usual_time') + # ### end Alembic commands ### diff --git a/migrations/versions/77bf71eef07f_.py b/migrations/versions/77bf71eef07f_.py new file mode 100644 index 0000000000000000000000000000000000000000..71827d3377ee44516c47eeb45eb44f3609e8fffe --- /dev/null +++ b/migrations/versions/77bf71eef07f_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 77bf71eef07f +Revises: f91d760158dc +Create Date: 2017-02-26 00:26:48.499578 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '77bf71eef07f' +down_revision = 'f91d760158dc' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('meetingreminders', sa.Column('additional_text', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('meetingreminders', 'additional_text') + # ### end Alembic commands ### diff --git a/models/database.py b/models/database.py index 5c180270fb6dc1df0e571b0d9b437fb8e605745d..2e709c69753b730586a725221327d1a7f4a0fa64 100644 --- a/models/database.py +++ b/models/database.py @@ -21,6 +21,7 @@ class ProtocolType(db.Model): name = db.Column(db.String, unique=True) short_name = db.Column(db.String, unique=True) organization = db.Column(db.String) + usual_time = db.Column(db.Time) is_public = db.Column(db.Boolean) private_group = db.Column(db.String) public_group = db.Column(db.String) @@ -36,12 +37,13 @@ class ProtocolType(db.Model): reminders = relationship("MeetingReminder", backref=backref("protocoltype"), cascade="all, delete-orphan", order_by="MeetingReminder.days_before") todos = relationship("Todo", backref=backref("protocoltype"), order_by="Todo.id") - def __init__(self, name, short_name, organization, + def __init__(self, name, short_name, organization, usual_time, is_public, private_group, public_group, private_mail, public_mail, use_wiki, wiki_category, wiki_only_public, printer): self.name = name self.short_name = short_name self.organization = organization + self.usual_time = usual_time self.is_public = is_public self.private_group = private_group self.public_group = public_group @@ -56,11 +58,11 @@ class ProtocolType(db.Model): return ("<ProtocolType(id={}, short_name={}, name={}, " "organization={}, is_public={}, private_group={}, " "public_group={}, use_wiki={}, wiki_category='{}', " - "wiki_only_public={}, printer={})>".format( + "wiki_only_public={}, printer={}, usual_time={})>".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.wiki_only_public, self.printer, self.usual_time)) def get_latest_protocol(self): candidates = sorted([protocol for protocol in self.protocols if protocol.is_done()], key=lambda p: p.date, reverse=True) @@ -385,12 +387,14 @@ class MeetingReminder(db.Model): days_before = db.Column(db.Integer) send_public = db.Column(db.Boolean) send_private = db.Column(db.Boolean) + additional_text = db.Column(db.String) - def __init__(self, protocoltype_id, days_before, send_public, send_private): + def __init__(self, protocoltype_id, days_before, send_public, send_private, additional_text): self.protocoltype_id = protocoltype_id self.days_before = days_before self.send_public = send_public self.send_private = send_private + self.additional_text = additional_text def __repr__(self): return "<MeetingReminder(id={}, protocoltype_id={}, days_before={}, send_public={}, send_private={})>".format( diff --git a/requirements.txt b/requirements.txt index 78fd29d3b2f7a85a018f12b226677f767f69a521..73034a329f1f111288cb4eb183fdea310aaf1ccb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ alembic==0.8.10 amqp==2.1.4 appdirs==1.4.0 +APScheduler==3.3.1 argh==0.26.2 billiard==3.5.0.2 blessings==1.6 @@ -37,6 +38,7 @@ regex==2017.2.8 requests==2.13.0 six==1.10.0 SQLAlchemy==1.1.5 +tzlocal==1.3 vine==1.1.3 watchdog==0.8.3 wcwidth==0.1.7 diff --git a/server.py b/server.py index 57bfc4ff335f998198e76eff54cc832f4c9ce90c..203d9dd160f586a7e750c2764174f0c20e5c367e 100755 --- a/server.py +++ b/server.py @@ -9,6 +9,10 @@ from flask_migrate import Migrate, MigrateCommand #from flask_socketio import SocketIO from celery import Celery from sqlalchemy import or_, and_ +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger +import atexit from io import StringIO, BytesIO import os from datetime import datetime @@ -39,6 +43,17 @@ celery = make_celery(app, config) # return socketio #socketio = make_socketio(app, config) +def make_scheduler(app, config, function): + scheduler = BackgroundScheduler() + scheduler.start() + scheduler.add_job( + func=function, + trigger=CronTrigger(hour='*'), + id="scheduler", + name="Do an action regularly", + replace_existing=True) + atexit.register(scheduler.shutdown) + app.jinja_env.trim_blocks = True app.jinja_env.lstrip_blocks = True app.jinja_env.filters["datify"] = date_filter @@ -87,7 +102,7 @@ def new_type(): flash("Du kannst keinen internen Protokolltypen anlegen, zu dem du selbst keinen Zugang hast.", "alert-error") else: protocoltype = ProtocolType(form.name.data, form.short_name.data, - form.organization.data, form.is_public.data, + form.organization.data, form.usual_time.data, form.is_public.data, form.private_group.data, form.public_group.data, form.private_mail.data, form.public_mail.data, form.use_wiki.data, form.wiki_category.data, @@ -148,7 +163,7 @@ def new_reminder(type_id): return redirect(request.args.get("next") or url_for("index")) form = MeetingReminderForm() if form.validate_on_submit(): - reminder = MeetingReminder(protocoltype.id, form.days_before.data, form.send_public.data, form.send_private.data) + reminder = MeetingReminder(protocoltype.id, form.days_before.data, form.send_public.data, form.send_private.data, form.additional_text.data) db.session.add(reminder) db.session.commit() return redirect(request.args.get("next") or url_for("show_type", type_id=protocoltype.id)) @@ -365,7 +380,7 @@ def list_protocols(): for text, matched in parts ])) search_results[protocol] = "<br />\n".join(formatted_lines) - protocols = sorted(protocols, key=lambda protocol: protocol.date, reverse=True) + protocols = sorted(protocols, key=lambda protocol: protocol.date, reverse=True) page = _get_page() page_count = int(math.ceil(len(protocols)) / config.PAGE_LENGTH) if page >= page_count: @@ -809,6 +824,20 @@ def logout(): flash("You are not logged in.", "alert-error") return redirect(url_for(".index")) +def check_and_send_reminders(): + with app.app_context(): + current_time = datetime.now() + current_day = current_time.date() + for protocol in Protocol.query.filter(Protocol.done == False).all(): + day_difference = (protocol.date - current_day).days + usual_time = protocol.protocoltype.usual_time + protocol_time = datetime(1, 1, 1, usual_time.hour, usual_time.minute) + hour_difference = (protocol_time - current_time).seconds // 3600 + print(protocol.get_identifier(), day_difference, hour_difference) + for reminder in protocol.protocoltype.reminders: + if day_difference == reminder.days_before and hour_difference == 0: + tasks.send_reminder(reminder, protocol) if __name__ == "__main__": + make_scheduler(app, config, check_and_send_reminders) manager.run() diff --git a/tasks.py b/tasks.py index 88fdebfd5359541ae9f729c750b816b675d9fb44..e82b2d4ab5b6a00b8b35d8acbda2923bafe4704d 100644 --- a/tasks.py +++ b/tasks.py @@ -5,7 +5,7 @@ import subprocess import shutil import tempfile -from models.database import Document, Protocol, Error, Todo, Decision, TOP, DefaultTOP +from models.database import Document, Protocol, Error, Todo, Decision, TOP, DefaultTOP, MeetingReminder from models.errors import DateNotMatchingException from server import celery, app from shared import db, escape_tex, unhyphen, date_filter, datetime_filter, date_filter_long, date_filter_short, time_filter, class_filter @@ -308,21 +308,33 @@ def print_file_async(filename, protocol_id): db.session.add(error) db.session.commit() -def send_mail(mail): - send_mail_async.delay(mail.id) +def send_reminder(reminder, protocol): + send_reminder_async.delay(reminder.id, protocol.id) @celery.task -def send_mail_async(mail_id): +def send_reminder_async(reminder_id, protocol_id): with app.app_context(): - mail = Mail.query.filter_by(id=mail_id).first() - if mail is None: - return False - mail.ready = False - mail.error = False - db.session.commit() - result = mail_manager.send(mail.to_addr, mail.subject, mail.content) - mail.ready = True - mail.error = not result - db.session.commit() + reminder = MeetingReminder.query.filter_by(id=reminder_id).first() + protocol = Protocol.query.filter_by(id=protocol_id).first() + reminder_text = render_template("reminder.txt", reminder=reminder, protocol=protocol) + if reminder.send_public: + send_mail(protocol, protocol.protocoltype.public_mail, "Tagesordnung der {}".format(protocol.protocoltype.name), reminder_text) + if reminder.send_private: + send_mail(protocol, protocol.protocoltype.private_mail, "Tagesordnung der {}".format(protocol.protocoltype.name), reminder_text) + +def send_mail(protocol, to_addr, subject, content): + if to_addr is not None and len(to_addr.strip()) > 0: + send_mail_async.delay(protocol.id, to_addr, subject, content) +@celery.task +def send_mail_async(protocol_id, to_addr, subject, content): + with app.app_context(): + protocol = Protocol.query.filter_by(id=protocol_id).first() + try: + print("sending {} to {}".format(subject, to_addr)) + mail_manager.send(to_addr, subject, content) + except Exception as exc: + error = protocol.create_error("Sending Mail", "Sending mail failed", str(exc)) + db.session.add(error) + db.session.commit() diff --git a/templates/reminder.txt b/templates/reminder.txt new file mode 100644 index 0000000000000000000000000000000000000000..c78c32df6b9cc8712f1ce37ee0ef479135ec2fa6 --- /dev/null +++ b/templates/reminder.txt @@ -0,0 +1,24 @@ +Die nächste {{protocol.protocoltype.name}} findet am {{protocol.date|datify}} um {{protocol.protocoltype.usual_time|timify}} statt. + +Die vorläufige Tagesordnung ist: +{% 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 %} + +{% if reminder.additional_text is not none %} +{{reminder.additional_text}} +{% endif %} diff --git a/utils.py b/utils.py index bd6f3ce79704fbe8865d20311e8e52fd69bda7b7..cc020e5dcba17ae0cd7e80fa0c68d0351af08550 100644 --- a/utils.py +++ b/utils.py @@ -67,7 +67,6 @@ class MailManager: self.hostname = getattr(config, "MAIL_HOST", "") self.username = getattr(config, "MAIL_USER", "") self.password = getattr(config, "MAIL_PASSWORD", "") - self.prefix = getattr(config, "MAIL_PREFIX", "") def send(self, to_addr, subject, content): if (not self.active @@ -75,21 +74,16 @@ class MailManager: or not self.username or not self.password or not self.from_addr): - return True - try: - msg = MIMEMultipart("alternative") - msg["From"] = self.from_addr - msg["To"] = to_addr - msg["Subject"] = "[{}] {}".format(self.prefix, subject) if self.prefix else subject - msg.attach(MIMEText(content, _charset="utf-8")) - server = smtplib.SMTP_SSL(self.hostname) - server.login(self.username, self.password) - server.sendmail(self.from_addr, to_addr, msg.as_string()) - server.quit() - except Exception as e: - print(e) - return False - return True + return + msg = MIMEMultipart("alternative") + msg["From"] = self.from_addr + msg["To"] = to_addr + msg["Subject"] = subject + msg.attach(MIMEText(content, _charset="utf-8")) + server = smtplib.SMTP_SSL(self.hostname) + server.login(self.username, self.password) + server.sendmail(self.from_addr, to_addr, msg.as_string()) + server.quit() mail_manager = MailManager(config) diff --git a/views/forms.py b/views/forms.py index 10ba631f53292059ec115bf744613ba0b89e93ca..90f4ddc80d9d55e6316c4324de8f7741a28fa200 100644 --- a/views/forms.py +++ b/views/forms.py @@ -1,5 +1,5 @@ from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, BooleanField, DateField, HiddenField, IntegerField, SelectField, FileField, DateTimeField +from wtforms import StringField, PasswordField, BooleanField, DateField, HiddenField, IntegerField, SelectField, FileField, DateTimeField, TextAreaField from wtforms.validators import InputRequired, Optional import config @@ -12,6 +12,7 @@ class ProtocolTypeForm(FlaskForm): name = StringField("Name", validators=[InputRequired("Du musst einen Namen angeben.")]) short_name = StringField("Abkürzung", validators=[InputRequired("Du musst eine Abkürzung angebene.")]) organization = StringField("Organisation", validators=[InputRequired("Du musst eine zugehörige Organisation angeben.")]) + usual_time = DateTimeField("Üblicher Beginn", validators=[InputRequired("Bitte gib die Zeit an, zu der die Sitzung beginnt.")], format="%H:%M") is_public = BooleanField("Öffentlich sichtbar") private_group = StringField("Interne Gruppe") public_group = StringField("Öffentliche Gruppe") @@ -30,6 +31,7 @@ class MeetingReminderForm(FlaskForm): days_before = IntegerField("Tage vor Sitzung", validators=[InputRequired("Du musst eine Dauer angeben.")]) send_public = BooleanField("Öffentlich einladen") send_private = BooleanField("Intern einladen") + additional_text = TextAreaField("Zusätzlicher Mailinhalt") class NewProtocolForm(FlaskForm): protocoltype = SelectField("Typ", choices=[], coerce=int) diff --git a/views/tables.py b/views/tables.py index 4a44cbb589844a58b7658979426f340b64eab987..ce3389f5e2375b570527bfd0ba533f385c5ff33d 100644 --- a/views/tables.py +++ b/views/tables.py @@ -105,8 +105,8 @@ class ProtocolTypeTable(SingleValueTable): super().__init__(protocoltype.name, protocoltype, newlink=url_for("edit_type", type_id=protocoltype.id)) def headers(self): - headers = ["Name", "Abkürzung", "Organisation", "Öffentlich", - "Interne Gruppe", "Öffentliche Gruppe", + headers = ["Name", "Abkürzung", "Organisation", "Beginn", + "Öffentlich", "Interne Gruppe", "Öffentliche Gruppe", "Interner Verteiler", "Öffentlicher Verteiler", "Drucker", "Wiki"] if self.value.use_wiki: @@ -118,6 +118,7 @@ class ProtocolTypeTable(SingleValueTable): self.value.name, self.value.short_name, self.value.organization, + self.value.usual_time.strftime("%H:%M") if self.value.usual_time is not None else "", # todo: remove if, this field is required Table.bool(self.value.is_public), self.value.private_group, self.value.public_group, @@ -156,12 +157,13 @@ class MeetingRemindersTable(Table): self.protocoltype = protocoltype def headers(self): - return ["Zeit", "Einladen", ""] + return ["Zeit", "Einladen", "Zusätzlicher Mailinhalt", ""] def row(self, reminder): return [ "{} Tage".format(reminder.days_before), self.get_send_summary(reminder), + reminder.additional_text or "", Table.concat([ Table.link(url_for("edit_reminder", type_id=self.protocoltype.id, reminder_id=reminder.id), "Ändern"), Table.link(url_for("delete_reminder", type_id=self.protocoltype.id, reminder_id=reminder.id), "Löschen", confirm="Bist du dir sicher, dass du die Einladungsmail {} Tage vor der Sitzung löschen willst?".format(reminder.days_before))