MIL-001 Baseline and project setup #23
@@ -0,0 +1,17 @@
|
||||
# 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_ENDPOINT=https://api.sheety.co/<your-id>/<your-project>/prices
|
||||
SHEETY_TOKEN=
|
||||
|
||||
# Flight provider: "serpapi" or "mock" (the course's mock flight service)
|
||||
FLIGHT_PROVIDER=mock
|
||||
SERPAPI_API_KEY=
|
||||
MOCK_FLIGHTS_APP_ID=
|
||||
MOCK_FLIGHTS_APP_KEY=
|
||||
# Optional: override the default URL of the mock flight service
|
||||
# MOCK_FLIGHTS_URL=
|
||||
|
||||
# WhatsApp: your own number with country code, for example +441234567890
|
||||
# Needs the optional extra: python -m pip install -e ".[whatsapp]"
|
||||
WHATSAPP_NUMBER=
|
||||
@@ -0,0 +1,9 @@
|
||||
# Fails a pull request that adds comments or docstrings which are not English.
|
||||
name: English comments
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
english-comments:
|
||||
uses: TirSystem/github-action/.gitea/workflows/english-comments.yml@v2
|
||||
@@ -0,0 +1,38 @@
|
||||
# Publish the project's wiki pages to the git host's wiki.
|
||||
#
|
||||
# On a push to main that changes docs/wiki/, this workflow calls the reusable
|
||||
# workflow of TirSystem/github-action, which copies the Markdown pages of
|
||||
# docs/wiki/ to the wiki of this repository (framework/process/wiki.md says
|
||||
# what the folder holds: English and the PO language as a minimum).
|
||||
#
|
||||
# INSTALLING THIS FILE IS THE OPT-IN. There is no marker file. A project that
|
||||
# does not copy it publishes nothing.
|
||||
#
|
||||
# Copy it to .gitea/workflows/publish-wiki.yml. Then:
|
||||
# 1. Create the secret TOKEN_FOR_GITEA_WIKI in the organization of the
|
||||
# repository: a Gitea token with scope write:repository for a user that
|
||||
# can write this repository (see guides/credentials.md of github-action).
|
||||
# 2. Enable the wiki in the repository settings.
|
||||
# 3. Keep @v1, or pin the exact release tag together with tools-ref.
|
||||
# A runner must be registered for the repository (or its organization).
|
||||
#
|
||||
# prune: "true" deletes wiki pages that are not in docs/wiki/, so the wiki
|
||||
# always equals the folder. Leave it "false" to keep hand-written pages.
|
||||
|
||||
name: Publish wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- "docs/wiki/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
wiki:
|
||||
uses: TirSystem/github-action/.gitea/workflows/publish-wiki-pages.yml@v1
|
||||
with:
|
||||
wiki_dir: docs/wiki
|
||||
prune: "true"
|
||||
secrets:
|
||||
TOKEN_FOR_GITEA_WIKI: ${{ secrets.TOKEN_FOR_GITEA_WIKI }}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Lint, format check and type check (ruff, mypy) through the shared TirSystem workflow.
|
||||
name: Quality
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: TirSystem/github-action/.gitea/workflows/quality-checks.yml@v1
|
||||
with:
|
||||
languages: python,shell
|
||||
@@ -0,0 +1,19 @@
|
||||
# Run the unit tests. The tests use no network and no .env file.
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4.2.2
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: python -m pip install --upgrade pip
|
||||
- run: python -m pip install -e ".[dev]"
|
||||
- run: python -m pytest
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Secrets and local data
|
||||
.env
|
||||
*.sqlite
|
||||
flight_cache.sqlite*
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
|
||||
# Doxygen output
|
||||
docs/doxygen/
|
||||
|
||||
# Editors and OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Written by pywhatkit; holds message history and phone numbers
|
||||
PyWhatKit_DB.txt
|
||||
|
||||
# AI agent tooling installed by the framework
|
||||
.agents/
|
||||
.claude/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "framework"]
|
||||
path = framework
|
||||
url = ssh://git@git.tirsystem.com:10022/TirSystem/SQA-QC-Framework.git
|
||||
@@ -0,0 +1,374 @@
|
||||
# Doxyfile 1.15.0
|
||||
|
||||
#---------------------------------------------------------------------------
|
||||
# Project related configuration options
|
||||
#---------------------------------------------------------------------------
|
||||
DOXYFILE_ENCODING = UTF-8
|
||||
PROJECT_NAME = "Personal Flight Club"
|
||||
PROJECT_NUMBER =
|
||||
PROJECT_BRIEF =
|
||||
PROJECT_LOGO =
|
||||
PROJECT_ICON =
|
||||
OUTPUT_DIRECTORY = docs/doxygen
|
||||
CREATE_SUBDIRS = NO
|
||||
CREATE_SUBDIRS_LEVEL = 8
|
||||
ALLOW_UNICODE_NAMES = NO
|
||||
OUTPUT_LANGUAGE = English
|
||||
BRIEF_MEMBER_DESC = YES
|
||||
REPEAT_BRIEF = YES
|
||||
ABBREVIATE_BRIEF = "The $name class" \
|
||||
"The $name widget" \
|
||||
"The $name file" \
|
||||
is \
|
||||
provides \
|
||||
specifies \
|
||||
contains \
|
||||
represents \
|
||||
a \
|
||||
an \
|
||||
the
|
||||
ALWAYS_DETAILED_SEC = NO
|
||||
INLINE_INHERITED_MEMB = NO
|
||||
FULL_PATH_NAMES = YES
|
||||
STRIP_FROM_PATH =
|
||||
STRIP_FROM_INC_PATH =
|
||||
SHORT_NAMES = NO
|
||||
JAVADOC_AUTOBRIEF = NO
|
||||
JAVADOC_BANNER = NO
|
||||
QT_AUTOBRIEF = NO
|
||||
MULTILINE_CPP_IS_BRIEF = NO
|
||||
PYTHON_DOCSTRING = YES
|
||||
INHERIT_DOCS = YES
|
||||
SEPARATE_MEMBER_PAGES = NO
|
||||
TAB_SIZE = 4
|
||||
ALIASES =
|
||||
OPTIMIZE_OUTPUT_FOR_C = NO
|
||||
OPTIMIZE_OUTPUT_JAVA = YES
|
||||
OPTIMIZE_FOR_FORTRAN = NO
|
||||
OPTIMIZE_OUTPUT_VHDL = NO
|
||||
OPTIMIZE_OUTPUT_SLICE = NO
|
||||
EXTENSION_MAPPING =
|
||||
MARKDOWN_SUPPORT = YES
|
||||
MARKDOWN_STRICT = YES
|
||||
TOC_INCLUDE_HEADINGS = 6
|
||||
MARKDOWN_ID_STYLE = DOXYGEN
|
||||
AUTOLINK_SUPPORT = YES
|
||||
AUTOLINK_IGNORE_WORDS =
|
||||
BUILTIN_STL_SUPPORT = NO
|
||||
CPP_CLI_SUPPORT = NO
|
||||
SIP_SUPPORT = NO
|
||||
IDL_PROPERTY_SUPPORT = YES
|
||||
DISTRIBUTE_GROUP_DOC = NO
|
||||
GROUP_NESTED_COMPOUNDS = NO
|
||||
SUBGROUPING = YES
|
||||
INLINE_GROUPED_CLASSES = NO
|
||||
INLINE_SIMPLE_STRUCTS = NO
|
||||
TYPEDEF_HIDES_STRUCT = NO
|
||||
LOOKUP_CACHE_SIZE = 0
|
||||
NUM_PROC_THREADS = 1
|
||||
TIMESTAMP = NO
|
||||
#---------------------------------------------------------------------------
|
||||
# Build related configuration options
|
||||
#---------------------------------------------------------------------------
|
||||
EXTRACT_ALL = YES
|
||||
EXTRACT_PRIVATE = NO
|
||||
EXTRACT_PRIV_VIRTUAL = NO
|
||||
EXTRACT_PACKAGE = NO
|
||||
EXTRACT_STATIC = NO
|
||||
EXTRACT_LOCAL_CLASSES = YES
|
||||
EXTRACT_LOCAL_METHODS = NO
|
||||
EXTRACT_ANON_NSPACES = NO
|
||||
RESOLVE_UNNAMED_PARAMS = YES
|
||||
HIDE_UNDOC_MEMBERS = NO
|
||||
HIDE_UNDOC_CLASSES = NO
|
||||
HIDE_UNDOC_NAMESPACES = YES
|
||||
HIDE_FRIEND_COMPOUNDS = NO
|
||||
HIDE_IN_BODY_DOCS = NO
|
||||
INTERNAL_DOCS = NO
|
||||
CASE_SENSE_NAMES = SYSTEM
|
||||
HIDE_SCOPE_NAMES = NO
|
||||
HIDE_COMPOUND_REFERENCE= NO
|
||||
SHOW_HEADERFILE = YES
|
||||
SHOW_INCLUDE_FILES = YES
|
||||
SHOW_GROUPED_MEMB_INC = NO
|
||||
FORCE_LOCAL_INCLUDES = NO
|
||||
INLINE_INFO = YES
|
||||
SORT_MEMBER_DOCS = YES
|
||||
SORT_BRIEF_DOCS = NO
|
||||
SORT_MEMBERS_CTORS_1ST = NO
|
||||
SORT_GROUP_NAMES = NO
|
||||
SORT_BY_SCOPE_NAME = NO
|
||||
STRICT_PROTO_MATCHING = NO
|
||||
GENERATE_TODOLIST = YES
|
||||
GENERATE_TESTLIST = YES
|
||||
GENERATE_BUGLIST = YES
|
||||
GENERATE_DEPRECATEDLIST= YES
|
||||
ENABLED_SECTIONS =
|
||||
MAX_INITIALIZER_LINES = 30
|
||||
SHOW_USED_FILES = YES
|
||||
SHOW_FILES = YES
|
||||
SHOW_NAMESPACES = YES
|
||||
FILE_VERSION_FILTER =
|
||||
LAYOUT_FILE =
|
||||
CITE_BIB_FILES =
|
||||
EXTERNAL_TOOL_PATH =
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to warning and progress messages
|
||||
#---------------------------------------------------------------------------
|
||||
QUIET = YES
|
||||
WARNINGS = YES
|
||||
WARN_IF_UNDOCUMENTED = YES
|
||||
WARN_IF_DOC_ERROR = YES
|
||||
WARN_IF_INCOMPLETE_DOC = YES
|
||||
WARN_NO_PARAMDOC = NO
|
||||
WARN_IF_UNDOC_ENUM_VAL = NO
|
||||
WARN_LAYOUT_FILE = YES
|
||||
WARN_AS_ERROR = NO
|
||||
WARN_FORMAT = "$file:$line: $text"
|
||||
WARN_LINE_FORMAT = "at line $line of file $file"
|
||||
WARN_LOGFILE =
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to the input files
|
||||
#---------------------------------------------------------------------------
|
||||
INPUT = src
|
||||
INPUT_ENCODING = UTF-8
|
||||
INPUT_FILE_ENCODING =
|
||||
FILE_PATTERNS = *.py
|
||||
RECURSIVE = YES
|
||||
EXCLUDE =
|
||||
EXCLUDE_SYMLINKS = NO
|
||||
EXCLUDE_PATTERNS =
|
||||
EXCLUDE_SYMBOLS =
|
||||
EXAMPLE_PATH =
|
||||
EXAMPLE_PATTERNS = *
|
||||
EXAMPLE_RECURSIVE = NO
|
||||
IMAGE_PATH =
|
||||
INPUT_FILTER =
|
||||
FILTER_PATTERNS =
|
||||
FILTER_SOURCE_FILES = NO
|
||||
FILTER_SOURCE_PATTERNS =
|
||||
USE_MDFILE_AS_MAINPAGE =
|
||||
IMPLICIT_DIR_DOCS = YES
|
||||
FORTRAN_COMMENT_AFTER = 72
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to source browsing
|
||||
#---------------------------------------------------------------------------
|
||||
SOURCE_BROWSER = NO
|
||||
INLINE_SOURCES = NO
|
||||
STRIP_CODE_COMMENTS = YES
|
||||
REFERENCED_BY_RELATION = NO
|
||||
REFERENCES_RELATION = NO
|
||||
REFERENCES_LINK_SOURCE = YES
|
||||
SOURCE_TOOLTIPS = YES
|
||||
USE_HTAGS = NO
|
||||
VERBATIM_HEADERS = YES
|
||||
CLANG_ASSISTED_PARSING = NO
|
||||
CLANG_ADD_INC_PATHS = YES
|
||||
CLANG_OPTIONS =
|
||||
CLANG_DATABASE_PATH =
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to the alphabetical class index
|
||||
#---------------------------------------------------------------------------
|
||||
ALPHABETICAL_INDEX = YES
|
||||
IGNORE_PREFIX =
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to the HTML output
|
||||
#---------------------------------------------------------------------------
|
||||
GENERATE_HTML = YES
|
||||
HTML_OUTPUT = html
|
||||
HTML_FILE_EXTENSION = .html
|
||||
HTML_HEADER =
|
||||
HTML_FOOTER =
|
||||
HTML_STYLESHEET =
|
||||
HTML_EXTRA_STYLESHEET =
|
||||
HTML_EXTRA_FILES =
|
||||
HTML_COLORSTYLE = AUTO_LIGHT
|
||||
HTML_COLORSTYLE_HUE = 220
|
||||
HTML_COLORSTYLE_SAT = 100
|
||||
HTML_COLORSTYLE_GAMMA = 80
|
||||
HTML_DYNAMIC_MENUS = YES
|
||||
HTML_DYNAMIC_SECTIONS = NO
|
||||
HTML_CODE_FOLDING = YES
|
||||
HTML_COPY_CLIPBOARD = YES
|
||||
HTML_PROJECT_COOKIE =
|
||||
HTML_INDEX_NUM_ENTRIES = 100
|
||||
GENERATE_DOCSET = NO
|
||||
DOCSET_FEEDNAME = "Doxygen generated docs"
|
||||
DOCSET_FEEDURL =
|
||||
DOCSET_BUNDLE_ID = org.doxygen.Project
|
||||
DOCSET_PUBLISHER_ID = org.doxygen.Publisher
|
||||
DOCSET_PUBLISHER_NAME = Publisher
|
||||
GENERATE_HTMLHELP = NO
|
||||
CHM_FILE =
|
||||
HHC_LOCATION =
|
||||
GENERATE_CHI = NO
|
||||
CHM_INDEX_ENCODING =
|
||||
BINARY_TOC = NO
|
||||
TOC_EXPAND = NO
|
||||
SITEMAP_URL =
|
||||
GENERATE_QHP = NO
|
||||
QCH_FILE =
|
||||
QHP_NAMESPACE = org.doxygen.Project
|
||||
QHP_VIRTUAL_FOLDER = doc
|
||||
QHP_CUST_FILTER_NAME =
|
||||
QHP_CUST_FILTER_ATTRS =
|
||||
QHP_SECT_FILTER_ATTRS =
|
||||
QHG_LOCATION =
|
||||
GENERATE_ECLIPSEHELP = NO
|
||||
ECLIPSE_DOC_ID = org.doxygen.Project
|
||||
DISABLE_INDEX = NO
|
||||
GENERATE_TREEVIEW = YES
|
||||
PAGE_OUTLINE_PANEL = YES
|
||||
FULL_SIDEBAR = NO
|
||||
ENUM_VALUES_PER_LINE = 4
|
||||
SHOW_ENUM_VALUES = NO
|
||||
TREEVIEW_WIDTH = 250
|
||||
EXT_LINKS_IN_WINDOW = NO
|
||||
OBFUSCATE_EMAILS = YES
|
||||
HTML_FORMULA_FORMAT = png
|
||||
FORMULA_FONTSIZE = 10
|
||||
FORMULA_MACROFILE =
|
||||
USE_MATHJAX = NO
|
||||
MATHJAX_VERSION = MathJax_2
|
||||
MATHJAX_FORMAT = HTML-CSS
|
||||
MATHJAX_RELPATH =
|
||||
MATHJAX_EXTENSIONS =
|
||||
MATHJAX_CODEFILE =
|
||||
SEARCHENGINE = YES
|
||||
SERVER_BASED_SEARCH = NO
|
||||
EXTERNAL_SEARCH = NO
|
||||
SEARCHENGINE_URL =
|
||||
SEARCHDATA_FILE = searchdata.xml
|
||||
EXTERNAL_SEARCH_ID =
|
||||
EXTRA_SEARCH_MAPPINGS =
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to the LaTeX output
|
||||
#---------------------------------------------------------------------------
|
||||
GENERATE_LATEX = NO
|
||||
LATEX_OUTPUT = latex
|
||||
LATEX_CMD_NAME =
|
||||
MAKEINDEX_CMD_NAME = makeindex
|
||||
LATEX_MAKEINDEX_CMD = makeindex
|
||||
COMPACT_LATEX = NO
|
||||
PAPER_TYPE = a4
|
||||
EXTRA_PACKAGES =
|
||||
LATEX_HEADER =
|
||||
LATEX_FOOTER =
|
||||
LATEX_EXTRA_STYLESHEET =
|
||||
LATEX_EXTRA_FILES =
|
||||
PDF_HYPERLINKS = YES
|
||||
USE_PDFLATEX = YES
|
||||
LATEX_BATCHMODE = NO
|
||||
LATEX_HIDE_INDICES = NO
|
||||
LATEX_BIB_STYLE = plainnat
|
||||
LATEX_EMOJI_DIRECTORY =
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to the RTF output
|
||||
#---------------------------------------------------------------------------
|
||||
GENERATE_RTF = NO
|
||||
RTF_OUTPUT = rtf
|
||||
COMPACT_RTF = NO
|
||||
RTF_HYPERLINKS = NO
|
||||
RTF_STYLESHEET_FILE =
|
||||
RTF_EXTENSIONS_FILE =
|
||||
RTF_EXTRA_FILES =
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to the man page output
|
||||
#---------------------------------------------------------------------------
|
||||
GENERATE_MAN = NO
|
||||
MAN_OUTPUT = man
|
||||
MAN_EXTENSION = .3
|
||||
MAN_SUBDIR =
|
||||
MAN_LINKS = NO
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to the XML output
|
||||
#---------------------------------------------------------------------------
|
||||
GENERATE_XML = NO
|
||||
XML_OUTPUT = xml
|
||||
XML_PROGRAMLISTING = YES
|
||||
XML_NS_MEMB_FILE_SCOPE = NO
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to the DOCBOOK output
|
||||
#---------------------------------------------------------------------------
|
||||
GENERATE_DOCBOOK = NO
|
||||
DOCBOOK_OUTPUT = docbook
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options for the AutoGen Definitions output
|
||||
#---------------------------------------------------------------------------
|
||||
GENERATE_AUTOGEN_DEF = NO
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to Sqlite3 output
|
||||
#---------------------------------------------------------------------------
|
||||
GENERATE_SQLITE3 = NO
|
||||
SQLITE3_OUTPUT = sqlite3
|
||||
SQLITE3_RECREATE_DB = YES
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to the Perl module output
|
||||
#---------------------------------------------------------------------------
|
||||
GENERATE_PERLMOD = NO
|
||||
PERLMOD_LATEX = NO
|
||||
PERLMOD_PRETTY = YES
|
||||
PERLMOD_MAKEVAR_PREFIX =
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to the preprocessor
|
||||
#---------------------------------------------------------------------------
|
||||
ENABLE_PREPROCESSING = YES
|
||||
MACRO_EXPANSION = NO
|
||||
EXPAND_ONLY_PREDEF = NO
|
||||
SEARCH_INCLUDES = YES
|
||||
INCLUDE_PATH =
|
||||
INCLUDE_FILE_PATTERNS =
|
||||
PREDEFINED =
|
||||
EXPAND_AS_DEFINED =
|
||||
SKIP_FUNCTION_MACROS = YES
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to external references
|
||||
#---------------------------------------------------------------------------
|
||||
TAGFILES =
|
||||
GENERATE_TAGFILE =
|
||||
ALLEXTERNALS = NO
|
||||
EXTERNAL_GROUPS = YES
|
||||
EXTERNAL_PAGES = YES
|
||||
#---------------------------------------------------------------------------
|
||||
# Configuration options related to diagram generator tools
|
||||
#---------------------------------------------------------------------------
|
||||
HIDE_UNDOC_RELATIONS = YES
|
||||
HAVE_DOT = NO
|
||||
DOT_NUM_THREADS = 0
|
||||
DOT_COMMON_ATTR = "fontname=Helvetica,fontsize=10"
|
||||
DOT_EDGE_ATTR = "labelfontname=Helvetica,labelfontsize=10"
|
||||
DOT_NODE_ATTR = "shape=box,height=0.2,width=0.4"
|
||||
DOT_FONTPATH =
|
||||
CLASS_GRAPH = YES
|
||||
COLLABORATION_GRAPH = YES
|
||||
GROUP_GRAPHS = YES
|
||||
UML_LOOK = NO
|
||||
UML_LIMIT_NUM_FIELDS = 10
|
||||
UML_MAX_EDGE_LABELS = 10
|
||||
DOT_UML_DETAILS = NO
|
||||
DOT_WRAP_THRESHOLD = 17
|
||||
TEMPLATE_RELATIONS = NO
|
||||
INCLUDE_GRAPH = YES
|
||||
INCLUDED_BY_GRAPH = YES
|
||||
CALL_GRAPH = NO
|
||||
CALLER_GRAPH = NO
|
||||
GRAPHICAL_HIERARCHY = YES
|
||||
DIRECTORY_GRAPH = YES
|
||||
DIR_GRAPH_MAX_DEPTH = 1
|
||||
DOT_IMAGE_FORMAT = png
|
||||
INTERACTIVE_SVG = NO
|
||||
DOT_PATH =
|
||||
DOTFILE_DIRS =
|
||||
DIA_PATH =
|
||||
DIAFILE_DIRS =
|
||||
PLANTUML_JAR_PATH =
|
||||
PLANTUML_CFG_FILE =
|
||||
PLANTUML_INCLUDE_PATH =
|
||||
PLANTUMLFILE_DIRS =
|
||||
DOT_GRAPH_MAX_NODES = 50
|
||||
MAX_DOT_GRAPH_DEPTH = 0
|
||||
DOT_MULTI_TARGETS = NO
|
||||
GENERATE_LEGEND = YES
|
||||
DOT_CLEANUP = YES
|
||||
MSCGEN_TOOL =
|
||||
MSCFILE_DIRS =
|
||||
@@ -0,0 +1,59 @@
|
||||
# Artifact Registry
|
||||
|
||||
This project's artifact state. Types, short names and `CrossReference
|
||||
Candidates` come from the framework catalog
|
||||
(`framework/registry/artifact-catalog.md`); this file only records where
|
||||
each document lives in *this* project and the next version to use.
|
||||
|
||||
Delete rows for types you don't use. Add a row the first time you create a
|
||||
document of a type. `Primary File` may contain a glob (e.g.
|
||||
`docs/uc-*/uc.md`); `framework/scripts/find-crossreferences.sh` reads it.
|
||||
|
||||
| Short Name | Artifact Type | Primary File | Next Available Version |
|
||||
| --- | --- | --- | --- |
|
||||
| BC | Business Case | docs/business-case.md | 002 |
|
||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||
| MIL | Milestone | docs/milestones/mil-*.md | 006 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 008 |
|
||||
|
||||
## Languages
|
||||
|
||||
Set the PO language when the project starts; `project-planning` asks for it
|
||||
if it is missing. An artifact of a type marked "Written in the PO language"
|
||||
exists once, in that language, under its normal name (`business-case.md`); the
|
||||
`artifact` skill ("One file per artifact") has the rule.
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| PO language | en |
|
||||
| PO domain | it |
|
||||
| High-level register | IT Executive English |
|
||||
| Technical register | IT Professional English |
|
||||
| Wiki languages | en and the PO language, at least (`framework/process/wiki.md`) |
|
||||
|
||||
Every artifact of a type marked "Yes" below states its language and domain in
|
||||
its `Language` and `Domain` Metadata rows; `new-artifact.sh` fills them from
|
||||
`PO language` and `PO domain`. `Language` is a BCP 47 code (`da`, `en`).
|
||||
`Domain` is a value from this list; add a row to introduce a domain, so a
|
||||
reviewer can see at once which professional vocabulary a document uses.
|
||||
|
||||
| Domain | Meaning |
|
||||
| --- | --- |
|
||||
| it | Software and IT |
|
||||
| medical | Healthcare and medical devices |
|
||||
| construction | Construction and civil engineering |
|
||||
|
||||
| Artifact types | Register | Written in the PO language |
|
||||
| --- | --- | --- |
|
||||
| BC, KPI, PP, MIL | IT Executive English | Yes |
|
||||
| SA, BMC, BPMN, UCD, US, UC, SSD, DM, RA, GOV, DICT | IT Professional English | Yes |
|
||||
| OC, SD, DCD, ERD, ADR, TM, RC, QC, source code | IT Professional English | No |
|
||||
|
||||
## Notes
|
||||
|
||||
- "Next Available Version" is the zero-padded (3-digit) version to use the
|
||||
*next* time a new document of that type is created. Increment it only when
|
||||
a brand-new document is created, not when an existing document's
|
||||
`## Version History` gets a row.
|
||||
- `ADR` uses 4 digits (`0001`); `RC` is sequential across all artifact types.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Business Case
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | BC-001 |
|
||||
| CrossReference | [SA-001] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Day 40 of Udemy's "100 Days of Code: The Complete Python Pro Bootcamp" turns the Day 39 flight-deal finder into a small flight club. The program now searches direct flights first and falls back to flights with stopovers, reads a customer list from a second Google Sheet ("users"), and sends every customer an email with the deal through SMTP. This project delivers that program as a tested, documented Python 3.13+ repository, built on the code of the `039-personal-flight-club` repository, for the course participant and for other participants who compare solutions.
|
||||
|
||||
## Methodological and Standards Foundation
|
||||
|
||||
The work follows the SQA and QC framework mounted at `framework/`: artifacts are reviewed against QC checklists tagged with ISO/IEC 25010:2023 characteristics, and code follows the `coding-conventions` and `clean-architecture` skills. Source code carries Doxygen comments and is tested with pytest.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The Day 39 program reports only the cheapest flight found, and says nothing about stopovers. Many popular destinations have no direct flight, so they are reported as "N/A". The deals reach only the participant, by WhatsApp; the club's customers, who sign up in a Google Form, get nothing. The starter files of the lesson use Twilio, which this participant replaced in Day 39 and does not want to bring back.
|
||||
|
||||
## Business Opportunity
|
||||
|
||||
A club that mails every member the best deal, direct or with stops, is a complete small product and a portfolio piece. Other participants (S02) and GitHub visitors (S03) get readable code with the assignment's function names and clear run instructions.
|
||||
|
||||
## Objectives
|
||||
|
||||
1. Search direct flights first (`check_flights(..., is_direct=True)`); when none is found, search again with stopovers, and record the number of stops in `FlightData.stops`.
|
||||
2. Read the customer emails from the "users" sheet with `DataManager.get_customer_emails()`; all environment variables, including both sheet endpoints, are read in `DataManager.__init__()`.
|
||||
3. Send every customer an email through SMTP with `NotificationManager.send_emails()`, with a different message for a direct flight and for a flight with a stopover; the text uses "GBP" and is encoded as UTF-8.
|
||||
4. Keep every secret in `.env` and never in the repository; keep the Day 39 response cache so free-tier quotas last.
|
||||
5. Publish a documented repository: README with screenshots, wiki in English, Danish and Bahasa Malaysia with screenshots, Doxygen configuration, CI workflows, description and topics.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- A Python 3.13+ project with `src/` and `tests/`, `pyproject.toml`, a local `.venv`, pytest, `constants.py`, a Doxyfile and a Python `.gitignore`, starting from the Day 39 code.
|
||||
- The assignment's classes and names: `DataManager` with `get_customer_emails()`, `FlightSearch.check_flights()` with `is_direct`, `FlightData` with `stops`, `NotificationManager.send_emails()`, and `main.py`.
|
||||
- The WhatsApp message of Day 39 stays; the email is added next to it.
|
||||
- README from the framework template, a three-language wiki, screenshots, CI workflows from the TirSystem workflow repository, repository description and topics.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Twilio SMS or WhatsApp, which the Day 39 project replaced.
|
||||
- A sign-up form or website (the Google Form is outside the repository) and email address validation beyond a basic format check.
|
||||
- Automated tests that call the real Sheety, SerpAPI, mock-flight, SMTP or WhatsApp services, or that read the real `.env`.
|
||||
- A database, scheduling of the daily run, unsubscribe handling and booking of flights.
|
||||
|
||||
## Expected Benefits
|
||||
|
||||
### Tangible Benefits
|
||||
|
||||
- A working, runnable lesson solution with automated tests.
|
||||
- A repository other participants can clone and run from the README alone.
|
||||
|
||||
### Intangible Benefits
|
||||
|
||||
- Practice with `smtplib`, environment secrets, API fallbacks and a second data source.
|
||||
- A reusable template for later course days.
|
||||
|
||||
## Strategic Alignment
|
||||
|
||||
The project supports the participant's learning goal (completing the bootcamp) and the shared goal of publishing readable solutions that others can learn from.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
| # | Criterion | Target | Measure |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Unit tests pass | 100% of tests pass, no network or `.env` access | `pytest` exit code 0 |
|
||||
| 2 | Indirect fallback | When the direct search finds nothing, a search with stops runs and `stops` is set | Unit test on `main` and `check_flights` |
|
||||
| 3 | Customer emails | `get_customer_emails()` returns the list of addresses from the users sheet | Unit test with a fake HTTP response |
|
||||
| 4 | Email content | Direct and stopover messages differ; text holds price, origin, destination, dates, stops and no "£" | Unit test on the message text |
|
||||
| 5 | Run instructions work | A fresh clone runs by following the README steps only | Manual run of the README steps |
|
||||
| 6 | Repository metadata and wiki | Description and at least 5 topics set; en, da and ms wiki pages with screenshots | Check on the git host and file review |
|
||||
| 7 | Runtime dependencies | Only packages the assignment needs; none added over Day 39 | `pyproject.toml` review |
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| Free-tier quotas run out (SerpAPI 100/month, Sheety 200/month) | Development stops | Keep the response cache; use the mock flight service; keep 3 to 5 destinations; the extra indirect search runs only when no direct flight exists |
|
||||
| Secrets committed by mistake | Credential leak | `.env` and cache files in `.gitignore`; only `.env.example` committed; secret scan before each PR |
|
||||
| SMTP credentials are not yet in the local `.env` | Real mails cannot be sent | SMTP is isolated in `NotificationManager`; tests use a fake `smtplib`; the missing values are an open issue in the Project Plan |
|
||||
| Mails sent to real customers by mistake during development | Spam to real people | Tests never send; README tells the participant to use a test sheet first |
|
||||
| CI workflows from the TirSystem repository do not fit this project | Red pipeline | Install only the needed workflows; stop and report after three failed fixes |
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The Day 39 code is the base. Its WhatsApp delivery (`pywhatkit`, optional and lazy) and the mock flight service remain; Twilio is not restored.
|
||||
- The Sheety project exposes a `prices` sheet (`city`, `iataCode`, `lowestPrice`, `id`) and a `users` sheet filled by a Google Form (`firstName`, `lastName`, `email`); both use Bearer authentication.
|
||||
- The participant (S01) is Product Owner, developer and reviewer; in autonomous mode the AI acts as stand-in reviewer and every such review says so.
|
||||
- The origin airport is LHR and the currency is GBP, both constants, as in the assignment.
|
||||
- Real tokens exist only in the participant's local `.env`, are used by the participant's own runs and by the repository setup on the git host, and never by tests. The AI uses only the git host token, to set the description and topics.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Python 3.13 or newer; `venv`; pytest; a `constants.py` for constants; `pyproject.toml` for configuration.
|
||||
- Doxygen comments in source and a Doxyfile.
|
||||
- Hosted at `https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code` (this project is the `040-personal-flight-club` repository); one branch and pull request per step.
|
||||
- No more runtime dependencies than needed; `smtplib` is part of the standard library.
|
||||
|
||||
## Cost–Benefit Assessment
|
||||
|
||||
| Costs | Benefits |
|
||||
| --- | --- |
|
||||
| The participant's spare time; free-tier API accounts; AI assistance | A finished lesson solution, a portfolio repository and a reusable template. Qualitative assessment: the project is small and the cost is time only. |
|
||||
|
||||
## Stakeholders
|
||||
|
||||
| Stakeholder ID (SA) | Interest in this project |
|
||||
| --- | --- |
|
||||
| S01 | Finish the lesson, own the product and review it |
|
||||
| S02 | Compare solutions; runnable code with the assignment's names |
|
||||
| S03 | Browse for ideas; clear description, topics and README |
|
||||
|
||||
## Recommendation
|
||||
|
||||
Proceed — the scope is small, builds on working Day 39 code, the risks are mitigated, and the result serves all three stakeholders.
|
||||
|
||||
---
|
||||
|
||||
[SA-001]: ./stakeholder-analysis.md
|
||||
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
|
||||
@@ -0,0 +1,72 @@
|
||||
# MIL-001 Baseline and project setup
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | MIL-001 |
|
||||
| CrossReference | [BC-001] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Decide whether the repository skeleton is ready for the Day 40 changes: Day 39 code carried over, tooling, configuration, secrets handling, CI and repository metadata.
|
||||
|
||||
## Deliverable
|
||||
|
||||
A Python 3.13+ project: pyproject.toml, src/ and tests/ layout with the Day 39 code and tests, constants.py, .gitignore, .env.example, Doxyfile, venv instructions, framework hooks, CI workflows from TirSystem/github-action, and the repository description and topics.
|
||||
|
||||
## Go / No-Go Criteria
|
||||
|
||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `pytest` runs in a fresh `.venv` and passes the carried-over Day 39 tests | Passes | Fails |
|
||||
| 2 | `.env` and cache files are ignored; only `.env.example` is tracked | Verified with `git check-ignore` | Secret tracked |
|
||||
| 3 | Installed CI workflows are present and pass on the pull request | Green | Red after 3 fixes |
|
||||
| 4 | Repository description and at least 5 topics are set | Set | Missing |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Depends on | Reason |
|
||||
| --- | --- |
|
||||
| None | First phase; the baseline documents (BC, SA, PP) are accepted before it |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Business Case objective / KPI / user story | Reference |
|
||||
| --- | --- |
|
||||
| Objectives 4 and 5 | [BC-001] Objectives |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Role | Stakeholder ID (SA) |
|
||||
| --- | --- |
|
||||
| Owner | S01 |
|
||||
| Approving reviewer | S01 |
|
||||
|
||||
## Target Date
|
||||
|
||||
2026-10-26 — within the two-week plan window of the Project Plan.
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Carry over the Day 39 code | Copy `src/flight_club/` and `tests/` from the `039-personal-flight-club` repository (same package and names) into this project and set the project name and description for Day 40 in `pyproject.toml` (Python >=3.13; runtime dependencies requests, requests-cache, python-dotenv; extras `whatsapp` and `dev`). Use `constants.py` for constants. Do not copy `.env`, caches or `.venv`. | No | |
|
||||
| 2 | Ignore file and env template | Add a Python `.gitignore` (also `.env`, `flight_cache.sqlite`, `.venv`, Doxygen output) and `.env.example` listing variable names without values. The real `.env` holds the participant's tokens and is never committed, tested or read by tests. | No | |
|
||||
| 3 | Doxygen configuration | Add a `Doxyfile` that scans `src/` and writes to a git-ignored folder; source files use Doxygen comments. | No | |
|
||||
| 4 | Install CI workflows | Install the workflows that fit from https://git.tirsystem.com/TirSystem/github-action (tests, quality, English-comments check, wiki publish, milestone sync) into `.gitea/workflows/`. Stop and report if they cannot fit. | No | |
|
||||
| 5 | Framework git hooks | Run `install-git-hooks.sh` with the plan gate enabled so commits to src/ and tests/ need a Task trailer. | No | |
|
||||
| 6 | Repository description and topics | Set the description and topics on the `040-personal-flight-club` repository through the host API with the local `GITEA_TOKEN`, read from `.env` by a script and never printed or put on a command line. | No | |
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
|
||||
@@ -0,0 +1,70 @@
|
||||
# MIL-002 Flight data and indirect search
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | MIL-002 |
|
||||
| CrossReference | [BC-001] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Decide whether the flight search handles destinations without direct flights (assignment Step 3).
|
||||
|
||||
## Deliverable
|
||||
|
||||
`FlightData` with a `stops` attribute; `FlightSearch.check_flights(origin_city_code, destination_city_code, from_time, to_time, is_direct=True)` using the SerpAPI `stops` parameter (and the mock provider's equivalent); `main.py` falling back to an indirect search with the assignment's console messages.
|
||||
|
||||
## Go / No-Go Criteria
|
||||
|
||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `FlightData.__init__()` takes `stops` and stores it; stops are computed as `len(flight["flights"]) - 1` | Unit tests pass | Missing or wrong |
|
||||
| 2 | `check_flights(..., is_direct=True)` sends the nonstop filter, and `is_direct=False` sends the filter that allows 1 or 2 stops | Unit test on the request parameters | Wrong parameters |
|
||||
| 3 | `main` prints "Getting direct flights for [city]..." and, when the price is "N/A", "No direct flight to [city]. Looking for indirect flights..." and searches with `is_direct=False` | Unit test with fakes | No fallback |
|
||||
| 4 | No test uses the network or the real `.env` | Verified | Violated |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Depends on | Reason |
|
||||
| --- | --- |
|
||||
| MIL-001 | Builds on the previous phase |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Business Case objective / KPI / user story | Reference |
|
||||
| --- | --- |
|
||||
| Objective 1 | [BC-001] Objectives |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Role | Stakeholder ID (SA) |
|
||||
| --- | --- |
|
||||
| Owner | S01 |
|
||||
| Approving reviewer | S01 |
|
||||
|
||||
## Target Date
|
||||
|
||||
2026-10-29 — within the two-week plan window of the Project Plan.
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | FlightData stops | Add `stops` to `FlightData.__init__()` and to the "N/A" result. Compute `nr_stops = len(flight["flights"]) - 1` when parsing a response. | No | |
|
||||
| 2 | check_flights with is_direct | Add the `is_direct=True` parameter. Direct: SerpAPI `stops=1` (nonstop only). Indirect: the SerpAPI value for up to 1 or 2 stops; the mock provider filters by number of stops. Pass the stops to `FlightData`. Keep the response cache. | No | |
|
||||
| 3 | Indirect fallback in main | For each destination, search direct first; if `cheapest_flight.price == "N/A"`, print the fallback message and search again with `is_direct=False`. Keep the Day 39 price comparison, sheet update and WhatsApp message. | No | |
|
||||
| 4 | Tests for stops and fallback | Unit tests for parsing the stops, the request parameters of both searches and the fallback logic, using fakes only. | No | |
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
|
||||
@@ -0,0 +1,70 @@
|
||||
# MIL-003 Customer emails
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | MIL-003 |
|
||||
| CrossReference | [BC-001] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Decide whether the customer list can be read from the second sheet (assignment Step 4).
|
||||
|
||||
## Deliverable
|
||||
|
||||
`DataManager` that reads all environment variables (token, prices endpoint, users endpoint) in `__init__()` and has `get_customer_emails()` returning the data of the "users" sheet; `.env.example` lists the new endpoint.
|
||||
|
||||
## Go / No-Go Criteria
|
||||
|
||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `DataManager.__init__()` reads every environment variable in one place | Verified in code and test | Variables read elsewhere |
|
||||
| 2 | `get_customer_emails()` returns the users' data and the list of email addresses | Unit test with a fake response | Fails |
|
||||
| 3 | `.env.example` lists the prices and users endpoints without values | Present | Missing |
|
||||
| 4 | No test uses the network or the real `.env` | Verified | Violated |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Depends on | Reason |
|
||||
| --- | --- |
|
||||
| MIL-002 | Builds on the previous phase |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Business Case objective / KPI / user story | Reference |
|
||||
| --- | --- |
|
||||
| Objective 2 | [BC-001] Objectives |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Role | Stakeholder ID (SA) |
|
||||
| --- | --- |
|
||||
| Owner | S01 |
|
||||
| Approving reviewer | S01 |
|
||||
|
||||
## Target Date
|
||||
|
||||
2026-10-31 — within the two-week plan window of the Project Plan.
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Settings in DataManager.__init__ | Read `SHEETY_PRICES_ENDPOINT`, `SHEETY_USERS_ENDPOINT` and the authentication variables in `__init__()`. Keep `SHEETY_ENDPOINT` working as a fallback for the prices endpoint so Day 39 `.env` files keep running. | No | |
|
||||
| 2 | get_customer_emails | Add `get_customer_emails()` that returns the rows of the "users" sheet and a helper that extracts the email addresses (column name from a constant, `email`), skipping rows without a valid-looking address. | No | |
|
||||
| 3 | Tests for DataManager | Unit tests with a fake `requests` session for both sheets. | No | |
|
||||
| 4 | Env template | Add the users endpoint to `.env.example`. | No | |
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
|
||||
@@ -0,0 +1,70 @@
|
||||
# MIL-004 Email notifications and main program
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | MIL-004 |
|
||||
| CrossReference | [BC-001] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Decide whether customers receive the flight deal by email (assignment Step 5).
|
||||
|
||||
## Deliverable
|
||||
|
||||
`NotificationManager` that reads all its environment variables (SMTP address, email, app password, WhatsApp number) in `__init__()` and has `send_emails()`; `main.py` crafts a direct-flight or a stopover message and sends it to all customers.
|
||||
|
||||
## Go / No-Go Criteria
|
||||
|
||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `NotificationManager.__init__()` reads every environment variable in one place | Verified in code and test | Variables read elsewhere |
|
||||
| 2 | `send_emails(email_list, message)` logs in through `smtplib` with the app password and sends to every address, UTF-8 encoded | Unit test with a fake `smtplib.SMTP` | Fails |
|
||||
| 3 | `main` writes a different message for a direct flight and for a flight with a stopover; the text uses "GBP" and holds price, origin, destination, dates and, for a stopover, the number of stops | Unit test on the text | Same text or "£" used |
|
||||
| 4 | No test sends mail or reads the real `.env` | Verified | Violated |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Depends on | Reason |
|
||||
| --- | --- |
|
||||
| MIL-003 | Builds on the previous phase |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Business Case objective / KPI / user story | Reference |
|
||||
| --- | --- |
|
||||
| Objectives 2 and 3 | [BC-001] Objectives |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Role | Stakeholder ID (SA) |
|
||||
| --- | --- |
|
||||
| Owner | S01 |
|
||||
| Approving reviewer | S01 |
|
||||
|
||||
## Target Date
|
||||
|
||||
2026-11-04 — within the two-week plan window of the Project Plan.
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | SMTP settings in NotificationManager | Read `SMTP_ADDRESS`, `EMAIL_ADDRESS`, `EMAIL_PASSWORD` and the WhatsApp number in `__init__()`; add them to `.env.example`. The SMTP port is a constant. | No | |
|
||||
| 2 | send_emails | Implement `send_emails(email_list, email_body)` with `smtplib` and STARTTLS; build the message as UTF-8 so non-ASCII characters do not fail; one message per customer, with a subject line. | No | |
|
||||
| 3 | Message text in main | Craft the direct and stopover messages (stops in the stopover text) using "GBP"; send the WhatsApp message and the emails when a cheaper flight is found. | No | |
|
||||
| 4 | Tests for email and messages | Unit tests with a fake SMTP class and tests of both message texts. | No | |
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
|
||||
@@ -0,0 +1,70 @@
|
||||
# MIL-005 Documentation and wiki
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | MIL-005 |
|
||||
| CrossReference | [BC-001] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Decide whether the repository is ready for participants and visitors.
|
||||
|
||||
## Deliverable
|
||||
|
||||
README from the framework template with run and test instructions and screenshots; wiki pages in English, Danish and Bahasa Malaysia with screenshots; Doxygen check; `.venv` and `python -m pip install --upgrade pip` instructions.
|
||||
|
||||
## Go / No-Go Criteria
|
||||
|
||||
| # | Criterion (objectively checkable) | Go | No-Go |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | README follows the framework template and `check-readme.sh` passes | Passes | Fails |
|
||||
| 2 | README documents creating and using `.venv` and `python -m pip install --upgrade pip`, running the program and running the tests | Present | Missing |
|
||||
| 3 | Wiki pages exist in en, da and ms, each with a screenshot | All present | Any missing |
|
||||
| 4 | `doxygen` runs with the Doxyfile without errors | No errors | Errors |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Depends on | Reason |
|
||||
| --- | --- |
|
||||
| MIL-004 | Builds on the previous phase |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Business Case objective / KPI / user story | Reference |
|
||||
| --- | --- |
|
||||
| Objective 5 | [BC-001] Objectives |
|
||||
|
||||
## Ownership
|
||||
|
||||
| Role | Stakeholder ID (SA) |
|
||||
| --- | --- |
|
||||
| Owner | S01 |
|
||||
| Approving reviewer | S01 |
|
||||
|
||||
## Target Date
|
||||
|
||||
2026-11-07 — within the two-week plan window of the Project Plan.
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Summary | Needs its own Use Case/User Story? | Reference |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Screenshots | Capture a sample run (mock data, no real addresses) and a test run, as images in `docs/images/`. Never show tokens or real email addresses. | No | |
|
||||
| 2 | README | Fill the framework README template: description, features, installation with `.venv` and `python -m pip install --upgrade pip`, `.env` setup, usage, tests, screenshots, licence. | No | |
|
||||
| 3 | Wiki in three languages | Write Home and Getting-Started pages in English, Danish and Bahasa Malaysia under `docs/wiki/`, with a sidebar, for the publish workflow. | No | |
|
||||
| 4 | Doxygen check | Run Doxygen on the Doxyfile and fix warnings in the source comments. | No | |
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../business-case.md
|
||||
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
|
||||
@@ -0,0 +1,97 @@
|
||||
# Project Plan
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | PP-001 |
|
||||
| CrossReference | [BC-001], [SA-001], [MIL-001], [MIL-002], [MIL-003], [MIL-004], [MIL-005] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
This plan schedules the five phases that deliver the Day 40 flight club (see [BC-001]) in a two-week window. One branch and one pull request per milestone keep track of the steps.
|
||||
|
||||
## Planning Assumptions
|
||||
|
||||
- The plan starts 2026-10-24 and ends by 2026-11-07.
|
||||
- Each phase ends in a pull request that closes its issues; the Product Owner is S01 ([SA-001]).
|
||||
- Autonomous mode: after the user accepts this plan, the AI creates, reviews as stand-in for S01, commits and merges each phase.
|
||||
- Tests never use the real `.env`, the network, SMTP or the WhatsApp session.
|
||||
|
||||
## Gateway Schedule
|
||||
|
||||
| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| Baseline and project setup | [MIL-001] | 2026-10-24 to 2026-10-26 | 2026-10-26 | S01 | none | Project skeleton, Day 39 code base, CI, repository metadata | |
|
||||
| Flight data and indirect search | [MIL-002] | 2026-10-26 to 2026-10-29 | 2026-10-29 | S01 | none | FlightData.stops, check_flights with is_direct, indirect fallback in main | |
|
||||
| Customer emails | [MIL-003] | 2026-10-29 to 2026-10-31 | 2026-10-31 | S01 | none | DataManager reads the users sheet and all settings in __init__ | |
|
||||
| Email notifications and main program | [MIL-004] | 2026-10-31 to 2026-11-04 | 2026-11-04 | S01 | none | NotificationManager.send_emails and the final main flow | |
|
||||
| Documentation and wiki | [MIL-005] | 2026-11-04 to 2026-11-07 | 2026-11-07 | S01 | none | README, wiki en/da/ms, screenshots | |
|
||||
|
||||
```plantuml
|
||||
@startgantt
|
||||
Project starts 2026-10-24
|
||||
[Baseline and project setup] starts 2026-10-24 and ends 2026-10-26
|
||||
[Flight data and indirect search] starts 2026-10-26 and ends 2026-10-29
|
||||
[Customer emails] starts 2026-10-29 and ends 2026-10-31
|
||||
[Email notifications and main program] starts 2026-10-31 and ends 2026-11-04
|
||||
[Documentation and wiki] starts 2026-11-04 and ends 2026-11-07
|
||||
[MIL-001 Go/No-Go] happens 2026-10-26
|
||||
[MIL-002 Go/No-Go] happens 2026-10-29
|
||||
[MIL-003 Go/No-Go] happens 2026-10-31
|
||||
[MIL-004 Go/No-Go] happens 2026-11-04
|
||||
[MIL-005 Go/No-Go] happens 2026-11-07
|
||||
@endgantt
|
||||
```
|
||||
|
||||
## Scope Coverage
|
||||
|
||||
| Business Case scope item | Gateway |
|
||||
| --- | --- |
|
||||
| Day 39 code carried over, pyproject, constants, .gitignore, Doxyfile, venv | [MIL-001] |
|
||||
| CI workflows, repository description and topics | [MIL-001] |
|
||||
| FlightData stops, check_flights with is_direct, indirect fallback | [MIL-002] |
|
||||
| DataManager.get_customer_emails and settings in __init__ | [MIL-003] |
|
||||
| NotificationManager.send_emails, direct and stopover messages, main.py | [MIL-004] |
|
||||
| README, wiki in en/da/ms, screenshots | [MIL-005] |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
MIL-001 → MIL-002 → MIL-003 → MIL-004 → MIL-005
|
||||
```
|
||||
|
||||
A No-Go on a phase moves every later date by the rework time.
|
||||
|
||||
## Plan Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| CI workflows need changes or a runner the host lacks | MIL-001 slips | Install only fitting workflows; stop and report after three failed fixes |
|
||||
| Screenshots cannot be captured without real data | MIL-005 slips | Use mock data and a test run only |
|
||||
| SMTP values are missing from the local `.env` | Real mails cannot be sent | Tests use fakes; the participant adds the values before a real run |
|
||||
|
||||
## Open Issues
|
||||
|
||||
- The local `.env` has no SMTP address, email address or app password, and no "users" sheet endpoint; they are needed only for a real run, not for the build or the tests.
|
||||
- The users sheet column names are assumed to be `firstName`, `lastName` and `email` (Sheety camel-cases the Google Form headings); the participant can correct them before acceptance.
|
||||
- The lesson's starter zip uses Twilio; this project keeps the Day 39 `pywhatkit` WhatsApp delivery instead, as the Business Case records.
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ./business-case.md
|
||||
[SA-001]: ./stakeholder-analysis.md
|
||||
[MIL-001]: ./milestones/mil-001-baseline-and-setup.md
|
||||
[MIL-002]: ./milestones/mil-002-flight-data-and-indirect-search.md
|
||||
[MIL-003]: ./milestones/mil-003-customer-emails.md
|
||||
[MIL-004]: ./milestones/mil-004-email-notifications-and-main.md
|
||||
[MIL-005]: ./milestones/mil-005-documentation-and-wiki.md
|
||||
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
|
||||
@@ -0,0 +1,68 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-001 |
|
||||
| CrossReference | [BC-001], [QC-BC-001], [QC-LANG-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [BC-001]
|
||||
- Checklist used: [QC-BC-001] and [QC-LANG-001]
|
||||
- Scope: full review
|
||||
- Language and domain: en / it
|
||||
- Language reviewer: none (the stand-in reads the language and knows the domain)
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | ROI/Cost-Benefit analysis is quantitative, or where qualitative, is explicitly justified | Pass | Cost-benefit is qualitative and the table says why (small project, cost is time only). |
|
||||
| 2 | Risks are identified with documented impact and mitigation | Pass | Risks table has five risks, each with impact and mitigation. |
|
||||
| 3 | Success criteria are measurable, stating explicit targets rather than vague aspirations | Pass | Seven success criteria with targets and measures. |
|
||||
| 4 | Scope explicitly separates In Scope vs Out of Scope | Pass | In Scope and Out of Scope subsections present. |
|
||||
| 5 | Stakeholders are cross-referenced to Stakeholder Analysis IDs rather than re-described inline | Pass | Stakeholders table cites S01, S02, S03 only; no inline role descriptions. |
|
||||
| 6 | Methodology and quality-standard foundation are stated explicitly (e.g. ISO/IEC 25010, Larman) | Pass | Methodology and standards section names the framework, ISO/IEC 25010 and coding skills. |
|
||||
| 7 | Assumptions and constraints are explicit and clearly distinguished from one another | Pass | Assumptions and Constraints are separate lists. |
|
||||
| 8 | Document supports executive decision-making with a clear, unambiguous recommendation | Pass | Recommendation is a single 'Proceed' with rationale. |
|
||||
|
||||
## Language and Domain Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Language `en` and Domain `it` rows are in Metadata. |
|
||||
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. |
|
||||
| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells are English. |
|
||||
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register matches the registry row for this type. |
|
||||
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | The project has no domain dictionary (DICT); the document uses standard IT terms consistently, with no synonyms. Stand-in judgement. |
|
||||
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Metadata keys, headings, IDs and statuses are English. |
|
||||
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists. |
|
||||
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | Pass | First version; no language change. |
|
||||
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | Confirmed by the stand-in for S01 (Product Owner, IT domain). Mode: autonomous, not independent. |
|
||||
| 10 | Abbreviations are spelled out on first use, in the stated language | Fail | Optional: SMTP, API, CI and similar abbreviations are not all spelled out on first use. Action item below. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — all mandatory criteria pass; the one failed criterion is optional (QC-LANG-001 criterion 10) and has an action item.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Spell out the abbreviations (SMTP, API, CI, IATA) on first use in the README glossary | S01 | 2026-11-07 |
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ../../docs/business-case.md
|
||||
[QC-BC-001]: ../../../framework/qc/artifacts/qc-business-case.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
|
||||
@@ -0,0 +1,68 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-002 |
|
||||
| CrossReference | [SA-001], [QC-SA-001], [QC-LANG-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [SA-001]
|
||||
- Checklist used: [QC-SA-001] and [QC-LANG-001]
|
||||
- Scope: full review
|
||||
- Language and domain: en / it
|
||||
- Language reviewer: none (the stand-in reads the language and knows the domain)
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Power/Interest grid is filled for every stakeholder, with no gaps or unclassified entries | Pass | Power, Interest and Quadrant filled for S01 to S03. |
|
||||
| 2 | Each stakeholder is assigned a unique, stable ID (e.g. S01-S11 style) reusable for RACI assignments in other artifacts | Pass | Stable IDs S01, S02, S03. |
|
||||
| 3 | Roles and organizational context are defined with explicit Power and Interest levels, not just narrative description | Pass | Roles, organisations and explicit levels given. |
|
||||
| 4 | Communication needs (channel, frequency, deliverable type) are mapped to project phases or milestones | Pass | Communication table ties each stakeholder to a milestone. |
|
||||
| 5 | Conflicting stakeholder interests are identified with documented mitigation or resolution strategies | Pass | Two conflicts, each with a mitigation. |
|
||||
| 6 | Stakeholder concerns are explicitly traced to Business Case objectives | Pass | Traceability table maps concerns to [BC-001] objectives. |
|
||||
| 7 | Primary concerns are expressed in both business language and a recognized quality-attribute mapping (e.g. FURPS+) | Pass | Concerns are in business language with a FURPS+ attribute. |
|
||||
| 8 | Document is understandable and navigable by non-technical stakeholders reviewing their own entry | Pass | Short, plain tables; each stakeholder can find their entry. |
|
||||
|
||||
## Language and Domain Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Language `en` and Domain `it` rows are in Metadata. |
|
||||
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. |
|
||||
| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells are English. |
|
||||
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register matches the registry row for this type. |
|
||||
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | The project has no domain dictionary (DICT); the document uses standard IT terms consistently, with no synonyms. Stand-in judgement. |
|
||||
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Metadata keys, headings, IDs and statuses are English. |
|
||||
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists. |
|
||||
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | Pass | First version; no language change. |
|
||||
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | Confirmed by the stand-in for S01 (Product Owner, IT domain). Mode: autonomous, not independent. |
|
||||
| 10 | Abbreviations are spelled out on first use, in the stated language | Fail | Optional: SMTP, API, CI and similar abbreviations are not all spelled out on first use. Action item below. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — all mandatory criteria pass; the one failed criterion is optional (QC-LANG-001 criterion 10) and has an action item.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Spell out the abbreviations (SMTP, API, CI, IATA) on first use in the README glossary | S01 | 2026-11-07 |
|
||||
|
||||
---
|
||||
|
||||
[SA-001]: ../../docs/stakeholder-analysis.md
|
||||
[QC-SA-001]: ../../../framework/qc/artifacts/qc-stakeholder-analysis.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
|
||||
@@ -0,0 +1,66 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-003 |
|
||||
| CrossReference | [MIL-001], [QC-MIL-001], [QC-LANG-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-001]
|
||||
- Checklist used: [QC-MIL-001] and [QC-LANG-001]
|
||||
- Scope: full review
|
||||
- Language and domain: en / it
|
||||
- Language reviewer: none (the stand-in reads the language and knows the domain)
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | A concrete deliverable is defined for every gate | Pass | Deliverable section names concrete output. |
|
||||
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Go/No-Go table has numbered, checkable criteria. |
|
||||
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Dependencies table present. |
|
||||
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceability table maps to [BC-001] objectives. |
|
||||
| 5 | Milestone owner and approving reviewer are identified | Pass | Owner and approving reviewer are S01. |
|
||||
| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target date set inside the 2026-10-24 to 2026-11-07 plan window. |
|
||||
|
||||
## Language and Domain Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Language `en` and Domain `it` rows are in Metadata. |
|
||||
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. |
|
||||
| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells are English. |
|
||||
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register matches the registry row for this type. |
|
||||
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | The project has no domain dictionary (DICT); the document uses standard IT terms consistently, with no synonyms. Stand-in judgement. |
|
||||
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Metadata keys, headings, IDs and statuses are English. |
|
||||
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists. |
|
||||
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | Pass | First version; no language change. |
|
||||
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | Confirmed by the stand-in for S01 (Product Owner, IT domain). Mode: autonomous, not independent. |
|
||||
| 10 | Abbreviations are spelled out on first use, in the stated language | Fail | Optional: SMTP, API, CI and similar abbreviations are not all spelled out on first use. Action item below. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — all mandatory criteria pass; the one failed criterion is optional (QC-LANG-001 criterion 10) and has an action item.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Spell out the abbreviations (SMTP, API, CI, IATA) on first use in the README glossary | S01 | 2026-11-07 |
|
||||
|
||||
---
|
||||
|
||||
[MIL-001]: ../../docs/milestones/mil-001-baseline-and-setup.md
|
||||
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.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
|
||||
@@ -0,0 +1,66 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-004 |
|
||||
| CrossReference | [MIL-002], [QC-MIL-001], [QC-LANG-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-002]
|
||||
- Checklist used: [QC-MIL-001] and [QC-LANG-001]
|
||||
- Scope: full review
|
||||
- Language and domain: en / it
|
||||
- Language reviewer: none (the stand-in reads the language and knows the domain)
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | A concrete deliverable is defined for every gate | Pass | Deliverable section names concrete output. |
|
||||
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Go/No-Go table has numbered, checkable criteria. |
|
||||
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Dependencies table present. |
|
||||
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceability table maps to [BC-001] objectives. |
|
||||
| 5 | Milestone owner and approving reviewer are identified | Pass | Owner and approving reviewer are S01. |
|
||||
| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target date set inside the 2026-10-24 to 2026-11-07 plan window. |
|
||||
|
||||
## Language and Domain Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Language `en` and Domain `it` rows are in Metadata. |
|
||||
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. |
|
||||
| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells are English. |
|
||||
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register matches the registry row for this type. |
|
||||
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | The project has no domain dictionary (DICT); the document uses standard IT terms consistently, with no synonyms. Stand-in judgement. |
|
||||
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Metadata keys, headings, IDs and statuses are English. |
|
||||
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists. |
|
||||
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | Pass | First version; no language change. |
|
||||
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | Confirmed by the stand-in for S01 (Product Owner, IT domain). Mode: autonomous, not independent. |
|
||||
| 10 | Abbreviations are spelled out on first use, in the stated language | Fail | Optional: SMTP, API, CI and similar abbreviations are not all spelled out on first use. Action item below. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — all mandatory criteria pass; the one failed criterion is optional (QC-LANG-001 criterion 10) and has an action item.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Spell out the abbreviations (SMTP, API, CI, IATA) on first use in the README glossary | S01 | 2026-11-07 |
|
||||
|
||||
---
|
||||
|
||||
[MIL-002]: ../../docs/milestones/mil-002-flight-data-and-indirect-search.md
|
||||
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.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
|
||||
@@ -0,0 +1,66 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-005 |
|
||||
| CrossReference | [MIL-003], [QC-MIL-001], [QC-LANG-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-003]
|
||||
- Checklist used: [QC-MIL-001] and [QC-LANG-001]
|
||||
- Scope: full review
|
||||
- Language and domain: en / it
|
||||
- Language reviewer: none (the stand-in reads the language and knows the domain)
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | A concrete deliverable is defined for every gate | Pass | Deliverable section names concrete output. |
|
||||
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Go/No-Go table has numbered, checkable criteria. |
|
||||
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Dependencies table present. |
|
||||
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceability table maps to [BC-001] objectives. |
|
||||
| 5 | Milestone owner and approving reviewer are identified | Pass | Owner and approving reviewer are S01. |
|
||||
| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target date set inside the 2026-10-24 to 2026-11-07 plan window. |
|
||||
|
||||
## Language and Domain Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Language `en` and Domain `it` rows are in Metadata. |
|
||||
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. |
|
||||
| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells are English. |
|
||||
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register matches the registry row for this type. |
|
||||
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | The project has no domain dictionary (DICT); the document uses standard IT terms consistently, with no synonyms. Stand-in judgement. |
|
||||
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Metadata keys, headings, IDs and statuses are English. |
|
||||
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists. |
|
||||
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | Pass | First version; no language change. |
|
||||
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | Confirmed by the stand-in for S01 (Product Owner, IT domain). Mode: autonomous, not independent. |
|
||||
| 10 | Abbreviations are spelled out on first use, in the stated language | Fail | Optional: SMTP, API, CI and similar abbreviations are not all spelled out on first use. Action item below. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — all mandatory criteria pass; the one failed criterion is optional (QC-LANG-001 criterion 10) and has an action item.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Spell out the abbreviations (SMTP, API, CI, IATA) on first use in the README glossary | S01 | 2026-11-07 |
|
||||
|
||||
---
|
||||
|
||||
[MIL-003]: ../../docs/milestones/mil-003-customer-emails.md
|
||||
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.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
|
||||
@@ -0,0 +1,66 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-006 |
|
||||
| CrossReference | [MIL-004], [QC-MIL-001], [QC-LANG-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-004]
|
||||
- Checklist used: [QC-MIL-001] and [QC-LANG-001]
|
||||
- Scope: full review
|
||||
- Language and domain: en / it
|
||||
- Language reviewer: none (the stand-in reads the language and knows the domain)
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | A concrete deliverable is defined for every gate | Pass | Deliverable section names concrete output. |
|
||||
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Go/No-Go table has numbered, checkable criteria. |
|
||||
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Dependencies table present. |
|
||||
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceability table maps to [BC-001] objectives. |
|
||||
| 5 | Milestone owner and approving reviewer are identified | Pass | Owner and approving reviewer are S01. |
|
||||
| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target date set inside the 2026-10-24 to 2026-11-07 plan window. |
|
||||
|
||||
## Language and Domain Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Language `en` and Domain `it` rows are in Metadata. |
|
||||
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. |
|
||||
| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells are English. |
|
||||
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register matches the registry row for this type. |
|
||||
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | The project has no domain dictionary (DICT); the document uses standard IT terms consistently, with no synonyms. Stand-in judgement. |
|
||||
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Metadata keys, headings, IDs and statuses are English. |
|
||||
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists. |
|
||||
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | Pass | First version; no language change. |
|
||||
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | Confirmed by the stand-in for S01 (Product Owner, IT domain). Mode: autonomous, not independent. |
|
||||
| 10 | Abbreviations are spelled out on first use, in the stated language | Fail | Optional: SMTP, API, CI and similar abbreviations are not all spelled out on first use. Action item below. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — all mandatory criteria pass; the one failed criterion is optional (QC-LANG-001 criterion 10) and has an action item.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Spell out the abbreviations (SMTP, API, CI, IATA) on first use in the README glossary | S01 | 2026-11-07 |
|
||||
|
||||
---
|
||||
|
||||
[MIL-004]: ../../docs/milestones/mil-004-email-notifications-and-main.md
|
||||
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.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
|
||||
@@ -0,0 +1,66 @@
|
||||
# SQA Review Record
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-007 |
|
||||
| CrossReference | [MIL-005], [QC-MIL-001], [QC-LANG-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [MIL-005]
|
||||
- Checklist used: [QC-MIL-001] and [QC-LANG-001]
|
||||
- Scope: full review
|
||||
- Language and domain: en / it
|
||||
- Language reviewer: none (the stand-in reads the language and knows the domain)
|
||||
- Mode: autonomous, not independent (the reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | A concrete deliverable is defined for every gate | Pass | Deliverable section names concrete output. |
|
||||
| 2 | Explicit Go/No-Go criteria are stated for each gate | Pass | Go/No-Go table has numbered, checkable criteria. |
|
||||
| 3 | Dependencies on other milestones are explicitly mapped | Pass | Dependencies table present. |
|
||||
| 4 | Each milestone is traceable to a Business Case objective or KPI | Pass | Traceability table maps to [BC-001] objectives. |
|
||||
| 5 | Milestone owner and approving reviewer are identified | Pass | Owner and approving reviewer are S01. |
|
||||
| 6 | Milestone has a defined target date consistent with project constraints | Pass | Target date set inside the 2026-10-24 to 2026-11-07 plan window. |
|
||||
|
||||
## Language and Domain Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | The Metadata table has a `Language` row and a `Domain` row, and neither is a placeholder | Pass | Language `en` and Domain `it` rows are in Metadata. |
|
||||
| 2 | `Language` is a BCP 47 code and `Domain` is a value from the registry's domain list | Pass | `en` is BCP 47; `it` is in the registry domain list. |
|
||||
| 3 | The content (prose and table cells) is written in the stated language | Pass | All prose and table cells are English. |
|
||||
| 4 | The register matches the one the registry gives for the artifact type | Pass | Register matches the registry row for this type. |
|
||||
| 5 | Domain terms are the PO terms of the domain's dictionary, with no synonyms | Pass | The project has no domain dictionary (DICT); the document uses standard IT terms consistently, with no synonyms. Stand-in judgement. |
|
||||
| 6 | Metadata keys, section headings, IDs and statuses are in English | Pass | Metadata keys, headings, IDs and statuses are English. |
|
||||
| 7 | No translated twin (`<name>.<language>.md`) exists beside the document | Pass | No translated twin exists. |
|
||||
| 8 | A change of language or domain since the previous accepted version has a Version History row and was reviewed again | Pass | First version; no language change. |
|
||||
| 9 | A reviewer competent in the domain, and in the language, has confirmed that the domain terms are used correctly | Pass | Confirmed by the stand-in for S01 (Product Owner, IT domain). Mode: autonomous, not independent. |
|
||||
| 10 | Abbreviations are spelled out on first use, in the stated language | Fail | Optional: SMTP, API, CI and similar abbreviations are not all spelled out on first use. Action item below. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — all mandatory criteria pass; the one failed criterion is optional (QC-LANG-001 criterion 10) and has an action item.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| Spell out the abbreviations (SMTP, API, CI, IATA) on first use in the README glossary | S01 | 2026-11-07 |
|
||||
|
||||
---
|
||||
|
||||
[MIL-005]: ../../docs/milestones/mil-005-documentation-and-wiki.md
|
||||
[QC-MIL-001]: ../../../framework/qc/artifacts/qc-milestones-gateways.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
|
||||
@@ -0,0 +1,80 @@
|
||||
# Stakeholder Analysis
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | SA-001 |
|
||||
| CrossReference | [BC-001] |
|
||||
| Language | en |
|
||||
| Domain | it |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-24 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [c5a6ed5] |
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
This analysis names the people the flight club serves, so owners, reviewers and the documentation can cite stable IDs. It uses the Power/Interest grid.
|
||||
|
||||
## Stakeholder Summary Table
|
||||
|
||||
| ID | Name | Role/Title | Organization | Power Level | Interest Level | Quadrant | Primary Concern (Business Language) |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| S01 | Jens Tirsvad Nielsen | Course participant; Product Owner, developer and reviewer | Private | HIGH | HIGH | Manage Closely | A working, well-documented lesson solution that is cheap to run |
|
||||
| S02 | Udemy coursists | Fellow course participants | Udemy course community | LOW | MEDIUM | Keep Informed | Readable, runnable code with the assignment's function names, and README run instructions |
|
||||
| S03 | GitHub viewers | Repository visitors | Public | LOW | LOW | Monitor | A clear description, topics and README, and no extra runtime dependencies |
|
||||
|
||||
## Power/Interest Classification Rationale
|
||||
|
||||
- **Manage Closely (S01):** decides scope, writes and reviews everything.
|
||||
- **Keep Informed (S02):** cannot change the project, but copies and compares the code, so readability and instructions matter.
|
||||
- **Monitor (S03):** casual visitors; the repository front page is enough.
|
||||
|
||||
## Primary Concerns and FURPS+ Mapping
|
||||
|
||||
| ID | Concern | FURPS+ attribute |
|
||||
| --- | --- | --- |
|
||||
| S01 | Emails and messages carry correct deal data, with stops | Functionality |
|
||||
| S01 | Free-tier quotas are not wasted | Performance |
|
||||
| S01 | Secrets stay out of the repository; no mail to real people from tests | Functionality (security) |
|
||||
| S02 | Assignment names kept; code readable | Supportability |
|
||||
| S02 | Run steps work on a fresh clone | Usability |
|
||||
| S03 | Description, topics, README, wiki in three languages | Usability |
|
||||
| S03 | No runtime dependencies beyond the assignment | Supportability |
|
||||
|
||||
## Communication Requirements
|
||||
|
||||
| ID | Channel | Frequency | Deliverable | Phase / Milestone |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| S01 | Chat and run summary | Per milestone | Run summary with RC records and stand-in decisions | All |
|
||||
| S02 | README and wiki | On release | Run instructions, wiki pages | MIL-005 |
|
||||
| S03 | Repository page | On release | Description, topics, README, screenshots | MIL-001, MIL-005 |
|
||||
|
||||
## Conflicting Interests and Mitigations
|
||||
|
||||
| Conflict | Stakeholders | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| S02 wants the assignment's structure; clean architecture would reshape it | S01, S02 | Keep the assignment's class and function names; put them in thin modules over a small `src/` package |
|
||||
| S03 wants no dependencies; WhatsApp and caching need packages | S01, S03 | Runtime: `requests`, `requests_cache`, `python-dotenv`; WhatsApp module optional and lazy; SMTP uses the standard library; dev tools only in the dev extra |
|
||||
|
||||
## Traceability Analysis
|
||||
|
||||
### Business Goal Alignment
|
||||
|
||||
| Stakeholder | Concern | Business Case objective |
|
||||
| --- | --- | --- |
|
||||
| S01 | Correct deals, quota, secrets | [BC-001] objectives 1 to 4 |
|
||||
| S02 | Readable, runnable code | [BC-001] objectives 2, 3 and 5 |
|
||||
| S03 | Findable repository | [BC-001] objective 5 |
|
||||
|
||||
## Sign-Off
|
||||
|
||||
Reviewer: S01 (the AI acts as stand-in in autonomous mode, recorded in the RC record).
|
||||
|
||||
---
|
||||
|
||||
[BC-001]: ./business-case.md
|
||||
[c5a6ed5]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/040-personal-flight-club/commit/c5a6ed5985f76d8836f763acc5638053a39157f2
|
||||
Submodule
+1
Submodule framework added at eca2514787
@@ -0,0 +1,47 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "flight-club"
|
||||
version = "0.1.0"
|
||||
description = "Day 40 flight club: find cheap direct and indirect flights from a Google Sheet and notify the club by email and WhatsApp."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"requests",
|
||||
"requests-cache",
|
||||
"python-dotenv",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
whatsapp = ["pywhatkit"]
|
||||
dev = ["pytest", "pytest-cov", "ruff", "mypy", "types-requests"]
|
||||
|
||||
[project.scripts]
|
||||
flight-club = "flight_club.main:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py313"
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
python_version = "3.13"
|
||||
|
||||
# The shared CI quality job runs mypy without the project dependencies installed.
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["requests", "requests_cache", "dotenv", "pywhatkit", "pytest"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["tests.*"]
|
||||
disallow_untyped_decorators = false
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
@file
|
||||
@brief Flight Club: finds cheap flights for destinations listed in a Google Sheet.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
@file
|
||||
@brief Lets `python -m flight_club` start the program.
|
||||
"""
|
||||
|
||||
from flight_club.main import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
@file
|
||||
@brief Local HTTP cache for flight searches.
|
||||
"""
|
||||
|
||||
import requests_cache
|
||||
|
||||
from flight_club.constants import CACHE_EXPIRE_SECONDS, CACHE_NAME, SHEETY_HOST
|
||||
|
||||
|
||||
## @brief Installs the HTTP cache; Sheety traffic is never cached.
|
||||
# @return None
|
||||
def install_flight_cache() -> None:
|
||||
requests_cache.install_cache(
|
||||
CACHE_NAME,
|
||||
expire_after=CACHE_EXPIRE_SECONDS,
|
||||
urls_expire_after={f"{SHEETY_HOST}*": requests_cache.DO_NOT_CACHE},
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
@file
|
||||
@brief Constants shared across Flight Club. Holds no logic.
|
||||
"""
|
||||
|
||||
## @brief IATA code of the airport every search departs from.
|
||||
ORIGIN_AIRPORT = "LHR"
|
||||
|
||||
## @brief Currency of every price requested and shown.
|
||||
CURRENCY = "GBP"
|
||||
|
||||
## @brief SerpApi search endpoint.
|
||||
SERPAPI_URL = "https://serpapi.com/search"
|
||||
|
||||
## @brief SerpApi engine used for flight searches.
|
||||
SERPAPI_ENGINE = "google_flights"
|
||||
|
||||
## @brief Default URL of the course's mock flight service (overridable via the MOCK_FLIGHTS_URL environment variable).
|
||||
DEFAULT_MOCK_FLIGHTS_URL = "https://app.100daysofpython.dev/v1/flights/search"
|
||||
|
||||
## @brief Host of the Sheety API.
|
||||
SHEETY_HOST = "api.sheety.co"
|
||||
|
||||
## @brief Name of the local HTTP cache for flight searches.
|
||||
CACHE_NAME = "flight_cache"
|
||||
|
||||
## @brief Seconds a cached flight search stays valid.
|
||||
CACHE_EXPIRE_SECONDS = 3600
|
||||
|
||||
## @brief Length of the search window in days (about six months).
|
||||
SEARCH_WINDOW_DAYS = 182
|
||||
|
||||
## @brief Text shown when no price is available.
|
||||
NOT_AVAILABLE = "N/A"
|
||||
|
||||
## @brief strftime format of dates sent to and read from providers.
|
||||
DATE_FORMAT = "%Y-%m-%d"
|
||||
|
||||
## @brief Name of the SerpApi flight provider.
|
||||
PROVIDER_SERPAPI = "serpapi"
|
||||
|
||||
## @brief Name of the mock flight provider.
|
||||
PROVIDER_MOCK = "mock"
|
||||
|
||||
## @brief Environment variable: Sheety sheet endpoint.
|
||||
ENV_SHEETY_ENDPOINT = "SHEETY_ENDPOINT"
|
||||
|
||||
## @brief Environment variable: Sheety Bearer token.
|
||||
ENV_SHEETY_TOKEN = "SHEETY_TOKEN"
|
||||
|
||||
## @brief Environment variable: selected flight provider.
|
||||
ENV_FLIGHT_PROVIDER = "FLIGHT_PROVIDER"
|
||||
|
||||
## @brief Environment variable: SerpApi API key.
|
||||
ENV_SERPAPI_API_KEY = "SERPAPI_API_KEY"
|
||||
|
||||
## @brief Environment variable: mock flight service app id.
|
||||
ENV_MOCK_APP_ID = "MOCK_FLIGHTS_APP_ID"
|
||||
|
||||
## @brief Environment variable: mock flight service app key.
|
||||
ENV_MOCK_APP_KEY = "MOCK_FLIGHTS_APP_KEY"
|
||||
|
||||
## @brief Environment variable: optional URL of the mock flight service.
|
||||
ENV_MOCK_FLIGHTS_URL = "MOCK_FLIGHTS_URL"
|
||||
|
||||
## @brief Environment variable: WhatsApp recipient number with country code.
|
||||
ENV_WHATSAPP_NUMBER = "WHATSAPP_NUMBER"
|
||||
|
||||
## @brief Seconds pywhatkit waits for WhatsApp Web to load before it sends the message.
|
||||
WHATSAPP_WAIT_SECONDS = 15
|
||||
|
||||
## @brief Seconds pywhatkit waits after sending before it closes the browser tab.
|
||||
WHATSAPP_CLOSE_SECONDS = 3
|
||||
|
||||
## @brief Seconds to wait for an HTTP response before giving up.
|
||||
REQUEST_TIMEOUT_SECONDS = 30
|
||||
|
||||
## @brief The longest piece of the flight service's error text that is printed.
|
||||
ERROR_TEXT_MAX_LENGTH = 200
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
@file
|
||||
@brief Reads and updates the destination sheet through the Sheety API.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from flight_club.constants import (
|
||||
ENV_SHEETY_ENDPOINT,
|
||||
ENV_SHEETY_TOKEN,
|
||||
REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
## @brief Raised when the sheet cannot be configured, read or updated.
|
||||
class DataManagerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
## @brief Talks to the Sheety sheet that holds the destinations.
|
||||
class DataManager:
|
||||
## @brief Reads the endpoint and token from the environment.
|
||||
# @param session Optional HTTP session; a new one is made per call if omitted.
|
||||
def __init__(self, session: requests.Session | None = None) -> None:
|
||||
self._endpoint = self._require_env(ENV_SHEETY_ENDPOINT)
|
||||
self._headers = {"Authorization": f"Bearer {self._require_env(ENV_SHEETY_TOKEN)}"}
|
||||
self._session = session
|
||||
|
||||
@staticmethod
|
||||
def _require_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
raise DataManagerError(f"Environment variable {name} is not set.")
|
||||
return value
|
||||
|
||||
def _get_session(self) -> requests.Session:
|
||||
# Created at call time so requests_cache.install_cache patching applies.
|
||||
return self._session if self._session is not None else requests.Session()
|
||||
|
||||
@staticmethod
|
||||
def _check(response: requests.Response, action: str) -> None:
|
||||
if not 200 <= response.status_code < 300:
|
||||
raise DataManagerError(f"Sheety {action} failed with HTTP {response.status_code}.")
|
||||
|
||||
## @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]]:
|
||||
response = self._get_session().get(
|
||||
self._endpoint, headers=self._headers, timeout=REQUEST_TIMEOUT_SECONDS
|
||||
)
|
||||
self._check(response, "read")
|
||||
body = response.json()
|
||||
prices = body.get("prices") if isinstance(body, dict) else None
|
||||
if not isinstance(prices, list):
|
||||
raise DataManagerError("Sheety response has no 'prices' list.")
|
||||
return prices
|
||||
|
||||
## @brief Stores a new lowest price for one row.
|
||||
# @param row_id Sheety row id.
|
||||
# @param new_price The new lowest price.
|
||||
# @return None
|
||||
def update_lowest_price(self, row_id: int, new_price: float) -> None:
|
||||
response = self._get_session().put(
|
||||
f"{self._endpoint}/{row_id}",
|
||||
json={"price": {"lowestPrice": new_price}},
|
||||
headers=self._headers,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
self._check(response, "update")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
@file
|
||||
@brief Picks the cheapest flight out of a SerpApi Google Flights response.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from flight_club.constants import NOT_AVAILABLE
|
||||
|
||||
|
||||
## @brief The cheapest flight found for one destination.
|
||||
@dataclass
|
||||
class FlightData:
|
||||
price: float | int | str
|
||||
origin_airport: str
|
||||
destination_airport: str
|
||||
out_date: str
|
||||
return_date: str
|
||||
|
||||
|
||||
def _unavailable() -> FlightData:
|
||||
return FlightData(
|
||||
price=NOT_AVAILABLE,
|
||||
origin_airport=NOT_AVAILABLE,
|
||||
destination_airport=NOT_AVAILABLE,
|
||||
out_date=NOT_AVAILABLE,
|
||||
return_date=NOT_AVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_price(entry: dict[str, Any]) -> float | None:
|
||||
price = entry.get("price")
|
||||
if isinstance(price, bool):
|
||||
return None
|
||||
try:
|
||||
return float(price) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _build_flight(entry: dict[str, Any], price: float, return_date: str) -> FlightData | None:
|
||||
try:
|
||||
legs = entry["flights"]
|
||||
return FlightData(
|
||||
price=price,
|
||||
origin_airport=legs[0]["departure_airport"]["id"],
|
||||
destination_airport=legs[-1]["arrival_airport"]["id"],
|
||||
out_date=legs[0]["departure_airport"]["time"].split(" ")[0],
|
||||
return_date=return_date,
|
||||
)
|
||||
except (KeyError, IndexError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
## @brief Finds the lowest priced flight across best and other flights.
|
||||
# @param data SerpApi response, or None when the search failed.
|
||||
# @param return_date Return date to store on the result.
|
||||
# @return The cheapest flight, or a FlightData filled with "N/A" when none is usable.
|
||||
def find_cheapest_flight(data: dict[str, Any] | None, return_date: str) -> FlightData:
|
||||
if not data:
|
||||
print("No flight data available.")
|
||||
return _unavailable()
|
||||
|
||||
entries = [*data.get("best_flights", []), *data.get("other_flights", [])]
|
||||
cheapest: FlightData | None = None
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
price = _parse_price(entry)
|
||||
if price is None:
|
||||
continue
|
||||
if cheapest is not None and price >= float(cheapest.price):
|
||||
continue
|
||||
flight = _build_flight(entry, price, return_date)
|
||||
if flight is not None:
|
||||
cheapest = flight
|
||||
|
||||
if cheapest is None:
|
||||
print("No valid flight found in the response.")
|
||||
return _unavailable()
|
||||
return cheapest
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
@file
|
||||
@brief Searches flight prices through SerpApi or the course's mock flight service.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from flight_club.constants import (
|
||||
CURRENCY,
|
||||
DATE_FORMAT,
|
||||
DEFAULT_MOCK_FLIGHTS_URL,
|
||||
ENV_FLIGHT_PROVIDER,
|
||||
ENV_MOCK_APP_ID,
|
||||
ENV_MOCK_APP_KEY,
|
||||
ENV_MOCK_FLIGHTS_URL,
|
||||
ENV_SERPAPI_API_KEY,
|
||||
ERROR_TEXT_MAX_LENGTH,
|
||||
PROVIDER_MOCK,
|
||||
PROVIDER_SERPAPI,
|
||||
REQUEST_TIMEOUT_SECONDS,
|
||||
SERPAPI_ENGINE,
|
||||
SERPAPI_URL,
|
||||
)
|
||||
|
||||
|
||||
## @brief Raised when the flight provider is configured wrongly.
|
||||
class FlightSearchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
## @brief Searches round trip flights with the provider chosen by FLIGHT_PROVIDER.
|
||||
#
|
||||
# The mock provider returns JSON shaped like a SerpApi Google Flights response, with
|
||||
# the keys best_flights and other_flights, so both providers feed the same parser.
|
||||
# The mock provider is called with the x-app-id and x-app-key headers and also with
|
||||
# the app key as the api_key query parameter, which the service requires.
|
||||
class FlightSearch:
|
||||
## @brief Reads the provider and its credentials from the environment.
|
||||
# @param session Optional HTTP session; a new one is made per call if omitted.
|
||||
def __init__(self, session: requests.Session | None = None) -> None:
|
||||
self._session = session
|
||||
self._provider = os.environ.get(ENV_FLIGHT_PROVIDER) or PROVIDER_SERPAPI
|
||||
self._api_key: str | None = None
|
||||
self._url = SERPAPI_URL
|
||||
self._headers: dict[str, str] = {}
|
||||
if self._provider == PROVIDER_SERPAPI:
|
||||
self._api_key = self._require_env(ENV_SERPAPI_API_KEY)
|
||||
elif self._provider == PROVIDER_MOCK:
|
||||
app_key = self._require_env(ENV_MOCK_APP_KEY)
|
||||
self._headers = {
|
||||
"x-app-id": self._require_env(ENV_MOCK_APP_ID),
|
||||
"x-app-key": app_key,
|
||||
}
|
||||
# The real mock service also demands the app key as the api_key query parameter.
|
||||
self._api_key = app_key
|
||||
self._url = os.environ.get(ENV_MOCK_FLIGHTS_URL) or DEFAULT_MOCK_FLIGHTS_URL
|
||||
else:
|
||||
raise FlightSearchError(
|
||||
f"Unknown {ENV_FLIGHT_PROVIDER} '{self._provider}'; "
|
||||
f"use '{PROVIDER_SERPAPI}' or '{PROVIDER_MOCK}'."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _require_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
raise FlightSearchError(f"Environment variable {name} is not set.")
|
||||
return value
|
||||
|
||||
## @brief Reads the service's own error text from a failed response.
|
||||
# @return The "error" string of a JSON body cut to the limit, or None if there is none.
|
||||
@staticmethod
|
||||
def _service_error_text(response: requests.Response) -> str | None:
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
if isinstance(body, dict) and isinstance(body.get("error"), str):
|
||||
return str(body["error"])[:ERROR_TEXT_MAX_LENGTH]
|
||||
return None
|
||||
|
||||
def _get_session(self) -> requests.Session:
|
||||
# Created at call time so requests_cache.install_cache patching applies.
|
||||
return self._session if self._session is not None else requests.Session()
|
||||
|
||||
def _build_params(
|
||||
self,
|
||||
origin_city_code: str,
|
||||
destination_city_code: str,
|
||||
from_time: datetime,
|
||||
to_time: datetime,
|
||||
) -> dict[str, str]:
|
||||
params = {
|
||||
"engine": SERPAPI_ENGINE,
|
||||
"departure_id": origin_city_code,
|
||||
"arrival_id": destination_city_code,
|
||||
"outbound_date": from_time.strftime(DATE_FORMAT),
|
||||
"return_date": to_time.strftime(DATE_FORMAT),
|
||||
"type": "1",
|
||||
"adults": "1",
|
||||
"currency": CURRENCY,
|
||||
}
|
||||
if self._api_key is not None:
|
||||
params["api_key"] = self._api_key
|
||||
return params
|
||||
|
||||
## @brief Searches the cheapest round trips between two airports.
|
||||
# @param origin_city_code IATA code to depart from.
|
||||
# @param destination_city_code IATA code to arrive at.
|
||||
# @param from_time Outbound date.
|
||||
# @param to_time Return date.
|
||||
# @return The decoded response, or None after printing why the search failed.
|
||||
def check_flights(
|
||||
self,
|
||||
origin_city_code: str,
|
||||
destination_city_code: str,
|
||||
from_time: datetime,
|
||||
to_time: datetime,
|
||||
) -> dict[str, Any] | None:
|
||||
params = self._build_params(origin_city_code, destination_city_code, from_time, to_time)
|
||||
try:
|
||||
response = self._get_session().get(
|
||||
self._url,
|
||||
params=params,
|
||||
headers=self._headers or None,
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as error:
|
||||
# The exception type is printed, not its text, which may echo the URL and key.
|
||||
print(f"Flight search request failed: {type(error).__name__}.")
|
||||
return None
|
||||
if not 200 <= response.status_code < 300:
|
||||
detail = self._service_error_text(response)
|
||||
suffix = f": {detail}" if detail else ""
|
||||
print(f"Flight search failed with HTTP {response.status_code}{suffix}.")
|
||||
return None
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
print("Flight search returned invalid JSON.")
|
||||
return None
|
||||
if not isinstance(body, dict):
|
||||
print("Flight search returned an unexpected response shape.")
|
||||
return None
|
||||
if "error" in body:
|
||||
print(f"Flight search returned an error: {body['error']}")
|
||||
return None
|
||||
return body
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
@file
|
||||
@brief Entry point: checks every destination in the sheet and alerts on lower prices.
|
||||
|
||||
Run it from the project root with `python -m flight_club` or the `flight-club` command,
|
||||
after filling the variables described in the README into a local `.env` file.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from flight_club.cache import install_flight_cache
|
||||
from flight_club.constants import (
|
||||
CURRENCY,
|
||||
DATE_FORMAT,
|
||||
NOT_AVAILABLE,
|
||||
ORIGIN_AIRPORT,
|
||||
SEARCH_WINDOW_DAYS,
|
||||
)
|
||||
from flight_club.data_manager import DataManager, DataManagerError
|
||||
from flight_club.flight_data import FlightData, find_cheapest_flight
|
||||
from flight_club.flight_search import FlightSearch, FlightSearchError
|
||||
from flight_club.notification_manager import NotificationManager
|
||||
|
||||
|
||||
def _is_checkable(row: dict[str, object]) -> bool:
|
||||
price = row.get("lowestPrice")
|
||||
return (
|
||||
bool(row.get("iataCode")) and isinstance(price, int | float) and not isinstance(price, bool)
|
||||
)
|
||||
|
||||
|
||||
## @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 flight_search Provider of flight offers.
|
||||
# @param notification_manager Sender of the alerts.
|
||||
# @param today Start of the run; the current time when omitted.
|
||||
# @return The cheaper flights that were found.
|
||||
def run(
|
||||
data_manager: DataManager,
|
||||
flight_search: FlightSearch,
|
||||
notification_manager: NotificationManager,
|
||||
today: datetime | None = None,
|
||||
) -> list[FlightData]:
|
||||
today = today if today is not None else datetime.now()
|
||||
tomorrow = today + timedelta(days=1)
|
||||
six_month_from_today = today + timedelta(days=SEARCH_WINDOW_DAYS)
|
||||
return_date = six_month_from_today.strftime(DATE_FORMAT)
|
||||
found: list[FlightData] = []
|
||||
|
||||
sheet_data = data_manager.get_destination_data()
|
||||
for row in sheet_data:
|
||||
if not _is_checkable(row):
|
||||
print(f"Skipping row without an IATA code or a numeric lowest price: {row.get('city')}")
|
||||
continue
|
||||
flights = flight_search.check_flights(
|
||||
ORIGIN_AIRPORT, row["iataCode"], tomorrow, six_month_from_today
|
||||
)
|
||||
cheapest = find_cheapest_flight(flights, return_date)
|
||||
print(f"{row['city']}: {CURRENCY} {cheapest.price}")
|
||||
if cheapest.price != NOT_AVAILABLE and cheapest.price < row["lowestPrice"]:
|
||||
print(f"Lower price flight found to {row['city']}!")
|
||||
data_manager.update_lowest_price(row["id"], cheapest.price) # type: ignore[arg-type]
|
||||
notification_manager.send_whatsapp(notification_manager.build_message(cheapest))
|
||||
found.append(cheapest)
|
||||
return found
|
||||
|
||||
|
||||
## @brief Wires the real collaborators and runs one check; exits with 1 on a setup error.
|
||||
# @return None
|
||||
def main() -> None:
|
||||
load_dotenv()
|
||||
install_flight_cache()
|
||||
try:
|
||||
run(DataManager(), FlightSearch(), NotificationManager())
|
||||
except (DataManagerError, FlightSearchError) as error:
|
||||
print(f"Error: {error}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
@file
|
||||
@brief Builds low price alerts and sends them through WhatsApp.
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
|
||||
from flight_club.constants import (
|
||||
CURRENCY,
|
||||
ENV_WHATSAPP_NUMBER,
|
||||
WHATSAPP_CLOSE_SECONDS,
|
||||
WHATSAPP_WAIT_SECONDS,
|
||||
)
|
||||
from flight_club.flight_data import FlightData
|
||||
|
||||
_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."
|
||||
|
||||
|
||||
## @brief Formats flight alerts and delivers them to the owner's WhatsApp.
|
||||
class NotificationManager:
|
||||
## @brief Creates the manager with an optional replacement for pywhatkit.
|
||||
# @param sender Callable taking (number, message); used instead of pywhatkit when given.
|
||||
def __init__(self, sender: Callable[[str, str], None] | None = None) -> None:
|
||||
self._sender = sender
|
||||
|
||||
## @brief Builds the alert text for one cheap flight.
|
||||
# @param flight The cheapest flight found for a destination.
|
||||
# @return A one line message with price, airports and both dates.
|
||||
def build_message(self, flight: FlightData) -> str:
|
||||
return (
|
||||
f"Low price alert! Only {CURRENCY} {flight.price} to fly "
|
||||
f"from {flight.origin_airport} to {flight.destination_airport}, "
|
||||
f"from {flight.out_date} to {flight.return_date}."
|
||||
)
|
||||
|
||||
## @brief Sends a message to the number in the environment; never raises.
|
||||
# @param message The text to send.
|
||||
# @return True when sent, False when it was only printed.
|
||||
def send_whatsapp(self, message: str) -> bool:
|
||||
number = os.environ.get(ENV_WHATSAPP_NUMBER)
|
||||
if not number:
|
||||
return self._print_instead(_NUMBER_HINT, message)
|
||||
if self._sender is not None:
|
||||
return self._send_with(self._sender, number, message)
|
||||
try:
|
||||
import pywhatkit # type: ignore[import-not-found,unused-ignore]
|
||||
except ImportError:
|
||||
return self._print_instead(_INSTALL_HINT, message)
|
||||
return self._send_with(self._pywhatkit_sender(pywhatkit), number, message)
|
||||
|
||||
@staticmethod
|
||||
def _pywhatkit_sender(module: object) -> Callable[[str, str], None]:
|
||||
def send(number: str, message: str) -> None:
|
||||
module.sendwhatmsg_instantly( # type: ignore[attr-defined]
|
||||
number, message, WHATSAPP_WAIT_SECONDS, True, WHATSAPP_CLOSE_SECONDS
|
||||
)
|
||||
|
||||
return send
|
||||
|
||||
@staticmethod
|
||||
def _send_with(sender: Callable[[str, str], None], number: str, message: str) -> bool:
|
||||
try:
|
||||
sender(number, message)
|
||||
except Exception as error: # noqa: BLE001
|
||||
# Only the type is printed: the text of the error might echo the phone number.
|
||||
print(f"WhatsApp message failed: {type(error).__name__}.")
|
||||
print(message)
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _print_instead(hint: str, message: str) -> bool:
|
||||
print(hint)
|
||||
print(message)
|
||||
return False
|
||||
@@ -0,0 +1,16 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests_cache
|
||||
|
||||
from flight_club.cache import install_flight_cache
|
||||
from flight_club.constants import CACHE_EXPIRE_SECONDS, CACHE_NAME, SHEETY_HOST
|
||||
|
||||
|
||||
def test_install_flight_cache_never_caches_sheety() -> None:
|
||||
with patch("flight_club.cache.requests_cache.install_cache", new=MagicMock()) as install:
|
||||
install_flight_cache()
|
||||
install.assert_called_once_with(
|
||||
CACHE_NAME,
|
||||
expire_after=CACHE_EXPIRE_SECONDS,
|
||||
urls_expire_after={f"{SHEETY_HOST}*": requests_cache.DO_NOT_CACHE},
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
from datetime import date
|
||||
|
||||
from flight_club import constants
|
||||
|
||||
|
||||
def test_search_window_is_positive() -> None:
|
||||
assert constants.SEARCH_WINDOW_DAYS > 0
|
||||
|
||||
|
||||
def test_provider_names_are_distinct() -> None:
|
||||
assert constants.PROVIDER_SERPAPI != constants.PROVIDER_MOCK
|
||||
|
||||
|
||||
def test_origin_is_three_letter_code() -> None:
|
||||
assert len(constants.ORIGIN_AIRPORT) == 3
|
||||
assert constants.ORIGIN_AIRPORT.isalpha()
|
||||
assert constants.ORIGIN_AIRPORT.isupper()
|
||||
|
||||
|
||||
def test_urls_use_https() -> None:
|
||||
assert constants.SERPAPI_URL.startswith("https://")
|
||||
assert constants.DEFAULT_MOCK_FLIGHTS_URL.startswith("https://")
|
||||
|
||||
|
||||
def test_date_format_round_trips() -> None:
|
||||
day = date(2026, 1, 31)
|
||||
assert day.strftime(constants.DATE_FORMAT) == "2026-01-31"
|
||||
|
||||
|
||||
def test_env_variable_names_are_distinct() -> None:
|
||||
names = [value for key, value in vars(constants).items() if key.startswith("ENV_")]
|
||||
assert len(names) == len(set(names))
|
||||
@@ -0,0 +1,84 @@
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from flight_club.constants import REQUEST_TIMEOUT_SECONDS
|
||||
from flight_club.data_manager import DataManager, DataManagerError
|
||||
|
||||
ENDPOINT = "https://api.sheety.co/abc/flights/prices"
|
||||
TOKEN = "super-secret-token"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SHEETY_ENDPOINT", ENDPOINT)
|
||||
monkeypatch.setenv("SHEETY_TOKEN", TOKEN)
|
||||
|
||||
|
||||
def _session(status: int = 200, body: Any = None) -> MagicMock:
|
||||
response = MagicMock()
|
||||
response.status_code = status
|
||||
response.json.return_value = body
|
||||
session = MagicMock()
|
||||
session.get.return_value = response
|
||||
session.put.return_value = response
|
||||
return session
|
||||
|
||||
|
||||
def test_get_destination_data_returns_prices() -> None:
|
||||
rows = [{"city": "Paris", "iataCode": "CDG", "lowestPrice": 54, "id": 2}]
|
||||
session = _session(body={"prices": rows})
|
||||
assert DataManager(session).get_destination_data() == rows
|
||||
session.get.assert_called_once_with(
|
||||
ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {TOKEN}"},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [{}, {"prices": "x"}, [1]])
|
||||
def test_get_destination_data_raises_without_prices_list(body: Any) -> None:
|
||||
with pytest.raises(DataManagerError, match="prices"):
|
||||
DataManager(_session(body=body)).get_destination_data()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [500, 401])
|
||||
def test_get_destination_data_raises_on_http_error(status: int) -> None:
|
||||
with pytest.raises(DataManagerError, match=str(status)):
|
||||
DataManager(_session(status=status)).get_destination_data()
|
||||
|
||||
|
||||
def test_update_lowest_price_puts_payload() -> None:
|
||||
session = _session()
|
||||
DataManager(session).update_lowest_price(7, 49.5)
|
||||
session.put.assert_called_once_with(
|
||||
f"{ENDPOINT}/7",
|
||||
json={"price": {"lowestPrice": 49.5}},
|
||||
headers={"Authorization": f"Bearer {TOKEN}"},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [500, 401])
|
||||
def test_update_lowest_price_raises_on_http_error(status: int) -> None:
|
||||
with pytest.raises(DataManagerError, match=str(status)):
|
||||
DataManager(_session(status=status)).update_lowest_price(1, 10)
|
||||
|
||||
|
||||
def test_errors_never_contain_token() -> None:
|
||||
with pytest.raises(DataManagerError) as read_error:
|
||||
DataManager(_session(status=401)).get_destination_data()
|
||||
with pytest.raises(DataManagerError) as write_error:
|
||||
DataManager(_session(status=500)).update_lowest_price(1, 10)
|
||||
assert TOKEN not in str(read_error.value)
|
||||
assert TOKEN not in str(write_error.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["SHEETY_ENDPOINT", "SHEETY_TOKEN"])
|
||||
def test_missing_env_variable_names_the_variable(
|
||||
monkeypatch: pytest.MonkeyPatch, name: str
|
||||
) -> None:
|
||||
monkeypatch.delenv(name)
|
||||
with pytest.raises(DataManagerError, match=name):
|
||||
DataManager(_session())
|
||||
@@ -0,0 +1,135 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from flight_club.constants import NOT_AVAILABLE
|
||||
from flight_club.flight_data import FlightData, find_cheapest_flight
|
||||
|
||||
RETURN_DATE = "2026-06-22"
|
||||
|
||||
SAMPLE_RESPONSE: dict[str, Any] = {
|
||||
"best_flights": [
|
||||
{
|
||||
"flights": [
|
||||
{
|
||||
"departure_airport": {"id": "LHR", "time": "2026-06-15 10:30"},
|
||||
"arrival_airport": {"id": "CDG", "time": "2026-06-15 12:50"},
|
||||
}
|
||||
],
|
||||
"price": 120,
|
||||
}
|
||||
],
|
||||
"other_flights": [
|
||||
{
|
||||
"flights": [
|
||||
{
|
||||
"departure_airport": {"id": "LHR", "time": "2026-06-16 07:00"},
|
||||
"arrival_airport": {"id": "CDG", "time": "2026-06-16 09:20"},
|
||||
}
|
||||
],
|
||||
"price": 95,
|
||||
},
|
||||
{
|
||||
"flights": [
|
||||
{
|
||||
"departure_airport": {"id": "LHR", "time": "2026-06-17 18:00"},
|
||||
"arrival_airport": {"id": "CDG", "time": "2026-06-17 20:20"},
|
||||
}
|
||||
],
|
||||
"price": 210,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _entry(price: Any, origin: str = "LHR", destination: str = "CDG") -> dict[str, Any]:
|
||||
return {
|
||||
"flights": [
|
||||
{
|
||||
"departure_airport": {"id": origin, "time": "2026-07-01 08:00"},
|
||||
"arrival_airport": {"id": destination, "time": "2026-07-01 10:00"},
|
||||
}
|
||||
],
|
||||
"price": price,
|
||||
}
|
||||
|
||||
|
||||
def _unavailable() -> FlightData:
|
||||
return FlightData(NOT_AVAILABLE, NOT_AVAILABLE, NOT_AVAILABLE, NOT_AVAILABLE, NOT_AVAILABLE)
|
||||
|
||||
|
||||
def test_find_cheapest_flight_picks_lowest_across_both_lists() -> None:
|
||||
result = find_cheapest_flight(SAMPLE_RESPONSE, RETURN_DATE)
|
||||
assert result == FlightData(95, "LHR", "CDG", "2026-06-16", RETURN_DATE)
|
||||
|
||||
|
||||
def test_find_cheapest_flight_uses_only_other_flights_when_best_missing() -> None:
|
||||
data = {"other_flights": [_entry(80), _entry(60, destination="ORY")]}
|
||||
result = find_cheapest_flight(data, RETURN_DATE)
|
||||
assert result.price == 60
|
||||
assert result.destination_airport == "ORY"
|
||||
|
||||
|
||||
def test_find_cheapest_flight_skips_missing_price() -> None:
|
||||
data = {"best_flights": [{"flights": _entry(1)["flights"]}, _entry(150)]}
|
||||
assert find_cheapest_flight(data, RETURN_DATE).price == 150
|
||||
|
||||
|
||||
@pytest.mark.parametrize("price", ["abc", None, [], True])
|
||||
def test_find_cheapest_flight_skips_non_numeric_price(price: Any) -> None:
|
||||
data = {"best_flights": [_entry(price), _entry(300)]}
|
||||
assert find_cheapest_flight(data, RETURN_DATE).price == 300
|
||||
|
||||
|
||||
def test_find_cheapest_flight_returns_unavailable_for_empty_dict() -> None:
|
||||
assert find_cheapest_flight({}, RETURN_DATE) == _unavailable()
|
||||
|
||||
|
||||
def test_find_cheapest_flight_returns_unavailable_for_none(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert find_cheapest_flight(None, RETURN_DATE) == _unavailable()
|
||||
assert capsys.readouterr().out.strip() != ""
|
||||
|
||||
|
||||
def test_find_cheapest_flight_skips_malformed_entries() -> None:
|
||||
data = {
|
||||
"best_flights": [
|
||||
{"price": 10},
|
||||
{"flights": [], "price": 10},
|
||||
{"flights": [{"departure_airport": {}}], "price": 10},
|
||||
"not a dict",
|
||||
_entry(200),
|
||||
]
|
||||
}
|
||||
assert find_cheapest_flight(data, RETURN_DATE).price == 200
|
||||
|
||||
|
||||
def test_find_cheapest_flight_returns_unavailable_when_all_malformed() -> None:
|
||||
data = {"best_flights": [{"price": 10}, {"flights": [], "price": 5}]}
|
||||
assert find_cheapest_flight(data, RETURN_DATE) == _unavailable()
|
||||
|
||||
|
||||
def test_find_cheapest_flight_takes_destination_from_last_leg() -> None:
|
||||
entry = {
|
||||
"flights": [
|
||||
{
|
||||
"departure_airport": {"id": "LHR", "time": "2026-08-01 06:00"},
|
||||
"arrival_airport": {"id": "AMS", "time": "2026-08-01 08:00"},
|
||||
},
|
||||
{
|
||||
"departure_airport": {"id": "AMS", "time": "2026-08-01 09:30"},
|
||||
"arrival_airport": {"id": "BCN", "time": "2026-08-01 11:45"},
|
||||
},
|
||||
],
|
||||
"price": 140,
|
||||
}
|
||||
result = find_cheapest_flight({"best_flights": [entry]}, RETURN_DATE)
|
||||
assert result.origin_airport == "LHR"
|
||||
assert result.destination_airport == "BCN"
|
||||
|
||||
|
||||
def test_find_cheapest_flight_trims_time_from_out_date() -> None:
|
||||
result = find_cheapest_flight({"best_flights": [_entry(50)]}, RETURN_DATE)
|
||||
assert result.out_date == "2026-07-01"
|
||||
assert result.return_date == RETURN_DATE
|
||||
@@ -0,0 +1,219 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from flight_club.constants import (
|
||||
CURRENCY,
|
||||
DEFAULT_MOCK_FLIGHTS_URL,
|
||||
REQUEST_TIMEOUT_SECONDS,
|
||||
SERPAPI_URL,
|
||||
)
|
||||
from flight_club.flight_search import FlightSearch, FlightSearchError
|
||||
|
||||
API_KEY = "serp-secret-key"
|
||||
APP_ID = "mock-app-id-value"
|
||||
APP_KEY = "mock-app-key-secret"
|
||||
FROM_TIME = datetime(2026, 6, 15)
|
||||
TO_TIME = datetime(2026, 6, 22)
|
||||
BODY: dict[str, Any] = {"best_flights": [{"flights": [], "price": 99}]}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in (
|
||||
"FLIGHT_PROVIDER",
|
||||
"SERPAPI_API_KEY",
|
||||
"MOCK_FLIGHTS_APP_ID",
|
||||
"MOCK_FLIGHTS_APP_KEY",
|
||||
"MOCK_FLIGHTS_URL",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
monkeypatch.setenv("SERPAPI_API_KEY", API_KEY)
|
||||
|
||||
|
||||
def _use_mock(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("FLIGHT_PROVIDER", "mock")
|
||||
monkeypatch.setenv("MOCK_FLIGHTS_APP_ID", APP_ID)
|
||||
monkeypatch.setenv("MOCK_FLIGHTS_APP_KEY", APP_KEY)
|
||||
|
||||
|
||||
def _session(status: int = 200, body: Any = BODY, invalid_json: bool = False) -> MagicMock:
|
||||
response = MagicMock()
|
||||
response.status_code = status
|
||||
if invalid_json:
|
||||
response.json.side_effect = ValueError("bad json")
|
||||
else:
|
||||
response.json.return_value = body
|
||||
session = MagicMock()
|
||||
session.get.return_value = response
|
||||
return session
|
||||
|
||||
|
||||
def _search(session: MagicMock) -> dict[str, Any] | None:
|
||||
return FlightSearch(session).check_flights("LHR", "CDG", FROM_TIME, TO_TIME)
|
||||
|
||||
|
||||
def test_check_flights_sends_serpapi_params() -> None:
|
||||
session = _session()
|
||||
assert _search(session) == BODY
|
||||
args, kwargs = session.get.call_args
|
||||
assert args == (SERPAPI_URL,)
|
||||
assert kwargs["params"] == {
|
||||
"engine": "google_flights",
|
||||
"departure_id": "LHR",
|
||||
"arrival_id": "CDG",
|
||||
"outbound_date": "2026-06-15",
|
||||
"return_date": "2026-06-22",
|
||||
"type": "1",
|
||||
"adults": "1",
|
||||
"currency": CURRENCY,
|
||||
"api_key": API_KEY,
|
||||
}
|
||||
assert kwargs["timeout"] == REQUEST_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def test_check_flights_uses_mock_headers_and_default_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_use_mock(monkeypatch)
|
||||
session = _session()
|
||||
assert _search(session) == BODY
|
||||
args, kwargs = session.get.call_args
|
||||
assert args == (DEFAULT_MOCK_FLIGHTS_URL,)
|
||||
assert kwargs["headers"] == {"x-app-id": APP_ID, "x-app-key": APP_KEY}
|
||||
assert kwargs["params"]["api_key"] == APP_KEY
|
||||
assert kwargs["params"]["departure_id"] == "LHR"
|
||||
|
||||
|
||||
def test_default_mock_url_targets_flights_search_when_env_unset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
assert DEFAULT_MOCK_FLIGHTS_URL.endswith("/v1/flights/search")
|
||||
_use_mock(monkeypatch)
|
||||
session = _session()
|
||||
_search(session)
|
||||
assert session.get.call_args.args[0].endswith("/v1/flights/search")
|
||||
|
||||
|
||||
def test_check_flights_uses_mock_url_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_use_mock(monkeypatch)
|
||||
monkeypatch.setenv("MOCK_FLIGHTS_URL", "https://example.test/flights")
|
||||
session = _session()
|
||||
_search(session)
|
||||
assert session.get.call_args.args == ("https://example.test/flights",)
|
||||
|
||||
|
||||
def test_init_raises_for_unknown_provider(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("FLIGHT_PROVIDER", "carrier-pigeon")
|
||||
with pytest.raises(FlightSearchError, match="FLIGHT_PROVIDER"):
|
||||
FlightSearch()
|
||||
|
||||
|
||||
def test_init_raises_without_serpapi_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("SERPAPI_API_KEY")
|
||||
with pytest.raises(FlightSearchError, match="SERPAPI_API_KEY"):
|
||||
FlightSearch()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", ["MOCK_FLIGHTS_APP_ID", "MOCK_FLIGHTS_APP_KEY"])
|
||||
def test_init_raises_without_mock_credentials(
|
||||
monkeypatch: pytest.MonkeyPatch, missing: str
|
||||
) -> None:
|
||||
_use_mock(monkeypatch)
|
||||
monkeypatch.delenv(missing)
|
||||
with pytest.raises(FlightSearchError, match=missing):
|
||||
FlightSearch()
|
||||
|
||||
|
||||
def test_check_flights_returns_none_on_http_error(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert _search(_session(status=500)) is None
|
||||
assert "500" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_check_flights_prints_service_error_text_on_http_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
_use_mock(monkeypatch)
|
||||
text = "Invalid input: expected string, received undefined"
|
||||
assert _search(_session(status=400, body={"error": text})) is None
|
||||
captured = capsys.readouterr()
|
||||
assert f"Flight search failed with HTTP 400: {text}." in captured.out
|
||||
for secret in (API_KEY, APP_ID, APP_KEY):
|
||||
assert secret not in captured.out + captured.err
|
||||
|
||||
|
||||
def test_check_flights_truncates_long_service_error_text(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert _search(_session(status=400, body={"error": "x" * 500})) is None
|
||||
out = capsys.readouterr().out
|
||||
assert "x" * 200 in out
|
||||
assert "x" * 201 not in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [["error"], {"error": 5}, {"other": "x"}])
|
||||
def test_check_flights_falls_back_when_error_body_is_not_text(
|
||||
capsys: pytest.CaptureFixture[str], body: Any
|
||||
) -> None:
|
||||
assert _search(_session(status=500, body=body)) is None
|
||||
assert capsys.readouterr().out == "Flight search failed with HTTP 500.\n"
|
||||
|
||||
|
||||
def test_check_flights_falls_back_when_error_body_is_not_json(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert _search(_session(status=502, invalid_json=True)) is None
|
||||
assert capsys.readouterr().out == "Flight search failed with HTTP 502.\n"
|
||||
|
||||
|
||||
def test_check_flights_returns_none_on_invalid_json(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert _search(_session(invalid_json=True)) is None
|
||||
assert "invalid JSON" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_check_flights_returns_none_on_error_body(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
assert _search(_session(body={"error": "Invalid API key"})) is None
|
||||
assert "Invalid API key" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_check_flights_returns_none_on_connection_error(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
session = MagicMock()
|
||||
session.get.side_effect = requests.ConnectionError(f"failed for key {API_KEY}")
|
||||
assert _search(session) is None
|
||||
assert "ConnectionError" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_check_flights_never_prints_serpapi_key(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
session = MagicMock()
|
||||
session.get.side_effect = requests.ConnectionError(f"failed for key {API_KEY}")
|
||||
_search(session)
|
||||
_search(_session(status=500))
|
||||
_search(_session(body={"error": "bad"}))
|
||||
captured = capsys.readouterr()
|
||||
assert API_KEY not in captured.out + captured.err
|
||||
|
||||
|
||||
def test_check_flights_never_prints_mock_secrets(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
_use_mock(monkeypatch)
|
||||
session = MagicMock()
|
||||
session.get.side_effect = requests.ConnectionError(f"{APP_ID} {APP_KEY}")
|
||||
_search(session)
|
||||
_search(_session(status=500))
|
||||
captured = capsys.readouterr()
|
||||
assert APP_ID not in captured.out + captured.err
|
||||
assert APP_KEY not in captured.out + captured.err
|
||||
@@ -0,0 +1,130 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from flight_club import main as main_module
|
||||
from flight_club.data_manager import DataManagerError
|
||||
from flight_club.flight_data import FlightData
|
||||
from flight_club.main import run
|
||||
|
||||
TODAY = datetime(2026, 10, 24, 12, 0)
|
||||
|
||||
|
||||
class FakeDataManager:
|
||||
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
||||
self.rows = rows
|
||||
self.updates: list[tuple[int, float]] = []
|
||||
|
||||
def get_destination_data(self) -> list[dict[str, Any]]:
|
||||
return self.rows
|
||||
|
||||
def update_lowest_price(self, row_id: int, new_price: float) -> None:
|
||||
self.updates.append((row_id, new_price))
|
||||
|
||||
|
||||
class FakeFlightSearch:
|
||||
def __init__(self, price: float | None) -> None:
|
||||
self.price = price
|
||||
self.calls: list[tuple[str, str, datetime, datetime]] = []
|
||||
|
||||
def check_flights(
|
||||
self, origin: str, destination: str, from_time: datetime, to_time: datetime
|
||||
) -> dict[str, Any] | None:
|
||||
self.calls.append((origin, destination, from_time, to_time))
|
||||
if self.price is None:
|
||||
return None
|
||||
leg = {
|
||||
"departure_airport": {"id": origin, "time": "2026-10-25 08:00"},
|
||||
"arrival_airport": {"id": destination, "time": "2026-10-25 10:00"},
|
||||
}
|
||||
return {"best_flights": [{"price": self.price, "flights": [leg]}]}
|
||||
|
||||
|
||||
class FakeNotificationManager:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[str] = []
|
||||
|
||||
def build_message(self, flight: FlightData) -> str:
|
||||
return f"alert {flight.destination_airport} {flight.price}"
|
||||
|
||||
def send_whatsapp(self, message: str) -> bool:
|
||||
self.sent.append(message)
|
||||
return True
|
||||
|
||||
|
||||
def _run(
|
||||
rows: list[dict[str, Any]], price: float | None
|
||||
) -> tuple[list[FlightData], FakeDataManager, FakeFlightSearch, FakeNotificationManager]:
|
||||
data, search, notifier = (
|
||||
FakeDataManager(rows),
|
||||
FakeFlightSearch(price),
|
||||
FakeNotificationManager(),
|
||||
)
|
||||
found = run(data, search, notifier, today=TODAY) # type: ignore[arg-type]
|
||||
return found, data, search, notifier
|
||||
|
||||
|
||||
def _row(lowest: Any = 100) -> dict[str, Any]:
|
||||
return {"city": "Paris", "iataCode": "CDG", "lowestPrice": lowest, "id": 2}
|
||||
|
||||
|
||||
def test_run_updates_and_notifies_when_price_is_lower() -> None:
|
||||
found, data, _, notifier = _run([_row(100)], 54)
|
||||
assert [f.price for f in found] == [54]
|
||||
assert data.updates == [(2, 54)]
|
||||
assert notifier.sent == ["alert CDG 54.0"]
|
||||
assert found[0].return_date == (TODAY + timedelta(days=182)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("price", [100, 150])
|
||||
def test_run_does_nothing_when_price_is_not_lower(price: float) -> None:
|
||||
found, data, _, notifier = _run([_row(100)], price)
|
||||
assert found == []
|
||||
assert data.updates == []
|
||||
assert notifier.sent == []
|
||||
|
||||
|
||||
def test_run_does_nothing_when_price_not_available() -> None:
|
||||
found, data, _, notifier = _run([_row(100)], None)
|
||||
assert found == []
|
||||
assert data.updates == []
|
||||
assert notifier.sent == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lowest", [None, "", "cheap"])
|
||||
def test_run_skips_row_without_numeric_lowest_price(lowest: Any) -> None:
|
||||
_, data, search, notifier = _run([_row(lowest)], 1)
|
||||
assert search.calls == []
|
||||
assert data.updates == []
|
||||
assert notifier.sent == []
|
||||
|
||||
|
||||
def test_run_skips_row_without_iata_code() -> None:
|
||||
row = {"city": "Nowhere", "iataCode": "", "lowestPrice": 100, "id": 3}
|
||||
_, _, search, _ = _run([row], 1)
|
||||
assert search.calls == []
|
||||
|
||||
|
||||
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"])]
|
||||
_, _, search, _ = _run(rows, None)
|
||||
assert [c[:2] for c in search.calls] == [("LHR", "CDG"), ("LHR", "BER"), ("LHR", "TYO")]
|
||||
for call in search.calls:
|
||||
assert call[2] == TODAY + timedelta(days=1)
|
||||
assert call[3] == TODAY + timedelta(days=182)
|
||||
|
||||
|
||||
def test_main_exits_with_one_on_data_manager_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
def fail() -> None:
|
||||
raise DataManagerError("Environment variable SHEETY_ENDPOINT is not set.")
|
||||
|
||||
monkeypatch.setattr(main_module, "load_dotenv", lambda: True)
|
||||
monkeypatch.setattr(main_module, "install_flight_cache", lambda: None)
|
||||
monkeypatch.setattr(main_module, "DataManager", fail)
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
main_module.main()
|
||||
assert exit_info.value.code == 1
|
||||
assert "SHEETY_ENDPOINT" in capsys.readouterr().out
|
||||
@@ -0,0 +1,91 @@
|
||||
import builtins
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from flight_club.constants import ENV_WHATSAPP_NUMBER
|
||||
from flight_club.flight_data import FlightData
|
||||
from flight_club.notification_manager import NotificationManager
|
||||
|
||||
NUMBER = "+441234567890"
|
||||
|
||||
|
||||
def _flight() -> FlightData:
|
||||
return FlightData(54, "LHR", "CDG", "2026-10-25", "2027-04-20")
|
||||
|
||||
|
||||
def test_build_message_contains_price_airports_and_dates() -> None:
|
||||
message = NotificationManager().build_message(_flight())
|
||||
assert message == (
|
||||
"Low price alert! Only GBP 54 to fly from LHR to CDG, from 2026-10-25 to 2027-04-20."
|
||||
)
|
||||
|
||||
|
||||
def test_send_whatsapp_returns_false_when_number_missing(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.delenv(ENV_WHATSAPP_NUMBER, raising=False)
|
||||
assert NotificationManager().send_whatsapp("hello") is False
|
||||
output = capsys.readouterr().out
|
||||
assert "hello" in output
|
||||
assert ENV_WHATSAPP_NUMBER in output
|
||||
|
||||
|
||||
def test_send_whatsapp_uses_injected_sender(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv(ENV_WHATSAPP_NUMBER, NUMBER)
|
||||
calls: list[tuple[str, str]] = []
|
||||
manager = NotificationManager(sender=lambda number, message: calls.append((number, message)))
|
||||
assert manager.send_whatsapp("hello") is True
|
||||
assert calls == [(NUMBER, "hello")]
|
||||
|
||||
|
||||
def test_send_whatsapp_returns_false_when_pywhatkit_missing(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setenv(ENV_WHATSAPP_NUMBER, NUMBER)
|
||||
monkeypatch.delitem(sys.modules, "pywhatkit", raising=False)
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
if name == "pywhatkit":
|
||||
raise ImportError(name)
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
assert NotificationManager().send_whatsapp("hello") is False
|
||||
output = capsys.readouterr().out
|
||||
assert "whatsapp" in output
|
||||
assert "hello" in output
|
||||
assert NUMBER not in output
|
||||
|
||||
|
||||
def test_send_whatsapp_never_prints_number_when_pywhatkit_fails(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setenv(ENV_WHATSAPP_NUMBER, NUMBER)
|
||||
fake = types.ModuleType("pywhatkit")
|
||||
|
||||
def fail(*args: Any) -> None:
|
||||
raise RuntimeError(f"cannot reach {args[0]}")
|
||||
|
||||
fake.sendwhatmsg_instantly = fail # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "pywhatkit", fake)
|
||||
assert NotificationManager().send_whatsapp("hello") is False
|
||||
output = capsys.readouterr().out
|
||||
assert "RuntimeError" in output
|
||||
assert "hello" in output
|
||||
assert NUMBER not in output
|
||||
|
||||
|
||||
def test_send_whatsapp_calls_pywhatkit_with_documented_arguments(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv(ENV_WHATSAPP_NUMBER, NUMBER)
|
||||
received: list[tuple[Any, ...]] = []
|
||||
fake = types.ModuleType("pywhatkit")
|
||||
fake.sendwhatmsg_instantly = lambda *args: received.append(args) # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "pywhatkit", fake)
|
||||
assert NotificationManager().send_whatsapp("hello") is True
|
||||
assert received == [(NUMBER, "hello", 15, True, 3)]
|
||||
Reference in New Issue
Block a user