Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0236647d0b | ||
|
|
d94a68ac10 | ||
|
|
d5af169837 | ||
|
|
2bbc35a82c | ||
|
|
49061c1105 | ||
|
|
29904c10f6 | ||
|
|
8a72222e8f | ||
|
|
52afb369a7 | ||
|
|
498e67a5d7 |
+10
-2
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user