diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index da7d163a..db0957c4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -50,7 +50,7 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. Linux/Windows/MacOS] - GCSA version: [e.g. 2.0.1] -- Python version: [e.g. 3.11] +- Python version: [e.g. 3.12] ## Additional context diff --git a/.github/workflows/code-cov.yml b/.github/workflows/code-cov.yml index b7c929a1..b8b07bbd 100644 --- a/.github/workflows/code-cov.yml +++ b/.github/workflows/code-cov.yml @@ -13,7 +13,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: '3.11' + python-version: '3.12' - name: Install dependencies run: pip install tox diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 717bc108..9fd1249b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,9 +12,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11' ] + python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11', '3.12' ] include: - - python-version: '3.11' + - python-version: '3.12' note: with-style-and-docs-checks steps: diff --git a/.gitignore b/.gitignore index a5013b18..fd8db29a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ build dist .eggs gcsa.egg-info +docs/html example.py coverage.xml diff --git a/.readthedocs.yml b/.readthedocs.yml index 2107520f..e520e314 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,7 +3,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.11" + python: "3.12" sphinx: configuration: docs/source/conf.py diff --git a/docs/source/attendees.rst b/docs/source/attendees.rst index 7c6e72a7..b5ef8f84 100644 --- a/docs/source/attendees.rst +++ b/docs/source/attendees.rst @@ -61,5 +61,21 @@ or event.add_attendee('attendee@gmail.com') +to add a single attendee. + +Use :py:meth:`~gcsa.event.Event.add_attendees` method to add multiple at once: + +.. code-block:: python + + event.add_attendees( + [ + Attendee('attendee@gmail.com', + display_name='Friend', + additional_guests=3 + ), + 'attendee_by_email1@gmail.com', + 'attendee_by_email2@gmail.com' + ] + ) Update event using :py:meth:`~gcsa.google_calendar.GoogleCalendar.update_event` method to save the changes. diff --git a/docs/source/change_log.rst b/docs/source/change_log.rst index fdd2ec7d..dcb9ce28 100644 --- a/docs/source/change_log.rst +++ b/docs/source/change_log.rst @@ -3,6 +3,26 @@ Change log ========== +v2.3.0 +~~~~~~ + +API +--- +* Adds `add_attendees` method to the `Event` for adding multiple attendees +* Add specific time reminders (N days before at HH:MM) +* Support Python3.12 +* Allow service account credentials in `GoogleCalendar` + +Core +---- +* Don't evaluate default arguments in code docs (primarily for `timezone=get_localzone_name()`) + +Backward compatibility +---------------------- +* If token is expired but doesn't have refresh token, raises `google.auth.exceptions.RefreshError` + instead of sending the request + + v2.2.0 ~~~~~~ diff --git a/docs/source/conf.py b/docs/source/conf.py index e92cb3ec..1e78fefe 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -186,3 +186,5 @@ 'css/custom.css', 'css/colors.css', ] + +autodoc_preserve_defaults = True diff --git a/docs/source/reminders.rst b/docs/source/reminders.rst index cb595020..764b641f 100644 --- a/docs/source/reminders.rst +++ b/docs/source/reminders.rst @@ -56,3 +56,60 @@ To use default reminders of the calendar, set ``default_reminders`` parameter of to ``True``. .. note:: You can add up to 5 reminders to one event. + +Specific time reminders +~~~~~~~~~~~~~~~~~~~~~~~ + +You can also set specific time for a reminder. + +.. code-block:: python + + from datetime import time + + event = Event( + 'Meeting', + start=(22/Apr/2019)[12:00], + reminders=[ + # Day before the event at 13:30 + EmailReminder(days_before=1, at=time(13, 30)), + # 2 days before the event at 19:15 + PopupReminder(days_before=2, at=time(19, 15)) + ] + ) + + event.add_popup_reminder(days_before=3, at=time(8, 30)) + event.add_email_reminder(days_before=4, at=time(9, 0)) + + +.. note:: Google calendar API only works with ``minutes_before_start``. + The GCSA's interface that uses ``days_before`` and ``at`` arguments is only a convenient way of setting specific time. + GCSA will convert ``days_before`` and ``at`` to ``minutes_before_start`` during API requests. + So after you add or update the event, it will have reminders with only ``minutes_before_start`` set even if they + were initially created with ``days_before`` and ``at``. + + .. code-block:: python + + from datetime import time + + event = Event( + 'Meeting', + start=(22/Apr/2019)[12:00], + reminders=[ + # Day before the event at 12:00 + EmailReminder(days_before=1, at=time(12, 00)) + ] + ) + + event.reminders[0].minutes_before_start is None + event.reminders[0].days_before == 1 + event.reminders[0].at == time(12, 00) + + event = gc.add_event(event) + + event.reminders[0].minutes_before_start == 24 * 60 # exactly one day before + event.reminders[0].days_before is None + event.reminders[0].at is None + + GCSA does not convert ``minutes_before_start`` to ``days_before`` and ``at`` (even for the whole-day events) + for backwards compatibility reasons. + diff --git a/gcsa/_services/authentication.py b/gcsa/_services/authentication.py index 0fb04c90..20582292 100644 --- a/gcsa/_services/authentication.py +++ b/gcsa/_services/authentication.py @@ -6,7 +6,7 @@ from googleapiclient import discovery from google_auth_oauthlib.flow import InstalledAppFlow from google.auth.transport.requests import Request -from google.oauth2.credentials import Credentials +from google.auth.credentials import Credentials class AuthenticatedService: @@ -24,7 +24,8 @@ def __init__( save_token: bool = True, read_only: bool = False, authentication_flow_host: str = 'localhost', - authentication_flow_port: int = 8080 + authentication_flow_port: int = 8080, + authentication_flow_bind_addr: str = None ): """ Specify ``credentials`` to use in requests or ``credentials_path`` and ``token_path`` to get credentials from. @@ -49,6 +50,9 @@ def __init__( Host to receive response during authentication flow :param authentication_flow_port: Port to receive response during authentication flow + :param authentication_flow_bind_addr: + Optional IP address for the redirect server to listen on when it is not the same as host + (e.g. in a container) """ if credentials: @@ -66,7 +70,8 @@ def __init__( scopes, save_token, authentication_flow_host, - authentication_flow_port + authentication_flow_port, + authentication_flow_bind_addr ) self.service = discovery.build('calendar', 'v3', credentials=self.credentials) @@ -75,7 +80,7 @@ def __init__( def _ensure_refreshed( credentials: Credentials ) -> Credentials: - if not credentials.valid and credentials.expired and credentials.refresh_token: + if not credentials.valid and credentials.expired: credentials.refresh(Request()) return credentials @@ -87,7 +92,8 @@ def _get_credentials( scopes: List[str], save_token: bool, host: str, - port: int + port: int, + bind_addr: str ) -> Credentials: credentials = None @@ -101,7 +107,7 @@ def _get_credentials( else: credentials_path = os.path.join(credentials_dir, credentials_file) flow = InstalledAppFlow.from_client_secrets_file(credentials_path, scopes) - credentials = flow.run_local_server(host=host, port=port) + credentials = flow.run_local_server(host=host, port=port, bind_addr=bind_addr) if save_token: with open(token_path, 'wb') as token_file: diff --git a/gcsa/event.py b/gcsa/event.py index e3ed1047..b6c547bb 100644 --- a/gcsa/event.py +++ b/gcsa/event.py @@ -3,7 +3,7 @@ from beautiful_date import BeautifulDate from tzlocal import get_localzone_name -from datetime import datetime, date, timedelta +from datetime import datetime, date, timedelta, time from ._resource import Resource from .attachment import Attachment @@ -223,6 +223,15 @@ def add_attendee( Attendee may be given as email string or :py:class:`~gcsa.attendee.Attendee` object.""" self.attendees.append(self._ensure_attendee_from_email(attendee)) + def add_attendees( + self, + attendees: List[Union[str, Attendee]] + ): + """Adds multiple attendees to an event. See :py:class:`~gcsa.attendee.Attendee`. + Each attendee may be given as email string or :py:class:`~gcsa.attendee.Attendee` object.""" + for a in attendees: + self.add_attendee(a) + def add_attachment( self, file_url: str, @@ -234,17 +243,21 @@ def add_attachment( def add_email_reminder( self, - minutes_before_start: int = 60 + minutes_before_start: int = None, + days_before: int = None, + at: time = None ): """Adds email reminder to an event. See :py:class:`~gcsa.reminders.EmailReminder`""" - self.add_reminder(EmailReminder(minutes_before_start)) + self.add_reminder(EmailReminder(minutes_before_start, days_before, at)) def add_popup_reminder( self, - minutes_before_start: int = 30 + minutes_before_start: int = None, + days_before: int = None, + at: time = None ): """Adds popup reminder to an event. See :py:class:`~gcsa.reminders.PopupReminder`""" - self.add_reminder(PopupReminder(minutes_before_start)) + self.add_reminder(PopupReminder(minutes_before_start, days_before, at)) def add_reminder( self, diff --git a/gcsa/google_calendar.py b/gcsa/google_calendar.py index 3356c579..592c620b 100644 --- a/gcsa/google_calendar.py +++ b/gcsa/google_calendar.py @@ -30,7 +30,8 @@ def __init__( save_token: bool = True, read_only: bool = False, authentication_flow_host: str = 'localhost', - authentication_flow_port: int = 8080 + authentication_flow_port: int = 8080, + authentication_flow_bind_addr: str = None ): """ Specify ``credentials`` to use in requests or ``credentials_path`` and ``token_path`` to get credentials from. @@ -63,6 +64,9 @@ def __init__( Host to receive response during authentication flow :param authentication_flow_port: Port to receive response during authentication flow + :param authentication_flow_bind_addr: + Optional IP address for the redirect server to listen on when it is not the same as host + (e.g. in a container) """ super().__init__( default_calendar=default_calendar, @@ -72,5 +76,6 @@ def __init__( save_token=save_token, read_only=read_only, authentication_flow_host=authentication_flow_host, - authentication_flow_port=authentication_flow_port + authentication_flow_port=authentication_flow_port, + authentication_flow_bind_addr=authentication_flow_bind_addr ) diff --git a/gcsa/reminders.py b/gcsa/reminders.py index b13dcdc6..2d32d56e 100644 --- a/gcsa/reminders.py +++ b/gcsa/reminders.py @@ -1,54 +1,137 @@ +from datetime import time, date, datetime +from typing import Union + +from beautiful_date import BeautifulDate, days + + class Reminder: def __init__( self, method: str, - minutes_before_start: int + minutes_before_start: int = None, + days_before: int = None, + at: time = None ): """Represents base reminder object + Provide `minutes_before_start` to create "relative" reminder. + Provide `days_before` and `at` to create "absolute" reminder. + :param method: Method of the reminder. Possible values: email or popup :param minutes_before_start: Minutes before reminder + :param days_before: + Days before reminder + :param at: + Specific time for a reminder """ + # Nothing was provided + if minutes_before_start is None and days_before is None and at is None: + raise ValueError("Relative reminder needs 'minutes_before_start'. " + "Absolute reminder 'days_before' and 'at' set. " + "None of them were provided.") + + # Both minutes_before_start and days_before/at were provided + if minutes_before_start is not None and (days_before is not None or at is not None): + raise ValueError("Only minutes_before_start or days_before/at can be specified.") + + # Only one of days_before and at was provided + if (days_before is None) != (at is None): + raise ValueError(f'Both "days_before" and "at" values need to be set ' + f'when using absolute time for a reminder. ' + f'Provided days_before={days_before} and at={at}.') + self.method = method self.minutes_before_start = minutes_before_start + self.days_before = days_before + self.at = at def __eq__(self, other): return ( isinstance(other, Reminder) and self.method == other.method and self.minutes_before_start == other.minutes_before_start + and self.days_before == other.days_before + and self.at == other.at ) def __str__(self): - return '{} - minutes_before_start:{}'.format(self.__class__.__name__, self.minutes_before_start) + if self.minutes_before_start is not None: + return '{} - minutes_before_start:{}'.format(self.__class__.__name__, self.minutes_before_start) + else: + return '{} - {} days before at {}'.format(self.__class__.__name__, self.days_before, self.at) def __repr__(self): return '<{}>'.format(self.__str__()) + def convert_to_relative(self, start: Union[date, datetime, BeautifulDate]) -> 'Reminder': + """Converts absolute reminder (with set `days_before` and `at`) to relative (with set `minutes_before_start`) + relative to `start` date/datetime. Returns self if `minutes_before_start` already set. + """ + if self.minutes_before_start is not None: + return self + + tzinfo = start.tzinfo if isinstance(start, datetime) else None + start_of_the_day = datetime.combine(start, datetime.min.time(), tzinfo=tzinfo) + + reminder_tzinfo = self.at.tzinfo or tzinfo + reminder_time = datetime.combine(start_of_the_day - self.days_before * days, self.at, tzinfo=reminder_tzinfo) + + if isinstance(start, datetime): + minutes_before_start = int((start - reminder_time).total_seconds() / 60) + else: + minutes_before_start = int((start_of_the_day - reminder_time).total_seconds() / 60) + + return Reminder( + method=self.method, + minutes_before_start=minutes_before_start + ) + class EmailReminder(Reminder): def __init__( self, - minutes_before_start: int = 60 + minutes_before_start: int = None, + days_before: int = None, + at: time = None ): """Represents email reminder object + Provide `minutes_before_start` to create "relative" reminder. + Provide `days_before` and `at` to create "absolute" reminder. + :param minutes_before_start: Minutes before reminder + :param days_before: + Days before reminder + :param at: + Specific time for a reminder """ - super().__init__('email', minutes_before_start) + if not days_before and not at and not minutes_before_start: + minutes_before_start = 60 + super().__init__('email', minutes_before_start, days_before, at) class PopupReminder(Reminder): def __init__( self, - minutes_before_start: int = 30 + minutes_before_start: int = None, + days_before: int = None, + at: time = None ): """Represents popup reminder object + Provide `minutes_before_start` to create "relative" reminder. + Provide `days_before` and `at` to create "absolute" reminder. + :param minutes_before_start: Minutes before reminder + :param days_before: + Days before reminder + :param at: + Specific time for a reminder """ - super().__init__('popup', minutes_before_start) + if not days_before and not at and not minutes_before_start: + minutes_before_start = 30 + super().__init__('popup', minutes_before_start, days_before, at) diff --git a/gcsa/serializers/event_serializer.py b/gcsa/serializers/event_serializer.py index f108fd6e..f2f9cb37 100644 --- a/gcsa/serializers/event_serializer.py +++ b/gcsa/serializers/event_serializer.py @@ -33,10 +33,6 @@ def _to_json(cls, event: Event): 'guestsCanModify': event.guests_can_modify, 'guestsCanSeeOtherGuests': event.guests_can_see_other_guests, 'transparency': event.transparency, - 'reminders': { - 'useDefault': event.default_reminders, - 'overrides': [ReminderSerializer.to_json(r) for r in event.reminders] - }, 'attachments': [AttachmentSerializer.to_json(a) for a in event.attachments], **event.other } @@ -54,16 +50,14 @@ def _to_json(cls, event: Event): data['start'] = {'date': event.start.isoformat()} data['end'] = {'date': event.end.isoformat()} - if event.default_reminders: - data['reminders'] = { - 'useDefault': True - } - else: - data['reminders'] = { - 'useDefault': False - } - if event.reminders: - data['reminders']['overrides'] = [ReminderSerializer.to_json(r) for r in event.reminders] + data['reminders'] = { + 'useDefault': event.default_reminders + } + if event.reminders: + data['reminders']['overrides'] = [ + ReminderSerializer.to_json(r.convert_to_relative(event.start)) + for r in event.reminders + ] if event.conference_solution is not None: if isinstance(event.conference_solution, ConferenceSolution): diff --git a/setup.py b/setup.py index fff15521..eb30b70e 100755 --- a/setup.py +++ b/setup.py @@ -1,22 +1,14 @@ #!/usr/bin/env python3 +import subprocess from setuptools import setup, find_packages, Command from shutil import rmtree import os import sys -try: - from sphinx.setup_command import BuildDoc -except ImportError: - class BuildDoc(Command): - user_options = [] - - def run(self): - raise - here = os.path.abspath(os.path.dirname(__file__)) -VERSION = '2.2.0' +VERSION = '2.3.0' class UploadCommand(Command): @@ -60,6 +52,49 @@ def run(self): sys.exit() +class BuildDoc(Command): + user_options = [] + + def initialize_options(self) -> None: + pass + + def finalize_options(self) -> None: + pass + + def run(self): + output_path = 'docs/html' + changed_files = [] + cmd = [ + 'sphinx-build', + 'docs/source', output_path, + '--builder', 'html', + '--define', f'version={VERSION}', + '--verbose' + ] + with subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + bufsize=1, + universal_newlines=True + ) as p: + for line in p.stdout: + print(line, end='') + if line.startswith('reading sources... ['): + file_name = line.rsplit(maxsplit=1)[1] + if file_name: + changed_files.append(file_name + '.html') + + index_path = os.path.join(os.getcwd(), output_path, 'index.html') + print('\nIndex:') + print(f'file://{index_path}') + + if changed_files: + print('Update pages:') + for cf in changed_files: + f_path = os.path.join(os.getcwd(), output_path, cf) + print(cf, f'file://{f_path}') + + with open('README.rst') as f: long_description = ''.join(f.readlines()) @@ -97,7 +132,7 @@ def run(self): "tzlocal>=4,<5", "google-api-python-client>=1.8", "google-auth-httplib2>=0.0.4", - "google-auth-oauthlib>=0.5,<1.0", + "google-auth-oauthlib>=0.5,<2.0", "python-dateutil>=2.7", "beautiful_date>=2.0.0", ], @@ -121,15 +156,10 @@ def run(self): 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', ], cmdclass={ 'upload': UploadCommand, 'docs': BuildDoc, - }, - command_options={ - 'docs': { - 'version': ('setup.py', VERSION), - 'build_dir': ('setup.py', 'docs/build') - } - }, + } ) diff --git a/tests/test_event.py b/tests/test_event.py index 1480faca..786e464a 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -1,3 +1,4 @@ +from datetime import time from unittest import TestCase from beautiful_date import Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sept, Oct, Dec, hours, days, Nov @@ -97,6 +98,18 @@ def test_add_reminders(self): self.assertIsInstance(e.reminders[1], PopupReminder) self.assertEqual(e.reminders[1].minutes_before_start, 41) + e.add_popup_reminder(days_before=1, at=time(12, 0)) + self.assertEqual(len(e.reminders), 3) + self.assertIsInstance(e.reminders[2], PopupReminder) + self.assertEqual(e.reminders[2].days_before, 1) + self.assertEqual(e.reminders[2].at, time(12, 0)) + + e.add_email_reminder(days_before=1, at=time(13, 30)) + self.assertEqual(len(e.reminders), 4) + self.assertIsInstance(e.reminders[3], EmailReminder) + self.assertEqual(e.reminders[3].days_before, 1) + self.assertEqual(e.reminders[3].at, time(13, 30)) + def test_add_attendees(self): e = Event('Good day', start=(17 / Jul / 2020), @@ -109,12 +122,18 @@ def test_add_attendees(self): self.assertEqual(len(e.attendees), 2) e.add_attendee(Attendee("attendee3@gmail.com")) e.add_attendee(Attendee(email="attendee4@gmail.com")) - self.assertEqual(len(e.attendees), 4) + e.add_attendees([ + Attendee(email="attendee5@gmail.com"), + "attendee6@gmail.com" + ]) + self.assertEqual(len(e.attendees), 6) self.assertEqual(e.attendees[0].email, "attendee@gmail.com") self.assertEqual(e.attendees[1].email, "attendee2@gmail.com") self.assertEqual(e.attendees[2].email, "attendee3@gmail.com") self.assertEqual(e.attendees[3].email, "attendee4@gmail.com") + self.assertEqual(e.attendees[4].email, "attendee5@gmail.com") + self.assertEqual(e.attendees[5].email, "attendee6@gmail.com") def test_reminders_checks(self): with self.assertRaises(ValueError): @@ -350,6 +369,38 @@ def test_to_json_reminders(self): } self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + e = Event('Good day', + start=(1 / Jan / 2019)[11:22:33], + timezone=TEST_TIMEZONE, + reminders=[ + PopupReminder(35), + EmailReminder(45), + PopupReminder(days_before=3, at=time(12, 30)), + EmailReminder(days_before=2, at=time(11, 25)), + ]) + expected_event_json = { + 'summary': 'Good day', + 'start': {'dateTime': '2019-01-01T11:22:33+13:00', 'timeZone': TEST_TIMEZONE}, + 'end': {'dateTime': '2019-01-01T12:22:33+13:00', 'timeZone': TEST_TIMEZONE}, + 'recurrence': [], + 'visibility': 'default', + 'attendees': [], + 'reminders': { + 'overrides': [ + {'method': 'popup', 'minutes': 35}, + {'method': 'email', 'minutes': 45}, + {'method': 'popup', 'minutes': (11 * 60 + 22) + (2 * 24 * 60 + 11 * 60 + 30)}, + {'method': 'email', 'minutes': (11 * 60 + 22) + (1 * 24 * 60 + 12 * 60 + 35)}, + ], + 'useDefault': False + }, + 'attachments': [], + 'guestsCanInviteOthers': True, + 'guestsCanModify': False, + 'guestsCanSeeOtherGuests': True, + } + self.assertDictEqual(EventSerializer.to_json(e), expected_event_json) + def test_to_json_attendees(self): e = Event('Good day', start=(1 / Jul / 2020)[11:22:33], diff --git a/tests/test_reminder.py b/tests/test_reminder.py index 8ab333fd..73978757 100644 --- a/tests/test_reminder.py +++ b/tests/test_reminder.py @@ -1,25 +1,89 @@ +from datetime import time, datetime, date from unittest import TestCase -from gcsa.reminders import EmailReminder, PopupReminder +from beautiful_date import Apr + +from gcsa.reminders import Reminder, EmailReminder, PopupReminder from gcsa.serializers.reminder_serializer import ReminderSerializer class TestReminder(TestCase): def test_email_reminder(self): + reminder = EmailReminder() + self.assertEqual(reminder.method, 'email') + self.assertEqual(reminder.minutes_before_start, 60) + reminder = EmailReminder(34) self.assertEqual(reminder.method, 'email') self.assertEqual(reminder.minutes_before_start, 34) + reminder = EmailReminder(days_before=1, at=time(0, 0)) + self.assertEqual(reminder.method, 'email') + self.assertEqual(reminder.minutes_before_start, None) + self.assertEqual(reminder.days_before, 1) + self.assertEqual(reminder.at, time(0, 0)) + def test_popup_reminder(self): + reminder = PopupReminder() + self.assertEqual(reminder.method, 'popup') + self.assertEqual(reminder.minutes_before_start, 30) + reminder = PopupReminder(51) self.assertEqual(reminder.method, 'popup') self.assertEqual(reminder.minutes_before_start, 51) + reminder = PopupReminder(days_before=1, at=time(0, 0)) + self.assertEqual(reminder.method, 'popup') + self.assertEqual(reminder.minutes_before_start, None) + self.assertEqual(reminder.days_before, 1) + self.assertEqual(reminder.at, time(0, 0)) + def test_repr_str(self): reminder = EmailReminder(34) self.assertEqual(reminder.__repr__(), "") self.assertEqual(reminder.__str__(), "EmailReminder - minutes_before_start:34") + reminder = PopupReminder(days_before=1, at=time(0, 0)) + self.assertEqual(reminder.__repr__(), "") + self.assertEqual(reminder.__str__(), "PopupReminder - 1 days before at 00:00:00") + + def test_absolute_reminders_conversion(self): + absolute_reminder = EmailReminder(days_before=1, at=time(12, 0)) + reminder = absolute_reminder.convert_to_relative(datetime(2024, 4, 16, 10, 15)) + self.assertEqual(reminder.method, 'email') + self.assertEqual(reminder.minutes_before_start, (12 + 10) * 60 + 15) + + absolute_reminder = PopupReminder(days_before=2, at=time(11, 30)) + reminder = absolute_reminder.convert_to_relative(date(2024, 4, 16)) + self.assertEqual(reminder.method, 'popup') + self.assertEqual(reminder.minutes_before_start, 24 * 60 + 12 * 60 + 30) + + absolute_reminder = PopupReminder(days_before=5, at=time(10, 25)) + reminder = absolute_reminder.convert_to_relative(16 / Apr / 2024) + self.assertEqual(reminder.method, 'popup') + self.assertEqual(reminder.minutes_before_start, 4 * 24 * 60 + 13 * 60 + 35) + + def test_reminder_checks(self): + # No time provided + with self.assertRaises(ValueError): + Reminder(method='email') + + # Both relative and absolute times provided + with self.assertRaises(ValueError): + Reminder(method='email', minutes_before_start=22, days_before=1) + with self.assertRaises(ValueError): + Reminder(method='email', minutes_before_start=22, at=time(0, 0)) + + # Only one of days_before and at provided + with self.assertRaises(ValueError): + Reminder(method='email', days_before=1) + with self.assertRaises(ValueError): + Reminder(method='email', at=time(0, 0)) + with self.assertRaises(ValueError): + PopupReminder(days_before=1) + with self.assertRaises(ValueError): + EmailReminder(at=time(0, 0)) + class TestReminderSerializer(TestCase): def test_to_json(self): diff --git a/tox.ini b/tox.ini index 1fc0cd49..4c88bef0 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,8 @@ python = 3.8: pytest 3.9: pytest 3.10: pytest - 3.11: pytest, flake8, sphinx + 3.11: pytest + 3.12: pytest, flake8, sphinx [flake8] max-line-length = 120