medicentrev3-auth 1.0.0__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.
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: medicentrev3-auth
3
+ Version: 1.0.0
4
+ Summary: Authentication and navigation module for MedicentreV3 automation
5
+ Author-email: Denis Ndiritu <denis@hanmak.co.ke>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: selenium>=4.0.0
13
+ Requires-Dist: python-dotenv>=0.19.0
14
+ Requires-Dist: webdriver-manager>=3.8.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
17
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
18
+ Requires-Dist: black>=22.0.0; extra == "dev"
19
+ Requires-Dist: flake8>=5.0.0; extra == "dev"
20
+
21
+ # MedicentreV3 Authentication Package
22
+
23
+ A reusable Python package for MedicentreV3 authentication and navigation.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ # Install from GitHub
29
+ pip install git+https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package.git
30
+
31
+ # For development
32
+ pip install -e git+https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package.git
33
+
34
+ ## Updating the Package
35
+
36
+ When updates are available, users should upgrade using:
37
+
38
+ ```bash
39
+ # Upgrade to latest version
40
+ pip install --upgrade git+https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package.git
41
+
42
+ # Or reinstall (force latest)
43
+ pip install --force-reinstall git+https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package.git
44
+ ```
45
+
46
+ **Important**: Always use `--upgrade` or `--force-reinstall` when updating from GitHub to ensure you get the latest changes.
@@ -0,0 +1,26 @@
1
+ # MedicentreV3 Authentication Package
2
+
3
+ A reusable Python package for MedicentreV3 authentication and navigation.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # Install from GitHub
9
+ pip install git+https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package.git
10
+
11
+ # For development
12
+ pip install -e git+https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package.git
13
+
14
+ ## Updating the Package
15
+
16
+ When updates are available, users should upgrade using:
17
+
18
+ ```bash
19
+ # Upgrade to latest version
20
+ pip install --upgrade git+https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package.git
21
+
22
+ # Or reinstall (force latest)
23
+ pip install --force-reinstall git+https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package.git
24
+ ```
25
+
26
+ **Important**: Always use `--upgrade` or `--force-reinstall` when updating from GitHub to ensure you get the latest changes.
@@ -0,0 +1,18 @@
1
+ """
2
+ MedicentreV3 Authentication Package
3
+
4
+ Provides reusable login, navigation, and session handling
5
+ for MedicentreV3 IHMIS automation.
6
+ """
7
+
8
+ from .auth import (
9
+ MedicentreAuth,
10
+ create_auth_session,
11
+ quick_login,
12
+ )
13
+
14
+ __all__ = [
15
+ "MedicentreAuth",
16
+ "create_auth_session",
17
+ "quick_login",
18
+ ]
@@ -0,0 +1,890 @@
1
+ """
2
+ MedicentreV3 Authentication & Navigation Module
3
+ ===============================================
4
+
5
+ A reusable module for MedicentreV3 login and navigation that can be imported
6
+ into multiple automation scripts.
7
+
8
+ Features:
9
+ 1. Handles MedicentreV3 login flow (facility code, branch selection, credentials)
10
+ 2. Provides flexible navigation to modules and panels
11
+ 3. Manages WebDriver setup and teardown
12
+ 4. Includes error handling and screenshot capabilities
13
+ 5. Supports environment variables and configuration
14
+
15
+ Usage:
16
+ from medicentre_auth import MedicentreAuth
17
+
18
+ # Create auth instance
19
+ auth = MedicentreAuth(headless=False)
20
+
21
+ # Setup and login
22
+ if auth.setup_and_login():
23
+ # Navigate to a module panel
24
+ if auth.navigate_to('procurement', 'suppliers'):
25
+ # Your module-specific automation here
26
+ pass
27
+ # Always logout when done
28
+ auth.logout()
29
+
30
+ Configuration:
31
+ Set environment variables in .env file:
32
+ MEDICENTRE_BASE_URL=https://medicentrev3.hanmak.co.ke
33
+ MEDICENTRE_ACCESS_CODE=your_access_code
34
+ MEDICENTRE_BRANCH=your_branch_name
35
+ MEDICENTRE_USERNAME=your_username
36
+ MEDICENTRE_PASSWORD=your_password
37
+ """
38
+
39
+ import os
40
+ import sys
41
+ import time
42
+ import logging
43
+ from datetime import datetime
44
+ from typing import Dict, Optional, List, Tuple, Union
45
+ from pathlib import Path
46
+
47
+ # Selenium imports
48
+ from selenium import webdriver
49
+ from selenium.webdriver.common.by import By
50
+ from selenium.webdriver.support.ui import WebDriverWait, Select
51
+ from selenium.webdriver.support import expected_conditions as EC
52
+ from selenium.common.exceptions import (
53
+ TimeoutException,
54
+ NoSuchElementException,
55
+ ElementNotInteractableException
56
+ )
57
+ from selenium.webdriver.chrome.service import Service
58
+ from selenium.webdriver.chrome.options import Options
59
+ from selenium.webdriver.common.action_chains import ActionChains
60
+ from selenium.webdriver.common.keys import Keys
61
+ from webdriver_manager.chrome import ChromeDriverManager
62
+
63
+ # Environment configuration
64
+ from dotenv import load_dotenv
65
+
66
+ # Load environment variables
67
+ load_dotenv()
68
+
69
+ # Configure module logger
70
+ logger = logging.getLogger(__name__)
71
+
72
+
73
+ class MedicentreAuth:
74
+ """Reusable authentication and navigation module for MedicentreV3."""
75
+
76
+ def __init__(self, headless: bool = False, base_dir: str = "."):
77
+ """
78
+ Initialize the authentication module.
79
+
80
+ Args:
81
+ headless: Run browser in headless mode (default: False)
82
+ base_dir: Base directory for logs and screenshots (default: current directory)
83
+ """
84
+ self.driver = None
85
+ self.wait = None
86
+ self.headless = headless
87
+ self.base_dir = base_dir
88
+
89
+ # Configuration from environment
90
+ self.base_url = os.getenv('MEDICENTRE_BASE_URL', 'https://medicentrev3.hanmak.co.ke')
91
+ self.accesscode = os.getenv('MEDICENTRE_ACCESS_CODE', '')
92
+ self.branch = os.getenv('MEDICENTRE_BRANCH', '')
93
+ self.username = os.getenv('MEDICENTRE_USERNAME', '')
94
+ self.password = os.getenv('MEDICENTRE_PASSWORD', '')
95
+
96
+ # Create directories if they don't exist
97
+ self._setup_directories()
98
+
99
+ # Common page elements
100
+ self.elements = {
101
+ # Login page elements
102
+ 'access_code': (By.ID, 'hospCode'),
103
+ 'proceed_button': (By.ID, 'btnProceed'),
104
+ 'branch_select': (By.ID, 'CompanyBranchID'),
105
+ 'login_username': (By.ID, 'userName'),
106
+ 'login_password': (By.ID, 'userPassword'),
107
+ 'login_button': (By.ID, 'btnLogin'),
108
+
109
+ # Navigation elements
110
+ 'billing_module': (By.XPATH, "//a[normalize-space()='Billing']"),
111
+ 'clinical_module': (By.XPATH, "//a[normalize-space()='Clinical']"),
112
+ 'cds_tools_module': (By.XPATH, "//a[normalize-space()='CDS Tools']"),
113
+ 'maternity_module': (By.XPATH, "//a[normalize-space()='Maternity']"),
114
+ 'dialysis_module': (By.XPATH, "//a[normalize-space()='Dialysis']"),
115
+ 'procurement_module': (By.XPATH, "//a[normalize-space()='Procurement']"),
116
+ 'inventory_module': (By.XPATH, "//a[@href='#'][normalize-space()='Inventory']"),
117
+ 'accounts_module': (By.XPATH, "//a[normalize-space()='Accounts']"),
118
+ 'human_resource_module': (By.XPATH, "//a[normalize-space()='Human Resource']"),
119
+ 'security_module': (By.XPATH, "//a[normalize-space()='Security']"),
120
+ 'configuration_module': (By.XPATH, "//a[normalize-space()='Configuration']"),
121
+ 'morgue_module': (By.XPATH, "//a[normalize-space()='Morgue']"),
122
+ 'communication_module': (By.XPATH, "//a[normalize-space()='Communication']"),
123
+
124
+ # Module PANELS
125
+ # Procurement panels
126
+ 'procurement.suppliers': (By.XPATH, "//a[normalize-space()='Suppliers']"),
127
+ 'procurement.supplier_bills': (By.XPATH, "//a[normalize-space()='Supplier Bills(A/P Invoices)']"),
128
+ 'procurement.purchase_requisition_note': (By.XPATH, "//a[normalize-space()='Purchase Requisition Note']"),
129
+ 'procurement.purchase_orders': (By.XPATH, "//a[normalize-space()='Purchase Orders']"),
130
+ 'procurement.goods_received_notes': (By.XPATH, "//a[normalize-space()='Goods Received Notes']"),
131
+ 'procurement.ap_payment_vouchers': (By.XPATH, "//a[normalize-space()='A/P Payment Vouchers']"),
132
+ 'procurement.supplier_prepayments': (By.XPATH, "//a[normalize-space()='Supplier Prepayments']"),
133
+
134
+ # Inventory panels
135
+ 'inventory.inventory': (By.XPATH, "//a[@href='/Inventory/Inventory']"),
136
+ 'inventory.unit_of_measure': (By.XPATH, "//a[normalize-space()='Unit Of Measure']"),
137
+
138
+ # Accounts panels
139
+ 'accounts.ledger_accounts': (By.XPATH, "//a[normalize-space()='Ledger Accounts']"),
140
+ 'accounts.payment_modes': (By.XPATH, "//a[normalize-space()='Payment Modes']"),
141
+ 'accounts.banks': (By.XPATH, "//a[contains(text(),'Banks')]"),
142
+ 'accounts.schemes': (By.XPATH, "//a[contains(text(),'Schemes')]"),
143
+ 'accounts.taxes': (By.XPATH, "//a[normalize-space()='Taxes']"),
144
+
145
+ # Human Resource panels
146
+ 'human_resource.consultants': (By.XPATH, "//a[normalize-space()='Consultants']"),
147
+ 'human_resource.employees': (By.XPATH, "//a[normalize-space()='Employees']"),
148
+ 'human_resource.tax_ranges': (By.XPATH, "//a[normalize-space()='PAYE Tax Ranges']"),
149
+
150
+ # Security panels
151
+ 'security.user_roles': (By.XPATH, "//a[normalize-space()='User Roles']"),
152
+ 'security.privileges': (By.XPATH, "//a[normalize-space()='Privileges']"),
153
+ 'security.system_users': (By.XPATH, "//a[normalize-space()='System Users']"),
154
+
155
+ # Configuration panels
156
+ 'configuration.services': (By.XPATH, "//a[normalize-space()='Services']"),
157
+ 'configuration.rooms': (By.XPATH, "//a[normalize-space()='Rooms']"),
158
+ 'configuration.ward_beds': (By.XPATH, "//a[normalize-space()='Ward Beds']"),
159
+ 'configuration.departments': (By.XPATH, "//a[normalize-space()='Departments']"),
160
+ 'configuration.storage_locations': (By.XPATH, "//a[normalize-space()='Storage Location']"),
161
+ 'configuration.medical_clinics': (By.XPATH, "//a[normalize-space()='Medical Clinics']"),
162
+
163
+ # Dashboard verification
164
+ 'dashboard_element': (By.XPATH, "//h5[normalize-space()='My Appointments']"),
165
+
166
+ # User menu/logout
167
+ 'user_menu': (By.XPATH, f"//a[@title='User: {self.username}']"),
168
+ 'logout_button': (By.XPATH, "//a[normalize-space()='Logout']"),
169
+ }
170
+
171
+ # Module aliases for easier access (optional but useful)
172
+ self.module_aliases = {
173
+ # Short names for modules
174
+ 'proc': 'procurement',
175
+ 'inv': 'inventory',
176
+ 'acc': 'accounts',
177
+ 'hr': 'human_resource',
178
+ 'sec': 'security',
179
+ 'config': 'configuration',
180
+
181
+ # Panel shortcuts
182
+ 'supp': 'suppliers',
183
+ 'bills': 'supplier_bills',
184
+ 'prn': 'purchase_requisition_note',
185
+ 'po': 'purchase_orders',
186
+ 'grn': 'goods_received_notes',
187
+ 'ap': 'ap_payment_vouchers',
188
+ 'prepay': 'supplier_prepayments',
189
+ 'uom': 'unit_of_measure',
190
+ 'ledger': 'ledger_accounts',
191
+ 'paymodes': 'payment_modes',
192
+ 'tax': 'taxes',
193
+ 'consult': 'consultants',
194
+ 'emp': 'employees',
195
+ 'taxrange': 'tax_ranges',
196
+ 'roles': 'user_roles',
197
+ 'priv': 'privileges',
198
+ 'sysusers': 'system_users',
199
+ 'serv': 'services',
200
+ 'room': 'rooms',
201
+ 'beds': 'ward_beds',
202
+ 'dept': 'departments',
203
+ 'storage': 'storage_locations',
204
+ 'clinics': 'medical_clinics',
205
+ }
206
+
207
+ # Validate environment on initialization
208
+ self._validate_environment()
209
+
210
+ def _setup_directories(self):
211
+ """Create necessary directories for logs and screenshots."""
212
+ directories = [
213
+ os.path.join(self.base_dir, 'logs'),
214
+ os.path.join(self.base_dir, 'screenshots'),
215
+ os.path.join(self.base_dir, 'auth_logs')
216
+ ]
217
+
218
+ for directory in directories:
219
+ if not os.path.exists(directory):
220
+ os.makedirs(directory)
221
+ logger.info(f"Created directory: {directory}")
222
+
223
+ def _validate_environment(self):
224
+ """Validate that all required environment variables are set."""
225
+ missing_vars = []
226
+
227
+ if not self.accesscode:
228
+ missing_vars.append('MEDICENTRE_ACCESS_CODE')
229
+ if not self.branch:
230
+ missing_vars.append('MEDICENTRE_BRANCH')
231
+ if not self.username:
232
+ missing_vars.append('MEDICENTRE_USERNAME')
233
+ if not self.password:
234
+ missing_vars.append('MEDICENTRE_PASSWORD')
235
+
236
+ if missing_vars:
237
+ error_msg = f"Missing required environment variables: {', '.join(missing_vars)}"
238
+ logger.error(error_msg)
239
+ raise ValueError(error_msg)
240
+
241
+ logger.info("Environment validation passed")
242
+
243
+ def setup_driver(self) -> bool:
244
+ """
245
+ Setup Chrome WebDriver with appropriate options.
246
+
247
+ Returns:
248
+ bool: True if setup successful, False otherwise
249
+ """
250
+ logger.info("Setting up Chrome WebDriver")
251
+
252
+ try:
253
+ chrome_options = Options()
254
+
255
+ if self.headless:
256
+ chrome_options.add_argument('--headless=new')
257
+
258
+ chrome_options.add_argument('--no-sandbox')
259
+ chrome_options.add_argument('--disable-dev-shm-usage')
260
+ chrome_options.add_argument('--disable-gpu')
261
+ chrome_options.add_argument('--disable-notifications')
262
+ chrome_options.add_argument('--disable-popup-blocking')
263
+ chrome_options.add_argument('--start-maximized')
264
+
265
+ prefs = {
266
+ "credentials_enable_service": False,
267
+ "profile.password_manager_enabled": False,
268
+ "profile.password_manager_leak_detection": False
269
+ }
270
+ chrome_options.add_experimental_option("prefs", prefs)
271
+
272
+ # Add preferences for better stability
273
+ chrome_options.add_experimental_option('excludeSwitches', ['enable-logging'])
274
+ chrome_options.add_experimental_option('prefs', {
275
+ 'credentials_enable_service': False,
276
+ 'profile.password_manager_enabled': False,
277
+ 'profile.default_content_setting_values.notifications': 2
278
+ })
279
+
280
+ # Use webdriver-manager to handle ChromeDriver
281
+ service = Service(ChromeDriverManager().install())
282
+ self.driver = webdriver.Chrome(service=service, options=chrome_options)
283
+
284
+ # Set window size
285
+ self.driver.maximize_window()
286
+ logger.info("Browser window maximized to full screen")
287
+
288
+ self.wait = WebDriverWait(self.driver, 30)
289
+
290
+ logger.info("WebDriver setup completed successfully")
291
+ return True
292
+
293
+ except Exception as e:
294
+ logger.error(f"Failed to setup WebDriver: {e}")
295
+ return False
296
+
297
+ def save_screenshot(self, screenshot_name: str, subdirectory: str = "auth") -> str:
298
+ """
299
+ Save screenshot to screenshots folder.
300
+
301
+ Args:
302
+ screenshot_name: Name of the screenshot file
303
+ subdirectory: Subdirectory within screenshots (default: "auth")
304
+
305
+ Returns:
306
+ str: Path to the saved screenshot
307
+ """
308
+ # Create subdirectory path
309
+ screenshot_dir = os.path.join(self.base_dir, 'screenshots', subdirectory)
310
+ if not os.path.exists(screenshot_dir):
311
+ os.makedirs(screenshot_dir)
312
+
313
+ # Create timestamped filename
314
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
315
+ filename = os.path.join(screenshot_dir, f"{screenshot_name}_{timestamp}.png")
316
+
317
+ try:
318
+ self.driver.save_screenshot(filename)
319
+ logger.info(f"Saved screenshot: {filename}")
320
+ return filename
321
+ except Exception as e:
322
+ logger.error(f"Failed to save screenshot: {e}")
323
+ return ""
324
+
325
+ def select_branch_on_login(self) -> bool:
326
+ """
327
+ Select branch on login page.
328
+
329
+ Returns:
330
+ bool: True if branch selected or not required, False if failed
331
+ """
332
+ logger.info(f"Selecting branch: {self.branch}")
333
+
334
+ try:
335
+ # Wait for branch select element
336
+ branch_element = self.wait.until(
337
+ EC.presence_of_element_located(self.elements['branch_select'])
338
+ )
339
+
340
+ # Check if it's a select element
341
+ if branch_element.tag_name.lower() == 'select':
342
+ select_obj = Select(branch_element)
343
+
344
+ # Get all options
345
+ options = select_obj.options
346
+ available_branches = [opt.text.strip() for opt in options if opt.text.strip()]
347
+ logger.info(f"Available branches: {', '.join(available_branches)}")
348
+
349
+ # Try to select the branch
350
+ try:
351
+ select_obj.select_by_visible_text(self.branch)
352
+ logger.info(f"Selected branch: {self.branch}")
353
+
354
+ # Verify selection
355
+ selected_option = select_obj.first_selected_option.text.strip()
356
+ if selected_option == self.branch:
357
+ logger.info(f"Branch selection verified: {selected_option}")
358
+ return True
359
+ else:
360
+ logger.warning(f"Selected '{selected_option}' instead of '{self.branch}'")
361
+ return True # Still return True as something was selected
362
+
363
+ except Exception as select_error:
364
+ logger.warning(f"Exact branch match failed: {select_error}")
365
+
366
+ # Try case-insensitive match
367
+ for option in options:
368
+ if option.text and self.branch.lower() in option.text.lower():
369
+ select_obj.select_by_visible_text(option.text)
370
+ logger.info(f"Selected branch (partial match): {option.text}")
371
+ return True
372
+
373
+ # Select first available branch as fallback
374
+ for option in options:
375
+ if option.text.strip():
376
+ select_obj.select_by_visible_text(option.text)
377
+ logger.warning(f"Selected first available branch: {option.text}")
378
+ return True
379
+
380
+ logger.error("No selectable branch options found")
381
+ return False
382
+ else:
383
+ logger.warning("Branch element is not a select dropdown")
384
+ # Try to click or send keys
385
+ branch_element.click()
386
+ branch_element.send_keys(self.branch)
387
+ logger.info(f"Set branch to: {self.branch}")
388
+ return True
389
+
390
+ except TimeoutException:
391
+ logger.warning("Branch select element not found - system may not require branch selection")
392
+ return True # Assume success if branch not required
393
+
394
+ except Exception as e:
395
+ logger.error(f"Error during branch selection: {e}")
396
+ return False
397
+
398
+ def verify_login_success(self) -> bool:
399
+ """
400
+ Verify if login was successful.
401
+
402
+ Returns:
403
+ bool: True if login successful, False otherwise
404
+ """
405
+ logger.info("Verifying login success")
406
+
407
+ try:
408
+ # Wait for dashboard or user menu
409
+ time.sleep(3)
410
+
411
+ # Check multiple indicators of successful login
412
+ success_indicators = [
413
+ (self.elements['dashboard_element'], "Dashboard found"),
414
+ (By.XPATH, "//h5[normalize-space()='My Appointments']"),
415
+ (By.XPATH, "//h5[normalize-space()='Todo List']"),
416
+ ]
417
+
418
+ for element_locator, message in success_indicators:
419
+ try:
420
+ element = self.driver.find_element(*element_locator)
421
+ if element.is_displayed():
422
+ logger.info(f"Login successful: {message}")
423
+ return True
424
+ except:
425
+ continue
426
+
427
+ # Check URL for dashboard or main page
428
+ current_url = self.driver.current_url.lower()
429
+ if 'https://medicentrev3.hanmak.co.ke' in current_url:
430
+ logger.info("Login successful - dashboard URL detected")
431
+ return True
432
+
433
+ # Check for absence of login form
434
+ try:
435
+ self.driver.find_element(*self.elements['login_username'])
436
+ logger.error("Login failed - still on login page")
437
+ return False
438
+ except:
439
+ logger.info("Login successful - login form no longer present")
440
+ return True
441
+
442
+ except Exception as e:
443
+ logger.error(f"Error verifying login: {e}")
444
+ return False
445
+
446
+ def login(self) -> bool:
447
+ """
448
+ Login to MedicentreV3 system.
449
+
450
+ Returns:
451
+ bool: True if login successful, False otherwise
452
+ """
453
+ logger.info(f"Attempting to login to {self.base_url}")
454
+
455
+ try:
456
+ # Navigate to login page
457
+ self.driver.get(self.base_url)
458
+ logger.info(f"Navigated to {self.base_url}")
459
+ time.sleep(2)
460
+
461
+ # Take screenshot of login page
462
+ self.save_screenshot("login_page")
463
+
464
+ # Step 1: Enter facility code
465
+ accesscode_field = self.wait.until(
466
+ EC.presence_of_element_located(self.elements['access_code'])
467
+ )
468
+ accesscode_field.clear()
469
+ accesscode_field.send_keys(self.accesscode)
470
+ logger.info("Entered facility access code")
471
+
472
+ # Step 2: Click proceed button
473
+ proceed_button = self.driver.find_element(*self.elements['proceed_button'])
474
+ proceed_button.click()
475
+ logger.info("Clicked proceed button")
476
+ time.sleep(2)
477
+
478
+ # Step 3: Select branch (if required)
479
+ branch_selected = self.select_branch_on_login()
480
+ if not branch_selected:
481
+ logger.warning("Branch selection may have failed, continuing...")
482
+
483
+ # Step 4: Enter username
484
+ username_field = self.wait.until(
485
+ EC.presence_of_element_located(self.elements['login_username'])
486
+ )
487
+ username_field.clear()
488
+ username_field.send_keys(self.username)
489
+ logger.info("Entered username")
490
+
491
+ # Step 5: Enter password
492
+ password_field = self.driver.find_element(*self.elements['login_password'])
493
+ password_field.clear()
494
+ password_field.send_keys(self.password)
495
+ logger.info("Entered password")
496
+
497
+ # Step 6: Click login button
498
+ login_button = self.driver.find_element(*self.elements['login_button'])
499
+ login_button.click()
500
+ logger.info("Clicked login button")
501
+
502
+ # Step 7: Verify login success
503
+ login_success = self.verify_login_success()
504
+
505
+ if login_success:
506
+ self.save_screenshot("after_login")
507
+
508
+ return login_success
509
+
510
+ except Exception as e:
511
+ logger.error(f"Login failed with error: {e}")
512
+ self.save_screenshot("login_error")
513
+ return False
514
+
515
+ def logout(self) -> bool:
516
+ """
517
+ Logout from MedicentreV3 system.
518
+
519
+ Returns:
520
+ bool: True if logout successful, False otherwise
521
+ """
522
+ logger.info("Attempting to logout")
523
+
524
+ try:
525
+ # Try to find and click user menu
526
+ try:
527
+ user_menu = self.driver.find_element(*self.elements['user_menu'])
528
+ user_menu.click()
529
+ time.sleep(1)
530
+ except:
531
+ logger.warning("User menu not found, trying direct logout")
532
+
533
+ # Try to find and click logout button
534
+ try:
535
+ logout_button = self.driver.find_element(*self.elements['logout_button'])
536
+ logout_button.click()
537
+ logger.info("Clicked logout button")
538
+ time.sleep(2)
539
+ return True
540
+ except:
541
+ # If logout button not found, navigate to logout URL directly
542
+ logout_url = f"{self.base_url}/Account/LogOut"
543
+ self.driver.get(logout_url)
544
+ logger.info(f"Navigated to logout URL: {logout_url}")
545
+ time.sleep(2)
546
+ return True
547
+
548
+ except Exception as e:
549
+ logger.error(f"Logout failed with error: {e}")
550
+ return False
551
+
552
+ def navigate_to(self, module: str, panel: str = None) -> bool:
553
+ """
554
+ Navigate to a module or module panel.
555
+
556
+ Args:
557
+ module: Main module name (e.g., 'procurement', 'accounts', 'configuration')
558
+ panel: Optional panel name (e.g., 'suppliers', 'ledger_accounts', 'rooms')
559
+ If None, just clicks the main module
560
+
561
+ Returns:
562
+ bool: True if navigation successful, False otherwise
563
+ """
564
+ # Resolve aliases
565
+ module = self.module_aliases.get(module, module)
566
+ if panel:
567
+ panel = self.module_aliases.get(panel, panel)
568
+
569
+ logger.info(f"Navigating to {' -> '.join(filter(None, [module, panel]))}")
570
+
571
+ try:
572
+ # Step 1: Click the main module menu
573
+ menu_key = f"{module}_menu"
574
+ if menu_key not in self.elements:
575
+ # Try alternative naming
576
+ menu_key = f"{module}_module"
577
+ if menu_key not in self.elements:
578
+ logger.error(f"Module '{module}' not found. Available modules: {[k for k in self.elements.keys() if k.endswith('_menu')]}")
579
+ return False
580
+
581
+ menu_element = self.wait.until(
582
+ EC.element_to_be_clickable(self.elements[menu_key])
583
+ )
584
+
585
+ # Click the menu
586
+ menu_element.click()
587
+ logger.info(f"Clicked {module} menu")
588
+ time.sleep(1) # Wait for dropdown to appear if applicable
589
+
590
+ # Step 2: If panel is specified, click it
591
+ if panel:
592
+ # First check if panel exists in elements with module.panel format
593
+ panel_key = f"{module}.{panel}"
594
+ if panel_key in self.elements:
595
+ panel_element = self.wait.until(
596
+ EC.element_to_be_clickable(self.elements[panel_key])
597
+ )
598
+ panel_element.click()
599
+ logger.info(f"Clicked {panel} panel")
600
+ else:
601
+ # Try alternative panel naming
602
+ panel_key = f"{panel}_panel"
603
+ if panel_key in self.elements:
604
+ panel_element = self.wait.until(
605
+ EC.element_to_be_clickable(self.elements[panel_key])
606
+ )
607
+ panel_element.click()
608
+ logger.info(f"Clicked {panel} panel")
609
+ else:
610
+ logger.error(f"Panel '{panel}' not found for module '{module}'")
611
+ return False
612
+
613
+ # Step 3: Wait for page to load
614
+ time.sleep(2)
615
+
616
+ # Step 4: Take screenshot
617
+ screenshot_name = f"{module}_{panel}" if panel else f"{module}"
618
+ self.save_screenshot(screenshot_name, "navigation")
619
+
620
+ # Step 5: Verify navigation (optional but recommended)
621
+ if self._verify_navigation(module, panel):
622
+ logger.info(f"Successfully navigated to {' -> '.join(filter(None, [module, panel]))}")
623
+ return True
624
+ else:
625
+ logger.warning(f"Navigation may not have completed as expected")
626
+ return True # Still return True as we likely reached some page
627
+
628
+ except Exception as e:
629
+ logger.error(f"Failed to navigate to {' -> '.join(filter(None, [module, panel]))}: {e}")
630
+ self.save_screenshot(f"nav_error_{module}_{panel if panel else ''}", "errors")
631
+ return False
632
+
633
+ def _verify_navigation(self, module: str, panel: str = None) -> bool:
634
+ """
635
+ Verify that navigation was successful.
636
+
637
+ Args:
638
+ module: Module name
639
+ panel: Panel name (optional)
640
+
641
+ Returns:
642
+ bool: True if navigation appears successful
643
+ """
644
+ try:
645
+ # Check URL for module/panel indicators
646
+ current_url = self.driver.current_url.lower()
647
+
648
+ # Check page title or headers
649
+ try:
650
+ page_title = self.driver.title
651
+ if page_title and page_title.strip():
652
+ logger.debug(f"Page title: {page_title}")
653
+ except:
654
+ pass
655
+
656
+ # Check for common page elements
657
+ try:
658
+ # Look for h1, h2, or h3 headers
659
+ headers = self.driver.find_elements(By.TAG_NAME, 'h1') + \
660
+ self.driver.find_elements(By.TAG_NAME, 'h2') + \
661
+ self.driver.find_elements(By.TAG_NAME, 'h3')
662
+
663
+ for header in headers[:3]: # Check first 3 headers
664
+ if header.text.strip():
665
+ logger.debug(f"Found header: {header.text.strip()}")
666
+ break
667
+ except:
668
+ pass
669
+
670
+ return True
671
+
672
+ except Exception as e:
673
+ logger.warning(f"Navigation verification failed: {e}")
674
+ return False
675
+
676
+ def navigate_to_url(self, url: str) -> bool:
677
+ """
678
+ Navigate to a specific URL.
679
+
680
+ Args:
681
+ url: URL to navigate to
682
+
683
+ Returns:
684
+ bool: True if navigation successful, False otherwise
685
+ """
686
+ logger.info(f"Navigating to URL: {url}")
687
+
688
+ try:
689
+ self.driver.get(url)
690
+ time.sleep(2)
691
+ logger.info(f"Successfully navigated to {url}")
692
+ return True
693
+ except Exception as e:
694
+ logger.error(f"Failed to navigate to {url}: {e}")
695
+ return False
696
+
697
+ def get_current_url(self) -> str:
698
+ """
699
+ Get the current URL.
700
+
701
+ Returns:
702
+ str: Current URL
703
+ """
704
+ return self.driver.current_url if self.driver else ""
705
+
706
+ def get_page_title(self) -> str:
707
+ """
708
+ Get the current page title.
709
+
710
+ Returns:
711
+ str: Page title
712
+ """
713
+ return self.driver.title if self.driver else ""
714
+
715
+ def is_element_present(self, locator: Tuple[str, str], timeout: int = 5) -> bool:
716
+ """
717
+ Check if an element is present on the page.
718
+
719
+ Args:
720
+ locator: Element locator tuple (By, value)
721
+ timeout: Time to wait for element (default: 5 seconds)
722
+
723
+ Returns:
724
+ bool: True if element is present, False otherwise
725
+ """
726
+ try:
727
+ WebDriverWait(self.driver, timeout).until(
728
+ EC.presence_of_element_located(locator)
729
+ )
730
+ return True
731
+ except:
732
+ return False
733
+
734
+ def wait_for_element(self, locator: Tuple[str, str], timeout: int = 30) -> bool:
735
+ """
736
+ Wait for an element to be present and visible.
737
+
738
+ Args:
739
+ locator: Element locator tuple (By, value)
740
+ timeout: Time to wait for element (default: 30 seconds)
741
+
742
+ Returns:
743
+ bool: True if element found and visible, False otherwise
744
+ """
745
+ try:
746
+ element = WebDriverWait(self.driver, timeout).until(
747
+ EC.visibility_of_element_located(locator)
748
+ )
749
+ return element is not None
750
+ except Exception as e:
751
+ logger.warning(f"Element not found within {timeout} seconds: {e}")
752
+ return False
753
+
754
+ def click_element(self, locator: Tuple[str, str]) -> bool:
755
+ """
756
+ Click on an element.
757
+
758
+ Args:
759
+ locator: Element locator tuple (By, value)
760
+
761
+ Returns:
762
+ bool: True if click successful, False otherwise
763
+ """
764
+ try:
765
+ element = self.wait.until(
766
+ EC.element_to_be_clickable(locator)
767
+ )
768
+ element.click()
769
+ return True
770
+ except Exception as e:
771
+ logger.error(f"Failed to click element: {e}")
772
+ return False
773
+
774
+ def find_element(self, locator: Tuple[str, str], timeout: int = 10):
775
+ """
776
+ Find an element on the page.
777
+
778
+ Args:
779
+ locator: Element locator tuple (By, value)
780
+ timeout: Time to wait for element (default: 10 seconds)
781
+
782
+ Returns:
783
+ WebElement if found, None otherwise
784
+ """
785
+ try:
786
+ return WebDriverWait(self.driver, timeout).until(
787
+ EC.presence_of_element_located(locator)
788
+ )
789
+ except:
790
+ return None
791
+
792
+ def find_elements(self, locator: Tuple[str, str], timeout: int = 10):
793
+ """
794
+ Find multiple elements on the page.
795
+
796
+ Args:
797
+ locator: Element locator tuple (By, value)
798
+ timeout: Time to wait for elements (default: 10 seconds)
799
+
800
+ Returns:
801
+ List of WebElements if found, empty list otherwise
802
+ """
803
+ try:
804
+ WebDriverWait(self.driver, timeout).until(
805
+ EC.presence_of_element_located(locator)
806
+ )
807
+ return self.driver.find_elements(*locator)
808
+ except:
809
+ return []
810
+
811
+ def add_element(self, name: str, locator: Tuple[str, str]):
812
+ """
813
+ Add a new element to the elements dictionary.
814
+ Useful for adding custom elements for specific scripts.
815
+
816
+ Args:
817
+ name: Element name/key
818
+ locator: Element locator tuple (By, value)
819
+ """
820
+ self.elements[name] = locator
821
+ logger.info(f"Added element: {name}")
822
+
823
+ def setup_and_login(self) -> bool:
824
+ """
825
+ Setup WebDriver and login in one call.
826
+
827
+ Returns:
828
+ bool: True if both setup and login successful, False otherwise
829
+ """
830
+ if not self.setup_driver():
831
+ logger.error("Failed to setup WebDriver")
832
+ return False
833
+
834
+ if not self.login():
835
+ logger.error("Failed to login")
836
+ self.quit_driver()
837
+ return False
838
+
839
+ return True
840
+
841
+ def quit_driver(self):
842
+ """Quit the WebDriver."""
843
+ if self.driver:
844
+ self.driver.quit()
845
+ logger.info("WebDriver quit")
846
+
847
+ def __enter__(self):
848
+ """Context manager entry."""
849
+ return self
850
+
851
+ def __exit__(self, exc_type, exc_val, exc_tb):
852
+ """Context manager exit - automatically logout and quit driver."""
853
+ try:
854
+ self.logout()
855
+ except:
856
+ pass
857
+ finally:
858
+ self.quit_driver()
859
+
860
+
861
+ # Convenience functions for common operations
862
+ def create_auth_session(headless: bool = False, base_dir: str = ".") -> MedicentreAuth:
863
+ """
864
+ Create a new MedicentreAuth session.
865
+
866
+ Args:
867
+ headless: Run browser in headless mode
868
+ base_dir: Base directory for logs and screenshots
869
+
870
+ Returns:
871
+ MedicentreAuth: Authentication session object
872
+ """
873
+ return MedicentreAuth(headless=headless, base_dir=base_dir)
874
+
875
+
876
+ def quick_login(headless: bool = False, base_dir: str = ".") -> Optional[MedicentreAuth]:
877
+ """
878
+ Quick login function that returns a logged-in session.
879
+
880
+ Args:
881
+ headless: Run browser in headless mode
882
+ base_dir: Base directory for logs and screenshots
883
+
884
+ Returns:
885
+ MedicentreAuth: Logged-in session or None if failed
886
+ """
887
+ auth = MedicentreAuth(headless=headless, base_dir=base_dir)
888
+ if auth.setup_and_login():
889
+ return auth
890
+ return None
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: medicentrev3-auth
3
+ Version: 1.0.0
4
+ Summary: Authentication and navigation module for MedicentreV3 automation
5
+ Author-email: Denis Ndiritu <denis@hanmak.co.ke>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: selenium>=4.0.0
13
+ Requires-Dist: python-dotenv>=0.19.0
14
+ Requires-Dist: webdriver-manager>=3.8.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
17
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
18
+ Requires-Dist: black>=22.0.0; extra == "dev"
19
+ Requires-Dist: flake8>=5.0.0; extra == "dev"
20
+
21
+ # MedicentreV3 Authentication Package
22
+
23
+ A reusable Python package for MedicentreV3 authentication and navigation.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ # Install from GitHub
29
+ pip install git+https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package.git
30
+
31
+ # For development
32
+ pip install -e git+https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package.git
33
+
34
+ ## Updating the Package
35
+
36
+ When updates are available, users should upgrade using:
37
+
38
+ ```bash
39
+ # Upgrade to latest version
40
+ pip install --upgrade git+https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package.git
41
+
42
+ # Or reinstall (force latest)
43
+ pip install --force-reinstall git+https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package.git
44
+ ```
45
+
46
+ **Important**: Always use `--upgrade` or `--force-reinstall` when updating from GitHub to ensure you get the latest changes.
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ medicentrev3_auth/__init__.py
5
+ medicentrev3_auth/auth.py
6
+ medicentrev3_auth.egg-info/PKG-INFO
7
+ medicentrev3_auth.egg-info/SOURCES.txt
8
+ medicentrev3_auth.egg-info/dependency_links.txt
9
+ medicentrev3_auth.egg-info/requires.txt
10
+ medicentrev3_auth.egg-info/top_level.txt
@@ -0,0 +1,9 @@
1
+ selenium>=4.0.0
2
+ python-dotenv>=0.19.0
3
+ webdriver-manager>=3.8.0
4
+
5
+ [dev]
6
+ pytest>=7.0.0
7
+ pytest-cov>=4.0.0
8
+ black>=22.0.0
9
+ flake8>=5.0.0
@@ -0,0 +1 @@
1
+ medicentrev3_auth
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "medicentrev3-auth"
7
+ version = "1.0.0"
8
+ description = "Authentication and navigation module for MedicentreV3 automation"
9
+ readme = "README.md"
10
+ authors = [
11
+ {name = "Denis Ndiritu", email = "denis@hanmak.co.ke"}
12
+ ]
13
+ license = {text = "MIT"}
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "selenium>=4.0.0",
21
+ "python-dotenv>=0.19.0",
22
+ "webdriver-manager>=3.8.0"
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=7.0.0",
28
+ "pytest-cov>=4.0.0",
29
+ "black>=22.0.0",
30
+ "flake8>=5.0.0"
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/Hanmak-Technologies-Automation-Project/medicentrev3-authentication-package"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ # setup.py
2
+ from setuptools import setup, find_packages
3
+
4
+ if __name__ == "__main__":
5
+ setup()