Compare commits
16 Commits
ca82885fb7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e4bd1bbc40 | |||
| 1fa8b21882 | |||
| 61f6070729 | |||
|
|
5f841ddbf9 | ||
|
|
11a91eb213 | ||
|
|
8d80985d79 | ||
|
|
069a2d2c63 | ||
|
|
243bdbb685 | ||
|
|
994aa1dab7 | ||
|
|
74e7f73c38 | ||
|
|
76cab3c296 | ||
|
|
79f9e99e74 | ||
|
|
48b5ff9bbc | ||
|
|
175390fda7 | ||
|
|
47bb6585d1 | ||
|
|
c271656f51 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.git/
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
shifts.db
|
shifts.db
|
||||||
|
|
||||||
|
__pycache__
|
||||||
161
API.py
161
API.py
@@ -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)
|
||||||
27
DB.py
27
DB.py
@@ -3,13 +3,17 @@ import sqlite3
|
|||||||
|
|
||||||
class Database:
|
class Database:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._con = sqlite3.connect("shifts.db")
|
self.db_path = "data/shifts.db"
|
||||||
self._setup_tables()
|
self._setup_tables()
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _get_connection(self):
|
||||||
|
return sqlite3.connect(self.db_path)
|
||||||
|
|
||||||
def _setup_tables(self):
|
def _setup_tables(self):
|
||||||
cur = self._con.cursor()
|
# create the shifts table.
|
||||||
|
con = self._get_connection()
|
||||||
|
cur = con.cursor()
|
||||||
cur.execute('''
|
cur.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS shifts (
|
CREATE TABLE IF NOT EXISTS shifts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -23,14 +27,18 @@ class Database:
|
|||||||
UNIQUE(shift_start, department) -- No duplicates
|
UNIQUE(shift_start, department) -- No duplicates
|
||||||
);
|
);
|
||||||
''')
|
''')
|
||||||
self._con.commit()
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
def insert_shifts(self, shifts):
|
def insert_shifts(self, shifts):
|
||||||
cur = self._con.cursor()
|
# start db connection
|
||||||
|
con = self._get_connection()
|
||||||
|
cur = con.cursor()
|
||||||
inserted = 0
|
inserted = 0
|
||||||
|
|
||||||
for shift in shifts:
|
for shift in shifts:
|
||||||
|
# parse all information from the shift
|
||||||
start = shift.get('start_datetime', 'N/A')
|
start = shift.get('start_datetime', 'N/A')
|
||||||
end = shift.get('end_datetime', 'N/A')
|
end = shift.get('end_datetime', 'N/A')
|
||||||
|
|
||||||
@@ -38,6 +46,7 @@ class Database:
|
|||||||
duration = shift.get('duration', 'N/A')
|
duration = shift.get('duration', 'N/A')
|
||||||
description = shift.get('shift_remark', shift.get('description', 'N/A'))
|
description = shift.get('shift_remark', shift.get('description', 'N/A'))
|
||||||
|
|
||||||
|
# insert it into the db.
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT OR IGNORE INTO shifts
|
INSERT OR IGNORE INTO shifts
|
||||||
(shift_start, shift_end, department, duration, description)
|
(shift_start, shift_end, department, duration, description)
|
||||||
@@ -47,6 +56,14 @@ class Database:
|
|||||||
if cur.rowcount > 0:
|
if cur.rowcount > 0:
|
||||||
inserted += 1
|
inserted += 1
|
||||||
|
|
||||||
self._con.commit()
|
con.commit()
|
||||||
|
con.close()
|
||||||
print(f"✅ Inserted {inserted}/{len(shifts)} new shifts")
|
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
19
Dockerfile
Normal 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"]
|
||||||
21
PMT.py
21
PMT.py
@@ -15,7 +15,6 @@ class PMT:
|
|||||||
self.base_url = os.environ.get("URL_PMT")
|
self.base_url = os.environ.get("URL_PMT")
|
||||||
|
|
||||||
def get_week_range(self, week_offset):
|
def get_week_range(self, week_offset):
|
||||||
"""Get Monday date for week_offset weeks from now"""
|
|
||||||
today = datetime.now()
|
today = datetime.now()
|
||||||
days_to_monday = 0 if today.weekday() == 0 else 7 - today.weekday()
|
days_to_monday = 0 if today.weekday() == 0 else 7 - today.weekday()
|
||||||
monday = today + timedelta(days=days_to_monday)
|
monday = today + timedelta(days=days_to_monday)
|
||||||
@@ -23,12 +22,13 @@ class PMT:
|
|||||||
return target_monday.strftime("%Y-%m-%d")
|
return target_monday.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
def login(self):
|
def login(self):
|
||||||
|
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
|
|
||||||
login_url = f"{self.base_url}/login"
|
login_url = f"{self.base_url}/login"
|
||||||
resp = self.session.get(login_url)
|
resp = self.session.get(login_url)
|
||||||
|
|
||||||
# Step 2: SSO login POST
|
# SSO login POST
|
||||||
sso_data = {
|
sso_data = {
|
||||||
"username": self._username,
|
"username": self._username,
|
||||||
"password": self._password,
|
"password": self._password,
|
||||||
@@ -44,23 +44,29 @@ class PMT:
|
|||||||
json=sso_data,
|
json=sso_data,
|
||||||
headers={'Content-Type': 'application/json'})
|
headers={'Content-Type': 'application/json'})
|
||||||
|
|
||||||
|
|
||||||
|
# Check if it succeeded.
|
||||||
if sso_resp.status_code != 200 or not sso_resp.json().get('result', {}).get('authenticated'):
|
if sso_resp.status_code != 200 or not sso_resp.json().get('result', {}).get('authenticated'):
|
||||||
print("Login failed!")
|
print("Login failed!")
|
||||||
print(sso_resp.text)
|
print(sso_resp.text)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Store the useful information in some class fields
|
||||||
login_data = sso_resp.json()['result']
|
login_data = sso_resp.json()['result']
|
||||||
self.context_token = login_data['context_token']
|
self.context_token = login_data['context_token']
|
||||||
self.user_token = login_data['user_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
|
return self.session, self.context_token, self.user_token
|
||||||
|
|
||||||
def get_shifts(self, days_ahead=14):
|
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:
|
if not self.session or not self.context_token or not self.user_token:
|
||||||
raise ValueError("Must call login() first!")
|
raise ValueError("Must call login() first!")
|
||||||
|
|
||||||
|
|
||||||
|
# define date ranges
|
||||||
shifts = []
|
shifts = []
|
||||||
|
|
||||||
from_date = datetime.now().strftime("%Y-%m-%d")
|
from_date = datetime.now().strftime("%Y-%m-%d")
|
||||||
@@ -68,6 +74,7 @@ class PMT:
|
|||||||
|
|
||||||
print(f"Fetching shifts from {from_date} to {to_date}...")
|
print(f"Fetching shifts from {from_date} to {to_date}...")
|
||||||
|
|
||||||
|
# send request for shifts
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0',
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0',
|
||||||
'Accept': 'application/json, text/plain, */*',
|
'Accept': 'application/json, text/plain, */*',
|
||||||
@@ -91,6 +98,8 @@ class PMT:
|
|||||||
|
|
||||||
resp = self.session.get(shifts_url, headers=headers, params=params)
|
resp = self.session.get(shifts_url, headers=headers, params=params)
|
||||||
|
|
||||||
|
|
||||||
|
# check if request succeeded
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
week_shifts = data.get('result', {}).get('shift_instances', [])
|
week_shifts = data.get('result', {}).get('shift_instances', [])
|
||||||
@@ -100,16 +109,16 @@ class PMT:
|
|||||||
print(f"❌ Failed: {resp.status_code}")
|
print(f"❌ Failed: {resp.status_code}")
|
||||||
print(resp.text[:500])
|
print(resp.text[:500])
|
||||||
|
|
||||||
|
# return all shifts fetched
|
||||||
return shifts
|
return shifts
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def print_shifts(cls, shifts):
|
def print_shifts(cls, shifts):
|
||||||
"""Pretty print shifts (class method since it doesn't need instance)"""
|
|
||||||
if not shifts:
|
if not shifts:
|
||||||
print("No upcoming shifts found.")
|
print("No upcoming shifts found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
print("\n📅 UPCOMING SHIFTS:\n")
|
print("\nUPCOMING SHIFTS:\n")
|
||||||
print("Date | Time | Dept | Duration | Description")
|
print("Date | Time | Dept | Duration | Description")
|
||||||
print("-" * 80)
|
print("-" * 80)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal 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
|
||||||
49
main.py
49
main.py
@@ -1,24 +1,55 @@
|
|||||||
from PMT import PMT
|
from PMT import PMT
|
||||||
from DB import Database
|
from DB import Database
|
||||||
|
from API import Webserver
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from time import sleep
|
||||||
import os
|
import os
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
def main():
|
def update_shifts(pmt, db):
|
||||||
load_dotenv(override=True)
|
try:
|
||||||
|
print("🔄 Updating shifts...")
|
||||||
db = Database()
|
|
||||||
pmt = PMT()
|
|
||||||
|
|
||||||
|
|
||||||
pmt.login()
|
pmt.login()
|
||||||
|
|
||||||
days_ahead = int(os.environ.get("DAYS_TO_FETCH"))
|
days_ahead = int(os.environ.get("DAYS_TO_FETCH", 14))
|
||||||
shifts = pmt.get_shifts(days_ahead)
|
shifts = pmt.get_shifts(days_ahead)
|
||||||
PMT.print_shifts(shifts)
|
|
||||||
|
|
||||||
|
db.delete_future_shifts()
|
||||||
db.insert_shifts(shifts)
|
db.insert_shifts(shifts)
|
||||||
|
|
||||||
print(f"\nFound {len(shifts)} shifts total.")
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
webserver.run()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Flask
|
||||||
|
icalendar
|
||||||
|
pytz
|
||||||
|
python-dotenv
|
||||||
|
APScheduler
|
||||||
|
requests
|
||||||
Reference in New Issue
Block a user