Author SHA1 Message Date
Tirsvad 521d74db0a Merge pull request 'MIL-005 Documentation and wiki' (#27) from mil-005-documentation-and-wiki into main
Tests / pytest (push) Successful in 1m4s
Publish wiki / publish-wiki (push) Successful in 17s
Publish wiki / wiki (push) Successful in 17s
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 8s
Quality checks / quality-checks (push) Successful in 29s
Quality / quality (push) Successful in 29s
2026-10-24 18:01:26 +02:00
Tirsvad dfb5b4645b Add README, three-language wiki and screenshots
English comments / english-comments (pull_request) Successful in 28s
Quality checks / quality-checks (pull_request) Successful in 39s
Quality / quality (pull_request) Successful in 39s
Tests / pytest (pull_request) Successful in 1m2s
Task: MIL-005#1
Task: MIL-005#2
Task: MIL-005#3
Task: MIL-005#4
2026-10-24 23:58:22 +08:00
Tirsvad 7612add9af Merge pull request 'MIL-004 Email notifications and main program' (#26) from mil-004-email-notifications into main
Tests / pytest (push) Successful in 59s
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 8s
Quality checks / quality-checks (push) Successful in 33s
Quality / quality (push) Successful in 33s
2026-10-24 17:56:03 +02:00
Tirsvad 0236647d0b Resolve pending commit links
Tests / pytest (pull_request) Successful in 46s
English comments / english-comments (pull_request) Successful in 31s
Quality checks / quality-checks (pull_request) Successful in 33s
Quality / quality (pull_request) Successful in 33s
2026-10-24 23:53:56 +08:00
Tirsvad d94a68ac10 Email the flight deal to every customer
Task: MIL-004#1
Task: MIL-004#2
Task: MIL-004#3
Task: MIL-004#4
2026-10-24 23:53:52 +08:00
Tirsvad d5af169837 Merge pull request 'MIL-003 Customer emails' (#25) from mil-003-customer-emails into main
Quality checks / quality-checks (push) Successful in 27s
Quality / quality (push) Successful in 27s
Tests / pytest (push) Successful in 48s
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-24 17:52:02 +02:00
Tirsvad 2bbc35a82c Resolve pending commit links
Quality checks / quality-checks (pull_request) Successful in 29s
Quality / quality (pull_request) Successful in 29s
English comments / english-comments (pull_request) Successful in 29s
Tests / pytest (pull_request) Successful in 47s
2026-10-24 23:50:03 +08:00
Tirsvad 49061c1105 Read the customer list from the users sheet
Task: MIL-003#1
Task: MIL-003#2
Task: MIL-003#3
Task: MIL-003#4
2026-10-24 23:50:00 +08:00
Tirsvad 29904c10f6 Merge pull request 'MIL-002 Flight data and indirect search' (#24) from mil-002-indirect-search into main
Quality checks / quality-checks (push) Successful in 27s
Quality / quality (push) Successful in 27s
Tests / pytest (push) Successful in 46s
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-24 17:48:00 +02:00
Tirsvad 8a72222e8f Resolve pending commit links
English comments / english-comments (pull_request) Successful in 26s
Quality checks / quality-checks (pull_request) Successful in 28s
Quality / quality (pull_request) Successful in 29s
Tests / pytest (pull_request) Successful in 46s
2026-10-24 23:45:46 +08:00
Tirsvad 52afb369a7 Search indirect flights when no direct flight exists
Add FlightData.stops, check_flights(is_direct) and the indirect fallback in main.
Record ADR-0001 and the code review RC-009.

Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
2026-10-24 23:45:43 +08:00
Tirsvad 498e67a5d7 Merge pull request 'MIL-001 Baseline and project setup' (#23) from mil-001-baseline-and-setup into main
Quality checks / quality-checks (push) Successful in 30s
Quality / quality (push) Successful in 30s
Tests / pytest (push) Successful in 50s
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-24 17:42:54 +02:00
35 changed files with 1234 additions and 43 deletions
+10 -2
View File
@@ -1,7 +1,9 @@
# Copy to .env and fill in. .env is ignored by git; never commit it. # Copy to .env and fill in. .env is ignored by git; never commit it.
# Sheety: the endpoint of the "prices" sheet and the Bearer token of the project # Sheety: the endpoints of the "prices" sheet (destinations) and the "users" sheet
SHEETY_ENDPOINT=https://api.sheety.co/<your-id>/<your-project>/prices # (the club's customers, filled by a Google Form), and the Bearer token of the project
SHEETY_PRICES_ENDPOINT=https://api.sheety.co/<your-id>/<your-project>/prices
SHEETY_USERS_ENDPOINT=https://api.sheety.co/<your-id>/<your-project>/users
SHEETY_TOKEN= SHEETY_TOKEN=
# Flight provider: "serpapi" or "mock" (the course's mock flight service) # Flight provider: "serpapi" or "mock" (the course's mock flight service)
@@ -15,3 +17,9 @@ MOCK_FLIGHTS_APP_KEY=
# WhatsApp: your own number with country code, for example +441234567890 # WhatsApp: your own number with country code, for example +441234567890
# Needs the optional extra: python -m pip install -e ".[whatsapp]" # Needs the optional extra: python -m pip install -e ".[whatsapp]"
WHATSAPP_NUMBER= WHATSAPP_NUMBER=
# Email: the SMTP server, the sender and an app password (not your normal password).
# Every customer in the "users" sheet gets the deal by email.
SMTP_ADDRESS=smtp.gmail.com
EMAIL_ADDRESS=
EMAIL_PASSWORD=
+155 -1
View File
@@ -1,2 +1,156 @@
# 040-personal-flight-club # 🚀 Personal Flight Club
A flight club (Day 40 of Udemy's "100 Days of Code: The Complete Python Pro Bootcamp"): it reads destinations and price limits from a Google Sheet, finds the cheapest return flight (direct first, then with stopovers), and emails every customer in a second sheet when a price is below the limit. It is for course participants who want to compare solutions and for anyone who browses the repository for ideas.
## 📚 Table of Contents
- [Overview](#-overview)
- [Requirements](#-requirements)
- [Setup](#-setup)
- [Run](#-run)
- [Tests](#-tests)
- [License](#-license)
- [Links](#-links)
- [Appendix: Project documents](#-appendix-project-documents)
## 🧭 Overview
The program keeps the assignment's class and function names. It builds on the Day 39 project and adds three things: a search for flights with stopovers when no direct flight exists, a customer list from a second sheet, and a deal email to every customer.
| Module (`src/flight_club/`) | What it does |
| --- | --- |
| `data_manager.py` | `DataManager`: reads the `prices` and `users` sheets from Sheety (`get_destination_data()`, `get_customer_emails()`) and writes a new lowest price back; reads all its settings in `__init__()` |
| `flight_search.py` | `FlightSearch.check_flights(..., is_direct=True)`: asks SerpAPI Google Flights, or the course's mock flight service, for nonstop flights, or for flights with up to two stops |
| `flight_data.py` | `FlightData` (with `stops`) and `find_cheapest_flight()`: picks the cheapest flight, with `N/A` when there is none |
| `notification_manager.py` | `NotificationManager`: builds the direct or stopover message, sends it by email with `send_emails()` and by WhatsApp |
| `main.py` | Wires everything together; `python -m flight_club` runs it |
| `constants.py` | All constants (URLs, origin airport, currency, SMTP port, variable names) |
| `cache.py` | Local response cache that never stores the Sheety answers |
![A sample run with mock data](docs/images/sample-run.png)
### Glossary
| Term | Meaning |
| --- | --- |
| API | Application programming interface: how one program asks another for data |
| CI | Continuous integration: the checks that run on every pull request |
| GBP | Pound sterling; the currency code used in all messages instead of the pound sign |
| IATA code | The three-letter airport code of the International Air Transport Association, such as `LHR` |
| SMTP | Simple Mail Transfer Protocol: how the program hands an email to your mail provider |
| Stopover | An intermediate stop of a flight; a direct flight has none |
## 📋 Requirements
- Python 3.13 or newer.
- Runtime packages (installed by pip): `requests`, `requests-cache`, `python-dotenv`. Email uses the standard library (`smtplib`).
- Optional, only to send WhatsApp messages: `pywhatkit`, a browser and a logged-in WhatsApp Web session.
- A Sheety project for your copy of the Google Sheet with a `prices` sheet and a `users` sheet (Bearer authentication, PUT enabled on `prices`), and either a SerpAPI key or the course's mock flight service credentials.
- An email account with an app password (not your normal password) to send the deal emails.
## 🛠️ Setup
Create and use a local virtual environment, then install the project.
PowerShell (Windows):
```powershell
py -3.13 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Bash (Linux, macOS):
```bash
python3.13 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
For WhatsApp delivery add the optional extra: `python -m pip install -e ".[whatsapp]"`.
Create your own `.env` from the template and fill in your values. `.env` is ignored by git; never commit it.
```bash
cp .env.example .env
```
| Variable | Meaning |
| --- | --- |
| `SHEETY_PRICES_ENDPOINT` | Endpoint of the `prices` sheet in your Sheety project (the Day 39 name `SHEETY_ENDPOINT` still works) |
| `SHEETY_USERS_ENDPOINT` | Endpoint of the `users` sheet that your Google Form fills (columns `firstName`, `lastName`, `email`) |
| `SHEETY_TOKEN` | Bearer token of the Sheety project |
| `FLIGHT_PROVIDER` | `serpapi` or `mock` |
| `SERPAPI_API_KEY` | Your SerpAPI key (provider `serpapi`) |
| `MOCK_FLIGHTS_APP_ID`, `MOCK_FLIGHTS_APP_KEY` | Credentials of the mock flight service (provider `mock`) |
| `MOCK_FLIGHTS_URL` | Optional: override the mock service URL |
| `SMTP_ADDRESS` | Your mail provider's SMTP server, for example `smtp.gmail.com` (port 587, STARTTLS) |
| `EMAIL_ADDRESS`, `EMAIL_PASSWORD` | The sender address and its app password |
| `WHATSAPP_NUMBER` | Your own number with country code, for example `+441234567890` |
Keep 3 to 5 destinations in the sheet to stay inside the free tiers (SerpAPI 100 searches a month, Sheety 200 requests a month). Flight answers are cached for one hour in `flight_cache.sqlite`; delete the file to force fresh searches. Try the program with a test `users` sheet that holds only your own address before you use the real customer list.
## ▶️ Run
```bash
python -m flight_club
```
or, after the editable install, `flight-club`. For every sheet row the program prints "Getting direct flights for ...". When there is no direct flight it prints "No direct flight to ... Looking for indirect flights..." and searches again with up to two stops. When the cheapest price is lower than the sheet's lowest price, it updates the sheet, sends a WhatsApp message and emails every customer in the `users` sheet. The message says "Flight has 1 stopover." for a flight with stops. Without the SMTP settings, `WHATSAPP_NUMBER` or `pywhatkit` it prints the message instead.
## 🧪 Tests
The tests use no network, send no email or WhatsApp message and never read your `.env`.
```bash
python -m pytest
```
![Test run](docs/images/tests-run.png)
Optional checks used by the CI: `python -m ruff check .`, `python -m ruff format --check .` and `python -m mypy --strict src tests`. Build the API documentation with `doxygen Doxyfile` (output in `docs/doxygen/`).
## 📄 License
See [LICENSE][license].
## 🔗 Links
- [Repository][repository]
- [Documentation][documentation]
- [Issue tracker][issues]
## 📎 Appendix: Project documents
The documents a newcomer needs, in reading order.
| Document | What it tells you |
| --- | --- |
| [AGENTS.md][agents] | The rules AI agents follow in this repository |
| [Business Case][BC-001] | Why the project exists, its scope and success criteria |
| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers |
| [Project Plan][PP-001] | The gateways and their schedule |
| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks |
| [Architecture decision][ADR-0001] | Why the code is one flat package that prints to the console |
| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain |
| [Framework README][framework-readme] | The framework this project uses |
| [Framework changelog][framework-changelog] | What changed in each framework release |
---
[license]: ./LICENSE
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/wiki
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/issues
[agents]: ./AGENTS.md
[BC-001]: ./docs/business-case.md
[SA-001]: ./docs/stakeholder-analysis.md
[PP-001]: ./docs/project-plan.md
[milestones]: ./docs/milestones/
[ADR-0001]: ./docs/adr/adr-0001-flat-module-layout-and-console-output.md
[registry]: ./docs/artifact-registry.md
[framework-readme]: ./framework/README.md
[framework-changelog]: ./framework/CHANGELOG.md
@@ -0,0 +1,56 @@
# Keep the assignment's flat module layout and console output
## Metadata
| Key | Value |
| --- | --- |
| ID | ADR-0001 |
| CrossReference | [BC-001], [SA-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [52afb36] |
Allowed Status values: `Proposed`, `Accepted`, `Rejected`, `Deprecated`, `Superseded by ADR-NNNN`.
---
## Context
The Day 40 program is an assignment solution. Stakeholder S02 wants the assignment's file and class names (`main.py`, `data_manager.py`, `flight_search.py`, `flight_data.py`, `notification_manager.py`), and S03 wants a small repository ([SA-001] conflicts). The Python checklist (`QC-PY-001`) asks for the layer folders `domain`, `application`, `infrastructure` and `presentation`, for logging instead of `print`, and for a Design Class Diagram.
Options considered:
1. Full clean-architecture layout with a `logging` set-up and a DCD. Cleanest on paper, but it hides the assignment's names behind layers and adds about four packages for five small classes.
2. Keep the Day 39 flat package `src/flight_club/` with one module per assignment class, console output through `print`, no DCD. Small and recognisable; the layers are merged.
3. A middle way with `domain/` only. Splits the two data classes from the rest and still breaks the assignment's file names.
The forces: the program is a one-shot console script whose output *is* the user interface ("Print a message" in the assignment), has about 300 lines and five classes, and has one delivery mechanism.
## Decision
Keep option 2: one flat package with the assignment's module and class names. The modules still depend inward in practice (`constants` imports nothing and `flight_data` imports only `constants`; `main` is the composition root and the only module that wires them), console messages use `print` because they are the program's output, and the assignment's class list serves as the design instead of a separate DCD.
## Consequences
**Positive:**
- S02 finds the assignment's names; S03 sees a small repository with no extra packages.
- The Day 39 code and tests carry over without a restructuring.
**Negative:**
- The layers `domain`, `application`, `infrastructure` and `presentation` are merged; `QC-PY-001` criterion 17 is met only by this ADR.
- `print` is used for the program's output, so there is no log level or log file; secrets are kept out of the output by printing error types instead of error texts.
- If the program grows (a second delivery mechanism, a database), this decision must be revisited and the layers split.
## Affected Artifacts
- [BC-001] — keeps the Business Case scope of the assignment's classes and names.
- [SA-001] — resolves the conflict between S02 and S03 and the clean-architecture folders.
---
[BC-001]: ../business-case.md
[SA-001]: ../stakeholder-analysis.md
[52afb36]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/52afb369a77cdd37c8ddc652a1495b4a3f4f59d6
+2 -1
View File
@@ -15,7 +15,8 @@ document of a type. `Primary File` may contain a glob (e.g.
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone | docs/milestones/mil-*.md | 006 | | MIL | Milestone | docs/milestones/mil-*.md | 006 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 008 | | ADR | Architecture Decision Record | docs/adr/adr-*.md | 0002 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 012 |
## Languages ## Languages
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+1 -1
View File
@@ -62,7 +62,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
--- ---
[BC-001]: ../../docs/business-case.md [BC-001]: ../../business-case.md
[QC-BC-001]: ../../../framework/qc/artifacts/qc-business-case.md [QC-BC-001]: ../../../framework/qc/artifacts/qc-business-case.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
@@ -62,7 +62,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
--- ---
[SA-001]: ../../docs/stakeholder-analysis.md [SA-001]: ../../stakeholder-analysis.md
[QC-SA-001]: ../../../framework/qc/artifacts/qc-stakeholder-analysis.md [QC-SA-001]: ../../../framework/qc/artifacts/qc-stakeholder-analysis.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
@@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
--- ---
[MIL-001]: ../../docs/milestones/mil-001-baseline-and-setup.md [MIL-001]: ../../milestones/mil-001-baseline-and-setup.md
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md [QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
@@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
--- ---
[MIL-002]: ../../docs/milestones/mil-002-flight-data-and-indirect-search.md [MIL-002]: ../../milestones/mil-002-flight-data-and-indirect-search.md
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md [QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
@@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
--- ---
[MIL-003]: ../../docs/milestones/mil-003-customer-emails.md [MIL-003]: ../../milestones/mil-003-customer-emails.md
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md [QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
@@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
--- ---
[MIL-004]: ../../docs/milestones/mil-004-email-notifications-and-main.md [MIL-004]: ../../milestones/mil-004-email-notifications-and-main.md
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md [QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
@@ -60,7 +60,7 @@ Go — all mandatory criteria pass; the one failed criterion is optional (QC-LAN
--- ---
[MIL-005]: ../../docs/milestones/mil-005-documentation-and-wiki.md [MIL-005]: ../../milestones/mil-005-documentation-and-wiki.md
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md [QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.md
[QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md [QC-LANG-001]: ../../../framework/qc/artifacts/qc-language-domain.md
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2 [c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
+53
View File
@@ -0,0 +1,53 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-008 |
| CrossReference | [ADR-0001], [QC-ADR-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [52afb36] |
---
## Artifact Under Review
- Instance reviewed: [ADR-0001]
- Checklist used: [QC-ADR-001]
- Scope: full review
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | The ID (`ADR-NNNN`, 4-digit) matches the filename number and is unique and sequential | Pass | ADR-0001 matches `adr-0001-...md`; it is the first ADR. |
| 2 | Context states the problem, the forces (cost, risk, constraints) and the options evaluated | Pass | Context lists the problem, the forces (assignment names, small program, console UI) and three options. |
| 3 | At least two options were considered, or the absence of alternatives is justified | Pass | Three options are evaluated. |
| 4 | Decision is stated in one or two unhedged sentences and matches one of the evaluated options | Pass | Decision is two sentences and matches option 2. |
| 5 | Consequences list both Positive and Negative outcomes | Pass | Consequences list Positive and Negative outcomes. |
| 6 | Quality-attribute impacts (performance, security, portability, etc.) are named where the decision affects them | Pass | Maintainability and extensibility impacts are named in the Negative consequences. |
| 7 | Affected Artifacts are listed as links, and each is also in CrossReference | Pass | Affected Artifacts [BC-001] and [SA-001] are linked and are also in CrossReference. |
| 8 | Status changes are recorded as new Version History rows with a Change summary and Commit link; retained rows are never rewritten | Pass | First version; one Version History row. |
| 9 | A superseded or deprecated ADR names its successor, and the successor links back | N-A | Optional: not superseded or deprecated. |
| 10 | Written in business or technical language of the domain, with no unexplained abbreviations | Pass | Plain technical English; abbreviations are common (ADR, DCD, UI). |
| 11 | Status is one of `Proposed`, `Accepted`, `Rejected`, `Deprecated` or `Superseded by ADR-NNNN`, and every Version History row uses only these | Pass | Status is Accepted, an allowed value. |
## Overall Verdict
Go — all mandatory criteria pass.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | - | - |
---
[ADR-0001]: ../../adr/adr-0001-flat-module-layout-and-console-output.md
[QC-ADR-001]: ../../../framework/qc/artifacts/qc-adr.md
[52afb36]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/52afb369a77cdd37c8ddc652a1495b4a3f4f59d6
+61
View File
@@ -0,0 +1,61 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-009 |
| CrossReference | [MIL-002], [QC-PY-001], [ADR-0001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [52afb36] |
---
## Artifact Under Review
- Instance reviewed: [MIL-002] (the Python code and tests of its tasks 1 to 4: `src/flight_club/`, `tests/`)
- Checklist used: [QC-PY-001]
- Scope: full review of the code changed by this milestone
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Modules, functions, variables and classes use snake_case, PascalCase and UPPER_SNAKE (`STOPS_NONSTOP`, `is_direct`). |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`is_direct`, `stops`, `indirect_price`); no cryptic abbreviations. |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass; no new suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every new function and test has annotations including `-> None`. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except and nothing swallowed; the new code adds no exception handling. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults (`is_direct: bool = True`, `stops: int = 0`); no shadowed builtins. |
| 7 | Files, locks and connections are managed with context managers | Pass | No files or connections opened by the new code; the HTTP session is unchanged. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public classes and functions carry Doxygen comments (what, not how). |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Console output uses `print` as the assignment requires; recorded in [ADR-0001]. No secrets are printed. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No DCD exists; the assignment's classes are the design; deviation recorded in [ADR-0001]. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | New behaviour is tested: stops parsing, request parameters of both searches, fallback and messages. Tests use fakes, no network, no order dependence. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes on `src` and `tests`; the only `Any` is the JSON response shape. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | No new dependencies. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The stops filter values are constants in one place; the fallback rule lives in `main.run` only. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `flight_data` imports only `constants`; `main` wires the others; no import cycles. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | `check_flights` gains a defaulted parameter, so existing callers still work; `FlightData` gains a defaulted field. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Flat layout accepted by [ADR-0001] (merged layers). |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File names are lower-case snake_case English with no spaces. |
## Overall Verdict
Go — all mandatory criteria pass; deviations are recorded in ADR-0001.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | - | - |
---
[MIL-002]: ../../milestones/mil-002-flight-data-and-indirect-search.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[ADR-0001]: ../../adr/adr-0001-flat-module-layout-and-console-output.md
[52afb36]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/52afb369a77cdd37c8ddc652a1495b4a3f4f59d6
+61
View File
@@ -0,0 +1,61 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-010 |
| CrossReference | [MIL-003], [QC-PY-001], [ADR-0001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [49061c1] |
---
## Artifact Under Review
- Instance reviewed: [MIL-003] (the Python code and tests of its tasks 1 to 4: `src/flight_club/`, `tests/`)
- Checklist used: [QC-PY-001]
- Scope: full review of the code changed by this milestone
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Names use snake_case, PascalCase and UPPER_SNAKE (`ENV_SHEETY_USERS_ENDPOINT`, `extract_emails`). |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`get_customer_emails`, `extract_emails`, `EMAIL_PATTERN`). |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass; no new suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every new function and test has annotations including `-> None`. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare except; HTTP and shape problems raise `DataManagerError` with specific messages. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults and no shadowed builtins; `_read_rows` is shared by both sheets. |
| 7 | Files, locks and connections are managed with context managers | Pass | No files or connections opened by the new code; the HTTP session is unchanged. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public classes and functions carry Doxygen comments (what, not how). |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Console output uses `print` as the assignment requires; recorded in [ADR-0001]. No secrets are printed. |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No DCD exists; the assignment's classes are the design; deviation recorded in [ADR-0001]. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | New behaviour is tested: users read, errors, missing users endpoint, email extraction, legacy endpoint name. Fakes only; no network or `.env`. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes on `src` and `tests`; the only `Any` is the JSON response shape. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | No new dependencies. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | One private `_read_rows` serves both sheets; endpoint names, keys and the email pattern are constants. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `flight_data` imports only `constants`; `main` wires the others; no import cycles. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | `get_destination_data` and `update_lowest_price` keep their contracts; the users endpoint is checked only when used, so Day 39 `.env` files still run. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Flat layout accepted by [ADR-0001] (merged layers). |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File names are lower-case snake_case English with no spaces. |
## Overall Verdict
Go — all mandatory criteria pass; deviations are recorded in ADR-0001.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | - | - |
---
[MIL-003]: ../../milestones/mil-003-customer-emails.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[ADR-0001]: ../../adr/adr-0001-flat-module-layout-and-console-output.md
[49061c1]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/49061c1105f9874e073f83048fd0020217a1600a
+61
View File
@@ -0,0 +1,61 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-011 |
| CrossReference | [MIL-004], [QC-PY-001], [ADR-0001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [d94a68a] |
---
## Artifact Under Review
- Instance reviewed: [MIL-004] (the Python code and tests of its tasks 1 to 4: `src/flight_club/`, `tests/`)
- Checklist used: [QC-PY-001]
- Scope: full review of the code changed by this milestone
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
## Checklist Results
| # | Criterion | Status | Evidence/Notes |
| --- | --- | --- | --- |
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | Names use snake_case, PascalCase and UPPER_SNAKE (`send_emails`, `ENV_SMTP_ADDRESS`, `SMTP_PORT`). |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`send_emails`, `_build_email`, `EMAIL_SUBJECT`). |
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass; no new suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every new function and test has annotations including `-> None`. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | Only `smtplib.SMTPException` and `OSError` are caught, in one place; the error type is reported and the loop result is returned, nothing is swallowed silently. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults (`is_direct: bool = True`, `stops: int = 0`); no shadowed builtins. |
| 7 | Files, locks and connections are managed with context managers | Pass | The SMTP connection is opened with a `with` block, so it is always closed. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Public classes and functions carry Doxygen comments (what, not how). |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Output uses `print` as the program's console output, recorded in [ADR-0001]. The SMTP error text and the app password are never printed (test `never_raises_or_prints_the_login`). |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | No DCD exists; the assignment's classes are the design; deviation recorded in [ADR-0001]. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | New behaviour is tested with a fake `smtplib.SMTP`: login and STARTTLS, one mail per address, UTF-8 body, missing settings, SMTP failure, both message texts, GBP and no pound sign, customers read once, WhatsApp still sent when customers cannot be read. No mail is sent; no `.env`. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` passes on `src` and `tests`; the only `Any` is the JSON response shape. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | No new dependencies. |
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The message text is built in one place (`build_message`) and reused for WhatsApp and email; env names, port and subject are constants. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `flight_data` imports only `constants`; `main` wires the others; no import cycles. |
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | `send_emails` is added next to `send_whatsapp` without changing it; the SMTP class is the standard one and is replaced in tests by monkeypatching. |
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | Flat layout accepted by [ADR-0001] (merged layers). |
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File names are lower-case snake_case English with no spaces. |
## Overall Verdict
Go — all mandatory criteria pass; deviations are recorded in ADR-0001.
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| None | - | - |
---
[MIL-004]: ../../milestones/mil-004-email-notifications-and-main.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
[ADR-0001]: ../../adr/adr-0001-flat-module-layout-and-console-output.md
[d94a68a]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/d94a68ac10edbcbd44b59f8f1f30c5aa14a9edb8
+63
View File
@@ -0,0 +1,63 @@
[English](Getting-Started) · **Dansk** · [Bahasa Malaysia](Getting-Started-ms)
# Kom i gang
## Krav
- Python 3.13 eller nyere
- Et Sheety-projekt til din kopi af Google Sheet med arkene `prices` og `users` (Bearer-godkendelse, PUT slået til på `prices`)
- En SerpAPI-nøgle eller adgangsoplysninger til mock-flyservicen
- En e-mailkonto med en app-adgangskode til at sende tilbudsmails
- Valgfrit: `pywhatkit`, en browser og en logget ind WhatsApp Web-session
## Opsætning
Opret et lokalt virtuelt miljø, opgradér pip og installér projektet:
```powershell
py -3.13 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
```bash
python3.13 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
## Konfiguration
Kopiér `.env.example` til `.env` og udfyld værdierne. Læg aldrig `.env` i git. Hold dig til 3 til 5 destinationer i arket for at holde dig inden for de gratis grænser, og prøv programmet med et test-ark `users`, der kun indeholder din egen adresse.
| Variabel | Betydning |
| --- | --- |
| `SHEETY_PRICES_ENDPOINT` | Endpoint for arket `prices` |
| `SHEETY_USERS_ENDPOINT` | Endpoint for arket `users` (`firstName`, `lastName`, `email`) |
| `SHEETY_TOKEN` | Bearer-token til Sheety-projektet |
| `FLIGHT_PROVIDER` | `serpapi` eller `mock` |
| `SERPAPI_API_KEY` | SerpAPI-nøgle |
| `MOCK_FLIGHTS_APP_ID`, `MOCK_FLIGHTS_APP_KEY` | Adgangsoplysninger til mock-flyservicen |
| `SMTP_ADDRESS` | Din mailudbyders SMTP-server (port 587) |
| `EMAIL_ADDRESS`, `EMAIL_PASSWORD` | Afsenderadresse og dens app-adgangskode |
| `WHATSAPP_NUMBER` | Dit eget nummer med landekode |
## Kørsel
Start programmet. Uden SMTP-indstillinger eller WhatsApp-nummer bliver beskeden skrevet i konsollen i stedet.
```bash
python -m flight_club
```
## Test
Testene bruger intet netværk, sender ingen e-mails og læser aldrig din `.env`.
```bash
python -m pytest
```
![Testkørsel](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/raw/branch/main/docs/images/tests-run.png)
+63
View File
@@ -0,0 +1,63 @@
[English](Getting-Started) · [Dansk](Getting-Started-da) · **Bahasa Malaysia**
# Bermula
## Keperluan
- Python 3.13 atau lebih baharu
- Projek Sheety untuk salinan Google Sheet anda dengan helaian `prices` dan `users` (pengesahan Bearer, PUT dihidupkan pada `prices`)
- Kunci SerpAPI atau kelayakan perkhidmatan penerbangan mock
- Akaun e-mel dengan kata laluan aplikasi untuk menghantar e-mel tawaran
- Pilihan: `pywhatkit`, pelayar dan sesi WhatsApp Web yang telah log masuk
## Pemasangan
Cipta persekitaran maya setempat, naik taraf pip dan pasang projek:
```powershell
py -3.13 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
```bash
python3.13 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
## Konfigurasi
Salin `.env.example` ke `.env` dan isi nilainya. Jangan sekali-kali commit `.env`. Kekalkan 3 hingga 5 destinasi dalam helaian supaya kekal dalam had percuma, dan cuba program dengan helaian `users` ujian yang hanya mengandungi alamat anda sendiri.
| Pemboleh ubah | Maksud |
| --- | --- |
| `SHEETY_PRICES_ENDPOINT` | Endpoint helaian `prices` |
| `SHEETY_USERS_ENDPOINT` | Endpoint helaian `users` (`firstName`, `lastName`, `email`) |
| `SHEETY_TOKEN` | Token Bearer projek Sheety |
| `FLIGHT_PROVIDER` | `serpapi` atau `mock` |
| `SERPAPI_API_KEY` | Kunci SerpAPI |
| `MOCK_FLIGHTS_APP_ID`, `MOCK_FLIGHTS_APP_KEY` | Kelayakan perkhidmatan penerbangan mock |
| `SMTP_ADDRESS` | Pelayan SMTP pembekal e-mel anda (port 587) |
| `EMAIL_ADDRESS`, `EMAIL_PASSWORD` | Alamat penghantar dan kata laluan aplikasinya |
| `WHATSAPP_NUMBER` | Nombor anda dengan kod negara |
## Jalankan
Mulakan program. Tanpa tetapan SMTP atau nombor WhatsApp, mesej dicetak sebagai ganti.
```bash
python -m flight_club
```
## Ujian
Ujian tidak menggunakan rangkaian, tidak menghantar e-mel dan tidak membaca `.env` anda.
```bash
python -m pytest
```
![Larian ujian](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/raw/branch/main/docs/images/tests-run.png)
+63
View File
@@ -0,0 +1,63 @@
**English** · [Dansk](Getting-Started-da) · [Bahasa Malaysia](Getting-Started-ms)
# Getting started
## Requirements
- Python 3.13 or newer
- A Sheety project for your copy of the Google Sheet, with a `prices` sheet and a `users` sheet (Bearer authentication, PUT enabled on `prices`)
- A SerpAPI key or the mock flight service credentials
- An email account with an app password, to send the deal emails
- Optional: `pywhatkit`, a browser and a logged-in WhatsApp Web session
## Setup
Create a local virtual environment, upgrade pip and install the project:
```powershell
py -3.13 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
```bash
python3.13 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
## Configuration
Copy `.env.example` to `.env` and fill in the values. Never commit `.env`. Keep 3 to 5 destinations in the sheet to stay inside the free tiers, and try the program with a test `users` sheet that holds only your own address.
| Variable | Meaning |
| --- | --- |
| `SHEETY_PRICES_ENDPOINT` | Endpoint of the `prices` sheet |
| `SHEETY_USERS_ENDPOINT` | Endpoint of the `users` sheet (`firstName`, `lastName`, `email`) |
| `SHEETY_TOKEN` | Bearer token of the Sheety project |
| `FLIGHT_PROVIDER` | `serpapi` or `mock` |
| `SERPAPI_API_KEY` | SerpAPI key |
| `MOCK_FLIGHTS_APP_ID`, `MOCK_FLIGHTS_APP_KEY` | Mock flight service credentials |
| `SMTP_ADDRESS` | SMTP server of your mail provider (port 587) |
| `EMAIL_ADDRESS`, `EMAIL_PASSWORD` | Sender address and its app password |
| `WHATSAPP_NUMBER` | Your own number with country code |
## Run
Start the program. Without the SMTP settings or a WhatsApp number the message is printed instead.
```bash
python -m flight_club
```
## Tests
The tests use no network, send no email and never read your `.env`.
```bash
python -m pytest
```
![Test run](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/raw/branch/main/docs/images/tests-run.png)
+18
View File
@@ -0,0 +1,18 @@
[English](Home) · **Dansk** · [Bahasa Malaysia](Home-ms)
# Personal Flight Club
En flyklub fra dag 40 i Udemys 100 Days of Code. Programmet læser destinationer og prisgrænser fra et Google Sheet, finder den billigste returflyvning de næste seks måneder (først direkte, derefter med mellemlandinger) og sender en e-mail til alle kunder i et andet ark, når en pris er under grænsen.
## Sådan virker det
1. Læs rækkerne (by, IATA-kode, laveste pris) fra arket `prices` via Sheety.
2. Søg efter den billigste direkte returflyvning fra i morgen til seks måneder frem for hver række, med SerpAPI Google Flights eller kursets mock-flyservice.
3. Hvis der ikke er en direkte flyvning, søges der igen efter flyvninger med op til to stop, og antallet af stop noteres.
4. Hvis prisen er lavere end arkets laveste pris, opdateres arket, der sendes en WhatsApp-besked, og alle kunder i arket `users` får en e-mail. En flyvning med stop siger "Flight has 1 stopover." i beskeden.
## Et eksempel på en kørsel med mock-data
![Et eksempel på en kørsel med mock-data](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/raw/branch/main/docs/images/sample-run.png)
Næste: [Kom i gang](Getting-Started-da)
+18
View File
@@ -0,0 +1,18 @@
[English](Home) · [Dansk](Home-da) · **Bahasa Malaysia**
# Personal Flight Club
Kelab penerbangan dari hari ke-40 kursus Udemy 100 Days of Code. Program ini membaca destinasi dan had harga daripada Google Sheet, mencari penerbangan pergi-balik termurah untuk enam bulan akan datang (penerbangan terus dahulu, kemudian dengan persinggahan) dan menghantar e-mel kepada setiap pelanggan dalam helaian kedua apabila harga lebih rendah daripada had.
## Cara ia berfungsi
1. Baca baris (bandar, kod IATA, harga terendah) daripada helaian `prices` melalui Sheety.
2. Cari penerbangan pergi-balik terus yang termurah dari esok hingga enam bulan lagi untuk setiap baris, dengan SerpAPI Google Flights atau perkhidmatan penerbangan olok-olok (mock) kursus.
3. Jika tiada penerbangan terus, cari semula penerbangan dengan sehingga dua persinggahan dan catat bilangan persinggahan.
4. Jika harga lebih rendah daripada harga terendah dalam helaian, kemas kini helaian, hantar mesej WhatsApp dan e-mel kepada setiap pelanggan dalam helaian `users`. Penerbangan dengan persinggahan menyatakan "Flight has 1 stopover." dalam mesej.
## Contoh larian dengan data mock
![Contoh larian dengan data mock](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/raw/branch/main/docs/images/sample-run.png)
Seterusnya: [Bermula](Getting-Started-ms)
+18
View File
@@ -0,0 +1,18 @@
**English** · [Dansk](Home-da) · [Bahasa Malaysia](Home-ms)
# Personal Flight Club
A flight club from Day 40 of Udemy's 100 Days of Code. It reads destinations and price limits from a Google Sheet, finds the cheapest return flight for the next six months (direct first, then with stopovers) and emails every customer in a second sheet when a price is below the limit.
## How it works
1. Read the rows (city, IATA code, lowest price) from the `prices` sheet through Sheety.
2. Search the cheapest direct return flight from tomorrow to six months ahead for every row, with SerpAPI Google Flights or the course's mock flight service.
3. If there is no direct flight, search again for flights with up to two stops and note the number of stops.
4. If the price is lower than the sheet's lowest price, update the sheet, send a WhatsApp message and email every customer in the `users` sheet. A flight with stops says "Flight has 1 stopover." in the message.
## A sample run with mock data
![A sample run with mock data](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/raw/branch/main/docs/images/sample-run.png)
Next: [Getting started](Getting-Started)
+14
View File
@@ -0,0 +1,14 @@
**English**
- [Home](Home)
- [Getting started](Getting-Started)
**Dansk**
- [Forside](Home-da)
- [Kom i gang](Getting-Started-da)
**Bahasa Malaysia**
- [Laman utama](Home-ms)
- [Bermula](Getting-Started-ms)
+40 -1
View File
@@ -36,13 +36,25 @@ NOT_AVAILABLE = "N/A"
## @brief strftime format of dates sent to and read from providers. ## @brief strftime format of dates sent to and read from providers.
DATE_FORMAT = "%Y-%m-%d" DATE_FORMAT = "%Y-%m-%d"
## @brief SerpApi `stops` value that asks for nonstop flights only.
STOPS_NONSTOP = "1"
## @brief SerpApi `stops` value that allows up to two stops.
STOPS_UP_TO_TWO = "3"
## @brief Name of the SerpApi flight provider. ## @brief Name of the SerpApi flight provider.
PROVIDER_SERPAPI = "serpapi" PROVIDER_SERPAPI = "serpapi"
## @brief Name of the mock flight provider. ## @brief Name of the mock flight provider.
PROVIDER_MOCK = "mock" PROVIDER_MOCK = "mock"
## @brief Environment variable: Sheety sheet endpoint. ## @brief Environment variable: Sheety endpoint of the "prices" sheet.
ENV_SHEETY_PRICES_ENDPOINT = "SHEETY_PRICES_ENDPOINT"
## @brief Environment variable: Sheety endpoint of the "users" sheet (the club's customers).
ENV_SHEETY_USERS_ENDPOINT = "SHEETY_USERS_ENDPOINT"
## @brief Environment variable: the Day 39 name of the prices endpoint, still accepted.
ENV_SHEETY_ENDPOINT = "SHEETY_ENDPOINT" ENV_SHEETY_ENDPOINT = "SHEETY_ENDPOINT"
## @brief Environment variable: Sheety Bearer token. ## @brief Environment variable: Sheety Bearer token.
@@ -66,12 +78,39 @@ ENV_MOCK_FLIGHTS_URL = "MOCK_FLIGHTS_URL"
## @brief Environment variable: WhatsApp recipient number with country code. ## @brief Environment variable: WhatsApp recipient number with country code.
ENV_WHATSAPP_NUMBER = "WHATSAPP_NUMBER" ENV_WHATSAPP_NUMBER = "WHATSAPP_NUMBER"
## @brief Environment variable: address of the SMTP server that sends the emails.
ENV_SMTP_ADDRESS = "SMTP_ADDRESS"
## @brief Environment variable: the sender's email address, also the SMTP login name.
ENV_EMAIL_ADDRESS = "EMAIL_ADDRESS"
## @brief Environment variable: the app password of the sender's email account.
ENV_EMAIL_PASSWORD = "EMAIL_PASSWORD"
## @brief Port of the SMTP server; the connection is upgraded with STARTTLS.
SMTP_PORT = 587
## @brief Subject line of the deal emails.
EMAIL_SUBJECT = "New Low Price Flight!"
## @brief Seconds pywhatkit waits for WhatsApp Web to load before it sends the message. ## @brief Seconds pywhatkit waits for WhatsApp Web to load before it sends the message.
WHATSAPP_WAIT_SECONDS = 15 WHATSAPP_WAIT_SECONDS = 15
## @brief Seconds pywhatkit waits after sending before it closes the browser tab. ## @brief Seconds pywhatkit waits after sending before it closes the browser tab.
WHATSAPP_CLOSE_SECONDS = 3 WHATSAPP_CLOSE_SECONDS = 3
## @brief Key that holds the rows in the Sheety response for the "prices" sheet.
PRICES_KEY = "prices"
## @brief Key that holds the rows in the Sheety response for the "users" sheet.
USERS_KEY = "users"
## @brief Field of a "users" row that holds the customer's email address.
EMAIL_FIELD = "email"
## @brief Pattern of an accepted email address: text, an at sign and a dotted domain.
EMAIL_PATTERN = r"[^@\s]+@[^@\s]+\.[^@\s]+"
## @brief Seconds to wait for an HTTP response before giving up. ## @brief Seconds to wait for an HTTP response before giving up.
REQUEST_TIMEOUT_SECONDS = 30 REQUEST_TIMEOUT_SECONDS = 30
+56 -12
View File
@@ -4,14 +4,21 @@
""" """
import os import os
import re
from typing import Any from typing import Any
import requests import requests
from flight_club.constants import ( from flight_club.constants import (
EMAIL_FIELD,
EMAIL_PATTERN,
ENV_SHEETY_ENDPOINT, ENV_SHEETY_ENDPOINT,
ENV_SHEETY_PRICES_ENDPOINT,
ENV_SHEETY_TOKEN, ENV_SHEETY_TOKEN,
ENV_SHEETY_USERS_ENDPOINT,
PRICES_KEY,
REQUEST_TIMEOUT_SECONDS, REQUEST_TIMEOUT_SECONDS,
USERS_KEY,
) )
@@ -20,15 +27,27 @@ class DataManagerError(RuntimeError):
pass pass
## @brief Talks to the Sheety sheet that holds the destinations. ## @brief Talks to the Sheety sheets: the destinations ("prices") and the customers ("users").
class DataManager: class DataManager:
## @brief Reads the endpoint and token from the environment. ## @brief Reads every setting from the environment, in this one place.
#
# The prices endpoint comes from SHEETY_PRICES_ENDPOINT, or from the Day 39 name
# SHEETY_ENDPOINT. The users endpoint is optional here and checked when it is used,
# so a run without customers still works.
# @param session Optional HTTP session; a new one is made per call if omitted. # @param session Optional HTTP session; a new one is made per call if omitted.
def __init__(self, session: requests.Session | None = None) -> None: def __init__(self, session: requests.Session | None = None) -> None:
self._endpoint = self._require_env(ENV_SHEETY_ENDPOINT) self._prices_endpoint = self._prices_endpoint_from_env()
self._users_endpoint = os.environ.get(ENV_SHEETY_USERS_ENDPOINT) or None
self._headers = {"Authorization": f"Bearer {self._require_env(ENV_SHEETY_TOKEN)}"} self._headers = {"Authorization": f"Bearer {self._require_env(ENV_SHEETY_TOKEN)}"}
self._session = session self._session = session
@classmethod
def _prices_endpoint_from_env(cls) -> str:
legacy = os.environ.get(ENV_SHEETY_ENDPOINT)
if legacy and not os.environ.get(ENV_SHEETY_PRICES_ENDPOINT):
return legacy
return cls._require_env(ENV_SHEETY_PRICES_ENDPOINT)
@staticmethod @staticmethod
def _require_env(name: str) -> str: def _require_env(name: str) -> str:
value = os.environ.get(name) value = os.environ.get(name)
@@ -45,18 +64,43 @@ class DataManager:
if not 200 <= response.status_code < 300: if not 200 <= response.status_code < 300:
raise DataManagerError(f"Sheety {action} failed with HTTP {response.status_code}.") raise DataManagerError(f"Sheety {action} failed with HTTP {response.status_code}.")
## @brief Fetch every destination row from the Google Sheet through the Sheety API. def _read_rows(self, endpoint: str, key: str) -> list[dict[str, Any]]:
# @return Rows such as {'city': 'Paris', 'iataCode': 'CDG', 'lowestPrice': 54, 'id': 2}.
def get_destination_data(self) -> list[dict[str, Any]]:
response = self._get_session().get( response = self._get_session().get(
self._endpoint, headers=self._headers, timeout=REQUEST_TIMEOUT_SECONDS endpoint, headers=self._headers, timeout=REQUEST_TIMEOUT_SECONDS
) )
self._check(response, "read") self._check(response, "read")
body = response.json() body = response.json()
prices = body.get("prices") if isinstance(body, dict) else None rows = body.get(key) if isinstance(body, dict) else None
if not isinstance(prices, list): if not isinstance(rows, list):
raise DataManagerError("Sheety response has no 'prices' list.") raise DataManagerError(f"Sheety response has no '{key}' list.")
return prices return rows
## @brief Fetch every destination row from the Google Sheet through the Sheety API.
# @return Rows such as {'city': 'Paris', 'iataCode': 'CDG', 'lowestPrice': 54, 'id': 2}.
def get_destination_data(self) -> list[dict[str, Any]]:
return self._read_rows(self._prices_endpoint, PRICES_KEY)
## @brief Fetch every customer row from the "users" sheet.
# @return Rows such as {'firstName': 'Ann', 'email': 'ann@example.com', 'id': 2}.
def get_customer_emails(self) -> list[dict[str, Any]]:
if self._users_endpoint is None:
raise DataManagerError(f"Environment variable {ENV_SHEETY_USERS_ENDPOINT} is not set.")
return self._read_rows(self._users_endpoint, USERS_KEY)
## @brief Picks the usable email addresses out of the customer rows.
# @param customers Rows from get_customer_emails().
# @return The addresses that look like an email address, in sheet order, without duplicates.
@staticmethod
def extract_emails(customers: list[dict[str, Any]]) -> list[str]:
emails: list[str] = []
for customer in customers:
email = customer.get(EMAIL_FIELD)
if not isinstance(email, str):
continue
email = email.strip()
if re.fullmatch(EMAIL_PATTERN, email) and email not in emails:
emails.append(email)
return emails
## @brief Stores a new lowest price for one row. ## @brief Stores a new lowest price for one row.
# @param row_id Sheety row id. # @param row_id Sheety row id.
@@ -64,7 +108,7 @@ class DataManager:
# @return None # @return None
def update_lowest_price(self, row_id: int, new_price: float) -> None: def update_lowest_price(self, row_id: int, new_price: float) -> None:
response = self._get_session().put( response = self._get_session().put(
f"{self._endpoint}/{row_id}", f"{self._prices_endpoint}/{row_id}",
json={"price": {"lowestPrice": new_price}}, json={"price": {"lowestPrice": new_price}},
headers=self._headers, headers=self._headers,
timeout=REQUEST_TIMEOUT_SECONDS, timeout=REQUEST_TIMEOUT_SECONDS,
+3 -1
View File
@@ -9,7 +9,7 @@ from typing import Any
from flight_club.constants import NOT_AVAILABLE from flight_club.constants import NOT_AVAILABLE
## @brief The cheapest flight found for one destination. ## @brief The cheapest flight found for one destination; `stops` is the number of stopovers.
@dataclass @dataclass
class FlightData: class FlightData:
price: float | int | str price: float | int | str
@@ -17,6 +17,7 @@ class FlightData:
destination_airport: str destination_airport: str
out_date: str out_date: str
return_date: str return_date: str
stops: int = 0
def _unavailable() -> FlightData: def _unavailable() -> FlightData:
@@ -48,6 +49,7 @@ def _build_flight(entry: dict[str, Any], price: float, return_date: str) -> Flig
destination_airport=legs[-1]["arrival_airport"]["id"], destination_airport=legs[-1]["arrival_airport"]["id"],
out_date=legs[0]["departure_airport"]["time"].split(" ")[0], out_date=legs[0]["departure_airport"]["time"].split(" ")[0],
return_date=return_date, return_date=return_date,
stops=len(legs) - 1,
) )
except (KeyError, IndexError, TypeError, AttributeError): except (KeyError, IndexError, TypeError, AttributeError):
return None return None
+9 -1
View File
@@ -24,6 +24,8 @@ from flight_club.constants import (
REQUEST_TIMEOUT_SECONDS, REQUEST_TIMEOUT_SECONDS,
SERPAPI_ENGINE, SERPAPI_ENGINE,
SERPAPI_URL, SERPAPI_URL,
STOPS_NONSTOP,
STOPS_UP_TO_TWO,
) )
@@ -93,6 +95,7 @@ class FlightSearch:
destination_city_code: str, destination_city_code: str,
from_time: datetime, from_time: datetime,
to_time: datetime, to_time: datetime,
is_direct: bool,
) -> dict[str, str]: ) -> dict[str, str]:
params = { params = {
"engine": SERPAPI_ENGINE, "engine": SERPAPI_ENGINE,
@@ -103,6 +106,7 @@ class FlightSearch:
"type": "1", "type": "1",
"adults": "1", "adults": "1",
"currency": CURRENCY, "currency": CURRENCY,
"stops": STOPS_NONSTOP if is_direct else STOPS_UP_TO_TWO,
} }
if self._api_key is not None: if self._api_key is not None:
params["api_key"] = self._api_key params["api_key"] = self._api_key
@@ -113,6 +117,7 @@ class FlightSearch:
# @param destination_city_code IATA code to arrive at. # @param destination_city_code IATA code to arrive at.
# @param from_time Outbound date. # @param from_time Outbound date.
# @param to_time Return date. # @param to_time Return date.
# @param is_direct True for nonstop flights only, False to allow up to two stops.
# @return The decoded response, or None after printing why the search failed. # @return The decoded response, or None after printing why the search failed.
def check_flights( def check_flights(
self, self,
@@ -120,8 +125,11 @@ class FlightSearch:
destination_city_code: str, destination_city_code: str,
from_time: datetime, from_time: datetime,
to_time: datetime, to_time: datetime,
is_direct: bool = True,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
params = self._build_params(origin_city_code, destination_city_code, from_time, to_time) params = self._build_params(
origin_city_code, destination_city_code, from_time, to_time, is_direct
)
try: try:
response = self._get_session().get( response = self._get_session().get(
self._url, self._url,
+21 -1
View File
@@ -32,6 +32,14 @@ def _is_checkable(row: dict[str, object]) -> bool:
) )
def _get_customer_emails(data_manager: DataManager) -> list[str]:
try:
return data_manager.extract_emails(data_manager.get_customer_emails())
except DataManagerError as error:
print(f"No emails sent: {error}")
return []
## @brief Checks every sheet row and reports each flight cheaper than the stored price. ## @brief Checks every sheet row and reports each flight cheaper than the stored price.
# @param data_manager Source of the destination rows and target of price updates. # @param data_manager Source of the destination rows and target of price updates.
# @param flight_search Provider of flight offers. # @param flight_search Provider of flight offers.
@@ -49,21 +57,33 @@ def run(
six_month_from_today = today + timedelta(days=SEARCH_WINDOW_DAYS) six_month_from_today = today + timedelta(days=SEARCH_WINDOW_DAYS)
return_date = six_month_from_today.strftime(DATE_FORMAT) return_date = six_month_from_today.strftime(DATE_FORMAT)
found: list[FlightData] = [] found: list[FlightData] = []
customer_emails: list[str] | None = None
sheet_data = data_manager.get_destination_data() sheet_data = data_manager.get_destination_data()
for row in sheet_data: for row in sheet_data:
if not _is_checkable(row): if not _is_checkable(row):
print(f"Skipping row without an IATA code or a numeric lowest price: {row.get('city')}") print(f"Skipping row without an IATA code or a numeric lowest price: {row.get('city')}")
continue continue
print(f"Getting direct flights for {row['city']}...")
flights = flight_search.check_flights( flights = flight_search.check_flights(
ORIGIN_AIRPORT, row["iataCode"], tomorrow, six_month_from_today ORIGIN_AIRPORT, row["iataCode"], tomorrow, six_month_from_today
) )
cheapest = find_cheapest_flight(flights, return_date) cheapest = find_cheapest_flight(flights, return_date)
if cheapest.price == NOT_AVAILABLE:
print(f"No direct flight to {row['city']}. Looking for indirect flights...")
flights = flight_search.check_flights(
ORIGIN_AIRPORT, row["iataCode"], tomorrow, six_month_from_today, is_direct=False
)
cheapest = find_cheapest_flight(flights, return_date)
print(f"{row['city']}: {CURRENCY} {cheapest.price}") print(f"{row['city']}: {CURRENCY} {cheapest.price}")
if cheapest.price != NOT_AVAILABLE and cheapest.price < row["lowestPrice"]: if cheapest.price != NOT_AVAILABLE and cheapest.price < row["lowestPrice"]:
print(f"Lower price flight found to {row['city']}!") print(f"Lower price flight found to {row['city']}!")
data_manager.update_lowest_price(row["id"], cheapest.price) # type: ignore[arg-type] data_manager.update_lowest_price(row["id"], cheapest.price) # type: ignore[arg-type]
notification_manager.send_whatsapp(notification_manager.build_message(cheapest)) message = notification_manager.build_message(cheapest)
notification_manager.send_whatsapp(message)
if customer_emails is None:
customer_emails = _get_customer_emails(data_manager)
notification_manager.send_emails(customer_emails, message)
found.append(cheapest) found.append(cheapest)
return found return found
+60 -5
View File
@@ -1,14 +1,21 @@
""" """
@file @file
@brief Builds low price alerts and sends them through WhatsApp. @brief Builds low price alerts and sends them through email and WhatsApp.
""" """
import os import os
import smtplib
from collections.abc import Callable from collections.abc import Callable
from email.message import EmailMessage
from flight_club.constants import ( from flight_club.constants import (
CURRENCY, CURRENCY,
EMAIL_SUBJECT,
ENV_EMAIL_ADDRESS,
ENV_EMAIL_PASSWORD,
ENV_SMTP_ADDRESS,
ENV_WHATSAPP_NUMBER, ENV_WHATSAPP_NUMBER,
SMTP_PORT,
WHATSAPP_CLOSE_SECONDS, WHATSAPP_CLOSE_SECONDS,
WHATSAPP_WAIT_SECONDS, WHATSAPP_WAIT_SECONDS,
) )
@@ -16,30 +23,78 @@ from flight_club.flight_data import FlightData
_INSTALL_HINT = 'Install the optional extra with: pip install -e ".[whatsapp]"' _INSTALL_HINT = 'Install the optional extra with: pip install -e ".[whatsapp]"'
_NUMBER_HINT = f"Set {ENV_WHATSAPP_NUMBER} (with country code) to receive WhatsApp messages." _NUMBER_HINT = f"Set {ENV_WHATSAPP_NUMBER} (with country code) to receive WhatsApp messages."
_EMAIL_HINT = (
f"Set {ENV_SMTP_ADDRESS}, {ENV_EMAIL_ADDRESS} and {ENV_EMAIL_PASSWORD} to send emails."
)
## @brief Formats flight alerts and delivers them to the owner's WhatsApp. ## @brief Formats flight alerts and delivers them by email to the customers and to the owner's WhatsApp.
class NotificationManager: class NotificationManager:
## @brief Creates the manager with an optional replacement for pywhatkit. ## @brief Reads every setting from the environment, in this one place.
# @param sender Callable taking (number, message); used instead of pywhatkit when given. # @param sender Callable taking (number, message); used instead of pywhatkit when given.
def __init__(self, sender: Callable[[str, str], None] | None = None) -> None: def __init__(self, sender: Callable[[str, str], None] | None = None) -> None:
self._sender = sender self._sender = sender
self._smtp_address = os.environ.get(ENV_SMTP_ADDRESS)
self._email_address = os.environ.get(ENV_EMAIL_ADDRESS)
self._email_password = os.environ.get(ENV_EMAIL_PASSWORD)
self._whatsapp_number = os.environ.get(ENV_WHATSAPP_NUMBER)
## @brief Builds the alert text for one cheap flight. ## @brief Builds the alert text for one cheap flight.
#
# A direct flight gets the short text; a flight with stopovers says how many.
# The price uses the currency code (GBP), never a currency symbol.
# @param flight The cheapest flight found for a destination. # @param flight The cheapest flight found for a destination.
# @return A one line message with price, airports and both dates. # @return A one line message with price, airports and both dates.
def build_message(self, flight: FlightData) -> str: def build_message(self, flight: FlightData) -> str:
return ( message = (
f"Low price alert! Only {CURRENCY} {flight.price} to fly " f"Low price alert! Only {CURRENCY} {flight.price} to fly "
f"from {flight.origin_airport} to {flight.destination_airport}, " f"from {flight.origin_airport} to {flight.destination_airport}, "
f"from {flight.out_date} to {flight.return_date}." f"from {flight.out_date} to {flight.return_date}."
) )
if flight.stops == 0:
return message
noun = "stopover" if flight.stops == 1 else "stopovers"
return f"{message} Flight has {flight.stops} {noun}."
## @brief Sends the message to every address; never raises.
#
# The mail is encoded as UTF-8, so non-ASCII characters do not fail.
# @param email_list Recipient addresses.
# @param email_body The text of the mail.
# @return The number of mails that were sent; 0 when the SMTP settings are missing.
def send_emails(self, email_list: list[str], email_body: str) -> int:
if not email_list:
return 0
if not (self._smtp_address and self._email_address and self._email_password):
print(_EMAIL_HINT)
print(email_body)
return 0
sent = 0
try:
with smtplib.SMTP(self._smtp_address, SMTP_PORT) as connection:
connection.starttls()
connection.login(self._email_address, self._email_password)
for address in email_list:
connection.send_message(self._build_email(address, email_body))
sent += 1
except (smtplib.SMTPException, OSError) as error:
# Only the type is printed: the text of the error might echo the login.
print(f"Sending email failed: {type(error).__name__}.")
return sent
def _build_email(self, address: str, email_body: str) -> EmailMessage:
message = EmailMessage()
message["Subject"] = EMAIL_SUBJECT
message["From"] = self._email_address
message["To"] = address
message.set_content(email_body, charset="utf-8")
return message
## @brief Sends a message to the number in the environment; never raises. ## @brief Sends a message to the number in the environment; never raises.
# @param message The text to send. # @param message The text to send.
# @return True when sent, False when it was only printed. # @return True when sent, False when it was only printed.
def send_whatsapp(self, message: str) -> bool: def send_whatsapp(self, message: str) -> bool:
number = os.environ.get(ENV_WHATSAPP_NUMBER) number = self._whatsapp_number
if not number: if not number:
return self._print_instead(_NUMBER_HINT, message) return self._print_instead(_NUMBER_HINT, message)
if self._sender is not None: if self._sender is not None:
+65 -2
View File
@@ -7,12 +7,15 @@ from flight_club.constants import REQUEST_TIMEOUT_SECONDS
from flight_club.data_manager import DataManager, DataManagerError from flight_club.data_manager import DataManager, DataManagerError
ENDPOINT = "https://api.sheety.co/abc/flights/prices" ENDPOINT = "https://api.sheety.co/abc/flights/prices"
USERS_ENDPOINT = "https://api.sheety.co/abc/flights/users"
TOKEN = "super-secret-token" TOKEN = "super-secret-token"
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _env(monkeypatch: pytest.MonkeyPatch) -> None: def _env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("SHEETY_ENDPOINT", ENDPOINT) monkeypatch.delenv("SHEETY_ENDPOINT", raising=False)
monkeypatch.setenv("SHEETY_PRICES_ENDPOINT", ENDPOINT)
monkeypatch.setenv("SHEETY_USERS_ENDPOINT", USERS_ENDPOINT)
monkeypatch.setenv("SHEETY_TOKEN", TOKEN) monkeypatch.setenv("SHEETY_TOKEN", TOKEN)
@@ -75,10 +78,70 @@ def test_errors_never_contain_token() -> None:
assert TOKEN not in str(write_error.value) assert TOKEN not in str(write_error.value)
@pytest.mark.parametrize("name", ["SHEETY_ENDPOINT", "SHEETY_TOKEN"]) @pytest.mark.parametrize("name", ["SHEETY_PRICES_ENDPOINT", "SHEETY_TOKEN"])
def test_missing_env_variable_names_the_variable( def test_missing_env_variable_names_the_variable(
monkeypatch: pytest.MonkeyPatch, name: str monkeypatch: pytest.MonkeyPatch, name: str
) -> None: ) -> None:
monkeypatch.delenv(name) monkeypatch.delenv(name)
with pytest.raises(DataManagerError, match=name): with pytest.raises(DataManagerError, match=name):
DataManager(_session()) DataManager(_session())
USERS = [
{"firstName": "Ann", "lastName": "Lee", "email": "ann@example.com", "id": 2},
{"firstName": "Bo", "lastName": "Kim", "email": " bo@example.com ", "id": 3},
]
def test_get_customer_emails_returns_users() -> None:
session = _session(body={"users": USERS})
assert DataManager(session).get_customer_emails() == USERS
session.get.assert_called_once_with(
USERS_ENDPOINT,
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=REQUEST_TIMEOUT_SECONDS,
)
@pytest.mark.parametrize("body", [{}, {"users": "x"}, [1]])
def test_get_customer_emails_raises_without_users_list(body: Any) -> None:
with pytest.raises(DataManagerError, match="users"):
DataManager(_session(body=body)).get_customer_emails()
def test_get_customer_emails_raises_on_http_error() -> None:
with pytest.raises(DataManagerError, match="401"):
DataManager(_session(status=401)).get_customer_emails()
def test_get_customer_emails_needs_the_users_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SHEETY_USERS_ENDPOINT")
manager = DataManager(_session(body={"users": USERS}))
with pytest.raises(DataManagerError, match="SHEETY_USERS_ENDPOINT"):
manager.get_customer_emails()
def test_extract_emails_keeps_valid_unique_addresses() -> None:
customers: list[dict[str, Any]] = [
*USERS,
{"email": "ann@example.com"},
{"email": "not-an-email"},
{"email": None},
{"firstName": "No", "lastName": "Mail"},
]
assert DataManager.extract_emails(customers) == ["ann@example.com", "bo@example.com"]
def test_legacy_endpoint_name_is_still_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SHEETY_PRICES_ENDPOINT")
monkeypatch.setenv("SHEETY_ENDPOINT", "https://api.sheety.co/old/prices")
session = _session(body={"prices": []})
DataManager(session).get_destination_data()
assert session.get.call_args.args == ("https://api.sheety.co/old/prices",)
def test_prices_endpoint_wins_over_the_legacy_name(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("SHEETY_ENDPOINT", "https://api.sheety.co/old/prices")
session = _session(body={"prices": []})
DataManager(session).get_destination_data()
assert session.get.call_args.args == (ENDPOINT,)
+18
View File
@@ -133,3 +133,21 @@ def test_find_cheapest_flight_trims_time_from_out_date() -> None:
result = find_cheapest_flight({"best_flights": [_entry(50)]}, RETURN_DATE) result = find_cheapest_flight({"best_flights": [_entry(50)]}, RETURN_DATE)
assert result.out_date == "2026-07-01" assert result.out_date == "2026-07-01"
assert result.return_date == RETURN_DATE assert result.return_date == RETURN_DATE
def _multi_leg_entry(price: float, legs: int) -> dict[str, Any]:
segment = {
"departure_airport": {"id": "LHR", "time": "2026-07-01 08:00"},
"arrival_airport": {"id": "DPS", "time": "2026-07-02 10:00"},
}
return {"flights": [segment] * legs, "price": price}
@pytest.mark.parametrize(("legs", "stops"), [(1, 0), (2, 1), (3, 2)])
def test_stops_is_the_number_of_segments_minus_one(legs: int, stops: int) -> None:
cheapest = find_cheapest_flight({"best_flights": [_multi_leg_entry(500, legs)]}, RETURN_DATE)
assert cheapest.stops == stops
def test_unavailable_flight_has_no_stops() -> None:
assert find_cheapest_flight(None, RETURN_DATE).stops == 0
+13
View File
@@ -70,6 +70,7 @@ def test_check_flights_sends_serpapi_params() -> None:
"type": "1", "type": "1",
"adults": "1", "adults": "1",
"currency": CURRENCY, "currency": CURRENCY,
"stops": "1",
"api_key": API_KEY, "api_key": API_KEY,
} }
assert kwargs["timeout"] == REQUEST_TIMEOUT_SECONDS assert kwargs["timeout"] == REQUEST_TIMEOUT_SECONDS
@@ -217,3 +218,15 @@ def test_check_flights_never_prints_mock_secrets(
captured = capsys.readouterr() captured = capsys.readouterr()
assert APP_ID not in captured.out + captured.err assert APP_ID not in captured.out + captured.err
assert APP_KEY not in captured.out + captured.err assert APP_KEY not in captured.out + captured.err
def test_check_flights_asks_for_up_to_two_stops_when_not_direct() -> None:
session = _session()
FlightSearch(session).check_flights("LHR", "DPS", FROM_TIME, TO_TIME, is_direct=False)
assert session.get.call_args.kwargs["params"]["stops"] == "3"
def test_check_flights_is_direct_by_default() -> None:
session = _session()
_search(session)
assert session.get.call_args.kwargs["params"]["stops"] == "1"
+97 -7
View File
@@ -15,6 +15,8 @@ class FakeDataManager:
def __init__(self, rows: list[dict[str, Any]]) -> None: def __init__(self, rows: list[dict[str, Any]]) -> None:
self.rows = rows self.rows = rows
self.updates: list[tuple[int, float]] = [] self.updates: list[tuple[int, float]] = []
self.customers_error: Exception | None = None
self.customer_reads = 0
def get_destination_data(self) -> list[dict[str, Any]]: def get_destination_data(self) -> list[dict[str, Any]]:
return self.rows return self.rows
@@ -22,28 +24,53 @@ class FakeDataManager:
def update_lowest_price(self, row_id: int, new_price: float) -> None: def update_lowest_price(self, row_id: int, new_price: float) -> None:
self.updates.append((row_id, new_price)) self.updates.append((row_id, new_price))
def get_customer_emails(self) -> list[dict[str, Any]]:
if self.customers_error is not None:
raise self.customers_error
self.customer_reads += 1
return [{"email": "ann@example.test"}, {"email": "bo@example.test"}]
@staticmethod
def extract_emails(customers: list[dict[str, Any]]) -> list[str]:
return [str(c["email"]) for c in customers]
class FakeFlightSearch: class FakeFlightSearch:
def __init__(self, price: float | None) -> None: def __init__(self, price: float | None, indirect_price: float | None = None) -> None:
self.price = price self.price = price
self.indirect_price = indirect_price
self.calls: list[tuple[str, str, datetime, datetime]] = [] self.calls: list[tuple[str, str, datetime, datetime]] = []
self.direct_flags: list[bool] = []
def check_flights( def check_flights(
self, origin: str, destination: str, from_time: datetime, to_time: datetime self,
origin: str,
destination: str,
from_time: datetime,
to_time: datetime,
is_direct: bool = True,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
self.calls.append((origin, destination, from_time, to_time)) self.calls.append((origin, destination, from_time, to_time))
if self.price is None: self.direct_flags.append(is_direct)
price = self.price if is_direct else self.indirect_price
if price is None:
return None return None
leg = { leg = {
"departure_airport": {"id": origin, "time": "2026-10-25 08:00"}, "departure_airport": {"id": origin, "time": "2026-10-25 08:00"},
"arrival_airport": {"id": destination, "time": "2026-10-25 10:00"}, "arrival_airport": {"id": destination, "time": "2026-10-25 10:00"},
} }
return {"best_flights": [{"price": self.price, "flights": [leg]}]} legs = [leg] if is_direct else [leg, leg]
return {"best_flights": [{"price": price, "flights": legs}]}
class FakeNotificationManager: class FakeNotificationManager:
def __init__(self) -> None: def __init__(self) -> None:
self.sent: list[str] = [] self.sent: list[str] = []
self.emails: list[tuple[list[str], str]] = []
def send_emails(self, email_list: list[str], email_body: str) -> int:
self.emails.append((email_list, email_body))
return len(email_list)
def build_message(self, flight: FlightData) -> str: def build_message(self, flight: FlightData) -> str:
return f"alert {flight.destination_airport} {flight.price}" return f"alert {flight.destination_airport} {flight.price}"
@@ -54,11 +81,11 @@ class FakeNotificationManager:
def _run( def _run(
rows: list[dict[str, Any]], price: float | None rows: list[dict[str, Any]], price: float | None, indirect_price: float | None = None
) -> tuple[list[FlightData], FakeDataManager, FakeFlightSearch, FakeNotificationManager]: ) -> tuple[list[FlightData], FakeDataManager, FakeFlightSearch, FakeNotificationManager]:
data, search, notifier = ( data, search, notifier = (
FakeDataManager(rows), FakeDataManager(rows),
FakeFlightSearch(price), FakeFlightSearch(price, indirect_price),
FakeNotificationManager(), FakeNotificationManager(),
) )
found = run(data, search, notifier, today=TODAY) # type: ignore[arg-type] found = run(data, search, notifier, today=TODAY) # type: ignore[arg-type]
@@ -108,7 +135,7 @@ def test_run_skips_row_without_iata_code() -> None:
def test_run_checks_every_row_with_origin_and_dates() -> None: def test_run_checks_every_row_with_origin_and_dates() -> None:
rows = [{**_row(), "iataCode": code, "id": i} for i, code in enumerate(["CDG", "BER", "TYO"])] rows = [{**_row(), "iataCode": code, "id": i} for i, code in enumerate(["CDG", "BER", "TYO"])]
_, _, search, _ = _run(rows, None) _, _, search, _ = _run(rows, 54)
assert [c[:2] for c in search.calls] == [("LHR", "CDG"), ("LHR", "BER"), ("LHR", "TYO")] assert [c[:2] for c in search.calls] == [("LHR", "CDG"), ("LHR", "BER"), ("LHR", "TYO")]
for call in search.calls: for call in search.calls:
assert call[2] == TODAY + timedelta(days=1) assert call[2] == TODAY + timedelta(days=1)
@@ -128,3 +155,66 @@ def test_main_exits_with_one_on_data_manager_error(
main_module.main() main_module.main()
assert exit_info.value.code == 1 assert exit_info.value.code == 1
assert "SHEETY_ENDPOINT" in capsys.readouterr().out assert "SHEETY_ENDPOINT" in capsys.readouterr().out
def test_run_prints_direct_search_message(capsys: pytest.CaptureFixture[str]) -> None:
_run([_row(100)], 54)
assert "Getting direct flights for Paris..." in capsys.readouterr().out
def test_run_searches_only_direct_when_a_direct_flight_exists() -> None:
_, _, search, _ = _run([_row(100)], 54, 10)
assert search.direct_flags == [True]
def test_run_falls_back_to_indirect_flights(capsys: pytest.CaptureFixture[str]) -> None:
found, data, search, notifier = _run([_row(100)], None, 70)
assert search.direct_flags == [True, False]
assert "No direct flight to Paris. Looking for indirect flights..." in capsys.readouterr().out
assert [f.price for f in found] == [70]
assert found[0].stops == 1
assert data.updates == [(2, 70)]
assert notifier.sent == ["alert CDG 70.0"]
def test_run_reports_nothing_when_neither_search_finds_a_flight() -> None:
found, data, search, _ = _run([_row(100)], None, None)
assert search.direct_flags == [True, False]
assert found == []
assert data.updates == []
def test_run_emails_every_customer_when_a_cheaper_flight_is_found() -> None:
_, _, _, notifier = _run([_row(100)], 54)
assert notifier.emails == [(["ann@example.test", "bo@example.test"], "alert CDG 54.0")]
def test_run_sends_no_email_when_nothing_is_cheaper() -> None:
_, _, _, notifier = _run([_row(100)], 150)
assert notifier.emails == []
def test_run_reads_the_customers_once_for_several_deals() -> None:
rows = [{**_row(), "iataCode": code, "id": i} for i, code in enumerate(["CDG", "BER"])]
data, search, notifier = FakeDataManager(rows), FakeFlightSearch(54), FakeNotificationManager()
run(data, search, notifier, today=TODAY) # type: ignore[arg-type]
assert data.customer_reads == 1
assert len(notifier.emails) == 2
def test_run_still_sends_whatsapp_when_the_customers_cannot_be_read(
capsys: pytest.CaptureFixture[str],
) -> None:
data, search, notifier = (
FakeDataManager([_row(100)]),
FakeFlightSearch(54),
FakeNotificationManager(),
)
data.customers_error = DataManagerError(
"Environment variable SHEETY_USERS_ENDPOINT is not set."
)
found = run(data, search, notifier, today=TODAY) # type: ignore[arg-type]
assert [f.price for f in found] == [54]
assert notifier.sent == ["alert CDG 54.0"]
assert notifier.emails == [([], "alert CDG 54.0")]
assert "SHEETY_USERS_ENDPOINT" in capsys.readouterr().out
+129 -2
View File
@@ -1,11 +1,18 @@
import builtins import builtins
import smtplib
import sys import sys
import types import types
from typing import Any from typing import Any, ClassVar, Self
import pytest import pytest
from flight_club.constants import ENV_WHATSAPP_NUMBER from flight_club.constants import (
ENV_EMAIL_ADDRESS,
ENV_EMAIL_PASSWORD,
ENV_SMTP_ADDRESS,
ENV_WHATSAPP_NUMBER,
SMTP_PORT,
)
from flight_club.flight_data import FlightData from flight_club.flight_data import FlightData
from flight_club.notification_manager import NotificationManager from flight_club.notification_manager import NotificationManager
@@ -89,3 +96,123 @@ def test_send_whatsapp_calls_pywhatkit_with_documented_arguments(
monkeypatch.setitem(sys.modules, "pywhatkit", fake) monkeypatch.setitem(sys.modules, "pywhatkit", fake)
assert NotificationManager().send_whatsapp("hello") is True assert NotificationManager().send_whatsapp("hello") is True
assert received == [(NUMBER, "hello", 15, True, 3)] assert received == [(NUMBER, "hello", 15, True, 3)]
SMTP_HOST = "smtp.example.test"
SENDER = "club@example.test"
APP_PASSWORD = "app-password-secret"
class FakeSMTP:
"""Stands in for smtplib.SMTP; records what the manager does and sends nothing."""
instances: ClassVar[list["FakeSMTP"]] = []
fail_with: ClassVar[Exception | None] = None
def __init__(self, host: str, port: int) -> None:
if FakeSMTP.fail_with is not None:
raise FakeSMTP.fail_with
self.host = host
self.port = port
self.started_tls = False
self.login_args: tuple[str, str] | None = None
self.messages: list[Any] = []
FakeSMTP.instances.append(self)
def __enter__(self) -> Self:
return self
def __exit__(self, *args: object) -> None:
return None
def starttls(self) -> None:
self.started_tls = True
def login(self, user: str, password: str) -> None:
self.login_args = (user, password)
def send_message(self, message: Any) -> None:
self.messages.append(message)
@pytest.fixture
def smtp(monkeypatch: pytest.MonkeyPatch) -> type[FakeSMTP]:
FakeSMTP.instances = []
FakeSMTP.fail_with = None
monkeypatch.setenv(ENV_SMTP_ADDRESS, SMTP_HOST)
monkeypatch.setenv(ENV_EMAIL_ADDRESS, SENDER)
monkeypatch.setenv(ENV_EMAIL_PASSWORD, APP_PASSWORD)
monkeypatch.setattr(smtplib, "SMTP", FakeSMTP)
return FakeSMTP
def test_send_emails_logs_in_and_sends_one_mail_per_address(smtp: type[FakeSMTP]) -> None:
sent = NotificationManager().send_emails(["a@example.test", "b@example.test"], "deal")
connection = smtp.instances[0]
assert sent == 2
assert (connection.host, connection.port) == (SMTP_HOST, SMTP_PORT)
assert connection.started_tls
assert connection.login_args == (SENDER, APP_PASSWORD)
assert [m["To"] for m in connection.messages] == ["a@example.test", "b@example.test"]
assert all(m["From"] == SENDER for m in connection.messages)
assert connection.messages[0].get_content().strip() == "deal"
def test_send_emails_encodes_non_ascii_text_as_utf8(smtp: type[FakeSMTP]) -> None:
NotificationManager().send_emails(["a@example.test"], "Only £54 to Zürich")
message = smtp.instances[0].messages[0]
assert "£54 to Zürich" in message.get_content()
assert message.get_content_charset() == "utf-8"
assert "£54".encode() in message.as_bytes()
def test_send_emails_without_recipients_does_not_connect(smtp: type[FakeSMTP]) -> None:
assert NotificationManager().send_emails([], "deal") == 0
assert smtp.instances == []
@pytest.mark.parametrize("name", [ENV_SMTP_ADDRESS, ENV_EMAIL_ADDRESS, ENV_EMAIL_PASSWORD])
def test_send_emails_prints_instead_when_a_setting_is_missing(
smtp: type[FakeSMTP],
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
name: str,
) -> None:
monkeypatch.delenv(name)
assert NotificationManager().send_emails(["a@example.test"], "deal") == 0
output = capsys.readouterr().out
assert "deal" in output
assert name in output
assert smtp.instances == []
def test_send_emails_never_raises_or_prints_the_login_when_smtp_fails(
smtp: type[FakeSMTP], capsys: pytest.CaptureFixture[str]
) -> None:
smtp.fail_with = smtplib.SMTPAuthenticationError(535, f"bad login {APP_PASSWORD}".encode())
assert NotificationManager().send_emails(["a@example.test"], "deal") == 0
output = capsys.readouterr().out
assert "SMTPAuthenticationError" in output
assert APP_PASSWORD not in output
def test_build_message_for_a_direct_flight_has_no_stopover_text() -> None:
assert "stopover" not in NotificationManager().build_message(_flight())
@pytest.mark.parametrize(("stops", "text"), [(1, "1 stopover."), (2, "2 stopovers.")])
def test_build_message_for_a_stopover_flight_says_how_many(stops: int, text: str) -> None:
flight = FlightData(540, "LHR", "DPS", "2026-10-25", "2027-04-20", stops)
message = NotificationManager().build_message(flight)
assert message.endswith(f"Flight has {text}")
assert message != NotificationManager().build_message(_flight())
def test_messages_use_gbp_and_not_the_pound_sign() -> None:
flight = FlightData(540, "LHR", "DPS", "2026-10-25", "2027-04-20", 1)
for message in (
NotificationManager().build_message(flight),
NotificationManager().build_message(_flight()),
):
assert "GBP" in message
assert "£" not in message