From b0bf20aadbcdf8c52e32af63a54939be95f31a38 Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Tue, 30 Jul 2024 19:30:30 -0400 Subject: [PATCH 01/15] small changes to the add_clubs command --- core/management/commands/add_clubs.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/core/management/commands/add_clubs.py b/core/management/commands/add_clubs.py index 0a782c11..0f95d71c 100644 --- a/core/management/commands/add_clubs.py +++ b/core/management/commands/add_clubs.py @@ -20,7 +20,9 @@ def add_arguments(self, parser): parser.add_argument( "sheets_link", type=str, - help="Link to Google Sheets (must be published as CSV)", + help= "Link to Google Sheets (must be published as CSV). " \ + "Follow this guide (https://support.google.com/docs/answer/183965) to publish the spreadsheet, " \ + "set the dropbox to 'Comma-separated-values (.csv)' and copy the link underneath (https://imgur.com/a/ype1qOl)", ) def handle(self, *args, **options): @@ -29,7 +31,7 @@ def handle(self, *args, **options): if not ("output=csv" in sheets_url or sheets_url.endswith(".csv")): print(sheets_url.endswith("?output=csv")) raise AssertionError( - "Make sure make a copy of the club spreadsheet and use the link provided when publishing as CSV (https://support.google.com/docs/answer/183965)" + "Make sure to make a copy of the club spreadsheet and use the link provided when publishing as .csv file (https://support.google.com/docs/answer/183965)" ) csv_reader = csv.reader(StringIO(requests.get(sheets_url).text)) @@ -47,17 +49,20 @@ def handle(self, *args, **options): "SOCIAL LINKS", ] - assert expected_header == next( - csv_reader - ), "Google Sheets layout changed since the last time the script was updated, please consult the backend team." + if expected_header != next(csv_reader): + raise AssertionError( + "Google Sheets layout changed since the last time the script was updated, please consult the backend team." + ) for row in csv_reader: organization_is_not_approved = row[1] != "TRUE" has_duplicate_owner = len(row[0]) == 0 + if organization_is_not_approved or has_duplicate_owner: self.stdout.write( self.style.ERROR( - f"Skipping {row[0]} because it is not approved or has a duplicate owner" + f"Skipping row in spreadsheet because the club has multiple owners\n" if has_duplicate_owner else \ + f"Skipping {row[0]} because it is not approved\n" ) ) continue @@ -88,7 +93,7 @@ def handle(self, *args, **options): skip_entry = False self.stdout.write( - "\tIf you have the correct email, please enter it here:" + "\tIf you have the correct email, please enter it here (type 'skip' to skip this entry):" ) while True: try: @@ -109,7 +114,9 @@ def handle(self, *args, **options): self.stdout.write("\tPlease re-enter email:") if skip_entry: - self.stdout.write(f"\tSkipped creation of {organization_name}") + self.stdout.write( + self.style.SUCCESS(f"\tSkipped creation of {organization_name}\n") + ) continue self.stdout.write( self.style.SUCCESS(f"\tFound a match for {owner_name}'s email") From bbf6164de679c24f91cbb37b5321f47d79106883 Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Fri, 23 Aug 2024 18:19:52 -0400 Subject: [PATCH 02/15] working calendar parser that can output event data to console --- core/management/commands/add_events.py | 70 ++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 core/management/commands/add_events.py diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py new file mode 100644 index 00000000..6c4a54a0 --- /dev/null +++ b/core/management/commands/add_events.py @@ -0,0 +1,70 @@ +""" +This script is used to. +Code owned by Phil of metropolis backend team. +""" + +import requests +from datetime import datetime +from django.core.management.base import BaseCommand +from django.db import IntegrityError + +from core.models import Event + +# TODO: consider adding this to metropolis/settings.py in the "Event calender Settings" section (?) +SECRET_CAL_ADDRESS = "Change me" + +class Command(BaseCommand): + # TODO: change help text + help = "Adds organizations from Google Sheets. Does not modify existing organizations. See https://github.com/wlmac/metropolis/issues/247" + + def handle(self, *args, **options): + if SECRET_CAL_ADDRESS == "Change me": + raise AssertionError("SECRET_CAL_ADDRESS is not set.") + # TODO: raise AssertionError("SECRET_CAL_ADDRESS is not set. Please change in metropolis/settings.py") + + rq = requests.get(SECRET_CAL_ADDRESS) + + if rq.status_code != 200: + raise AssertionError("Could not get calendar data. Are you sure the calendar link is set correctly? See the instructions in core/management/commands/add_events.py") + + lines = rq.text.splitlines() + + event_data = {} + is_in_event = False + + for l in lines: + # self.stdout.write(l) + l = l.strip() + + if l == "BEGIN:VEVENT": + is_in_event = True + + elif l == "END:VEVENT": + is_in_event = False + + # TODO: because past events are not deleted from the calendar, + # we have to somehow make sure we don't add the same event twice + # => maybe discard any events that have end before the script was run? + self.stdout.write(f"New event discovered:\n") + self.stdout.write(f"\tData: {event_data}\n") + for k, v in event_data.items(): + self.stdout.write(f"\t{k}: {v}\n") + + event_data = {} + + elif is_in_event: + if l.startswith("SUMMARY:"): + event_data["name"] = l[len("SUMMARY:"):] + + elif l.startswith("DESCRIPTION:"): + event_data["description"] = l[len("DESCRIPTION:"):] + + elif l.startswith("DTSTART:"): + dtstart = l[len("DTSTART:"):] + event_data["start"] = datetime.strptime(dtstart, "%Y%m%dT%H%M%SZ") # TODO: check if timezone is correct + # TODO: all-day events are stored as DTSTART;VALUE=DATE:%Y%m%d for some weird reason + + elif l.startswith("DTEND:"): + dtend = l[len("DTEND:"):] + event_data["end"] = datetime.strptime(dtend, "%Y%m%dT%H%M%SZ") # TODO: check if timezone is correct + # TODO: all-day events are stored as DTSTART;VALUE=DATE:%Y%m%d for some weird reason From 859651ada5fb42a4873ac539510f23ae500730e8 Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Fri, 23 Aug 2024 20:59:09 -0400 Subject: [PATCH 03/15] created interactive prompt that query django model db to fill out information for each event --- core/management/commands/add_events.py | 226 +++++++++++++++++++++++-- 1 file changed, 210 insertions(+), 16 deletions(-) diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py index 6c4a54a0..ceb3dc80 100644 --- a/core/management/commands/add_events.py +++ b/core/management/commands/add_events.py @@ -8,7 +8,7 @@ from django.core.management.base import BaseCommand from django.db import IntegrityError -from core.models import Event +from core.models import Event, Organization, Term # TODO: consider adding this to metropolis/settings.py in the "Event calender Settings" section (?) SECRET_CAL_ADDRESS = "Change me" @@ -25,7 +25,7 @@ def handle(self, *args, **options): rq = requests.get(SECRET_CAL_ADDRESS) if rq.status_code != 200: - raise AssertionError("Could not get calendar data. Are you sure the calendar link is set correctly? See the instructions in core/management/commands/add_events.py") + raise AssertionError(f"Error {rq.status_code}, {rq.reason}. Could not get calendar data. Are you sure the calendar link is set correctly? See the instructions in core/management/commands/add_events.py") lines = rq.text.splitlines() @@ -37,20 +37,205 @@ def handle(self, *args, **options): l = l.strip() if l == "BEGIN:VEVENT": + # Default values + event_data = { + "name": "Unnamed Event", + "description": "", + "start_date": None, + "end_date": None, + "term": None, + "organization": None, + "schedule_format": "default", + "is_instructional": True, + "is_public": True, + "should_announce": False, + # "tags": [], + } is_in_event = True elif l == "END:VEVENT": is_in_event = False - # TODO: because past events are not deleted from the calendar, - # we have to somehow make sure we don't add the same event twice - # => maybe discard any events that have end before the script was run? - self.stdout.write(f"New event discovered:\n") - self.stdout.write(f"\tData: {event_data}\n") + # Because past events are not deleted from the calendar, we don't + # add any events that have already passed to prevent duplication. + if datetime.now() > event_data["end_date"]: + self.stdout.write( + self.style.ERROR(f"Event '{event_data["name"]}' skipped because it has already passed.") + ) + continue + + self.stdout.write( + self.style.SUCCESS(f"\nNew event created:") + ) + # self.stdout.write(f"\tData: {event_data}\n") for k, v in event_data.items(): self.stdout.write(f"\t{k}: {v}\n") + + term = None + self.stdout.write( + "\n\tPlease enter the name of the event's affiliated term (case insensitive):" + ) + while True: + try: + print("\t", end="") + term_name = input() + term = Term.objects.get(name__iexact=term_name) + + self.stdout.write( + self.style.SUCCESS(f"\tTerm found: {term}") + ) + break + except Term.DoesNotExist: + self.stdout.write( + self.style.ERROR( + "\tTerm not found. Did you make a typo?" + ) + ) + self.stdout.write("\tPlease re-enter term name:") + event_data["term"] = term + + organization = None + self.stdout.write( + "\n\tPlease enter the name OR slug of the event's affiliated organization (case insensitive):" + ) + while True: + try: + print("\t", end="") + org_name = input() + organization = Organization.objects.get(name__iexact=org_name) + self.stdout.write( + self.style.SUCCESS( + f"\tOrganization found: {organization}" + ) + ) + break + + except Organization.DoesNotExist: + try: + organization = Organization.objects.get(slug__iexact=org_name) + self.stdout.write( + self.style.SUCCESS( + f"\tOrganization found: {organization}" + ) + ) + break + + except Organization.DoesNotExist: + self.stdout.write( + self.style.ERROR( + "\tOrganization not found. Did you make a typo?" + ) + ) + self.stdout.write("\tPlease re-enter organization name:") + event_data["organization"] = organization + + schedule_format = "default" + while True: + self.stdout.write( + f"\n\tChange schedule format from its default value ({event_data["schedule_format"]})? (y/N):" + ) + print("\t", end="") + + i = input() + + if i.lower() == "y": + options = [ + "early-dismissal", + "one-hour-lunch", + "early-early-dismissal", + "default", + "half-day", + "late-start", + "sac-election", + ] + + for i, option in enumerate(options): + self.stdout.write(f"\t{i+1}: {option}") + + while True: + self.stdout.write("\tSelect a new format by number:") + + print("\t", end="") + num = input() + try: + if int(num) > 0 and int(num) <= len(options): + schedule_format = options[int(num) - 1] + self.stdout.write( + self.style.SUCCESS( + f"\tNew value: {event_data["schedule_format"]}" + ) + ) + break + else: + raise ValueError + + except ValueError: + self.stdout.write( + self.style.ERROR( + "\tInvalid input. Please enter a number within the list." + ) + ) + break + + elif i.lower() == "n" or i.lower() == "": + self.stdout.write( + self.style.SUCCESS(f"\tNo change") + ) + break + + else: + self.stdout.write( + self.style.ERROR( + "\tInvalid input. Please enter 'y' or 'n' (leave blank for 'n')." + ) + ) + event_data["schedule_format"] = schedule_format + + for s in ["is_instructional", "is_public", "should_announce"]: + boolean = event_data[s] + while True: + self.stdout.write( + f"\n\tChange {s} from its default value ({boolean})? (y/N):" + ) + print("\t", end="") + + i = input() + + if i.lower() == "y": + boolean = not boolean + self.stdout.write( + self.style.SUCCESS(f"\tNew value: {boolean}") + ) + break + + elif i.lower() == "n" or len(i) == 0: + self.stdout.write( + self.style.SUCCESS(f"\tNo change") + ) + break + + else: + self.stdout.write( + self.style.ERROR( + "\tInvalid input. Please enter 'y' or 'n' (leave blank for 'n')." + ) + ) - event_data = {} + event = Event( + name=event_data["name"], + description=event_data["description"], + start_date=event_data["start_date"], + end_date=event_data["end_date"], + term=event_data["term"], + organization=event_data["organization"], + schedule_format=event_data["schedule_format"], + is_instructional=event_data["is_instructional"], + is_public=event_data["is_public"], + should_announce=event_data["should_announce"], + ) + event.save() + + self.stdout.write(self.style.SUCCESS(f"\nEvent saved: {event}")) elif is_in_event: if l.startswith("SUMMARY:"): @@ -59,12 +244,21 @@ def handle(self, *args, **options): elif l.startswith("DESCRIPTION:"): event_data["description"] = l[len("DESCRIPTION:"):] - elif l.startswith("DTSTART:"): - dtstart = l[len("DTSTART:"):] - event_data["start"] = datetime.strptime(dtstart, "%Y%m%dT%H%M%SZ") # TODO: check if timezone is correct - # TODO: all-day events are stored as DTSTART;VALUE=DATE:%Y%m%d for some weird reason + elif l.startswith("DTSTART"): + if l.startswith("DTSTART;VALUE=DATE:"): + dtstart = l[len("DTSTART;VALUE=DATE:"):] + event_data["start_date"] = datetime.strptime(dtstart, "%Y%m%d") # TODO: fix timezone + + elif l.startswith("DTSTART:"): + dtstart = l[len("DTSTART:"):] + event_data["start_date"] = datetime.strptime(dtstart, "%Y%m%dT%H%M%SZ") # TODO: fix timezone + + elif l.startswith("DTEND"): + if l.startswith("DTEND;VALUE=DATE:"): + dtend = l[len("DTEND;VALUE=DATE:"):] + event_data["end_date"] = datetime.strptime(dtend, "%Y%m%d") # TODO: fix timezone + + elif l.startswith("DTEND:"): + dtend = l[len("DTEND:"):] + event_data["end_date"] = datetime.strptime(dtend, "%Y%m%dT%H%M%SZ") # TODO: fix timezone - elif l.startswith("DTEND:"): - dtend = l[len("DTEND:"):] - event_data["end"] = datetime.strptime(dtend, "%Y%m%dT%H%M%SZ") # TODO: check if timezone is correct - # TODO: all-day events are stored as DTSTART;VALUE=DATE:%Y%m%d for some weird reason From fcf631da1726b3f49fc093be566d9d98376cd39f Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Sat, 24 Aug 2024 16:15:09 -0400 Subject: [PATCH 04/15] code cleanup --- core/management/commands/add_events.py | 370 ++++++++++++------------- 1 file changed, 177 insertions(+), 193 deletions(-) diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py index ceb3dc80..88c7d873 100644 --- a/core/management/commands/add_events.py +++ b/core/management/commands/add_events.py @@ -14,30 +14,26 @@ SECRET_CAL_ADDRESS = "Change me" class Command(BaseCommand): - # TODO: change help text - help = "Adds organizations from Google Sheets. Does not modify existing organizations. See https://github.com/wlmac/metropolis/issues/247" + help = "Imports events that that have not yet ended from Google Calendar. See https://github.com/wlmac/metropolis/issues/250" def handle(self, *args, **options): if SECRET_CAL_ADDRESS == "Change me": - raise AssertionError("SECRET_CAL_ADDRESS is not set.") - # TODO: raise AssertionError("SECRET_CAL_ADDRESS is not set. Please change in metropolis/settings.py") + raise AssertionError("SECRET_CAL_ADDRESS is not set. Please change in metropolis/settings.py") - rq = requests.get(SECRET_CAL_ADDRESS) + response = requests.get(SECRET_CAL_ADDRESS) - if rq.status_code != 200: - raise AssertionError(f"Error {rq.status_code}, {rq.reason}. Could not get calendar data. Are you sure the calendar link is set correctly? See the instructions in core/management/commands/add_events.py") - - lines = rq.text.splitlines() + if response.status_code != 200: + raise AssertionError( + f"Error {response.status_code}, {response.reason}. Could not get calendar data. Are you sure the calendar link is set correctly? See the instructions in core/management/commands/add_events.py" + ) event_data = {} - is_in_event = False + in_event = False - for l in lines: - # self.stdout.write(l) - l = l.strip() + for line in response.text.splitlines(): + line = line.strip() - if l == "BEGIN:VEVENT": - # Default values + if line == "BEGIN:VEVENT": event_data = { "name": "Unnamed Event", "description": "", @@ -49,178 +45,36 @@ def handle(self, *args, **options): "is_instructional": True, "is_public": True, "should_announce": False, - # "tags": [], } - is_in_event = True - - elif l == "END:VEVENT": - is_in_event = False + in_event = True + + elif line == "END:VEVENT": + in_event = False - # Because past events are not deleted from the calendar, we don't + # Because past events are not deleted from the .ics file, we don't # add any events that have already passed to prevent duplication. if datetime.now() > event_data["end_date"]: self.stdout.write( - self.style.ERROR(f"Event '{event_data["name"]}' skipped because it has already passed.") + self.style.ERROR( + f"\nEvent '{event_data["name"]}' skipped because it has already passed." + ) ) continue self.stdout.write( self.style.SUCCESS(f"\nNew event created:") ) - # self.stdout.write(f"\tData: {event_data}\n") - for k, v in event_data.items(): - self.stdout.write(f"\t{k}: {v}\n") - - term = None - self.stdout.write( - "\n\tPlease enter the name of the event's affiliated term (case insensitive):" - ) - while True: - try: - print("\t", end="") - term_name = input() - term = Term.objects.get(name__iexact=term_name) - - self.stdout.write( - self.style.SUCCESS(f"\tTerm found: {term}") - ) - break - except Term.DoesNotExist: - self.stdout.write( - self.style.ERROR( - "\tTerm not found. Did you make a typo?" - ) - ) - self.stdout.write("\tPlease re-enter term name:") - event_data["term"] = term - organization = None - self.stdout.write( - "\n\tPlease enter the name OR slug of the event's affiliated organization (case insensitive):" - ) - while True: - try: - print("\t", end="") - org_name = input() - organization = Organization.objects.get(name__iexact=org_name) - self.stdout.write( - self.style.SUCCESS( - f"\tOrganization found: {organization}" - ) - ) - break - - except Organization.DoesNotExist: - try: - organization = Organization.objects.get(slug__iexact=org_name) - self.stdout.write( - self.style.SUCCESS( - f"\tOrganization found: {organization}" - ) - ) - break - - except Organization.DoesNotExist: - self.stdout.write( - self.style.ERROR( - "\tOrganization not found. Did you make a typo?" - ) - ) - self.stdout.write("\tPlease re-enter organization name:") - event_data["organization"] = organization - - schedule_format = "default" - while True: - self.stdout.write( - f"\n\tChange schedule format from its default value ({event_data["schedule_format"]})? (y/N):" - ) - print("\t", end="") - - i = input() - - if i.lower() == "y": - options = [ - "early-dismissal", - "one-hour-lunch", - "early-early-dismissal", - "default", - "half-day", - "late-start", - "sac-election", - ] - - for i, option in enumerate(options): - self.stdout.write(f"\t{i+1}: {option}") - - while True: - self.stdout.write("\tSelect a new format by number:") - - print("\t", end="") - num = input() - try: - if int(num) > 0 and int(num) <= len(options): - schedule_format = options[int(num) - 1] - self.stdout.write( - self.style.SUCCESS( - f"\tNew value: {event_data["schedule_format"]}" - ) - ) - break - else: - raise ValueError - - except ValueError: - self.stdout.write( - self.style.ERROR( - "\tInvalid input. Please enter a number within the list." - ) - ) - break - - elif i.lower() == "n" or i.lower() == "": - self.stdout.write( - self.style.SUCCESS(f"\tNo change") - ) - break - - else: - self.stdout.write( - self.style.ERROR( - "\tInvalid input. Please enter 'y' or 'n' (leave blank for 'n')." - ) - ) - event_data["schedule_format"] = schedule_format - - for s in ["is_instructional", "is_public", "should_announce"]: - boolean = event_data[s] - while True: - self.stdout.write( - f"\n\tChange {s} from its default value ({boolean})? (y/N):" - ) - print("\t", end="") + for key, value in event_data.items(): + self.stdout.write(f"\t{key}: {value}\n") - i = input() + event_data["term"] = self._get_term() + event_data["organization"] = self._get_organization() + event_data["schedule_format"] = self._get_schedule_format() - if i.lower() == "y": - boolean = not boolean - self.stdout.write( - self.style.SUCCESS(f"\tNew value: {boolean}") - ) - break - - elif i.lower() == "n" or len(i) == 0: - self.stdout.write( - self.style.SUCCESS(f"\tNo change") - ) - break + for key in ["is_instructional", "is_public", "should_announce"]: + event_data[key] = self._get_boolean(key, event_data[key]) - else: - self.stdout.write( - self.style.ERROR( - "\tInvalid input. Please enter 'y' or 'n' (leave blank for 'n')." - ) - ) - event = Event( name=event_data["name"], description=event_data["description"], @@ -235,30 +89,160 @@ def handle(self, *args, **options): ) event.save() - self.stdout.write(self.style.SUCCESS(f"\nEvent saved: {event}")) - - elif is_in_event: - if l.startswith("SUMMARY:"): - event_data["name"] = l[len("SUMMARY:"):] + self.stdout.write(self.style.SUCCESS(f"\n\tEvent saved: {event}")) + + elif in_event: + if line.startswith("SUMMARY:"): + event_data["name"] = line[len("SUMMARY:"):] + elif line.startswith("DESCRIPTION:"): + event_data["description"] = line[len("DESCRIPTION:"):] + elif line.startswith("DTSTART"): + if line.startswith("DTSTART;VALUE=DATE:"): + start_date = line[len("DTSTART;VALUE=DATE:"):] + event_data["start_date"] = datetime.strptime(start_date, "%Y%m%d") + elif line.startswith("DTSTART:"): + start_date = line[len("DTSTART:"):] + event_data["start_date"] = datetime.strptime(start_date, "%Y%m%dT%H%M%SZ") + elif line.startswith("DTEND"): + if line.startswith("DTEND;VALUE=DATE:"): + end_date = line[len("DTEND;VALUE=DATE:"):] + event_data["end_date"] = datetime.strptime(end_date, "%Y%m%d") + elif line.startswith("DTEND:"): + end_date = line[len("DTEND:"):] + event_data["end_date"] = datetime.strptime(end_date, "%Y%m%dT%H%M%SZ") + + def _get_yesno_response(self, question): + while True: + self.stdout.write(f"\t{question} (y/N):", ending="") + + i = input().lower() + + if i == "y": + return True + elif i == "n" or i == "": + return False + else: + self.stdout.write( + self.style.ERROR("Please enter 'y' or 'n' (leave blank for 'n')."), ending="\n\t" + ) + + def _get_term(self): + self.stdout.write(f"\n\tPlease enter the name of the event's affiliated term (case insensitive): ", ending="\n\t") + + while True: + term_name = input() + try: + term = Term.objects.get(name__iexact=term_name) + + self.stdout.write( + self.style.SUCCESS(f"\tTerm found: {term}") + ) + return term + + except Term.DoesNotExist: + self.stdout.write( + self.style.ERROR( + "\tTerm not found. Did you make a typo? Please try again." + ), ending="\n\t" + ) - elif l.startswith("DESCRIPTION:"): - event_data["description"] = l[len("DESCRIPTION:"):] + def _get_organization(self): + self.stdout.write(f"\n\tPlease enter the name of the event's affiliated organization (case insensitive): ", ending="\n\t") + organization = None - elif l.startswith("DTSTART"): - if l.startswith("DTSTART;VALUE=DATE:"): - dtstart = l[len("DTSTART;VALUE=DATE:"):] - event_data["start_date"] = datetime.strptime(dtstart, "%Y%m%d") # TODO: fix timezone + while True: + organization_name = input() + try: + organization = Organization.objects.get(name__iexact=organization_name) - elif l.startswith("DTSTART:"): - dtstart = l[len("DTSTART:"):] - event_data["start_date"] = datetime.strptime(dtstart, "%Y%m%dT%H%M%SZ") # TODO: fix timezone + self.stdout.write( + self.style.SUCCESS( + f"\tOrganization found: {organization}" + ) + ) + break - elif l.startswith("DTEND"): - if l.startswith("DTEND;VALUE=DATE:"): - dtend = l[len("DTEND;VALUE=DATE:"):] - event_data["end_date"] = datetime.strptime(dtend, "%Y%m%d") # TODO: fix timezone + except Organization.DoesNotExist: + try: + organization = Organization.objects.get(slug__iexact=organization_name) - elif l.startswith("DTEND:"): - dtend = l[len("DTEND:"):] - event_data["end_date"] = datetime.strptime(dtend, "%Y%m%dT%H%M%SZ") # TODO: fix timezone + self.stdout.write( + self.style.SUCCESS( + f"\tOrganization found: {organization}" + ) + ) + break + except Organization.DoesNotExist: + self.stdout.write( + self.style.ERROR( + "\tOrganization not found. Did you make a typo? Please try again." + ), ending="\n\t" + ) + + return organization + + def _get_schedule_format(self): + schedule_format = "default" + + if self._get_yesno_response(f"\n\tChange schedule format from its default value ({schedule_format})?"): + options = [ + "early-dismissal", + "one-hour-lunch", + "early-early-dismissal", + "default", + "half-day", + "late-start", + "sac-election", + ] + + self.stdout.write("\t------------------------") + for i, option in enumerate(options): + self.stdout.write(f"\t{i+1}: {option}") + self.stdout.write("\t------------------------") + + while True: + self.stdout.write("\tSelect a new format by number: ", ending="") + + i = input() + + try: + if int(i) > 0 and int(i) <= len(options): + schedule_format = options[int(i) - 1] + + self.stdout.write( + self.style.SUCCESS( + f"\tNew value: {schedule_format}" + ) + ) + break + + else: + raise ValueError + + except ValueError: + self.stdout.write( + self.style.ERROR( + "\tInvalid input. Please enter a number within the list." + ) + ) + else: + self.stdout.write( + self.style.SUCCESS(f"\tNo change") + ) + + return schedule_format + + def _get_boolean(self, key, value): + if self._get_yesno_response(f"\n\tChange {key} from its default value ({value})?"): + self.stdout.write( + self.style.SUCCESS(f"\tNew value: {not value}") + ) + return not value + + else: + self.stdout.write( + self.style.SUCCESS(f"\tNo change") + ) + return value + \ No newline at end of file From 0a240ee8663ec2d7292373d3c48508da9d1a7609 Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Sat, 24 Aug 2024 18:46:42 -0400 Subject: [PATCH 05/15] time zone fixes and other finishing touches --- core/management/commands/add_events.py | 57 +++++++++++++++++++------- metropolis/settings.py | 2 +- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py index 88c7d873..103562be 100644 --- a/core/management/commands/add_events.py +++ b/core/management/commands/add_events.py @@ -1,26 +1,31 @@ """ -This script is used to. +This script is used to add events from Google Calendar to the maclyonsden database. + +Instructions on how to get the SECRET_GCAL_ADDRESS: +https://imgur.com/a/kC62n5p + Code owned by Phil of metropolis backend team. """ import requests -from datetime import datetime -from django.core.management.base import BaseCommand -from django.db import IntegrityError +import datetime from core.models import Event, Organization, Term -# TODO: consider adding this to metropolis/settings.py in the "Event calender Settings" section (?) -SECRET_CAL_ADDRESS = "Change me" +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils import timezone class Command(BaseCommand): help = "Imports events that that have not yet ended from Google Calendar. See https://github.com/wlmac/metropolis/issues/250" def handle(self, *args, **options): - if SECRET_CAL_ADDRESS == "Change me": - raise AssertionError("SECRET_CAL_ADDRESS is not set. Please change in metropolis/settings.py") + SECRET_GCAL_ADDRESS = settings.SECRET_GCAL_ADDRESS - response = requests.get(SECRET_CAL_ADDRESS) + if SECRET_GCAL_ADDRESS == "Change me": + raise AssertionError("SECRET_GCAL_ADDRESS is not set. Please change in metropolis/settings.py") + + response = requests.get(SECRET_GCAL_ADDRESS) if response.status_code != 200: raise AssertionError( @@ -53,7 +58,7 @@ def handle(self, *args, **options): # Because past events are not deleted from the .ics file, we don't # add any events that have already passed to prevent duplication. - if datetime.now() > event_data["end_date"]: + if timezone.now() > event_data["end_date"]: self.stdout.write( self.style.ERROR( f"\nEvent '{event_data["name"]}' skipped because it has already passed." @@ -61,6 +66,26 @@ def handle(self, *args, **options): ) continue + # Skip creating a duplicate event of one that already exists + if Event.objects.filter( + name__iexact=event_data["name"], + start_date=event_data["start_date"], + end_date=event_data["end_date"], + ).exists(): + print(timezone.now()) + e = Event.objects.get( + name__iexact=event_data["name"], + start_date=event_data["start_date"], + end_date=event_data["end_date"], + ) + print(f"{e.start_date} {e.end_date}") + self.stdout.write( + self.style.ERROR( + f"\nEvent '{event_data['name']}' skipped because it already exists." + ) + ) + continue + self.stdout.write( self.style.SUCCESS(f"\nNew event created:") ) @@ -99,17 +124,21 @@ def handle(self, *args, **options): elif line.startswith("DTSTART"): if line.startswith("DTSTART;VALUE=DATE:"): start_date = line[len("DTSTART;VALUE=DATE:"):] - event_data["start_date"] = datetime.strptime(start_date, "%Y%m%d") + # gcalendar's all-day events aren't supported on the mld calendar so we hack it here and hope the people that are looking are in america/toronto + event_data["start_date"] = timezone.make_aware(datetime.datetime.strptime(start_date, "%Y%m%d"), timezone.get_current_timezone()) elif line.startswith("DTSTART:"): start_date = line[len("DTSTART:"):] - event_data["start_date"] = datetime.strptime(start_date, "%Y%m%dT%H%M%SZ") + event_data["start_date"] = timezone.make_aware(datetime.datetime.strptime(start_date, "%Y%m%dT%H%M%SZ"), datetime.timezone.utc) elif line.startswith("DTEND"): if line.startswith("DTEND;VALUE=DATE:"): end_date = line[len("DTEND;VALUE=DATE:"):] - event_data["end_date"] = datetime.strptime(end_date, "%Y%m%d") + # gcalendar's all-day events aren't supported on the mld calendar so we hack it here and hope the people that are looking are in america/toronto + event_data["end_date"] = timezone.make_aware(datetime.datetime.strptime(end_date, "%Y%m%d"), timezone.get_current_timezone()) elif line.startswith("DTEND:"): end_date = line[len("DTEND:"):] - event_data["end_date"] = datetime.strptime(end_date, "%Y%m%dT%H%M%SZ") + event_data["end_date"] = timezone.make_aware(datetime.datetime.strptime(end_date, "%Y%m%dT%H%M%SZ"), datetime.timezone.utc) + + self.stdout.write(self.style.SUCCESS("Done.")) def _get_yesno_response(self, question): while True: diff --git a/metropolis/settings.py b/metropolis/settings.py index 7ca20990..b68f6fde 100644 --- a/metropolis/settings.py +++ b/metropolis/settings.py @@ -519,7 +519,7 @@ # Event calender Settings ICAL_PADDING = timedelta(days=4 * 7) # iCalendar Feed - +SECRET_GCAL_ADDRESS = "Change me" # Qualified Trials QLTR: Dict[str, Dict] = { From bf95f90157fb87d7168c1c4308fe1cea607597a6 Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Tue, 27 Aug 2024 13:42:37 -0400 Subject: [PATCH 06/15] added optional flag and small fixes --- core/management/commands/add_events.py | 44 +++++++++++++------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py index 103562be..d425e80e 100644 --- a/core/management/commands/add_events.py +++ b/core/management/commands/add_events.py @@ -19,7 +19,25 @@ class Command(BaseCommand): help = "Imports events that that have not yet ended from Google Calendar. See https://github.com/wlmac/metropolis/issues/250" + def add_arguments(self, parser): + parser.add_argument( + "--term", + "-t", + type=str, + help="The name of the term globally assigned for all future events from the google calendar.", + ) + def handle(self, *args, **options): + term_override = None + if options["term"]: + try: + term_override = Term.objects.get(name__iexact=options["term"]) + self.stdout.write( + self.style.SUCCESS(f"Using term '{term_override}' for all upcoming events...") + ) + except Term.DoesNotExist: + raise AssertionError(f"Term '{options["term"]}' does not exist. Did you make a typo?") + SECRET_GCAL_ADDRESS = settings.SECRET_GCAL_ADDRESS if SECRET_GCAL_ADDRESS == "Change me": @@ -72,16 +90,9 @@ def handle(self, *args, **options): start_date=event_data["start_date"], end_date=event_data["end_date"], ).exists(): - print(timezone.now()) - e = Event.objects.get( - name__iexact=event_data["name"], - start_date=event_data["start_date"], - end_date=event_data["end_date"], - ) - print(f"{e.start_date} {e.end_date}") self.stdout.write( self.style.ERROR( - f"\nEvent '{event_data['name']}' skipped because it already exists." + f"\nEvent '{event_data["name"]}' skipped because it already exists." ) ) continue @@ -93,25 +104,14 @@ def handle(self, *args, **options): for key, value in event_data.items(): self.stdout.write(f"\t{key}: {value}\n") - event_data["term"] = self._get_term() + event_data["term"] = term_override if options["term"] else self._get_term() event_data["organization"] = self._get_organization() event_data["schedule_format"] = self._get_schedule_format() for key in ["is_instructional", "is_public", "should_announce"]: event_data[key] = self._get_boolean(key, event_data[key]) - event = Event( - name=event_data["name"], - description=event_data["description"], - start_date=event_data["start_date"], - end_date=event_data["end_date"], - term=event_data["term"], - organization=event_data["organization"], - schedule_format=event_data["schedule_format"], - is_instructional=event_data["is_instructional"], - is_public=event_data["is_public"], - should_announce=event_data["should_announce"], - ) + event = Event(**event_data) event.save() self.stdout.write(self.style.SUCCESS(f"\n\tEvent saved: {event}")) @@ -274,4 +274,4 @@ def _get_boolean(self, key, value): self.style.SUCCESS(f"\tNo change") ) return value - \ No newline at end of file + From ae31981f361a4822b3ea702e55955916abe32f02 Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Sat, 31 Aug 2024 19:49:45 -0400 Subject: [PATCH 07/15] overhaul terms and move gcal address --- core/management/commands/add_events.py | 117 ++++++++++++++++++++----- metropolis/local_settings_sample.py | 1 + metropolis/settings.py | 1 - 3 files changed, 98 insertions(+), 21 deletions(-) diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py index d425e80e..c4137f17 100644 --- a/core/management/commands/add_events.py +++ b/core/management/commands/add_events.py @@ -12,7 +12,7 @@ from core.models import Event, Organization, Term -from django.conf import settings +from django.conf import local_settings from django.core.management.base import BaseCommand from django.utils import timezone @@ -28,20 +28,10 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): - term_override = None - if options["term"]: - try: - term_override = Term.objects.get(name__iexact=options["term"]) - self.stdout.write( - self.style.SUCCESS(f"Using term '{term_override}' for all upcoming events...") - ) - except Term.DoesNotExist: - raise AssertionError(f"Term '{options["term"]}' does not exist. Did you make a typo?") - - SECRET_GCAL_ADDRESS = settings.SECRET_GCAL_ADDRESS + SECRET_GCAL_ADDRESS = local_settings.SECRET_GCAL_ADDRESS if SECRET_GCAL_ADDRESS == "Change me": - raise AssertionError("SECRET_GCAL_ADDRESS is not set. Please change in metropolis/settings.py") + raise AssertionError("SECRET_GCAL_ADDRESS is not set. Please change in metropolis/local_settings.py") response = requests.get(SECRET_GCAL_ADDRESS) @@ -78,7 +68,7 @@ def handle(self, *args, **options): # add any events that have already passed to prevent duplication. if timezone.now() > event_data["end_date"]: self.stdout.write( - self.style.ERROR( + self.style.WARNING( f"\nEvent '{event_data["name"]}' skipped because it has already passed." ) ) @@ -91,12 +81,39 @@ def handle(self, *args, **options): end_date=event_data["end_date"], ).exists(): self.stdout.write( - self.style.ERROR( + self.style.WARNING( f"\nEvent '{event_data["name"]}' skipped because it already exists." ) ) continue + if options["term"]: + event_data["term"] = self._get_term_from_args(options) + else: + try: + event_data["term"] = Term.get_current(event_data["start_date"]) + except Term.DoesNotExist: + try: + event_data["term"] = Term.get_current(event_data["end_date"]) + + except Term.DoesNotExist: + self.stdout.write( + self.style.ERROR( + f"\nNo term found for event '{event_data["name"]}' in its time range." + ) + ) + + self.stdout.write( + f"\n\tPlease enter the name of the event's affiliated term (case insensitive, type 'skip' to skip the creation this event): ", + ending="\n\t" + ) + + event_data["term"] = self._get_term_from_name() + + if not event_data["term"]: + continue + + self.stdout.write( self.style.SUCCESS(f"\nNew event created:") ) @@ -104,7 +121,6 @@ def handle(self, *args, **options): for key, value in event_data.items(): self.stdout.write(f"\t{key}: {value}\n") - event_data["term"] = term_override if options["term"] else self._get_term() event_data["organization"] = self._get_organization() event_data["schedule_format"] = self._get_schedule_format() @@ -152,14 +168,42 @@ def _get_yesno_response(self, question): return False else: self.stdout.write( - self.style.ERROR("Please enter 'y' or 'n' (leave blank for 'n')."), ending="\n\t" + self.style.ERROR("Please enter 'y' or 'n' (leave blank for 'n')."), + ending="\n\t" ) - def _get_term(self): - self.stdout.write(f"\n\tPlease enter the name of the event's affiliated term (case insensitive): ", ending="\n\t") + def _get_term_from_args(self, options): + if not options["term"]: + return None + + term = None + try: + term = Term.objects.get(name__iexact=options["term"]) + self.stdout.write( + self.style.SUCCESS(f"Using term '{term}' for all upcoming events...") + ) + + return term + + except Term.DoesNotExist: + raise AssertionError(f"Term '{options["term"]}' does not exist. Did you make a typo?") + except Term.MultipleObjectsReturned: + self.stdout.write( + self.style.WARNING("Multiple terms found. Please provide the term's id:"), + ) + + return self._get_term_from_id() + + return term + + def _get_term_from_name(self): while True: term_name = input() + + if term_name == "skip": + return None + try: term = Term.objects.get(name__iexact=term_name) @@ -174,9 +218,42 @@ def _get_term(self): "\tTerm not found. Did you make a typo? Please try again." ), ending="\n\t" ) + + except Term.MultipleObjectsReturned: + self.stdout.write( + self.style.WARNING("Multiple terms found. Please provide the term's id:"), + ) + + return self._get_term_from_id() + + def _get_term_from_id(self): + for term in Term.objects.filter(name__iexact=options["term"]): + self.stdout.write(f"\t{term.name} (id = {term.id}): ") + for field in {"description", "start_date", "end_date", "timetable_format"}: + self.stdout.write(f"\t\t{field}: {getattr(term, field)}") + + while True: + self.stdout.write(f"\n\tEnter the term's id: ", ending="") + + term_id = input() + try: + term = Term.objects.get(id=term_id) + self.stdout.write( + self.style.SUCCESS(f"Using term '{term}' for all upcoming events...") + ) + + return term + except (Term.DoesNotExist, ValueError): + self.stdout.write( + self.style.ERROR(f"\tTerm '{term_id}' does not exist. Please try again: "), + ending="\n\t" + ) def _get_organization(self): - self.stdout.write(f"\n\tPlease enter the name of the event's affiliated organization (case insensitive): ", ending="\n\t") + self.stdout.write( + f"\n\tPlease enter the name of the event's affiliated organization (case insensitive): ", + ending="\n\t" + ) organization = None while True: diff --git a/metropolis/local_settings_sample.py b/metropolis/local_settings_sample.py index 225ad9a4..374ebf8e 100644 --- a/metropolis/local_settings_sample.py +++ b/metropolis/local_settings_sample.py @@ -6,6 +6,7 @@ DEBUG = True EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" # to emails just get printed to the console. ALLOWED_HOSTS = ["localhost", "127.0.0.1", ".ngrok.io", ".ngrok-free.app"] +SECRET_GCAL_ADDRESS = "Change me" if DEBUG: import mimetypes diff --git a/metropolis/settings.py b/metropolis/settings.py index b68f6fde..17fd3131 100644 --- a/metropolis/settings.py +++ b/metropolis/settings.py @@ -519,7 +519,6 @@ # Event calender Settings ICAL_PADDING = timedelta(days=4 * 7) # iCalendar Feed -SECRET_GCAL_ADDRESS = "Change me" # Qualified Trials QLTR: Dict[str, Dict] = { From e1aef13c1eecd532abe5ed30ea913bb3d9393b58 Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Sat, 31 Aug 2024 20:02:24 -0400 Subject: [PATCH 08/15] dynamically get schedule/timetable format options --- core/management/commands/add_events.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py index c4137f17..8d62db8d 100644 --- a/core/management/commands/add_events.py +++ b/core/management/commands/add_events.py @@ -16,6 +16,8 @@ from django.core.management.base import BaseCommand from django.utils import timezone +from ....metropolis.timetable_formats import TIMETABLE_FORMATS + class Command(BaseCommand): help = "Imports events that that have not yet ended from Google Calendar. See https://github.com/wlmac/metropolis/issues/250" @@ -54,7 +56,7 @@ def handle(self, *args, **options): "end_date": None, "term": None, "organization": None, - "schedule_format": "default", + "schedule_format": TIMETABLE_FORMATS[-1]["schedules"][0], "is_instructional": True, "is_public": True, "should_announce": False, @@ -291,16 +293,8 @@ def _get_organization(self): def _get_schedule_format(self): schedule_format = "default" - if self._get_yesno_response(f"\n\tChange schedule format from its default value ({schedule_format})?"): - options = [ - "early-dismissal", - "one-hour-lunch", - "early-early-dismissal", - "default", - "half-day", - "late-start", - "sac-election", - ] + if self._get_yesno_response(f"\n\tChange schedule format from its default value ({TIMETABLE_FORMATS[-1]["schedules"][0]})?"): + options = [TIMETABLE_FORMATS[-1]["schedules"]] # scuffed? yes, but it works self.stdout.write("\t------------------------") for i, option in enumerate(options): From 9d76db577f256a3acafb70ee7914b7ae34804306 Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Sat, 31 Aug 2024 20:21:58 -0400 Subject: [PATCH 09/15] sanity checks before a term is saved --- core/models/course.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/models/course.py b/core/models/course.py index 1fdcdc23..a19cea9d 100644 --- a/core/models/course.py +++ b/core/models/course.py @@ -160,6 +160,20 @@ def day_schedule(self, target_date=None): class MisconfiguredTermError(Exception): pass + def save(self, *args, **kwargs): + if self.start_date > self.end_date: + raise self.MisconfiguredTermError("Start date must be before end date") + + # check for overlapping terms + for term in Term.objects.all(): + if term.start_date <= self.start_date < term.end_date: + raise self.MisconfiguredTermError("Current term's date range overlaps with existing term") + + if term.start_date < self.end_date <= term.end_date: + raise self.MisconfiguredTermError("Current term's date range overlaps with existing term") + + super().save(*args, **kwargs) + @classmethod def get_current(cls, target_date=None): target_date = utils.get_localdate(date=target_date) From 1f8b5e653dc9e9b248325e40f4b274a4347157d1 Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Sat, 31 Aug 2024 21:21:57 -0400 Subject: [PATCH 10/15] some fixes --- core/management/commands/add_events.py | 84 ++++++++++++++------------ core/models/course.py | 3 + 2 files changed, 50 insertions(+), 37 deletions(-) diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py index 8d62db8d..89a11ec2 100644 --- a/core/management/commands/add_events.py +++ b/core/management/commands/add_events.py @@ -12,12 +12,10 @@ from core.models import Event, Organization, Term -from django.conf import local_settings +from django.conf import settings from django.core.management.base import BaseCommand from django.utils import timezone -from ....metropolis.timetable_formats import TIMETABLE_FORMATS - class Command(BaseCommand): help = "Imports events that that have not yet ended from Google Calendar. See https://github.com/wlmac/metropolis/issues/250" @@ -30,7 +28,9 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): - SECRET_GCAL_ADDRESS = local_settings.SECRET_GCAL_ADDRESS + term_override = self._get_term_from_args(options) + + SECRET_GCAL_ADDRESS = settings.SECRET_GCAL_ADDRESS if SECRET_GCAL_ADDRESS == "Change me": raise AssertionError("SECRET_GCAL_ADDRESS is not set. Please change in metropolis/local_settings.py") @@ -54,9 +54,9 @@ def handle(self, *args, **options): "description": "", "start_date": None, "end_date": None, - "term": None, + "term": options["term"] if options["term"] else None, "organization": None, - "schedule_format": TIMETABLE_FORMATS[-1]["schedules"][0], + "schedule_format": "default", "is_instructional": True, "is_public": True, "should_announce": False, @@ -66,6 +66,8 @@ def handle(self, *args, **options): elif line == "END:VEVENT": in_event = False + event_data["term"] = term_override if term_override else self._get_term_from_date(event_data["start_date"], event_data["end_date"]) + # Because past events are not deleted from the .ics file, we don't # add any events that have already passed to prevent duplication. if timezone.now() > event_data["end_date"]: @@ -89,32 +91,13 @@ def handle(self, *args, **options): ) continue - if options["term"]: - event_data["term"] = self._get_term_from_args(options) - else: - try: - event_data["term"] = Term.get_current(event_data["start_date"]) - except Term.DoesNotExist: - try: - event_data["term"] = Term.get_current(event_data["end_date"]) - - except Term.DoesNotExist: - self.stdout.write( - self.style.ERROR( - f"\nNo term found for event '{event_data["name"]}' in its time range." - ) - ) - - self.stdout.write( - f"\n\tPlease enter the name of the event's affiliated term (case insensitive, type 'skip' to skip the creation this event): ", - ending="\n\t" - ) - - event_data["term"] = self._get_term_from_name() - - if not event_data["term"]: - continue - + if not event_data["term"]: + self.stdout.write( + self.style.WARNING( + f"\nEvent '{event_data['name']}' skipped because it has no term." + ) + ) + continue self.stdout.write( self.style.SUCCESS(f"\nNew event created:") @@ -123,8 +106,23 @@ def handle(self, *args, **options): for key, value in event_data.items(): self.stdout.write(f"\t{key}: {value}\n") + if Term.get_current(event_data["start_date"]) != event_data["term"]: + self.stdout.write( + self.style.WARNING( + f"\nEvent '{event_data['name']}' has a term that does not match the current term's date range." + ) + ) + + if not event_data["term"]: + self.stdout.write( + f"\n\tPlease enter the name of the event's affiliated term (case insensitive, type 'skip' to skip the creation this event): ", + ending="\n\t" + ) + + event_data["term"] = self._get_term_from_name() + event_data["organization"] = self._get_organization() - event_data["schedule_format"] = self._get_schedule_format() + event_data["schedule_format"] = self._get_schedule_format(event_data["term"]) for key in ["is_instructional", "is_public", "should_announce"]: event_data[key] = self._get_boolean(key, event_data[key]) @@ -156,7 +154,7 @@ def handle(self, *args, **options): end_date = line[len("DTEND:"):] event_data["end_date"] = timezone.make_aware(datetime.datetime.strptime(end_date, "%Y%m%dT%H%M%SZ"), datetime.timezone.utc) - self.stdout.write(self.style.SUCCESS("Done.")) + self.stdout.write(self.style.SUCCESS("\nDone.")) def _get_yesno_response(self, question): while True: @@ -174,6 +172,15 @@ def _get_yesno_response(self, question): ending="\n\t" ) + def _get_term_from_date(self, start_date, end_date): + try: + return Term.get_current(start_date) + except Term.DoesNotExist: + try: + return Term.get_current(end_date) + except Term.DoesNotExist: + return None + def _get_term_from_args(self, options): if not options["term"]: return None @@ -290,11 +297,14 @@ def _get_organization(self): return organization - def _get_schedule_format(self): + def _get_schedule_format(self, term): schedule_format = "default" - if self._get_yesno_response(f"\n\tChange schedule format from its default value ({TIMETABLE_FORMATS[-1]["schedules"][0]})?"): - options = [TIMETABLE_FORMATS[-1]["schedules"]] # scuffed? yes, but it works + if self._get_yesno_response(f"\n\tChange schedule format from its default value ('default')?"): + options = list( + settings.TIMETABLE_FORMATS[term.timetable_format]["schedules"] + .keys() + ) self.stdout.write("\t------------------------") for i, option in enumerate(options): diff --git a/core/models/course.py b/core/models/course.py index a19cea9d..325ac954 100644 --- a/core/models/course.py +++ b/core/models/course.py @@ -166,6 +166,9 @@ def save(self, *args, **kwargs): # check for overlapping terms for term in Term.objects.all(): + if term.id == self.id: + continue + if term.start_date <= self.start_date < term.end_date: raise self.MisconfiguredTermError("Current term's date range overlaps with existing term") From 8328c89d9c329aa3a974dc383b53707726feecfa Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Sun, 1 Sep 2024 21:42:13 -0400 Subject: [PATCH 11/15] =?UTF-8?q?my=20poor=20terminal=20=F0=9F=98=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/management/commands/add_events.py | 32 +++++++++++++++++++------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py index 89a11ec2..58c597d1 100644 --- a/core/management/commands/add_events.py +++ b/core/management/commands/add_events.py @@ -27,6 +27,20 @@ def add_arguments(self, parser): help="The name of the term globally assigned for all future events from the google calendar.", ) + parser.add_argument( + "--log-past-events", + "-P", + action="store_true", + help="Logs the names of past events that got skipped", + ) + + parser.add_argument( + "--log-duplicate-events", + "-D", + action="store_true", + help="Logs the names of duplicate events that got skipped", + ) + def handle(self, *args, **options): term_override = self._get_term_from_args(options) @@ -71,11 +85,12 @@ def handle(self, *args, **options): # Because past events are not deleted from the .ics file, we don't # add any events that have already passed to prevent duplication. if timezone.now() > event_data["end_date"]: - self.stdout.write( - self.style.WARNING( - f"\nEvent '{event_data["name"]}' skipped because it has already passed." + if options["log_past_events"]: + self.stdout.write( + self.style.WARNING( + f"\nEvent '{event_data["name"]}' skipped because it has already passed." + ) ) - ) continue # Skip creating a duplicate event of one that already exists @@ -84,11 +99,12 @@ def handle(self, *args, **options): start_date=event_data["start_date"], end_date=event_data["end_date"], ).exists(): - self.stdout.write( - self.style.WARNING( - f"\nEvent '{event_data["name"]}' skipped because it already exists." + if options["log_duplicate_events"]: + self.stdout.write( + self.style.WARNING( + f"\nEvent '{event_data["name"]}' skipped because it already exists." + ) ) - ) continue if not event_data["term"]: From ed8388306ec2f82a0dd2fdb5a0a7f42f07be0335 Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Mon, 2 Sep 2024 00:11:23 -0400 Subject: [PATCH 12/15] bug fixes (note: i excruciatingly hate ical files) --- core/management/commands/add_events.py | 134 ++++++++++++++++--------- 1 file changed, 88 insertions(+), 46 deletions(-) diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py index 58c597d1..e284d32a 100644 --- a/core/management/commands/add_events.py +++ b/core/management/commands/add_events.py @@ -9,6 +9,7 @@ import requests import datetime +from zoneinfo import ZoneInfo from core.models import Event, Organization, Term @@ -42,7 +43,8 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): - term_override = self._get_term_from_args(options) + TERM_OVERRIDE = self._get_term_from_args(options) + TZID = "America/Toronto" SECRET_GCAL_ADDRESS = settings.SECRET_GCAL_ADDRESS @@ -58,11 +60,10 @@ def handle(self, *args, **options): event_data = {} in_event = False + vevent_paragraph = "" for line in response.text.splitlines(): - line = line.strip() - - if line == "BEGIN:VEVENT": + if line.startswith("BEGIN:VEVENT"): event_data = { "name": "Unnamed Event", "description": "", @@ -76,11 +77,36 @@ def handle(self, *args, **options): "should_announce": False, } in_event = True + vevent_paragraph = "" - elif line == "END:VEVENT": + elif line.startswith("END:VEVENT"): in_event = False + + if event_data["start_date"] is None: + self.stdout.write( + self.style.ERROR( + f"\nEvent '{event_data['name']}' will be skipped because start date does not exist or is not parsed correctly." + ) + ) + + self.stdout.write( + f"Event raw data: \n{vevent_paragraph}" + ) + + self.stdout.write( + f"Press 'Enter' to acknowledge that you will have to create the event manually" + ) + + input() + + continue + + # Events that do not have a DTEND + if event_data["end_date"] is None: + event_data["end_date"] = event_data["start_date"] + datetime.timedelta(minutes=1) - event_data["term"] = term_override if term_override else self._get_term_from_date(event_data["start_date"], event_data["end_date"]) + # Uses cli argument if possible, otherwise use date from event + event_data["term"] = TERM_OVERRIDE if TERM_OVERRIDE is not None else self._get_term_from_date(event_data["start_date"], event_data["end_date"]) # Because past events are not deleted from the .ics file, we don't # add any events that have already passed to prevent duplication. @@ -107,18 +133,11 @@ def handle(self, *args, **options): ) continue - if not event_data["term"]: - self.stdout.write( - self.style.WARNING( - f"\nEvent '{event_data['name']}' skipped because it has no term." - ) - ) - continue - self.stdout.write( self.style.SUCCESS(f"\nNew event created:") ) + # Print known attributes of the event for key, value in event_data.items(): self.stdout.write(f"\t{key}: {value}\n") @@ -129,13 +148,13 @@ def handle(self, *args, **options): ) ) - if not event_data["term"]: + if event_data["term"] is None: self.stdout.write( f"\n\tPlease enter the name of the event's affiliated term (case insensitive, type 'skip' to skip the creation this event): ", ending="\n\t" ) - event_data["term"] = self._get_term_from_name() + event_data["term"] = self._get_term_from_name(options) event_data["organization"] = self._get_organization() event_data["schedule_format"] = self._get_schedule_format(event_data["term"]) @@ -149,26 +168,50 @@ def handle(self, *args, **options): self.stdout.write(self.style.SUCCESS(f"\n\tEvent saved: {event}")) elif in_event: - if line.startswith("SUMMARY:"): - event_data["name"] = line[len("SUMMARY:"):] - elif line.startswith("DESCRIPTION:"): - event_data["description"] = line[len("DESCRIPTION:"):] - elif line.startswith("DTSTART"): - if line.startswith("DTSTART;VALUE=DATE:"): - start_date = line[len("DTSTART;VALUE=DATE:"):] - # gcalendar's all-day events aren't supported on the mld calendar so we hack it here and hope the people that are looking are in america/toronto - event_data["start_date"] = timezone.make_aware(datetime.datetime.strptime(start_date, "%Y%m%d"), timezone.get_current_timezone()) - elif line.startswith("DTSTART:"): - start_date = line[len("DTSTART:"):] - event_data["start_date"] = timezone.make_aware(datetime.datetime.strptime(start_date, "%Y%m%dT%H%M%SZ"), datetime.timezone.utc) - elif line.startswith("DTEND"): - if line.startswith("DTEND;VALUE=DATE:"): - end_date = line[len("DTEND;VALUE=DATE:"):] - # gcalendar's all-day events aren't supported on the mld calendar so we hack it here and hope the people that are looking are in america/toronto - event_data["end_date"] = timezone.make_aware(datetime.datetime.strptime(end_date, "%Y%m%d"), timezone.get_current_timezone()) - elif line.startswith("DTEND:"): - end_date = line[len("DTEND:"):] - event_data["end_date"] = timezone.make_aware(datetime.datetime.strptime(end_date, "%Y%m%dT%H%M%SZ"), datetime.timezone.utc) + vevent_paragraph += line.strip() + "\n" + + try: + if line.startswith("SUMMARY"): + event_data["name"] = line[line.index(":")+1:] + + elif line.startswith("DESCRIPTION"): + event_data["description"] = line[line.index(":")+1:].replace("\\r", "\r").replace("\\n", "\n") + + elif line.startswith("DTSTART"): + start_date = line[line.index(":")+1:] + if "VALUE=DATE" in line: + # gcalendar's all-day events aren't supported on the mld calendar so we hack it here and hope the people that are looking are in america/toronto + event_data["start_date"] = timezone.make_aware(datetime.datetime.strptime(start_date, "%Y%m%d"), timezone=ZoneInfo(TZID)) + elif "TZID" in line: + TEMP_TZID = line[line.index("TZID=")+5:line.index(":")] + event_data["start_date"] = timezone.make_aware(datetime.datetime.strptime(start_date, "%Y%m%dT%H%M%S"), timezone=ZoneInfo(TEMP_TZID)) + else: + event_data["start_date"] = timezone.make_aware(datetime.datetime.strptime(start_date, "%Y%m%dT%H%M%SZ"), datetime.timezone.utc) + + elif line.startswith("DTEND"): + end_date = line[line.index(":")+1:] + if "VALUE=DATE:" in line: + # gcalendar's all-day events aren't supported on the mld calendar so we hack it here and hope the people that are looking are in america/toronto + event_data["end_date"] = timezone.make_aware(datetime.datetime.strptime(end_date, "%Y%m%d"), timezone.get_current_timezone()) + elif "TZID" in line: + TEMP_TZID = line[line.index("TZID=")+5:line.index(":")] + event_data["end_date"] = timezone.make_aware(datetime.datetime.strptime(end_date, "%Y%m%dT%H%M%S"), timezone=ZoneInfo(TEMP_TZID)) + else: + event_data["end_date"] = timezone.make_aware(datetime.datetime.strptime(end_date, "%Y%m%dT%H%M%SZ"), datetime.timezone.utc) + + elif line.startswith(" "): # to whoever decided to turn DESCRIPTION into a multiline field, i hope your coffee machine malfunctions often + event_data["description"] += line[1:].replace("\\r", "\r").replace("\\n", "\n") + + elif line.startswith("RRULE"): + # TODO: parse rrule + continue + + except ValueError: + pass + + elif line.startswith("TZID"): + tzid = line[line.index(":")+1:] + self.stdout.write(self.style.SUCCESS("\nDone.")) @@ -198,10 +241,9 @@ def _get_term_from_date(self, start_date, end_date): return None def _get_term_from_args(self, options): - if not options["term"]: + if options["term"] is None: return None - term = None try: term = Term.objects.get(name__iexact=options["term"]) self.stdout.write( @@ -218,11 +260,9 @@ def _get_term_from_args(self, options): self.style.WARNING("Multiple terms found. Please provide the term's id:"), ) - return self._get_term_from_id() + return self._get_term_from_id(options["term"]) - return term - - def _get_term_from_name(self): + def _get_term_from_name(self, options): while True: term_name = input() @@ -249,10 +289,10 @@ def _get_term_from_name(self): self.style.WARNING("Multiple terms found. Please provide the term's id:"), ) - return self._get_term_from_id() + return self._get_term_from_id(term_name) - def _get_term_from_id(self): - for term in Term.objects.filter(name__iexact=options["term"]): + def _get_term_from_id(self, term_name): + for term in Term.objects.filter(name__iexact=term_name): self.stdout.write(f"\t{term.name} (id = {term.id}): ") for field in {"description", "start_date", "end_date", "timetable_format"}: self.stdout.write(f"\t\t{field}: {getattr(term, field)}") @@ -263,11 +303,13 @@ def _get_term_from_id(self): term_id = input() try: term = Term.objects.get(id=term_id) + self.stdout.write( - self.style.SUCCESS(f"Using term '{term}' for all upcoming events...") + self.style.SUCCESS(f"Using term '{term}' with id '{term.id}'.") ) return term + except (Term.DoesNotExist, ValueError): self.stdout.write( self.style.ERROR(f"\tTerm '{term_id}' does not exist. Please try again: "), From 477b789b0593008c6d260de42af9c0562045997e Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Mon, 2 Sep 2024 01:06:17 -0400 Subject: [PATCH 13/15] my head hurts really bad --- core/management/commands/add_events.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py index e284d32a..1ca5bd5b 100644 --- a/core/management/commands/add_events.py +++ b/core/management/commands/add_events.py @@ -203,8 +203,8 @@ def handle(self, *args, **options): event_data["description"] += line[1:].replace("\\r", "\r").replace("\\n", "\n") elif line.startswith("RRULE"): - # TODO: parse rrule - continue + # TODO: might implement this in the future but this thing gave me a headache for way too long + pass except ValueError: pass @@ -214,6 +214,7 @@ def handle(self, *args, **options): self.stdout.write(self.style.SUCCESS("\nDone.")) + self.stdout.write(self.style.WARNING("\nPlease double check that all the events from google calendar were added correctly.\n")) def _get_yesno_response(self, question): while True: From 388a196f26a3c6213a415ec7043f7ea287da937b Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Mon, 2 Sep 2024 14:23:25 -0400 Subject: [PATCH 14/15] internet archive the goat --- core/management/commands/add_clubs.py | 2 +- core/management/commands/add_events.py | 8 ++++++-- metropolis/local_settings_sample.py | 3 +++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/core/management/commands/add_clubs.py b/core/management/commands/add_clubs.py index 0f95d71c..67fc2e85 100644 --- a/core/management/commands/add_clubs.py +++ b/core/management/commands/add_clubs.py @@ -22,7 +22,7 @@ def add_arguments(self, parser): type=str, help= "Link to Google Sheets (must be published as CSV). " \ "Follow this guide (https://support.google.com/docs/answer/183965) to publish the spreadsheet, " \ - "set the dropbox to 'Comma-separated-values (.csv)' and copy the link underneath (https://imgur.com/a/ype1qOl)", + "set the dropbox to 'Comma-separated-values (.csv)' and copy the link underneath (https://web.archive.org/web/20240902165418/https://cdn.discordapp.com/attachments/1280208592712241285/1280209073949638717/publish_to_web.png?ex=66d73f1c&is=66d5ed9c&hm=616b70187f8f3a54885b050e5f80c606d275318382333e5819364e020ba421bb&)", ) def handle(self, *args, **options): diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py index 1ca5bd5b..6ea08334 100644 --- a/core/management/commands/add_events.py +++ b/core/management/commands/add_events.py @@ -1,8 +1,12 @@ """ This script is used to add events from Google Calendar to the maclyonsden database. -Instructions on how to get the SECRET_GCAL_ADDRESS: -https://imgur.com/a/kC62n5p +If you are getting an error related to the SECRET_GCAL_ADDRESS not being set correctly, +first obtain the correct link by following the instructions below: +https://web.archive.org/web/20240902165230/https://cdn.discordapp.com/attachments/1280208592712241285/1280208610848407714/instructions.png?ex=66d73ead&is=66d5ed2d&hm=8f1e5943cad6f4869d7d83737d5d171d8512b5a67b2b3818271a0e84e9e02125& + +After you have copied the link, go to metropolis/local_settings.py +and add the link to the SECRET_GCAL_ADDRESS variable. Code owned by Phil of metropolis backend team. """ diff --git a/metropolis/local_settings_sample.py b/metropolis/local_settings_sample.py index 374ebf8e..1e216eb0 100644 --- a/metropolis/local_settings_sample.py +++ b/metropolis/local_settings_sample.py @@ -6,6 +6,9 @@ DEBUG = True EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" # to emails just get printed to the console. ALLOWED_HOSTS = ["localhost", "127.0.0.1", ".ngrok.io", ".ngrok-free.app"] + +# If you need to change the calendar address (see add_events.py management command), follow the instructions here: +# https://web.archive.org/web/20240902165230/https://cdn.discordapp.com/attachments/1280208592712241285/1280208610848407714/instructions.png?ex=66d73ead&is=66d5ed2d&hm=8f1e5943cad6f4869d7d83737d5d171d8512b5a67b2b3818271a0e84e9e02125& SECRET_GCAL_ADDRESS = "Change me" if DEBUG: From c2bfb4ed230e1fddc72264fafab7db2677f51a71 Mon Sep 17 00:00:00 2001 From: pinwheeeel Date: Wed, 4 Sep 2024 14:16:56 -0400 Subject: [PATCH 15/15] warn if theres rrule --- core/management/commands/add_events.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/management/commands/add_events.py b/core/management/commands/add_events.py index 6ea08334..29be0e1d 100644 --- a/core/management/commands/add_events.py +++ b/core/management/commands/add_events.py @@ -65,6 +65,7 @@ def handle(self, *args, **options): event_data = {} in_event = False vevent_paragraph = "" + contains_rrule = False for line in response.text.splitlines(): if line.startswith("BEGIN:VEVENT"): @@ -137,6 +138,15 @@ def handle(self, *args, **options): ) continue + if contains_rrule: + self.stdout.write( + self.style.WARNING( + "Automatically recurring event (RRULE) detected in event. Reccurence rules are not supported by the script; you must create any future instances of this event manually." + ) + ) + + contains_rrule = False + self.stdout.write( self.style.SUCCESS(f"\nNew event created:") ) @@ -208,6 +218,7 @@ def handle(self, *args, **options): elif line.startswith("RRULE"): # TODO: might implement this in the future but this thing gave me a headache for way too long + contains_rrule = True pass except ValueError: