diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b0ccb3b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,53 @@ +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + code-quality: + name: Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install + + - name: Install dependencies + run: uv sync + + - name: Run linter + run: uv run ruff check . + + - name: Run formatter check + run: uv run ruff format --check . + + test: + name: Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install + + - name: Install dependencies + run: uv sync + + - name: Run tests + run: uv run pytest tests/ -v diff --git a/Makefile b/Makefile index 793f8f1..c4fa07c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,15 @@ -format: ## Run black and isort - black . - isort . +install: ## Install dependencies using uv + uv sync + +test: ## Run tests + uv run pytest tests/ -v + +lint: ## Run ruff linter + uv run ruff check . + +format: ## Run ruff formatter and linter + uv run ruff check --fix . + uv run ruff format . update_reqs: ## Update requirements.txt pipreqs --force . \ No newline at end of file diff --git a/app.py b/app.py index f596584..76faa54 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,11 @@ -import json import os +import re import pandas as pd +from datetime import datetime from flask import Flask, render_template, request, send_file, session -from core import get_flight, make_ics_from_selected_df_index +from core import get_flight, make_ics_from_selected_df_index, make_ics_from_manual_data app = Flask(__name__) @@ -42,11 +43,83 @@ def create_ical_from_selected(index): return "No flight data found", 400 df = pd.read_json(df_json, orient="split") + + # Check if custom times were provided + custom_departure = request.form.get("custom_departure") + custom_arrival = request.form.get("custom_arrival") + + if custom_departure and custom_arrival: + # Update the DataFrame with custom times + df.at[index, "scheduled_departure"] = custom_departure + df.at[index, "scheduled_arrival"] = custom_arrival + ics_data = make_ics_from_selected_df_index(df, index) flight = df.iloc[index]["flight_number"] return send_file(ics_data, as_attachment=True, download_name=f"{flight}.ics") +@app.route("/manual_entry") +def manual_entry(): + return render_template("manual_entry.html") + + +@app.route("/create_manual_event", methods=["POST"]) +def create_manual_event(): + try: + # Get all form data + flight_data = { + "flight_number": request.form.get("flight_number"), + "airline_name": request.form.get("airline_name"), + "origin_airport": request.form.get("origin_airport"), + "origin_airport_code": request.form.get("origin_airport_code"), + "destination_airport": request.form.get("destination_airport"), + "destination_airport_code": request.form.get("destination_airport_code"), + "scheduled_departure": request.form.get("scheduled_departure"), + "scheduled_arrival": request.form.get("scheduled_arrival"), + "origin_timezone": request.form.get("origin_timezone"), + "destination_timezone": request.form.get("destination_timezone"), + } + + # Validate required fields + required_fields = [ + "flight_number", + "airline_name", + "origin_airport", + "origin_airport_code", + "destination_airport", + "destination_airport_code", + "scheduled_departure", + "scheduled_arrival", + "origin_timezone", + "destination_timezone", + ] + for field in required_fields: + if not flight_data.get(field): + raise ValueError(f"Missing required field: {field}") + + # Validate airport codes (should be 3 uppercase letters) + if not re.match(r"^[A-Z]{3}$", flight_data["origin_airport_code"]): + raise ValueError("Origin airport code must be 3 uppercase letters") + if not re.match(r"^[A-Z]{3}$", flight_data["destination_airport_code"]): + raise ValueError("Destination airport code must be 3 uppercase letters") + + # Validate datetime format (YYYY-MM-DD HH:MM) + try: + datetime.strptime(flight_data["scheduled_departure"], "%Y-%m-%d %H:%M") + datetime.strptime(flight_data["scheduled_arrival"], "%Y-%m-%d %H:%M") + except ValueError: + raise ValueError("Invalid datetime format. Use: yyyy-mm-dd hh:mm") + + # Create iCal file from manual data + ics_data = make_ics_from_manual_data(flight_data) + flight = flight_data["flight_number"] + + return send_file(ics_data, as_attachment=True, download_name=f"{flight}.ics") + except Exception as e: + error_message = str(e) + return render_template("manual_entry.html", error=error_message) + + if __name__ == "__main__": app.run() diff --git a/core.py b/core.py index cf9f7b0..c42543f 100644 --- a/core.py +++ b/core.py @@ -1,6 +1,6 @@ import io import re -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone as dt_timezone import icalendar import pandas as pd @@ -207,8 +207,8 @@ def make_ical_event(data: dict): event = icalendar.Event() event.add( "summary", - f'🛫 {data["airline_name"]} {data["origin_airport_code"]} ➡️ ' - f'{data["destination_airport_code"]} {data["flight_number"]}', + f"🛫 {data['airline_name']} {data['origin_airport_code']} ➡️ " + f"{data['destination_airport_code']} {data['flight_number']}", ) origin_tz = timezone(data["origin_timezone"]) destination_tz = timezone(data["destination_timezone"]) @@ -222,12 +222,12 @@ def make_ical_event(data: dict): event.add("dtstart", dtstart) event.add("dtend", dtend) - event.add("location", f'{data["origin_airport"]}') + event.add("location", f"{data['origin_airport']}") event.add( "description", - f'{data["airline_name"]} flight {data["flight_number"]} / Departs {data["origin_airport"]}, {data["origin_airport_code"]}', + f"{data['airline_name']} flight {data['flight_number']} / Departs {data['origin_airport']}, {data['origin_airport_code']}", ) - event.add("dtstamp", datetime.now()) + event.add("dtstamp", datetime.now(dt_timezone.utc)) event.add("status", "CONFIRMED") @@ -238,3 +238,44 @@ def make_ical_event(data: dict): def save_ical_event(ical_event: bytes): ical_bytes = io.BytesIO(ical_event) return ical_bytes + + +def make_ics_from_manual_data(data: dict): + """Create iCal event from manually entered data.""" + cal = icalendar.Calendar() + cal.add("prodid", "-//eluceo/ical//2.0/EN") + cal.add("version", "2.0") + cal.add("calscale", "GREGORIAN") + cal.add("method", "REQUEST") + + event = icalendar.Event() + event.add( + "summary", + f"🛫 {data['airline_name']} {data['origin_airport_code']} ➡️ " + f"{data['destination_airport_code']} {data['flight_number']}", + ) + + origin_tz = timezone(data["origin_timezone"]) + destination_tz = timezone(data["destination_timezone"]) + + # Parse datetime format "YYYY-MM-DD HH:MM" to datetime + dtstart = origin_tz.localize( + datetime.strptime(data["scheduled_departure"], "%Y-%m-%d %H:%M") + ) + dtend = destination_tz.localize( + datetime.strptime(data["scheduled_arrival"], "%Y-%m-%d %H:%M") + ) + + event.add("dtstart", dtstart) + event.add("dtend", dtend) + event.add("location", f"{data['origin_airport']}") + event.add( + "description", + f"{data['airline_name']} flight {data['flight_number']} / Departs {data['origin_airport']}, {data['origin_airport_code']}", + ) + event.add("dtstamp", datetime.now(dt_timezone.utc)) + event.add("status", "CONFIRMED") + + cal.add_component(event) + ical_event = cal.to_ical() + return save_ical_event(ical_event) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a2dab91 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "flightcal" +version = "0.1.0" +description = "Flight calendar event generator" +requires-python = ">=3.9" +dependencies = [ + "Flask==3.0.3", + "icalendar==6.0.1", + "pandas>=2.2.0", + "pyflightdata==0.8.6.2", + "pytz==2024.1", + "gunicorn==23.0.0", + "numpy>=1.26.0", +] + +[dependency-groups] +dev = [ + "ruff>=0.8.0", + "pytest>=8.0.0", + "pytest-mock>=3.12.0", +] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8dc6a12..0000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -Flask==3.0.3 -icalendar==6.0.1 -pandas==2.0.3 -pyflightdata==0.8.6.2 -pytz==2024.1 -gunicorn==23.0.0 -numpy==1.24.4 \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 49fe5e4..8576670 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,3 +1,5 @@ + + @@ -64,6 +66,20 @@ input[type="submit"]:hover { background-color: #0056b3; } + .manual-entry-button { + margin-top: 15px; + padding: 10px 20px; + border: none; + border-radius: 5px; + background-color: #28a745; + color: white; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s ease; + } + .manual-entry-button:hover { + background-color: #218838; + } .emoji { font-size: 5rem; cursor: pointer; @@ -95,6 +111,7 @@

Create Flight Calendar Event

{{ error }}
+ {% endif %}

Enter your flight number and date to create an iCal event.

@@ -103,7 +120,7 @@

Create Flight Calendar Event



-

+

@@ -113,8 +130,9 @@

Create Flight Calendar Event

Made with ❤️ by Michael Schmid - \ No newline at end of file + + \ No newline at end of file diff --git a/templates/manual_entry.html b/templates/manual_entry.html new file mode 100644 index 0000000..90dd4a0 --- /dev/null +++ b/templates/manual_entry.html @@ -0,0 +1,227 @@ + + + + + + FlightCal - Manual Entry + + + + +
✈️
+

Manual Flight Entry

+

Enter your flight details manually

+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + + +
+
+ + + + + + diff --git a/templates/select_flight.html b/templates/select_flight.html index 07c5706..d45cd72 100644 --- a/templates/select_flight.html +++ b/templates/select_flight.html @@ -1,3 +1,5 @@ + + @@ -95,6 +97,106 @@ footer a:hover { text-decoration: underline; } + + .clock-icon { + cursor: pointer; + font-size: 1.2rem; + margin-left: 10px; + display: inline-block; + transition: transform 0.2s ease; + } + + .clock-icon:hover { + transform: scale(1.2); + } + + .modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + } + + .modal-content { + background-color: white; + margin: 10% auto; + padding: 30px; + border-radius: 10px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + width: 90%; + max-width: 400px; + } + + .close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; + } + + .close:hover, + .close:focus { + color: #000; + } + + .modal-content h2 { + margin-top: 0; + } + + .modal-content label { + display: block; + margin-top: 15px; + font-weight: bold; + } + + .modal-content input[type="text"] { + width: 100%; + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); + margin-top: 5px; + box-sizing: border-box; + } + + .modal-content .button-group { + display: flex; + gap: 10px; + margin-top: 20px; + } + + .modal-content button { + flex: 1; + padding: 10px 20px; + border: none; + border-radius: 5px; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s ease; + } + + .modal-content .save-button { + background-color: #007bff; + color: white; + } + + .modal-content .save-button:hover { + background-color: #0056b3; + } + + .modal-content .cancel-button { + background-color: #6c757d; + color: white; + } + + .modal-content .cancel-button:hover { + background-color: #545b62; + } @media (max-width: 600px) { body { @@ -118,21 +220,42 @@

Available Flights

{% for flight in flights %}
-

{{ flight.flight_number }}

+

{{ flight.flight_number }} + 🕒 +

Airline: {{ flight.airline_name }}

-

Departure: {{ flight.nice_departure_date }}

-

Arrival: {{ flight.nice_arrival_date }}

+

Departure: {{ flight.nice_departure_date }}

+

Arrival: {{ flight.nice_arrival_date }}

From: {{ flight.origin_airport_code }}

To: {{ flight.destination_airport_code }}

-
+ + +
{% endfor %}
+ + + +