diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 80484daa..2785977f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,11 @@ updates: - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" + interval: "monthly" + groups: + all: + patterns: + - "*" - package-ecosystem: "docker" directory: "/" schedule: @@ -13,3 +17,14 @@ updates: directory: "/" schedule: interval: "weekly" + groups: + eve: + patterns: + - "eve" + - "flask" + - "pymongo" + test: + patterns: + - "pytest*" + - "tox" + - "flake8" diff --git a/README.md b/README.md index c98819ef..266dab33 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ Now it's time to configure AMIV API. Create a file `config.py` ```python +import logging + # Root password, *definitely* change this! ROOT_PASSWORD = 'root' @@ -96,6 +98,8 @@ MONGO_DBNAME = 'amivapi' MONGO_USERNAME = 'amivapi' MONGO_PASSWORD = 'amivapi' +LOG_LEVEL = logging.INFO + # Sentry error logging # SENTRY_DSN = "https://@sentry.io/" # SENTRY_ENVIRONMENT = 'production' diff --git a/amivapi/auth/sessions.py b/amivapi/auth/sessions.py index 76e3dabe..ec0c1136 100644 --- a/amivapi/auth/sessions.py +++ b/amivapi/auth/sessions.py @@ -5,6 +5,7 @@ """Sessions endpoint.""" import datetime +import re from amivapi import ldap from amivapi.auth import AmivTokenAuth @@ -17,13 +18,7 @@ from flask import abort from flask import current_app as app from ldap3.core.exceptions import LDAPException - - -# Change when we drop python3.5 support -try: - from secrets import token_urlsafe -except ImportError: - from amivapi.utils import token_urlsafe +from secrets import token_urlsafe class SessionAuth(AmivTokenAuth): @@ -173,28 +168,33 @@ def process_login(items): items (list): List of items as passed by EVE to post hooks. """ for item in items: - username = item['username'] + username = ldap_username = item['username'] password = item['password'] + # If the username matches an ethz email address, we just take the part + # before the @ as the username for the authentication against LDAP. + if re.match(r"^[^@]+@([^@]+[.]{1}){0,1}ethz.ch$", username): + ldap_username = username.split('@', 2)[0] + # LDAP if (app.config.get('ldap_connector') and - ldap.authenticate_user(username, password)): + ldap.authenticate_user(ldap_username, password)): # Success, sync user and get token try: - user = ldap.sync_one(username) + user = ldap.sync_one(ldap_username) app.logger.info( - "User '%s' was authenticated with LDAP" % username) + "User '%s' was authenticated with LDAP" % ldap_username) except LDAPException: # Sync failed! Try to find user in db. - user = _find_user(username) + user = _find_user(ldap_username) if user: app.logger.error( - f"User '{username}' authenticated with LDAP and found " - "in db, but LDAP sync failed.") + f"User '{ldap_username}' authenticated with LDAP and " + "found in db, but LDAP sync failed.") else: - status = (f"Login failed: user '{username}' authenticated " - "with LDAP but not found in db, and LDAP sync " - "failed.") + status = (f"Login failed: user '{ldap_username}' " + "authenticated with LDAP but not found in db, " + "and LDAP sync failed.") app.logger.error(status) abort(401, description=debug_error_message(status)) diff --git a/amivapi/bootstrap.py b/amivapi/bootstrap.py index 9bc153b9..ebcd37a5 100644 --- a/amivapi/bootstrap.py +++ b/amivapi/bootstrap.py @@ -7,6 +7,7 @@ from os import getcwd, getenv from os.path import abspath +import logging from eve import Eve from flask import Config @@ -88,6 +89,7 @@ def create_app(config_file=None, **kwargs): app = Eve("amivapi", # Flask needs this name to find the static folder settings=config, validator=ValidatorAMIV) + app.logger.setLevel(app.config.get('LOG_LEVEL') or logging.INFO) app.logger.info(config_status) # Set up error logging with sentry diff --git a/amivapi/events/__init__.py b/amivapi/events/__init__.py index 60b510ac..00caead8 100644 --- a/amivapi/events/__init__.py +++ b/amivapi/events/__init__.py @@ -30,6 +30,7 @@ ) from amivapi.events.queue import ( add_accepted_before_insert, + notify_users_after_update, update_waiting_list_after_delete, update_waiting_list_after_insert, ) @@ -72,4 +73,7 @@ def init_app(app): app.on_deleted_item_eventsignups += notify_signup_deleted app.on_deleted_item_eventsignups += update_waiting_list_after_delete + # Notify users on manual updates + app.on_updated_eventsignups += notify_users_after_update + app.register_blueprint(email_blueprint) diff --git a/amivapi/events/emails.py b/amivapi/events/emails.py index 5fdb3982..258f5332 100644 --- a/amivapi/events/emails.py +++ b/amivapi/events/emails.py @@ -6,11 +6,12 @@ Needed when users are notified about their event signups. """ +from datetime import datetime, timezone from flask import current_app, url_for from itsdangerous import URLSafeSerializer from amivapi.events.utils import get_token_secret -from amivapi.utils import mail_from_template +from amivapi.utils import mail_from_template, get_calendar_invite def find_reply_to_email(event): @@ -49,13 +50,39 @@ def notify_signup_accepted(event, signup, waiting_list=False): deletion_link = url_for('emails.on_delete_signup', token=token, _external=True) + event_id = event[id_field] title_en = event['title_en'] title_de = event['title_de'] + description_en = event['description_en'] + description_de = event['description_de'] + location = (event['location'] or '') + time_start = event['time_start'] + time_end = event['time_end'] + time_now = datetime.now(timezone.utc) + signup_additional_info_en = event['signup_additional_info_en'] signup_additional_info_de = event['signup_additional_info_de'] reply_to_email = find_reply_to_email(event) + # Time is a required property for ics calendar events + if time_start: + calendar_invite = ( + get_calendar_invite('events_accept_calendar_invite', dict( + title=(title_en or title_de), + event_id=event_id, + time_start=time_start, + time_end=time_end, + time_now=time_now, + description=(description_en or description_de or ''), + location=location, + signup_additional_info=(signup_additional_info_en or + signup_additional_info_de or + ''), + ))) + else: + calendar_invite = None + if waiting_list: mail_from_template( to=[email], @@ -83,7 +110,8 @@ def notify_signup_accepted(event, signup, waiting_list=False): signup_additional_info_de=(signup_additional_info_de or signup_additional_info_en), deadline=event['time_deregister_end']), - reply_to=reply_to_email) + reply_to=reply_to_email, + calendar_invite=calendar_invite) def notify_signup_deleted(signup): diff --git a/amivapi/events/queue.py b/amivapi/events/queue.py index b417fdc9..db1ab6b1 100644 --- a/amivapi/events/queue.py +++ b/amivapi/events/queue.py @@ -109,3 +109,15 @@ def update_waiting_list_after_delete(signup): return update_waiting_list(signup['event']) + + +def notify_users_after_update(signup_updates, original_signup): + """Hook to notify users after a signup is updated.""" + if signup_updates.get('accepted') and not original_signup.get('accepted'): + # User was on the waitinglist and got accepted: Notify him + lookup = {current_app.config['ID_FIELD']: original_signup.get('event')} + event = current_app.data.find_one('events', None, **lookup) + if event is not None: + new_signup = original_signup.copy() + new_signup.update(signup_updates) + notify_signup_accepted(event, new_signup, False) diff --git a/amivapi/ldap.py b/amivapi/ldap.py index 1fddc262..278bdeac 100644 --- a/amivapi/ldap.py +++ b/amivapi/ldap.py @@ -154,9 +154,13 @@ def _process_data(data): to the correct fields for the user resource. """ res = {'nethz': data.get('cn', [None])[0], - 'legi': data.get('swissEduPersonMatriculationNumber'), 'firstname': data.get('givenName', [None])[0], 'lastname': data.get('sn', [None])[0]} + if ('swissEduPersonMatriculationNumber' in data and + isinstance(data['swissEduPersonMatriculationNumber'], str)): + # add legi only if the LDAP value is a string as it might also be an + # empty array. + res['legi'] = data['swissEduPersonMatriculationNumber'] if res['nethz'] is not None: # email can be removed when Eve switches to Cerberus 1.x, then # We could do this as a default value in the user model @@ -165,7 +169,7 @@ def _process_data(data): res['gender'] = \ u"male" if int(data['swissEduPersonGender']) == 1 else u"female" - # See file docstring for explanation of `deparmentNumber` field + # See file docstring for explanation of `departmentNumber` field # In some rare cases, the departmentNumber field is either empty # or missing -> normalize to empty string department_info = next(iter( @@ -210,7 +214,7 @@ def _create_or_update_user(ldap_data): with admin_permissions(): if db_data: # Membership will not be downgraded and email not be overwritten - # Newletter settings will also not be adjusted + # Newsletter settings will also not be adjusted ldap_data.pop('email', None) if db_data.get('membership') != u"none": ldap_data.pop('membership', None) diff --git a/amivapi/templates/events_accept_calendar_invite.ics b/amivapi/templates/events_accept_calendar_invite.ics new file mode 100644 index 00000000..7d4847fe --- /dev/null +++ b/amivapi/templates/events_accept_calendar_invite.ics @@ -0,0 +1,19 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:https://www.amiv.ethz.ch/ +X-MS-OLK-FORCEINSPECTOROPEN:TRUE +METHOD:PUBLISH +BEGIN:VEVENT +UID:{{ event_id }}@amiv.ethz.ch +LOCATION:{{ location }} +SUMMARY:{{ title }} +{% if signup_additional_info is not none and signup_additional_info|length -%} +DESCRIPTION:{{ signup_additional_info }}\n\n{{ description }} +{% else -%} +DESCRIPTION:{{ description }} +{% endif -%} +DTSTART:{{ time_start.strftime('%Y%m%dT%H%M%SZ') }} +DTEND:{{ time_end.strftime('%Y%m%dT%H%M%SZ') }} +DTSTAMP:{{ time_now.strftime('%Y%m%dT%H%M%SZ') }} +END:VEVENT +END:VCALENDAR \ No newline at end of file diff --git a/amivapi/tests/auth/test_oauth.py b/amivapi/tests/auth/test_oauth.py index f4a4db1b..349b519a 100644 --- a/amivapi/tests/auth/test_oauth.py +++ b/amivapi/tests/auth/test_oauth.py @@ -57,7 +57,7 @@ def test_relogin(self): user = self.new_object('users') token = self.get_user_token(user['_id']) - self.api.set_cookie('localhost', 'token', token) + self.api.set_cookie('token', token, domain='localhost') login_page = self.api.get( '/oauth?' 'response_type=token' @@ -245,7 +245,7 @@ def test_personal_greeting(self): user = self.new_object('users', firstname='Pablito') token = self.get_user_token(user['_id']) - self.api.set_cookie('localhost', 'token', token) + self.api.set_cookie('token', token, domain='localhost') login_page = self.api.get( '/oauth?' 'response_type=token' diff --git a/amivapi/tests/events/test_emails.py b/amivapi/tests/events/test_emails.py index 64e3d90a..33762172 100644 --- a/amivapi/tests/events/test_emails.py +++ b/amivapi/tests/events/test_emails.py @@ -4,6 +4,7 @@ # you to buy us beer if we meet and you like the software. """Test email confirmation system for external event signups.""" +import datetime import re from amivapi.tests.utils import WebTestNoAuth @@ -230,6 +231,82 @@ def test_no_nones_in_emails(self): for field in mail.values(): self.assertTrue('None' not in field) + def test_calendar_invite_format(self): + """Test that the calendar invite format. + Specifically looks for the correct line format and the presence of + required and desired fields. + """ + event = self.new_object( + 'events', + spots=100, + selection_strategy='fcfs', + allow_email_signup=True, + time_start=datetime.datetime.strptime('2019-01-01T00:00:00Z', + '%Y-%m-%dT%H:%M:%SZ'), + time_end=datetime.datetime.strptime('2019-01-01T01:00:00Z', + '%Y-%m-%dT%H:%M:%SZ'), + description_en=('Description\nSpanning\nmultiple\nlines.'), + ) + + user = self.new_object('users') + + self.api.post('/eventsignups', + data={ + 'user': str(user['_id']), + 'event': str(event['_id']) + }, + status_code=201).json + + mail = self.app.test_mails[0] + + # No missing fields of importance + self.assertTrue(mail["calendar_invite"] is not None and + 'None' not in mail["calendar_invite"]) + + # Check the overall format + non_null_fields = [] + for line in mail["calendar_invite"].splitlines(): + # Check that the line is not empty + self.assertTrue(line) + # Check the line format + regex = r'^(?P[A-Z\-]+)(?::|;(?P.+?):)(?P.*)$' + re_match = re.match(regex, line) + self.assertTrue(re_match) # No empty or non-conforming lines + if len(re_match.group("value")) > 0: + non_null_fields.append(re_match.group("key")) + # Check that the required and desired fields are present + self.assertTrue('VERSION' in non_null_fields) + self.assertTrue('PRODID' in non_null_fields) + self.assertTrue('UID' in non_null_fields) + self.assertTrue('DTSTAMP' in non_null_fields) + self.assertTrue('DTSTART' in non_null_fields) + self.assertTrue('DTEND' in non_null_fields) # Not strictly required + self.assertTrue('SUMMARY' in non_null_fields) # Not strictly required + + def test_no_calendar_if_time_not_set(self): + """Test that no calendar invite is created if the event has no time.""" + event = self.new_object( + 'events', + spots=100, + selection_strategy='fcfs', + allow_email_signup=True, + time_start=None, + time_end=None, + ) + + user = self.new_object('users') + + self.api.post('/eventsignups', + data={ + 'user': str(user['_id']), + 'event': str(event['_id']) + }, + status_code=201) + + mail = self.app.test_mails[0] + + self.assertTrue(mail.get("calendar_invite") is None) + def test_moderator_reply_to(self): """Check whether `reply-to` header is the moderator in email if set.""" user = self.new_object('users') @@ -298,3 +375,65 @@ def test_signup_additional_info(self): mail = self.app.test_mails[0] self.assertTrue(text in mail['text']) + + def test_notify_waitlist_on_signup_update(self): + """Test that users on the waiting list get notified when a moderator + updates his signup acceptance.""" + # Case 1: Manual Event with waiting list + manual_event = self.new_object('events', + spots=100, + selection_strategy='manual', + allow_email_signup=True) + manual_user = self.new_object('users') + + # User put on waiting list + manual_signup = self.api.post('/eventsignups', + data={ + 'user': str(manual_user['_id']), + 'event': str(manual_event['_id']) + }, + status_code=201).json + self.assertTrue('was rejected' in self.app.test_mails[0]['text']) + + # User manually accepted from waiting list + self.api.patch('/eventsignups/%s' % manual_signup['_id'], + data={'accepted': True}, + status_code=200, + headers={'If-Match': manual_signup['_etag']}) + self.assertTrue('was accepted' in self.app.test_mails[1]['text']) + + # Case 2: Full FCFS event with waiting list: User deregistered after + # deregistration deadline by contacting moderators and they + # manually accept a new user + fcfc_user_accepted = self.new_object('users') + fcfc_user_waitlist = self.new_object('users') + fcfc_event = self.new_object('events', + spots=1, + selection_strategy='fcfs', + allow_email_signup=True) + # User accepted + self.api.post('/eventsignups', + data={ + 'user': str(fcfc_user_accepted['_id']), + 'event': str(fcfc_event['_id']) + }, + status_code=201) + self.assertTrue('was accepted' in self.app.test_mails[2]['text']) + + # User put on waiting list + fcfc_signup_waitlist = self.api.post( + '/eventsignups', + data={ + 'user': str(fcfc_user_waitlist['_id']), + 'event': str(fcfc_event['_id']) + }, + status_code=201).json + print(self.app.test_mails[2]['text']) + self.assertTrue('was rejected' in self.app.test_mails[3]['text']) + + # User manually accepted from waiting list + self.api.patch('/eventsignups/%s' % fcfc_signup_waitlist['_id'], + data={'accepted': True}, + headers={'If-Match': fcfc_signup_waitlist['_etag']}, + status_code=200) + self.assertTrue('was accepted' in self.app.test_mails[4]['text']) diff --git a/amivapi/utils.py b/amivapi/utils.py index 9bbb8c60..c1486433 100644 --- a/amivapi/utils.py +++ b/amivapi/utils.py @@ -4,8 +4,6 @@ # you to buy us beer if we meet and you like the software. """Utilities.""" - -from base64 import b64encode from contextlib import contextmanager from copy import deepcopy from email.mime.multipart import MIMEMultipart @@ -23,24 +21,6 @@ from flask import g -def token_urlsafe(nbytes=32): - """Cryptographically random generate a token that can be passed in a URL. - - This function is available as secrets.token_urlsafe in python3.6. We can - remove this function when we drop python3.5 support. - - Args: - nbytes: Number of random bytes used to generate the token. Note that - this is not the resulting length of the token, just the amount of - randomness. - - Returns: - str: A random string containing only urlsafe characters. - """ - return b64encode(urandom(nbytes)).decode("utf-8").replace("+", "-").replace( - "/", "_").rstrip("=") - - @contextmanager def admin_permissions(): """Switch to a context with admin rights and restore state afterwards. @@ -77,7 +57,8 @@ def get_id(item): def mail_from_template( - to, subject, template_name, template_args, reply_to=None + to, subject, template_name, template_args, reply_to=None, + calendar_invite=None ): """Send a mail to a list of recipients by using the given jinja2 template. @@ -102,10 +83,27 @@ def mail_from_template( except jinja2.exceptions.TemplateNotFound: html = None - mail(to, subject, text, html, reply_to) + mail(to, subject, text, html, reply_to, calendar_invite) + + +def get_calendar_invite(template_name, template_args): + """ Get the calendar invite for an event. + Also performs escaping of necessary fields. + + Args: + template_name(string): Jinja2 template name + template_args(dict): arguments passed to the templating engine + """ + + for key, value in template_args.items(): + if isinstance(value, str): + template_args[key] = value.replace('\n', '\\n') + calendar_invite = render_template('{}.ics'.format(template_name), + **template_args) + return calendar_invite -def mail(to, subject, text, html=None, reply_to=None): +def mail(to, subject, text, html=None, reply_to=None, calendar_invite=None): """Send a mail to a list of recipients. The mail is sent from the address specified by `API_MAIL` in the config, @@ -118,6 +116,7 @@ def mail(to, subject, text, html=None, reply_to=None): text(string): Mail content as plaintext html(string): Mail content as HTML reply_to(string): Address of event moderator + calendar_invite(string): ICS calendar event """ sender_address = app.config['API_MAIL_ADDRESS'] sender_name = app.config['API_MAIL_NAME'] @@ -135,16 +134,28 @@ def mail(to, subject, text, html=None, reply_to=None): if reply_to is not None: mail['reply-to'] = reply_to + if calendar_invite is not None: + mail['calendar_invite'] = calendar_invite app.test_mails.append(mail) elif config.SMTP_SERVER and config.SMTP_PORT: if html is not None: - msg = MIMEMultipart('alternative') - msg.attach(MIMEText(text, 'plain')) - msg.attach(MIMEText(html, 'html')) + msg = MIMEMultipart('mixed') + msg_body = MIMEMultipart('alternative') + msg_body.attach(MIMEText(text, 'plain')) + msg_body.attach(MIMEText(html, 'html')) + msg.attach(msg_body) else: - msg = MIMEText(text) + msg = MIMEMultipart('mixed') + msg.attach(MIMEText(text)) + + if calendar_invite is not None: + calendar_mime = MIMEText(calendar_invite, 'calendar', "utf-8") + calendar_mime['Content-Disposition'] = ( + 'attachment; filename="invite.ics"; ' + + 'charset="utf-8"; method=PUBLISH') + msg.attach(calendar_mime) msg['Subject'] = subject msg['From'] = sender diff --git a/dev_config.py b/dev_config.py index 9605193b..2d74d381 100644 --- a/dev_config.py +++ b/dev_config.py @@ -1,3 +1,5 @@ +import logging + # Mongo config. Do not change! MONGO_HOST = 'mongodb' MONGO_PORT = 27017 @@ -8,6 +10,8 @@ # Add other config options as you need below. ROOT_PASSWORD = 'root' +LOG_LEVEL = logging.DEBUG + # Sentry error logging # SENTRY_DSN = "https://@sentry.io/" # SENTRY_ENVIRONMENT = 'production' diff --git a/requirements.txt b/requirements.txt index 40f45eab..383a30a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,20 @@ eve==2.1.0 +# Set eve dependencies (flask, pymongo) to specific version as it is not restricted by eve itself +flask==3.0.0 +pymongo==4.5.0 git+https://github.com/amiv-eth/eve-swagger.git@de78e466fd34a0614d6f556a371e0ae8d973aca9#egg=Eve_Swagger # "nethz" must be installed in editable mode, otherwise some certs are not found # Wontfix: With the upcoming migration, this library will not be needed anymore --e git+https://github.com/NotSpecial/nethz.git@fcd5ced2dd365f237047748abfedb9c35a468393#egg=nethz +-e git+https://github.com/amiv-eth/nethz.git@fcd5ced2dd365f237047748abfedb9c35a468393#egg=nethz passlib==1.7.4 -jsonschema==4.19.0 -freezegun==1.2.2 -sentry-sdk[flask]==1.31.0 +jsonschema==4.20.0 +freezegun==1.3.1 +sentry-sdk[flask]==1.38.0 beautifulsoup4==4.12.2 -Pillow==10.0.1 +Pillow==10.1.0 # Test requirements. It's not worth the hassle to keep them separate. -pytest==7.4.2 +pytest==7.4.3 pytest-cov==4.1.0 -tox==4.11.3 +tox==4.11.4 flake8==6.1.0