diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..22a46ac --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,32 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: eAUrnik + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v3 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Test with unittest + run: | + python -m unittest diff --git a/API.py b/API.py index d8a9935..b075bec 100755 --- a/API.py +++ b/API.py @@ -4,26 +4,45 @@ # import Timetable -from flask import Flask, request, make_response -from flask_restful import Api, Resource, reqparse +from flask import Flask, make_response +from flask_restful import Api +from flask_cors import CORS app = Flask(__name__) api = Api(app) +CORS(app) + @app.errorhandler(404) def page_not_found(e): response = make_response("This request could not be processed. Check the provided information and try again.", 400) response.headers["content-type"] = "application/json" return response -class handle(Resource): - def get(self, school, class_, student): - timetable = Timetable.get(school, class_, student) - response = make_response(timetable, 200) - response.headers["content-type"] = "text/calendar" - return response +@app.route("/urniki//razredi//dijak/") +def get_student(school, class_, student): + """Parse a student's calendar.""" + timetable = Timetable.get_student(school, class_, student) + response = make_response(timetable, 200) + response.headers["content-type"] = "text/calendar" + return response -api.add_resource(handle, "/urniki//razredi//dijak/") +@app.route("/urniki//razredi/") +def get_class(school, class_): + """Parse a class's calendar.""" + timetable = Timetable.get_class(school, class_) + response = make_response(timetable, 200) + response.headers["content-type"] = "text/calendar" + return response + +@app.route("/urniki//ucitelj/") +@app.route("/urniki//ucitelj//tednov/") +def get_teacher_weeks(school, teacher, weeks=1): + """Parse a teacher's calender for multiple weeks ahead. If number of weeks isn't specified, defaults to 1.""" + timetable = Timetable.get_teacher(school, teacher, weeks) + response = make_response(timetable, 200) + response.headers["content-type"] = "text/calendar" + return response if __name__ == "__main__": app.run(host = "::") diff --git a/Calendar.py b/Calendar.py deleted file mode 100755 index 74e0810..0000000 --- a/Calendar.py +++ /dev/null @@ -1,45 +0,0 @@ -# -# Calendar.py -# eAUrnik -# - -from ics import Calendar, Event -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo - -def make(parsed, monday): - calendar = Calendar() - calendar.creator = "eAUrnik - Fork me on GitHub: https://git.io/JO5Za" - - durations = [] - for duration in parsed[0]: - start, end = duration.split(" - ") - start_hours, start_minutes = map(int, start.split(":")) - end_hours, end_minutes = map(int, end.split(":")) - durations.append(((start_hours, start_minutes), (end_hours, end_minutes))) - - data = parsed[1] - for day_index in range(0, len(data)): - day = monday + timedelta(days = day_index) - lessons = data[day_index] - for lesson_index in range(0, len(lessons)): - for lesson in lessons[lesson_index]: - title = lesson[0] - subtitle = lesson[1] - - duration = durations[lesson_index] - timezone = ZoneInfo("Europe/Ljubljana") - start = datetime(day.year, day.month, day.day, duration[0][0], duration[0][1], tzinfo = timezone) - end = datetime(day.year, day.month, day.day, duration[1][0], duration[1][1], tzinfo = timezone) - - event = Event() - event.name = title - event.location = subtitle - event.begin = start - event.end = end - calendar.events.add(event) - - return string(calendar) - -def string(calendar): - return "".join(calendar) diff --git a/CalendarGenerator.py b/CalendarGenerator.py new file mode 100755 index 0000000..fd30797 --- /dev/null +++ b/CalendarGenerator.py @@ -0,0 +1,45 @@ +# +# Calendar.py +# eAUrnik +# + +from ics import Calendar, Event +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +class CalendarGenerator: + def __init__(self): + self.calendar = Calendar() + self.calendar.creator = "eAUrnik - Fork me on GitHub: https://git.io/JO5Za" + + def append_week(self, parsed, monday): + durations = [] + for duration in parsed[0]: + start, end = duration.split(" - ") + start_hours, start_minutes = map(int, start.split(":")) + end_hours, end_minutes = map(int, end.split(":")) + durations.append(((start_hours, start_minutes), (end_hours, end_minutes))) + + data = parsed[1] + for day_index in range(0, len(data)): + day = monday + timedelta(days = day_index) + lessons = data[day_index] + for lesson_index in range(0, len(lessons)): + for lesson in lessons[lesson_index]: + title = lesson[0] + subtitle = lesson[1] + + duration = durations[lesson_index] + timezone = ZoneInfo("Europe/Ljubljana") + start = datetime(day.year, day.month, day.day, duration[0][0], duration[0][1], tzinfo = timezone) + end = datetime(day.year, day.month, day.day, duration[1][0], duration[1][1], tzinfo = timezone) + + event = Event() + event.name = title + event.location = subtitle + event.begin = start + event.end = end + self.calendar.events.add(event) + + def get_string(self): + return "".join(self.calendar) diff --git a/Parser.py b/Parser.py index a788e0d..6ea2d04 100755 --- a/Parser.py +++ b/Parser.py @@ -12,17 +12,15 @@ def parse_block(block): class_attribute = block.get("class") if "ednevnik-seznam_ur_teden-td-odpadlo" in class_attribute: return - # if "ednevnik-seznam_ur_teden-td-nadomescanje" in class_attribute: - # title += " (N)" + if "ednevnik-seznam_ur_teden-td-nadomescanje" in class_attribute: + title += " (N)" if "ednevnik-seznam_ur_teden-td-zaposlitev" in class_attribute: title += " (Z)" icon = block.xpath("table/tr/td[2]/img") if icon and icon[0].get("title") in ["JV", "PB"]: return if block.xpath("div"): - subtitle_unformatted = block.xpath("div")[0].text.strip() - subtitle_components = subtitle_unformatted.split(", ") - subtitle = subtitle_components[1] + ", " + subtitle_components[0] + subtitle= block.xpath("div")[0].text.strip() else: subtitle = "" return (title, subtitle) @@ -46,16 +44,15 @@ def lessons(page): rows = [] for j in range(1, len(coloumns)): cell = coloumns[j] - blocks = cell.xpath("div") + + blocks = cell.xpath(".//div[contains(@class, 'ednevnik-seznam_ur_teden-urnik')]") + if not blocks: rows.append([]) continue + coloumn_lessons = [] - parsed = parse_block(blocks[0]) - if parsed: - coloumn_lessons = [parsed] - for k in range(1, len(blocks)): - block = blocks[k].xpath("div")[0] + for block in blocks: parsed = parse_block(block) if parsed: coloumn_lessons.append(parsed) diff --git a/Timetable.py b/Timetable.py index 782c98f..6ccef1b 100755 --- a/Timetable.py +++ b/Timetable.py @@ -4,28 +4,42 @@ # import Parser -import Calendar +import CalendarGenerator import requests -import datetime - -def get(school, class_, student): - today = datetime.date.today() - monday = today + datetime.timedelta(days = -today.weekday()) - if today.weekday() == 5 or today.weekday() == 6: # Saturday or Sunday - monday += datetime.timedelta(weeks = 1) - - schoolyear = today.year - if today.month < 8: - schoolyear -= 1 - schoolyearStart = datetime.date(schoolyear, 9, 1) - while schoolyearStart.weekday() == 5 or schoolyearStart.weekday() == 6: - schoolyearStart += datetime.timedelta(days = 1) - - week = 1 - for i in range((monday - schoolyearStart).days): - if (schoolyearStart + datetime.timedelta(days = i + 1)).weekday() == 0: - week += 1 - +from datetime import date, timedelta + +def get_monday(today): + """Returns the upcoming Monday. If today is Saturday or Sunday, return next Monday.""" + monday = today - timedelta(days=today.weekday()) # Get current week's Monday + + # If it's the weekend, take next week. + if today.weekday() >= 5: + monday += timedelta(weeks=1) + + return monday + +def get_schoolyear_start(schoolyear): + """Returns the start of the school year, adjusting if it starts on a weekend.""" + schoolyear_start = date(schoolyear, 9, 1) + # If it falls on a weekend, move to the next Monday + while schoolyear_start.weekday() >= 5: + schoolyear_start += timedelta(days=1) + return schoolyear_start + +def calculate_week(today): + """Calculates the week number in the school year.""" + monday = get_monday(today) + + # Determine the school year based on the current date + schoolyear = today.year - 1 if today.month < 9 else today.year # If the month is before September, take previous year. + schoolyear_start = get_schoolyear_start(schoolyear) + + # Calculate the number of Mondays between the school year start and current Monday + delta_days = (monday - schoolyear_start).days + return delta_days // 7 + 1 + + +def get_session(): session = requests.Session() session.get("https://www.easistent.com") @@ -34,11 +48,63 @@ def get(school, class_, student): name = cookie.name if name != "vxcaccess": session.cookies.pop(name) - - URL = "https://www.easistent.com/urniki/izpis/" + school + "/" + str(class_) + "/0/0/0/" + str(week) + "/" + str(student) + + return session + +def get_lessons(session, schoolId, classId=0, professorId=0, classroomId=0, week=0, studentId=0): + # The IDs used in the API call are in the following order: + # 1. School + # 2. Class + # 3. Professor/teacher + # 4. Classroom + # 5. Not sure + # 6. Week + # 7. Student + # + # Setting any of these IDs (except the school) to 0 returns all. + URL = f"https://www.easistent.com/urniki/izpis/{schoolId}/{classId}/{professorId}/{classroomId}/0/{week}/{studentId}" response = session.get(URL) - lessons = Parser.lessons(response.content) - timetable = Calendar.make(lessons, monday) - - return timetable + return Parser.lessons(response.content) + +def get_student(school, class_, student): + today = date.today() + monday = get_monday(today) + week = calculate_week(today) + + session = get_session() + + lessons = get_lessons(session, schoolId=school, classId=class_, week=week, studentId=student) + + calendar = CalendarGenerator.CalendarGenerator() + calendar.append_week(lessons, monday) + return calendar.get_string() + +def get_class(school, class_): + today = date.today() + monday = get_monday(today) + week = calculate_week(today) + + session = get_session() + + lessons = get_lessons(session, schoolId=school, classId=class_, week=week) + + calendar = CalendarGenerator.CalendarGenerator() + calendar.append_week(lessons, monday) + return calendar.get_string() + +def get_teacher(school, teacher, weeks = 1): + today = date.today() + monday = get_monday(today) + current_week = calculate_week(today) + + session = get_session() + + calendar = CalendarGenerator.CalendarGenerator() + + for i in range(weeks): + lessons_week = current_week + i - 1 + lessons = get_lessons(session, schoolId=school, professorId=teacher, week=lessons_week) + calendar.append_week(lessons, monday + timedelta(weeks=i-1)) + + return calendar.get_string() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7f3126f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +aniso8601==9.0.1 +arrow==1.3.0 +attrs==24.2.0 +blinker==1.8.2 +certifi==2024.8.30 +charset-normalizer==3.3.2 +click==8.1.7 +Flask==3.0.3 +Flask-Cors==5.0.0 +Flask-RESTful==0.3.10 +ics==0.7.2 +idna==3.8 +itsdangerous==2.2.0 +Jinja2==3.1.4 +lxml==5.3.0 +MarkupSafe==2.1.5 +python-dateutil==2.9.0.post0 +pytz==2024.2 +requests==2.32.3 +six==1.16.0 +TatSu==5.12.1 +types-python-dateutil==2.9.0.20240906 +urllib3==2.2.3 +Werkzeug==3.0.4 diff --git a/test_dates.py b/test_dates.py new file mode 100644 index 0000000..7c4f692 --- /dev/null +++ b/test_dates.py @@ -0,0 +1,90 @@ +import unittest +from datetime import date + +from Timetable import calculate_week, get_monday, get_schoolyear_start + +class TestDateMethods(unittest.TestCase): + def compare_dates(self, day, expected_monday): + test_date = date.fromisoformat(day) + actual_monday = get_monday(test_date) + self.assertEqual(date.fromisoformat(expected_monday), actual_monday) + + def test_get_monday(self): + self.compare_dates('2024-09-16', '2024-09-16') + self.compare_dates('2024-09-17', '2024-09-16') + self.compare_dates('2024-09-18', '2024-09-16') + self.compare_dates('2024-09-19', '2024-09-16') + self.compare_dates('2024-09-20', '2024-09-16') + self.compare_dates('2024-09-21', '2024-09-23') + self.compare_dates('2024-09-22', '2024-09-23') + self.compare_dates('2024-09-23', '2024-09-23') + self.compare_dates('2024-09-24', '2024-09-23') + self.compare_dates('2024-09-25', '2024-09-23') + self.compare_dates('2024-09-26', '2024-09-23') + self.compare_dates('2024-09-27', '2024-09-23') + self.compare_dates('2024-09-28', '2024-09-30') + + def test_get_schoolyear_start(self): + self.assertEqual(get_schoolyear_start(2020), date.fromisoformat('2020-09-01')) + self.assertEqual(get_schoolyear_start(2021), date.fromisoformat('2021-09-01')) + self.assertEqual(get_schoolyear_start(2022), date.fromisoformat('2022-09-01')) + self.assertEqual(get_schoolyear_start(2023), date.fromisoformat('2023-09-01')) + self.assertEqual(get_schoolyear_start(2024), date.fromisoformat('2024-09-02')) + self.assertEqual(get_schoolyear_start(2025), date.fromisoformat('2025-09-01')) + + def test_calculate_week(self): + self.assertEqual(1, calculate_week(date.fromisoformat('2024-09-02'))) + self.assertEqual(2, calculate_week(date.fromisoformat('2024-09-09'))) + self.assertEqual(3, calculate_week(date.fromisoformat('2024-09-16'))) + self.assertEqual(4, calculate_week(date.fromisoformat('2024-09-23'))) + self.assertEqual(5, calculate_week(date.fromisoformat('2024-09-30'))) + self.assertEqual(6, calculate_week(date.fromisoformat('2024-10-07'))) + self.assertEqual(7, calculate_week(date.fromisoformat('2024-10-14'))) + self.assertEqual(8, calculate_week(date.fromisoformat('2024-10-21'))) + self.assertEqual(9, calculate_week(date.fromisoformat('2024-10-28'))) + self.assertEqual(10, calculate_week(date.fromisoformat('2024-11-04'))) + self.assertEqual(11, calculate_week(date.fromisoformat('2024-11-11'))) + self.assertEqual(12, calculate_week(date.fromisoformat('2024-11-18'))) + self.assertEqual(13, calculate_week(date.fromisoformat('2024-11-25'))) + self.assertEqual(14, calculate_week(date.fromisoformat('2024-12-02'))) + self.assertEqual(15, calculate_week(date.fromisoformat('2024-12-09'))) + self.assertEqual(16, calculate_week(date.fromisoformat('2024-12-16'))) + self.assertEqual(17, calculate_week(date.fromisoformat('2024-12-23'))) + self.assertEqual(18, calculate_week(date.fromisoformat('2024-12-30'))) + self.assertEqual(19, calculate_week(date.fromisoformat('2025-01-06'))) + self.assertEqual(20, calculate_week(date.fromisoformat('2025-01-13'))) + self.assertEqual(21, calculate_week(date.fromisoformat('2025-01-20'))) + self.assertEqual(22, calculate_week(date.fromisoformat('2025-01-27'))) + self.assertEqual(23, calculate_week(date.fromisoformat('2025-02-03'))) + self.assertEqual(24, calculate_week(date.fromisoformat('2025-02-10'))) + self.assertEqual(25, calculate_week(date.fromisoformat('2025-02-17'))) + self.assertEqual(26, calculate_week(date.fromisoformat('2025-02-24'))) + self.assertEqual(27, calculate_week(date.fromisoformat('2025-03-03'))) + self.assertEqual(28, calculate_week(date.fromisoformat('2025-03-10'))) + self.assertEqual(29, calculate_week(date.fromisoformat('2025-03-17'))) + self.assertEqual(30, calculate_week(date.fromisoformat('2025-03-24'))) + self.assertEqual(31, calculate_week(date.fromisoformat('2025-03-31'))) + self.assertEqual(32, calculate_week(date.fromisoformat('2025-04-07'))) + self.assertEqual(33, calculate_week(date.fromisoformat('2025-04-14'))) + self.assertEqual(34, calculate_week(date.fromisoformat('2025-04-21'))) + self.assertEqual(35, calculate_week(date.fromisoformat('2025-04-28'))) + self.assertEqual(36, calculate_week(date.fromisoformat('2025-05-05'))) + self.assertEqual(37, calculate_week(date.fromisoformat('2025-05-12'))) + self.assertEqual(38, calculate_week(date.fromisoformat('2025-05-19'))) + self.assertEqual(39, calculate_week(date.fromisoformat('2025-05-26'))) + self.assertEqual(40, calculate_week(date.fromisoformat('2025-06-02'))) + self.assertEqual(41, calculate_week(date.fromisoformat('2025-06-09'))) + self.assertEqual(42, calculate_week(date.fromisoformat('2025-06-16'))) + self.assertEqual(43, calculate_week(date.fromisoformat('2025-06-23'))) + self.assertEqual(44, calculate_week(date.fromisoformat('2025-06-30'))) + self.assertEqual(45, calculate_week(date.fromisoformat('2025-07-07'))) + self.assertEqual(46, calculate_week(date.fromisoformat('2025-07-14'))) + self.assertEqual(47, calculate_week(date.fromisoformat('2025-07-21'))) + self.assertEqual(48, calculate_week(date.fromisoformat('2025-07-28'))) + self.assertEqual(49, calculate_week(date.fromisoformat('2025-08-04'))) + self.assertEqual(50, calculate_week(date.fromisoformat('2025-08-11'))) + self.assertEqual(51, calculate_week(date.fromisoformat('2025-08-18'))) + self.assertEqual(52, calculate_week(date.fromisoformat('2025-08-25'))) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..dd5c009 --- /dev/null +++ b/vercel.json @@ -0,0 +1,10 @@ +{ + "version": 2, + "builds": [ + { "src": "API.py", "use": "@vercel/python" } + ], + "routes": [ + { "src": "/(.*)", "dest": "/API.py" } + ], + "regions": ["fra1"] + }