pharmaradar 1.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. pharmaradar-1.1.1/.github/workflows/ci.yml +232 -0
  2. pharmaradar-1.1.1/.github/workflows/test.yml +63 -0
  3. pharmaradar-1.1.1/.gitignore +15 -0
  4. pharmaradar-1.1.1/CHANGELOG.md +42 -0
  5. pharmaradar-1.1.1/LICENSE +21 -0
  6. pharmaradar-1.1.1/PKG-INFO +256 -0
  7. pharmaradar-1.1.1/README.md +233 -0
  8. pharmaradar-1.1.1/pyproject.toml +65 -0
  9. pharmaradar-1.1.1/scripts/local-pypi-server.sh +0 -0
  10. pharmaradar-1.1.1/scripts/pre-commit-check.sh +37 -0
  11. pharmaradar-1.1.1/setup.cfg +4 -0
  12. pharmaradar-1.1.1/src/pharmaradar/__init__.py +19 -0
  13. pharmaradar-1.1.1/src/pharmaradar/availability_level.py +25 -0
  14. pharmaradar-1.1.1/src/pharmaradar/database/database_interface.py +99 -0
  15. pharmaradar-1.1.1/src/pharmaradar/location_selector.py +313 -0
  16. pharmaradar-1.1.1/src/pharmaradar/medicine.py +134 -0
  17. pharmaradar-1.1.1/src/pharmaradar/medicine_scraper.py +421 -0
  18. pharmaradar-1.1.1/src/pharmaradar/pharmacy_info.py +62 -0
  19. pharmaradar-1.1.1/src/pharmaradar/scraping_utils.py +463 -0
  20. pharmaradar-1.1.1/src/pharmaradar/service/medicine_watchdog.py +306 -0
  21. pharmaradar-1.1.1/src/pharmaradar/text_parsers.py +886 -0
  22. pharmaradar-1.1.1/src/pharmaradar/webdriver_utils.py +324 -0
  23. pharmaradar-1.1.1/src/pharmaradar.egg-info/PKG-INFO +256 -0
  24. pharmaradar-1.1.1/src/pharmaradar.egg-info/SOURCES.txt +31 -0
  25. pharmaradar-1.1.1/src/pharmaradar.egg-info/dependency_links.txt +1 -0
  26. pharmaradar-1.1.1/src/pharmaradar.egg-info/requires.txt +12 -0
  27. pharmaradar-1.1.1/src/pharmaradar.egg-info/top_level.txt +1 -0
  28. pharmaradar-1.1.1/tests/__init__.py +0 -0
  29. pharmaradar-1.1.1/tests/test_medicine.py +211 -0
  30. pharmaradar-1.1.1/tests/test_medicine_feature.py +174 -0
  31. pharmaradar-1.1.1/tests/test_medicine_scraper.py +413 -0
  32. pharmaradar-1.1.1/tests/test_name_matching.py +86 -0
  33. pharmaradar-1.1.1/tests/test_text_parsers.py +447 -0
@@ -0,0 +1,232 @@
1
+ # CI/CD Pipeline with Semantic Versioning
2
+ #
3
+ # This workflow automatically versions and publishes the package based on conventional commits:
4
+ # - feat: adds new features → minor version bump (0.1.0 → 0.2.0)
5
+ # - fix: bug fixes → patch version bump (0.1.0 → 0.1.1)
6
+ # - BREAKING CHANGE: in commit body → major version bump (0.1.0 → 1.0.0)
7
+ #
8
+ # The pipeline:
9
+ # 1. Runs code quality checks (linting, formatting)
10
+ # 2. Runs unit tests
11
+ # 3. Analyzes commits and bumps version if needed (main branch only)
12
+ # 4. Builds package
13
+ # 5. Creates GitHub release (if version changed)
14
+ # 6. Publishes to PyPI (if version changed and in release environment)
15
+ #
16
+ # Note: Workflow skips execution for semantic-release automated commits to prevent infinite loops
17
+
18
+ name: CI/CD
19
+
20
+ on:
21
+ push:
22
+ branches: [ main ]
23
+ # Don't run on tags created by semantic-release
24
+ tags-ignore: [ 'v*' ]
25
+ pull_request:
26
+ branches: [ main ]
27
+
28
+ jobs:
29
+ quality:
30
+ name: Code Quality
31
+ runs-on: ubuntu-latest
32
+ # Skip if this is a semantic-release commit
33
+ if: github.actor != 'github-actions[bot]' && !contains(github.event.head_commit.message, '[skip ci]')
34
+
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+
38
+ - name: Set up Python
39
+ uses: actions/setup-python@v4
40
+ with:
41
+ python-version: "3.13"
42
+
43
+ - name: Install dependencies
44
+ run: |
45
+ python -m pip install --upgrade pip
46
+ pip install -e ".[dev]"
47
+
48
+ - name: Lint with flake8
49
+ run: |
50
+ flake8 src/ tests/ --count --select=E9,F63,F7,F82 --show-source --statistics
51
+ flake8 src/ tests/ --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics
52
+
53
+ - name: Check formatting with black
54
+ run: black --check --diff src/ tests/
55
+
56
+ test:
57
+ name: Test Suite
58
+ runs-on: ubuntu-latest
59
+ needs: quality
60
+ # Skip if this is a semantic-release commit
61
+ if: github.actor != 'github-actions[bot]' && !contains(github.event.head_commit.message, '[skip ci]')
62
+ strategy:
63
+ matrix:
64
+ python-version: ["3.13"]
65
+
66
+ steps:
67
+ - uses: actions/checkout@v4
68
+
69
+ - name: Set up Python ${{ matrix.python-version }}
70
+ uses: actions/setup-python@v4
71
+ with:
72
+ python-version: ${{ matrix.python-version }}
73
+
74
+ - name: Install system dependencies
75
+ run: |
76
+ sudo apt-get update
77
+ sudo apt-get install -y chromium-browser chromium-chromedriver
78
+
79
+ - name: Install dependencies
80
+ run: |
81
+ python -m pip install --upgrade pip
82
+ pip install -e ".[dev]"
83
+
84
+ - name: Run tests
85
+ run: |
86
+ pytest tests/ --verbose --tb=short --junitxml=test-results.xml
87
+ env:
88
+ DISPLAY: :99
89
+
90
+ - name: Upload test results
91
+ uses: actions/upload-artifact@v4
92
+ if: always()
93
+ with:
94
+ name: test-results-${{ matrix.python-version }}
95
+ path: test-results.xml
96
+
97
+ build:
98
+ name: Build Package
99
+ runs-on: ubuntu-latest
100
+ needs: [quality, test]
101
+ # Skip if this is a semantic-release commit, and only run on main branch pushes
102
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.actor != 'github-actions[bot]' && !contains(github.event.head_commit.message, '[skip ci]')
103
+ outputs:
104
+ version-changed: ${{ steps.semantic.outputs.version-changed }}
105
+ old-version: ${{ steps.semantic.outputs.old-version }}
106
+ new-version: ${{ steps.semantic.outputs.new-version }}
107
+
108
+ steps:
109
+ - uses: actions/checkout@v4
110
+ with:
111
+ fetch-depth: 0 # Full history for semantic versioning
112
+ token: ${{ secrets.TOKEN }}
113
+
114
+ - name: Set up Python
115
+ uses: actions/setup-python@v4
116
+ with:
117
+ python-version: "3.13"
118
+
119
+ - name: Install build dependencies
120
+ run: |
121
+ python -m pip install --upgrade pip
122
+ pip install build twine python-semantic-release
123
+
124
+ - name: Configure git for semantic release
125
+ run: |
126
+ git config user.name "github-actions[bot]"
127
+ git config user.email "github-actions[bot]@users.noreply.github.com"
128
+
129
+ - name: Run semantic release
130
+ id: semantic
131
+ env:
132
+ GH_TOKEN: ${{ secrets.TOKEN }}
133
+ run: |
134
+ # Check if there are changes that warrant a new version
135
+ echo "🔍 Analyzing commits for version bump..."
136
+
137
+ # Get current version before semantic release
138
+ OLD_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
139
+ echo "old-version=$OLD_VERSION" >> $GITHUB_OUTPUT
140
+ echo "📋 Current version: $OLD_VERSION"
141
+
142
+ # Run semantic release to bump version if needed
143
+ echo "🔄 Running semantic release..."
144
+ if semantic-release --noop version; then
145
+ echo "📦 Changes detected, proceeding with version bump"
146
+ semantic-release version
147
+ else
148
+ echo "ℹ️ No significant changes found, keeping current version"
149
+ fi
150
+
151
+ # Get new version after semantic release
152
+ NEW_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
153
+ echo "new-version=$NEW_VERSION" >> $GITHUB_OUTPUT
154
+
155
+ # Check if version changed
156
+ if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then
157
+ echo "version-changed=true" >> $GITHUB_OUTPUT
158
+ echo "✅ Version bumped from $OLD_VERSION to $NEW_VERSION"
159
+ else
160
+ echo "version-changed=false" >> $GITHUB_OUTPUT
161
+ echo "ℹ️ No version change needed (current: $NEW_VERSION)"
162
+ fi
163
+
164
+ - name: Create GitHub Release
165
+ if: steps.semantic.outputs.version-changed == 'true'
166
+ env:
167
+ GH_TOKEN: ${{ secrets.TOKEN }}
168
+ run: |
169
+ NEW_VERSION="${{ steps.semantic.outputs.new-version }}"
170
+ echo "🚀 Preparing GitHub release for version $NEW_VERSION"
171
+
172
+ # Create a git tag if it doesn't exist
173
+ if git tag -a "v$NEW_VERSION" -m "Release v$NEW_VERSION" 2>/dev/null; then
174
+ echo "✅ Created tag v$NEW_VERSION"
175
+ git push origin "v$NEW_VERSION" || echo "Tag already pushed"
176
+ else
177
+ echo "ℹ️ Tag v$NEW_VERSION already exists"
178
+ fi
179
+
180
+ # Check if release already exists
181
+ if gh release view "v$NEW_VERSION" >/dev/null 2>&1; then
182
+ echo "ℹ️ Release v$NEW_VERSION already exists, skipping creation"
183
+ else
184
+ echo "📝 Creating GitHub release for v$NEW_VERSION"
185
+ gh release create "v$NEW_VERSION" \
186
+ --title "Release v$NEW_VERSION" \
187
+ --generate-notes \
188
+ --latest
189
+ echo "✅ GitHub release created successfully"
190
+ fi
191
+
192
+ - name: Build package
193
+ run: python -m build
194
+
195
+ - name: Check package
196
+ run: twine check dist/*
197
+
198
+ - name: Summary
199
+ run: |
200
+ echo "📊 Build Summary:"
201
+ echo "Old version: ${{ steps.semantic.outputs.old-version }}"
202
+ echo "New version: ${{ steps.semantic.outputs.new-version }}"
203
+ echo "Version changed: ${{ steps.semantic.outputs.version-changed }}"
204
+ echo "Build artifacts:"
205
+ ls -la dist/
206
+
207
+ - name: Upload build artifacts
208
+ uses: actions/upload-artifact@v4
209
+ with:
210
+ name: dist
211
+ path: dist/
212
+ retention-days: 90
213
+
214
+ # publish:
215
+ # name: Publish to PyPI
216
+ # runs-on: ubuntu-latest
217
+ # needs: build
218
+ # if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.build.outputs.version-changed == 'true'
219
+ # environment: release
220
+
221
+ # steps:
222
+ # - name: Download build artifacts
223
+ # uses: actions/download-artifact@v4
224
+ # with:
225
+ # name: dist
226
+ # path: dist/
227
+
228
+ # - name: Publish to PyPI
229
+ # uses: pypa/gh-action-pypi-publish@release/v1
230
+ # with:
231
+ # password: ${{ secrets.PYPI_API_TOKEN }}
232
+ # skip-existing: true # Skip if version already exists
@@ -0,0 +1,63 @@
1
+ name: Unit Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, develop ]
6
+ pull_request:
7
+ branches: [ main, develop ]
8
+
9
+ jobs:
10
+ test:
11
+ name: Run Unit Tests
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ matrix:
15
+ python-version: ["3.13"]
16
+
17
+ steps:
18
+ - name: Checkout code
19
+ uses: actions/checkout@v4
20
+
21
+ - name: Set up Python ${{ matrix.python-version }}
22
+ uses: actions/setup-python@v4
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+
26
+ - name: Install system dependencies for Selenium
27
+ run: |
28
+ sudo apt-get update
29
+ sudo apt-get install -y chromium-browser chromium-chromedriver xvfb
30
+
31
+ - name: Cache pip dependencies
32
+ uses: actions/cache@v3
33
+ with:
34
+ path: ~/.cache/pip
35
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
36
+ restore-keys: |
37
+ ${{ runner.os }}-pip-
38
+
39
+ - name: Install Python dependencies
40
+ run: |
41
+ python -m pip install --upgrade pip
42
+ pip install -e ".[dev]"
43
+
44
+ - name: Run unit tests
45
+ run: |
46
+ # Start virtual display for headless browser tests
47
+ export DISPLAY=:99
48
+ Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
49
+
50
+ # Run tests with verbose output
51
+ pytest tests/ \
52
+ --verbose \
53
+ --tb=short \
54
+ --durations=10 \
55
+ --junitxml=test-results.xml
56
+
57
+ - name: Upload test results
58
+ uses: actions/upload-artifact@v4
59
+ if: always()
60
+ with:
61
+ name: test-results-python-${{ matrix.python-version }}
62
+ path: test-results.xml
63
+ retention-days: 30
@@ -0,0 +1,15 @@
1
+ log/
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+ __pycache__
6
+ *.pyc
7
+ .pytest_cache
8
+ .vscode
9
+ .env
10
+ .env.local
11
+ .venv
12
+
13
+ # Jenkins artifacts
14
+ test-results.xml
15
+ *.log
@@ -0,0 +1,42 @@
1
+ # CHANGELOG
2
+
3
+ <!-- version list -->
4
+
5
+ ## v1.1.1 (2025-09-03)
6
+
7
+ ### Enhancement
8
+
9
+ - Freeze build and twine packages
10
+ ([`f8c01af`](https://github.com/bartekmp/pharmaradar/commit/f8c01afffd2be7064cb366635d7bc80d3bdd7419))
11
+
12
+
13
+ ## v1.1.0 (2025-08-22)
14
+
15
+ ### Feature
16
+
17
+ - Add selective fields update method
18
+ ([`55fe226`](https://github.com/bartekmp/pharmaradar/commit/55fe226d4aef26ecd6e9efe7d6231c32ff3c4fa2))
19
+
20
+
21
+ ## v1.0.2 (2025-08-20)
22
+
23
+ ### Enhancement
24
+
25
+ - Update readme, remove unused file
26
+ ([`23f66fc`](https://github.com/bartekmp/pharmaradar/commit/23f66fcd73f83f3645962eb39279e18a92f7649f))
27
+
28
+
29
+ ## v1.0.1 (2025-08-19)
30
+
31
+ ### Bug Fixes
32
+
33
+ - Don't run builds on tag releases
34
+ ([`a8b9e8e`](https://github.com/bartekmp/pharmaradar/commit/a8b9e8e7ad33ae38374e866600bde332d8348fbb))
35
+
36
+ - Use right semver option
37
+ ([`10258b5`](https://github.com/bartekmp/pharmaradar/commit/10258b5dd97b9747cd480da60de48a5350f44f1a))
38
+
39
+
40
+ ## v1.0.0 (2025-08-19)
41
+
42
+ - Initial Release
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 bartekmp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,256 @@
1
+ Metadata-Version: 2.4
2
+ Name: pharmaradar
3
+ Version: 1.1.1
4
+ Summary: Python scraping package for KtoMaLek.pl website to monitor drug availability.
5
+ Author: bartekmp
6
+ License-Expression: MIT
7
+ Keywords: ktomalek,pharmaradar,web scraping,selenium
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: selenium>=4.15.0
12
+ Requires-Dist: lxml>=5.0.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest==8.4.1; extra == "dev"
15
+ Requires-Dist: pytest-mock==3.14.1; extra == "dev"
16
+ Requires-Dist: black==25.1.0; extra == "dev"
17
+ Requires-Dist: flake8==7.3.0; extra == "dev"
18
+ Requires-Dist: isort==6.0.1; extra == "dev"
19
+ Requires-Dist: python-semantic-release==10.2.0; extra == "dev"
20
+ Requires-Dist: build==1.3.0; extra == "dev"
21
+ Requires-Dist: twine==6.1.0; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # PharmaRadar
25
+
26
+ [![Unit Tests](https://github.com/bartekmp/pharmaradar/actions/workflows/test.yml/badge.svg)](https://github.com/bartekmp/pharmaradar/actions/workflows/test.yml)
27
+ [![CI/CD](https://github.com/bartekmp/pharmaradar/actions/workflows/ci.yml/badge.svg)](https://github.com/bartekmp/pharmaradar/actions/workflows/ci.yml)
28
+
29
+ Python package for searching and managing pharmacy medicine availability from [KtoMaLek.pl](https://ktomalek.pl).
30
+
31
+ ## Requirements
32
+ Pharmaradar requires `chromium-browser`, `chromium-chromedriver` and `xvfb` to run, as the prerequisites for Selenium used to scrape the data from the KtoMaLek.pl page, as they do not provide an open API to get the data easily.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install pharmaradar
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ To work with searches use the `Medicine` object, which represents a search query including all required details about what you're looking for.
43
+ If you'd like to find nearest pharmacies, that have at least low availability of Euthyrox N 50 medicine, nearby the location like Złota street in Warsaw and the max radius of 10 kilometers, create it like this:
44
+ ```python
45
+ import pharmaradar
46
+
47
+ medicine = pharmaradar.Medicine(
48
+ name="Euthyrox N 50",
49
+ dosage="50 mcg",
50
+ location="Warszawa, Złota",
51
+ radius_km=10.0,
52
+ min_availability=AvailabilityLevel.LOW,
53
+ )
54
+ ```
55
+
56
+ Now create an instance of `MedicineFinder` class:
57
+ ```python
58
+ finder = pharmaradar.MedicineFinder()
59
+ ```
60
+
61
+ Then test if the connection to KtoMaLek.pl is possible and search for given medicine:
62
+ ```python
63
+ if finder.test_connection():
64
+ pharmacies = finder.search_medicine(medicine)
65
+ ```
66
+
67
+ If the search was successful, the `pharmacies` will contain a list of `PharmacyInfo` objects, with all important data found on the page:
68
+ ```python
69
+ for pharmacy in pharmacies:
70
+ print(f"Pharmacy Name: {pharmacy.name}")
71
+ print(f"Address: {pharmacy.address}")
72
+ print(f"Availability: {pharmacy.availability}")
73
+ if pharmacy.price_full:
74
+ print(f"Price: {pharmacy.price_full} zł")
75
+ if pharmacy.distance_km:
76
+ print(f"Distance: {pharmacy.distance_km} km")
77
+ if pharmacy.reservation_url:
78
+ print(f"Reservation URL: {pharmacy.reservation_url}")
79
+ ```
80
+
81
+ ## Medicine watchdog
82
+
83
+ `MedicineWatchdog` is a class useful in async and continuous tasks. It implements certain methods, like `add_medicine`, `update_medicine`, `remove_medicine`, `get_medicine`, etc. that interact with the database layer, which is responsible for operating on the actual database. It can be used to create an automated bot, which periodically will retrieve the medicine quieries using `get_all_medicines` method, and then will perform searching and notifying.
84
+ ```python
85
+ import sqlite3
86
+ from time import sleep
87
+
88
+ sql_db_client = SqliteInterface("my_database.db")
89
+ watchdog = pharmaradar.MedicineWatchdog(db_client)
90
+
91
+ while True:
92
+
93
+ all_medicines: list[Medicine] = watchdog.get_all_medicines()
94
+ for medicine in all_medicines:
95
+
96
+ print(f"Medicine: {medicine.name}")
97
+
98
+ found_pharmacies_for_medicine: list[PharmacyInfo] = await watchdog.search_medicine(medicine)
99
+
100
+ if found_pharmacies_for_medicine:
101
+
102
+ print(f"Found {len(found_pharmacies_for_medicine)}")
103
+
104
+ for p in found_pharmacies_for_medicine:
105
+ print(str(p))
106
+ else:
107
+ print(f"Medicine not available in pharmacies located in {medicine.distance_km} kilometer distance")
108
+
109
+ sleep(60) # 1 minute
110
+ ```
111
+
112
+ ### Database interface
113
+ The database interface instance passed to `MedicineWatchdog` must implement `MedicineDatabaseInterface`, which is basically a CRUD interface. The watchdog object will use this interface to interact with the data in the table. Example for an implementation for `sqlite` database:
114
+
115
+ ```python
116
+ from pharmaradar import Medicine, MedicineDatabaseInterface
117
+
118
+ class SqliteInterface(MedicineDatabaseInterface):
119
+ def __init__(self, db_file_path: str):
120
+ self.conn = sqlite3.connect(db_path)
121
+ self.cur = self.conn.cursor()
122
+
123
+ def _parse_row_to_medicine(self, row: tuple) -> Medicine:
124
+ """Convert a database row to a Medicine object."""
125
+ medicine_data = {
126
+ "id": row[0],
127
+ "name": row[1],
128
+ "dosage": row[2],
129
+ "amount": row[3],
130
+ "location": row[4],
131
+ "radius_km": row[5],
132
+ "max_price": row[6],
133
+ "min_availability": row[7],
134
+ "title": row[8],
135
+ "created_at": datetime.datetime.fromisoformat(row[9]) if row[9] else None,
136
+ "last_search_at": datetime.datetime.fromisoformat(row[10]) if row[10] else None,
137
+ "active": row[11], # Default to True for existing records
138
+ }
139
+ return Medicine(**medicine_data)
140
+
141
+ def get_medicine(self, medicine_id: int) -> Medicine | None:
142
+ row = self.cur.execute("SELECT * FROM medicine WHERE id = ?", (medicine_id,)).fetchone()
143
+ if row is None:
144
+ return None
145
+ return self._parse_row_to_medicine(row)
146
+
147
+ def get_medicines(self) -> list[Medicine]:
148
+ rows = self.cur.execute("SELECT * FROM medicine").fetchall()
149
+ medicines = []
150
+ for medicine_row in res:
151
+ medicines.append(self._parse_row_to_medicine(medicine_row))
152
+ return medicines
153
+
154
+ def remove_medicine(self, medicine_id: int) -> bool:
155
+ with self.conn:
156
+ res = self.cur.execute("DELETE FROM medicine WHERE id = (?)", (medicine_id,))
157
+ return res.rowcount > 0
158
+
159
+ def save_medicine(self, medicine: Medicine) -> int:
160
+ with self.conn:
161
+ self.cur.execute(
162
+ "INSERT INTO medicine VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
163
+ (
164
+ medicine.name,
165
+ medicine.dosage,
166
+ medicine.amount,
167
+ medicine.location,
168
+ medicine.radius_km,
169
+ medicine.max_price,
170
+ medicine.min_availability.value,
171
+ medicine.title,
172
+ medicine.created_at.isoformat() if medicine.created_at else None,
173
+ medicine.last_search_at.isoformat() if medicine.last_search_at else None,
174
+ medicine.active,
175
+ ),
176
+ )
177
+ return self.cur.lastrowid or 0
178
+
179
+ def update_medicine(
180
+ self,
181
+ medicine_id: int,
182
+ *,
183
+ name: str | None = None,
184
+ dosage: str | None = None,
185
+ amount: str | None = None,
186
+ location: str | None = None,
187
+ radius_km: float | None = None,
188
+ max_price: float | None = None,
189
+ min_availability: str | None = None,
190
+ title: str | None = None,
191
+ last_search_at: datetime.datetime | None = None,
192
+ active: bool | None = None,
193
+ ) -> bool:
194
+ sql = []
195
+ values = []
196
+ if name is not None:
197
+ sql.append("name = ?")
198
+ values.append(name)
199
+ if dosage is not None:
200
+ sql.append("dosage = ?")
201
+ values.append(dosage)
202
+ if amount is not None:
203
+ sql.append("amount = ?")
204
+ values.append(amount)
205
+ if location is not None:
206
+ sql.append("location = ?")
207
+ values.append(location)
208
+ if radius_km is not None:
209
+ sql.append("radius_km = ?")
210
+ values.append(radius_km)
211
+ if max_price is not None:
212
+ sql.append("max_price = ?")
213
+ values.append(max_price)
214
+ if min_availability is not None:
215
+ sql.append("min_availability = ?")
216
+ values.append(min_availability)
217
+ if title is not None:
218
+ sql.append("title = ?")
219
+ values.append(title)
220
+ if last_search_at is not None:
221
+ sql.append("last_search_at = ?")
222
+ values.append(last_search_at.isoformat())
223
+ if active is not None:
224
+ sql.append("active = ?")
225
+ values.append(active)
226
+
227
+ values.append(medicine_id)
228
+ sql = f"UPDATE medicine SET {', '.join(sql)} WHERE id = ?"
229
+
230
+ with self.conn:
231
+ result = self.cur.execute(sql, values)
232
+ return result.rowcount > 0
233
+
234
+ ```
235
+
236
+ Currently, the database itself must define the `medicine` table, declared as follows:
237
+ ```sql
238
+ medicine(
239
+ id INTEGER PRIMARY KEY,
240
+ name TEXT NOT NULL,
241
+ dosage TEXT,
242
+ amount TEXT,
243
+ location TEXT NOT NULL,
244
+ radius_km REAL DEFAULT 10,
245
+ max_price REAL,
246
+ min_availability TEXT DEFAULT 'low',
247
+ title TEXT,
248
+ created_at TEXT,
249
+ last_search_at TEXT,
250
+ active BOOLEAN DEFAULT 1
251
+ )
252
+ ```
253
+
254
+ ## License
255
+
256
+ MIT License