Compare commits

...

16 Commits

Author SHA1 Message Date
e4bd1bbc40 change the scheduler interval stuff 2026-03-20 16:08:32 +01:00
1fa8b21882 make flask run in seperate threads 2026-03-20 16:04:18 +01:00
61f6070729 Update main.py 2026-03-20 15:58:17 +01:00
Valentijn
5f841ddbf9 forgot to add env file to compose 2026-03-20 14:40:05 +01:00
Valentijn
11a91eb213 oops forgot that 2026-03-20 14:38:40 +01:00
Valentijn
8d80985d79 indentation issues 2026-03-20 14:36:02 +01:00
Valentijn
069a2d2c63 added compose 2026-03-20 14:33:48 +01:00
Valentijn
243bdbb685 changed db path for deployment later 2026-03-19 16:30:07 +01:00
Valentijn
994aa1dab7 added docker ignore template for python 2026-03-19 16:27:59 +01:00
Valentijn
74e7f73c38 added docker support 2026-03-19 16:27:12 +01:00
Valentijn
76cab3c296 added cron 2026-03-19 16:25:22 +01:00
Valentijn
79f9e99e74 added ics support. and some thread safety 2026-03-19 16:20:48 +01:00
Valentijn
48b5ff9bbc added py cache to gitignore 2026-03-19 16:03:59 +01:00
Valentijn
175390fda7 make it a bit more logical structure 2026-03-19 16:02:53 +01:00
Valentijn
47bb6585d1 added comments 2026-03-18 19:57:30 +01:00
Valentijn
c271656f51 removed some copilot added comments 2026-03-18 19:52:27 +01:00
11 changed files with 292 additions and 23 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.env
venv/
.git/

4
.gitignore vendored
View File

@@ -1,2 +1,4 @@
.env
shifts.db
shifts.db
__pycache__

161
API.py
View File

@@ -0,0 +1,161 @@
from flask import Flask, Response
from icalendar import Calendar, Event
from datetime import datetime
import pytz
import uuid
class Webserver:
def __init__(self, ip, port, db_instance):
self.ip = ip
self.port = port
self.db = db_instance
self.app = Flask(__name__)
self.tz = pytz.timezone("Europe/Amsterdam")
self._register_routes()
def _register_routes(self):
self.app.add_url_rule("/", "index", lambda: "Calendar server running ✅")
self.app.add_url_rule("/calendar.ics", "calendar", self._calendar_endpoint)
def _fetch_shifts(self):
con = self.db._get_connection()
cur = con.cursor()
cur.execute("""
SELECT shift_start, shift_end, department, description
FROM shifts
ORDER BY shift_start ASC
""")
rows = cur.fetchall()
con.close()
shifts = []
for row in rows:
start_str, end_str, department, description = row
# Convert string → datetime
start = self._parse_datetime(start_str)
end = self._parse_datetime(end_str)
shifts.append({
"start": start,
"end": end,
"description": f"{description} ",
"department": f"{department} "
})
return shifts
def _parse_datetime(self, dt_str):
dt = datetime.fromisoformat(dt_str)
if dt.tzinfo is None:
dt = self.tz.localize(dt)
return dt
def _calendar_endpoint(self):
cal = Calendar()
cal.add('prodid', '-//Shift Calendar//example//')
cal.add('version', '2.0')
shifts = self._fetch_shifts()
for shift in shifts:
event = Event()
event.add('uid', str(uuid.uuid4()))
event.add('dtstart', shift["start"])
event.add('dtend', shift["end"])
event.add('summary', shift["department"])
event.add('description', shift["description"])
event.add('dtstamp', datetime.now(tz=self.tz))
cal.add_component(event)
return Response(cal.to_ical(), mimetype="text/calendar")
def run(self):
print(f"🚀 Starting server on {self.ip}:{self.port}")
from flask import Flask, Response
from icalendar import Calendar, Event
from datetime import datetime
import pytz
import uuid
class Webserver:
def __init__(self, ip, port, db_instance):
self.ip = ip
self.port = port
self.db = db_instance
self.app = Flask(__name__)
self.tz = pytz.timezone("Europe/Amsterdam")
self._register_routes()
def _register_routes(self):
self.app.add_url_rule("/", "index", lambda: "Calendar server running ✅")
self.app.add_url_rule("/calendar.ics", "calendar", self._calendar_endpoint)
def _fetch_shifts(self):
con = self.db._get_connection()
cur = con.cursor()
cur.execute("""
SELECT shift_start, shift_end, department, description
FROM shifts
ORDER BY shift_start ASC
""")
rows = cur.fetchall()
con.close()
shifts = []
for row in rows:
start_str, end_str, department, description = row
# Convert string → datetime
start = self._parse_datetime(start_str)
end = self._parse_datetime(end_str)
shifts.append({
"start": start,
"end": end,
"description": f"{description} ",
"department": f"{department} "
})
return shifts
def _parse_datetime(self, dt_str):
dt = datetime.fromisoformat(dt_str)
if dt.tzinfo is None:
dt = self.tz.localize(dt)
return dt
def _calendar_endpoint(self):
cal = Calendar()
cal.add('prodid', '-//Shift Calendar//example//')
cal.add('version', '2.0')
shifts = self._fetch_shifts()
for shift in shifts:
event = Event()
event.add('uid', str(uuid.uuid4()))
event.add('dtstart', shift["start"])
event.add('dtend', shift["end"])
event.add('summary', shift["department"])
event.add('description', shift["description"])
event.add('dtstamp', datetime.now(tz=self.tz))
cal.add_component(event)
return Response(cal.to_ical(), mimetype="text/calendar")
def run(self):
print(f"🚀 Starting server on {self.ip}:{self.port}")
self.app.run(host=self.ip, port=self.port, threaded=True)

29
DB.py
View File

@@ -3,13 +3,17 @@ import sqlite3
class Database:
def __init__(self):
self._con = sqlite3.connect("shifts.db")
self.db_path = "data/shifts.db"
self._setup_tables()
pass
def _get_connection(self):
return sqlite3.connect(self.db_path)
def _setup_tables(self):
cur = self._con.cursor()
# create the shifts table.
con = self._get_connection()
cur = con.cursor()
cur.execute('''
CREATE TABLE IF NOT EXISTS shifts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -23,14 +27,18 @@ class Database:
UNIQUE(shift_start, department) -- No duplicates
);
''')
self._con.commit()
con.commit()
con.close()
def insert_shifts(self, shifts):
cur = self._con.cursor()
# start db connection
con = self._get_connection()
cur = con.cursor()
inserted = 0
for shift in shifts:
# parse all information from the shift
start = shift.get('start_datetime', 'N/A')
end = shift.get('end_datetime', 'N/A')
@@ -38,6 +46,7 @@ class Database:
duration = shift.get('duration', 'N/A')
description = shift.get('shift_remark', shift.get('description', 'N/A'))
# insert it into the db.
cur.execute("""
INSERT OR IGNORE INTO shifts
(shift_start, shift_end, department, duration, description)
@@ -47,6 +56,14 @@ class Database:
if cur.rowcount > 0:
inserted += 1
self._con.commit()
con.commit()
con.close()
print(f"✅ Inserted {inserted}/{len(shifts)} new shifts")
def delete_future_shifts(self):
con = self._get_connection()
cur = con.cursor()
cur.execute("DELETE FROM shifts WHERE shift_start > current_timestamp")
con.commit()
con.close()
print(f"✅ Deleted all future shifts")

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.13-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python", "main.py"]

25
PMT.py
View File

@@ -15,7 +15,6 @@ class PMT:
self.base_url = os.environ.get("URL_PMT")
def get_week_range(self, week_offset):
"""Get Monday date for week_offset weeks from now"""
today = datetime.now()
days_to_monday = 0 if today.weekday() == 0 else 7 - today.weekday()
monday = today + timedelta(days=days_to_monday)
@@ -23,12 +22,13 @@ class PMT:
return target_monday.strftime("%Y-%m-%d")
def login(self):
self.session = requests.Session()
login_url = f"{self.base_url}/login"
resp = self.session.get(login_url)
# Step 2: SSO login POST
# SSO login POST
sso_data = {
"username": self._username,
"password": self._password,
@@ -44,23 +44,29 @@ class PMT:
json=sso_data,
headers={'Content-Type': 'application/json'})
# Check if it succeeded.
if sso_resp.status_code != 200 or not sso_resp.json().get('result', {}).get('authenticated'):
print("Login failed!")
print(sso_resp.text)
sys.exit(1)
# Store the useful information in some class fields
login_data = sso_resp.json()['result']
self.context_token = login_data['context_token']
self.user_token = login_data['user_token']
print(f"✅ Logged in as {self._username} (store AH 8541 Rhoon)")
# return session info
print(f"✅ Logged in as {self._username}")
return self.session, self.context_token, self.user_token
def get_shifts(self, days_ahead=14):
"""Fetch shifts from now to days_ahead using the REAL endpoint"""
# raise if not logged in.
if not self.session or not self.context_token or not self.user_token:
raise ValueError("Must call login() first!")
# define date ranges
shifts = []
from_date = datetime.now().strftime("%Y-%m-%d")
@@ -68,6 +74,7 @@ class PMT:
print(f"Fetching shifts from {from_date} to {to_date}...")
# send request for shifts
headers = {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0',
'Accept': 'application/json, text/plain, */*',
@@ -90,7 +97,9 @@ class PMT:
}
resp = self.session.get(shifts_url, headers=headers, params=params)
# check if request succeeded
if resp.status_code == 200:
data = resp.json()
week_shifts = data.get('result', {}).get('shift_instances', [])
@@ -100,16 +109,16 @@ class PMT:
print(f"❌ Failed: {resp.status_code}")
print(resp.text[:500])
# return all shifts fetched
return shifts
@classmethod
def print_shifts(cls, shifts):
"""Pretty print shifts (class method since it doesn't need instance)"""
if not shifts:
print("No upcoming shifts found.")
return
print("\n📅 UPCOMING SHIFTS:\n")
print("\nUPCOMING SHIFTS:\n")
print("Date | Time | Dept | Duration | Description")
print("-" * 80)

Binary file not shown.

Binary file not shown.

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
app:
build: .
container_name: pmt
ports:
- "8080:8080"
restart: unless-stopped
networks:
- proxy-network
volumes:
- "./data:/app/data"
env_file:
- .env
networks:
proxy-network:
external: true

47
main.py
View File

@@ -1,24 +1,55 @@
from PMT import PMT
from DB import Database
from API import Webserver
from dotenv import load_dotenv
from time import sleep
import os
from apscheduler.schedulers.background import BackgroundScheduler
def update_shifts(pmt, db):
try:
print("🔄 Updating shifts...")
pmt.login()
days_ahead = int(os.environ.get("DAYS_TO_FETCH", 14))
shifts = pmt.get_shifts(days_ahead)
db.delete_future_shifts()
db.insert_shifts(shifts)
print("✅ Shift update complete")
except Exception as e:
print(f"❌ Error updating shifts: {e}")
def main():
# Load the environment
load_dotenv(override=True)
# Set up database, webserver and PMT connection
db = Database()
pmt = PMT()
webserver = Webserver("0.0.0.0", 8080, db)
# update the shifts on boot
update_shifts(pmt, db)
pmt.login()
# Every hour at minute 0
scheduler = BackgroundScheduler(timezone="Europe/Amsterdam")
scheduler.add_job(
lambda: update_shifts(pmt, db),
trigger="interval",
hours=1,
next_run_time=datetime.now()
)
scheduler.start()
days_ahead = int(os.environ.get("DAYS_TO_FETCH"))
shifts = pmt.get_shifts(days_ahead)
PMT.print_shifts(shifts)
webserver.run()
db.insert_shifts(shifts)
print(f"\nFound {len(shifts)} shifts total.")
if __name__ == "__main__":
main()

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
Flask
icalendar
pytz
python-dotenv
APScheduler
requests