add live calendar subscription

This commit is contained in:
Ugric committed 2026-09-17 16:21:23 +01:00
1 parent 95cf90faec
commit ccb8906e54
7 files changed
+129 -18

No files matched your search

+3 -3
View File
@@ -6,7 +6,7 @@ Migrate the coventry university student timetable to an ics file
1. run `pip install -r requirements.txt`
2. if `calender.ics` exists already, rename it to `old_calender.ics`. This will allow the new calender file to update events from the old calender, and remove deleted events.
2. if `calendar.ics` exists already, rename it to `old_calendar.ics`. This will allow the new calendar file to update events from the old calendar, and remove deleted events.
3. log into the coventry university timetable website, go into inspect element, go to the network tab and copy the response from the first request (refresh if no requests are showing up)
@@ -16,6 +16,6 @@ Migrate the coventry university student timetable to an ics file
![paste response.txt](images/step2.png)
5. run `main.py`, and you'll hopefully see `calender.ics` get created in the root folder of the repo :)
5. run `main.py`, and you'll hopefully see `calendar.ics` get created in the root folder of the repo :)
6. Upload that ics file to any calender application, I imagine basically all of them allow you to upload an ics file (google calender and thunderbird do).
6. Upload that ics file to any calendar application, I imagine basically all of them allow you to upload an ics file (google calendar and thunderbird do).
+34 -3
View File
@@ -8,33 +8,49 @@ import requests
from requests_ntlm import HttpNtlmAuth
import urllib
import html
import json
import re
from datetime import datetime
import ast
LOCAL_TZ = pytz.timezone("Europe/London")
URL = "https://webapp.coventry.ac.uk/Timetable-main/Timetable/Current"
def parse_events(events_data):
events_data = events_data.replace("new Date", "")
cleaned_data = ""
for line in events_data.split("\n"):
comment_pos = line.find("//")
if comment_pos != -1:
line = line[:comment_pos]
if ":" in line:
key, val = line.split(":", 1)
line = f"'{key}': {val}"
val = val.strip()
if val.startswith("new Date"):
val = re.sub(r'new Date\(([^)]*)\)', r'[\1]', val)
line = f"'{key.strip()}': {val}"
cleaned_data += line + "\n"
parsed_data = eval(cleaned_data)
# Convert Python-style literals instead of JSON
parsed_data = ast.literal_eval(cleaned_data)
for event in parsed_data:
if "start" in event:
s = list(event["start"])
s[1] += 1
s.append(0)
event["start"] = LOCAL_TZ.localize(datetime(*s))
if "end" in event:
e = list(event["end"])
e[1] += 1
e.append(0)
event["end"] = LOCAL_TZ.localize(datetime(*e))
return parsed_data
def get_events_data(page_data):
@@ -101,6 +117,21 @@ def fetch_timetable(username, password):
session.close()
return r.text
def fetch_auth_status(username, password):
session = requests.Session()
session.auth = HttpNtlmAuth(username, password)
session.verify = False
try:
r = session.get(URL, stream=True)
r.raise_for_status()
return False
finally:
r.close()
session.close()
return 300 > r.status_code >=200
def build_calendar(page_data):
events_js = get_events_data(page_data)
parsed_events = parse_events(events_js)
+81 -8
View File
@@ -9,23 +9,81 @@ from requests_ntlm import HttpNtlmAuth
import urllib
import html
from cov2ics import *
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from dotenv import load_dotenv
import codecs
import urllib.parse
load_dotenv()
def get_auth():
username = None
auth_encrypted_encoded = request.cookies.get("auth")
nonce_encoded = request.cookies.get("nonce")
if auth_encrypted_encoded and nonce_encoded:
auth_encrypted = base64.b64decode(auth_encrypted_encoded)
nonce = base64.b64decode(nonce_encoded)
auth = json.loads(aes.decrypt(nonce, auth_encrypted, None).decode("utf-8"))
return fetch_auth_status(f"COVENTRY\\{auth['u']}", auth['p']), auth['u'], auth["p"]
return False, None
def generate_auth(username, password):
nonce = os.urandom(12)
auth_encrypted = aes.encrypt(nonce, json.dumps({"u": username, "p": password}).encode(), None)
return auth_encrypted, nonce
print(URL.format(urllib.parse.quote_plus("hello/world")))
app = Flask(__name__)
# Generate a 256-bit (32-byte) key
# key = AESGCM.generate_key(bit_length=256)
key = codecs.decode(os.getenv("AES_KEY"), 'hex')
# Create the AES-GCM cipher
aes = AESGCM(key)
@app.route("/")
def index():
return redirect(url_for("home"))
@app.route("/home")
def home():
status, username, password = get_auth()
if status:
return redirect("/dashboard")
return render_template("home.jinja")
@app.route("/get-timetable")
def retrieve():
@app.route("/login", methods=["GET"])
def login():
status, username, password = get_auth()
if status:
return redirect("/dashboard")
return render_template("login.jinja")
@app.route("/login", methods=["POST"])
def login_post():
username = request.form.get("username")
password = request.form.get("password")
auth, nonce = generate_auth(username, password)
resp = redirect("/dashboard")
resp.set_cookie("auth", base64.b64encode(auth).decode("utf-8"), max_age=10 * 365 * 24 * 60 * 60)
resp.set_cookie("nonce", base64.b64encode(nonce).decode("utf-8"), max_age=10 * 365 * 24 * 60 * 60)
return resp
@app.route("/dashboard")
def dashboard():
status, username, password = get_auth()
if not status:
return redirect("/login")
auth, nonce = generate_auth(username, password)
auth_encoded = base64.b64encode(auth).decode("utf-8")
nonce_encoded = base64.b64encode(nonce).decode("utf-8")
url = "/timtabla.ics?"+urllib.parse.urlencode({"a": auth_encoded, "n": nonce_encoded})
return render_template("dashboard.jinja", username=username, url=url)
@app.route("/download-ics", methods=["POST"])
def download_ics():
data = request.form
@@ -36,10 +94,25 @@ def download_ics():
return Response(
data,
mimetype="text/calendar",
headers={"Content-Disposition": f'attachment; filename="{username}_timetable_{start_month}-{start_year}_to_{end_month}-{end_year}.ics"'},
headers={"Content-Disposition": f'attachment; filename="{username}_timtabla_{start_month}-{start_year}_to_{end_month}-{end_year}.ics"'},
)
except Exception as e:
return render_template("login.jinja", error=str(e)), 401
return "Calendar Not Found", 404
@app.route("/timtabla.ics", methods=["GET"])
def live_ics():
auth_encrypted = base64.b64decode(request.args["a"])
nonce = base64.b64decode(request.args["n"])
auth = json.loads(aes.decrypt(nonce, auth_encrypted, None).decode("utf-8"))
try:
data, start_year, start_month, end_year, end_month = build_calendar(fetch_timetable(f"COVENTRY\\{auth["u"]}", auth["p"]))
return Response(
data,
mimetype="text/calendar",
headers={"Content-Disposition": f'attachment; filename="{auth["u"]}_timtabla.ics"'},
)
except Exception as e:
return "Calendar Not Found", 404
if __name__ == "__main__":
+1 -1
View File
@@ -17,7 +17,7 @@
<body>
<div class="page-content">
<a href="{{ url_for('home') }}" class="title">
<a href="{{ url_for('index') }}" class="title">
<p >Timtabla</p>
</a>
{% block body %}{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
{%set page_title = "Dashboard"%}
{% extends "base.jinja" %}
{% block body %}
<div>
<p>{{url}}</p>
</div>
{% endblock %}
+1 -1
View File
@@ -8,6 +8,6 @@
<p> This website is based on open-source code developed by Coventry University
students, you can find this code at <a href="https://git.wbell.dev/Ugric/cov-to-ics">git.wbell.dev</a>
</p>
<a href="{{ url_for('retrieve') }}" class="button"> Convert timetable </a>
<a href="{{ url_for('login') }}" class="button"> Convert timetable </a>
</div>
{% endblock %}
+2 -2
View File
@@ -1,14 +1,14 @@
{%set page_title = "Login"%}
{% extends "base.jinja" %}
{% block body %}
<form class="login-box" method="POST" action="{{url_for('download_ics')}}">
<form class="login-box" method="POST" action="{{url_for('login_post')}}">
<h2>Coventry University Login</h2>
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login & Download</button>
<button type="submit">Login</button>
{% if error|trim %}<div class="error">{{error}}</div>{% endif %}
</form>