| """ |
| Localization module for Bahrain. |
| |
| Single source of truth for all region-specific content: |
| - Crisis resources (hotlines, hospitals, NGOs) |
| - Phone number validation and formatting (+973, 8 digits) |
| - CPR (Civil Personal Record) national ID validation |
| - Date/time formatting (DD/MM/YYYY, AST UTC+3) |
| - Safety disclaimers |
| """ |
|
|
| import re |
| from datetime import UTC, date, datetime |
|
|
| |
|
|
| COUNTRY_CODE = "BH" |
| COUNTRY_NAME = "Bahrain" |
| PHONE_COUNTRY_CODE = "+973" |
| TIMEZONE = "Asia/Bahrain" |
| LOCALE_CODE = "en-GB" |
|
|
|
|
| |
| |
| |
| |
| |
|
|
| EMERGENCY_NUMBER = "999" |
| CHILD_PROTECTION = "998" |
|
|
| CRISIS_RESOURCES = [ |
| { |
| "id": "emergency", |
| "name": "National Emergency", |
| "name_short": "Emergency", |
| "phone": "999", |
| "phone_display": "999", |
| "description": "Police, ambulance, or fire. Call when immediate help is needed. Toll-free, 24/7.", |
| "priority": 1, |
| "available_247": True, |
| }, |
| { |
| "id": "psychiatric_hospital", |
| "name": "Psychiatric Hospital (Salmaniya Medical Complex)", |
| "name_short": "Psychiatric Hospital", |
| "phone": "+97317288888", |
| "phone_display": "+973 1728 8888", |
| "description": "Main switchboard for Bahrain's public psychiatric hospital. For mental health emergencies, ambulance to Salmaniya emergency department.", |
| "priority": 2, |
| "available_247": True, |
| }, |
| { |
| "id": "psychiatric_appointments", |
| "name": "Psychiatric Hospital β Appointments & Referrals", |
| "name_short": "Psychiatric Appointments", |
| "phone": "+97317279311", |
| "phone_display": "+973 1727 9311", |
| "description": "Schedule an appointment with a psychiatrist at Salmaniya.", |
| "priority": 3, |
| "available_247": False, |
| }, |
| { |
| "id": "shamsaha", |
| "name": "Shamsaha", |
| "name_short": "Shamsaha", |
| "phone": "17651421", |
| "phone_display": "17651421", |
| "description": "24/7 free, confidential telephone and in-person support for victims of domestic and sexual violence.", |
| "priority": 4, |
| "available_247": True, |
| }, |
| { |
| "id": "dar_al_aman", |
| "name": "Dar Al-Aman", |
| "name_short": "Dar Al-Aman", |
| "phone": "80008001", |
| "phone_display": "8000 8001", |
| "description": "Support for women and children experiencing domestic violence.", |
| "priority": 5, |
| "available_247": False, |
| }, |
| { |
| "id": "child_protection", |
| "name": "Child Protection Centre", |
| "name_short": "Child Protection", |
| "phone": "998", |
| "phone_display": "998", |
| "description": "Toll-free, 24/7. Receives reports of violence, abuse, or danger to children.", |
| "priority": 6, |
| "available_247": True, |
| }, |
| { |
| "id": "taafi", |
| "name": "Taafi Drug Recovery Association", |
| "name_short": "Taafi", |
| "phone": "+97317300978", |
| "phone_display": "+973 1730 0978", |
| "description": "Addiction counseling and recovery support.", |
| "priority": 7, |
| "available_247": False, |
| }, |
| ] |
|
|
| CRISIS_RESOURCES_BY_PRIORITY = sorted(CRISIS_RESOURCES, key=lambda r: r["priority"]) |
|
|
|
|
| |
|
|
| SAFETY_DISCLAIMER = ( |
| "This tool is a research prototype. It is not FDA-cleared, CE-marked, or approved " |
| "by any regulatory body. It is not a diagnostic instrument. " |
| "This is a screening companion β not a medical diagnosis. What it shows is one " |
| "small window into how you're doing, and a clinician can see much more. In Bahrain, " |
| "if things feel urgent, 999 is there 24/7, and Salmaniya Medical Complex's " |
| "psychiatric emergency department is available any time." |
| ) |
|
|
| CRISIS_RESPONSE = """I hear you, and what you're carrying is real. Thank you for saying it out loud β that took courage. |
| |
| Right now, you don't have to do anything big. Just one small thing. Take a slow breath if you can. Notice where you are. Maybe get a glass of water. |
| |
| You said something really heavy, and I want to sit with you for a moment rather than rush past it. Feelings like this can come in waves β they don't always mean what they feel like they mean in the worst moments. |
| |
| Is there one person you trust who you could reach out to tonight? A family member, a friend, or someone you could sit next to? |
| |
| --- |
| |
| _And whenever you're ready β these are here for you, 24/7:_ |
| |
| - **Shamsaha** β 17651421 β free, confidential, just to talk |
| - **999** β if you're in immediate danger |
| - **Salmaniya Psychiatric Hospital** β +973 1728 8888 |
| - If you're under 18: **Child Protection Centre** β 998 |
| |
| You don't have to know what to say. Just calling is enough. They want to help.""" |
|
|
|
|
| |
|
|
| BAHRAIN_PHONE_RE = re.compile(r"^\+973\d{8}$") |
| |
| |
| BAHRAIN_MOBILE_RE = re.compile(r"^\+973(3\d{7}|66[39]\d{6})$") |
| BAHRAIN_LANDLINE_RE = re.compile(r"^\+9731\d{7}$") |
|
|
|
|
| def normalize_phone(phone: str) -> str: |
| """Normalize a phone number to +973XXXXXXXX format. |
| |
| Accepts: '3XXXXXXX', '+973 3XXX XXXX', '973-3XXX-XXXX', '+9733XXXXXXX', etc. |
| Returns: '+9733XXXXXXX' (or raises ValueError). |
| """ |
| if not phone: |
| raise ValueError("Phone number is empty") |
|
|
| |
| digits = re.sub(r"[\s\-()]+", "", phone.strip()) |
|
|
| |
| if digits.startswith("+"): |
| if digits.startswith("+973"): |
| normalized = digits |
| else: |
| raise ValueError(f"Only Bahrain phone numbers (+973) supported, got {phone}") |
| elif digits.startswith("00973"): |
| normalized = "+" + digits[2:] |
| elif digits.startswith("973"): |
| normalized = "+" + digits |
| else: |
| |
| if len(digits) == 8 and digits.isdigit(): |
| normalized = "+973" + digits |
| else: |
| raise ValueError( |
| f"Invalid Bahrain phone number: {phone}. Expected 8 digits (local) or +973XXXXXXXX (international)." |
| ) |
|
|
| if not BAHRAIN_PHONE_RE.match(normalized): |
| raise ValueError(f"Invalid Bahrain phone format: {normalized}. Must be +973 followed by 8 digits.") |
|
|
| return normalized |
|
|
|
|
| def format_phone_display(phone: str) -> str: |
| """Format a normalized phone number for display: '+973 3XXX XXXX'.""" |
| try: |
| normalized = normalize_phone(phone) |
| digits = normalized[4:] |
| return f"+973 {digits[:4]} {digits[4:]}" |
| except ValueError: |
| return phone |
|
|
|
|
| def classify_phone(phone: str) -> str: |
| """Return 'mobile', 'landline', or 'unknown'.""" |
| try: |
| normalized = normalize_phone(phone) |
| if BAHRAIN_MOBILE_RE.match(normalized): |
| return "mobile" |
| if BAHRAIN_LANDLINE_RE.match(normalized): |
| return "landline" |
| return "unknown" |
| except ValueError: |
| return "unknown" |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| CPR_RE = re.compile(r"^\d{9}$") |
|
|
|
|
| def _compute_cpr_check_digit(first_8: str) -> int: |
| """Compute the Bahrain CPR check digit. |
| |
| Bahrain uses a weighted sum where each digit is multiplied by its position |
| weight and the check digit is (sum * X) mod 11 or similar. Exact algorithm |
| is not officially published, so we implement the most widely-referenced |
| variant: weighted Luhn-style. |
| |
| Weights: [2, 7, 6, 5, 4, 3, 2, 1] for the 8 digits. |
| Check digit = (11 - (sum of weighted digits mod 11)) mod 11 |
| If result is 10, use 0. |
| |
| This is the algorithm documented by Bahrain national ID implementations |
| in open-source libraries (e.g., python-stdnum). Note: a minority of |
| older CPRs don't follow this exact pattern. |
| """ |
| if len(first_8) != 8 or not first_8.isdigit(): |
| raise ValueError("First 8 characters must be digits") |
|
|
| weights = [2, 7, 6, 5, 4, 3, 2, 1] |
| total = sum(int(first_8[i]) * weights[i] for i in range(8)) |
| check = (11 - (total % 11)) % 11 |
| return 0 if check == 10 else check |
|
|
|
|
| def validate_cpr(cpr: str, strict_check_digit: bool = False) -> bool: |
| """Validate a Bahrain CPR number. |
| |
| Args: |
| cpr: The CPR string (with or without separators). |
| strict_check_digit: If True, enforce the check digit algorithm. |
| If False (default), only validate format and DOB plausibility. |
| Strict mode may reject valid older CPRs that don't follow |
| the modern algorithm. |
| |
| Returns: |
| True if valid format + plausible birth date. |
| """ |
| if not cpr: |
| return False |
|
|
| |
| digits = re.sub(r"[\s\-]+", "", cpr.strip()) |
|
|
| if not CPR_RE.match(digits): |
| return False |
|
|
| |
| year_prefix = int(digits[:2]) |
| month = int(digits[2:4]) |
|
|
| if month < 1 or month > 12: |
| return False |
|
|
| |
| |
| current_year_2digit = datetime.now(UTC).year % 100 |
| |
| if year_prefix > current_year_2digit and year_prefix < (current_year_2digit + 10): |
| |
| return False |
|
|
| if strict_check_digit: |
| try: |
| expected_check = _compute_cpr_check_digit(digits[:8]) |
| actual_check = int(digits[8]) |
| return expected_check == actual_check |
| except (ValueError, IndexError): |
| return False |
|
|
| return True |
|
|
|
|
| def extract_dob_from_cpr(cpr: str) -> tuple[int, int] | None: |
| """Extract (year_4digit, month) from a CPR number. |
| |
| Since the year is 2-digit, we infer century: if YY > current year's 2-digit, |
| assume 19XX; otherwise 20XX. Returns None if invalid. |
| """ |
| if not validate_cpr(cpr): |
| return None |
|
|
| digits = re.sub(r"[\s\-]+", "", cpr.strip()) |
| yy = int(digits[:2]) |
| mm = int(digits[2:4]) |
|
|
| current_year_full = datetime.now(UTC).year |
| current_yy = current_year_full % 100 |
|
|
| |
| if yy > current_yy: |
| century = (current_year_full // 100 - 1) * 100 |
| else: |
| century = (current_year_full // 100) * 100 |
|
|
| year_full = century + yy |
| return (year_full, mm) |
|
|
|
|
| def format_cpr_display(cpr: str) -> str: |
| """Format a CPR for display: '850423456' β '8504-2345-6'.""" |
| digits = re.sub(r"[\s\-]+", "", cpr.strip()) |
| if len(digits) != 9 or not digits.isdigit(): |
| return cpr |
| return f"{digits[:4]}-{digits[4:8]}-{digits[8]}" |
|
|
|
|
| |
|
|
|
|
| def format_date(d: date | datetime) -> str: |
| """Format a date as DD/MM/YYYY.""" |
| if isinstance(d, datetime): |
| d = d.date() |
| return d.strftime("%d/%m/%Y") |
|
|
|
|
| def format_datetime(dt: datetime) -> str: |
| """Format a datetime as DD/MM/YYYY HH:MM (24h).""" |
| return dt.strftime("%d/%m/%Y %H:%M") |
|
|
|
|
| def format_date_long(d: date | datetime) -> str: |
| """Format a date with full month name: 'DD Month YYYY'.""" |
| if isinstance(d, datetime): |
| d = d.date() |
| return d.strftime("%d %B %Y") |
|
|
|
|
| |
|
|
|
|
| def calculate_age(dob: date | datetime) -> int: |
| """Calculate age in years from date of birth.""" |
| if isinstance(dob, datetime): |
| dob = dob.date() |
| today = date.today() |
| years = today.year - dob.year |
| if (today.month, today.day) < (dob.month, dob.day): |
| years -= 1 |
| return years |
|
|
|
|
| def validate_dob(dob: date | datetime, min_age: int = 13, max_age: int = 120) -> bool: |
| """Validate that a date of birth produces an age within bounds.""" |
| try: |
| age = calculate_age(dob) |
| return min_age <= age <= max_age |
| except (TypeError, ValueError): |
| return False |
|
|
|
|
| |
|
|
| SUPPORTED_LANGUAGES = ["en", "ar"] |
| DEFAULT_LANGUAGE = "en" |
| LANGUAGE_NAMES = { |
| "en": "English", |
| "ar": "Ψ§ΩΨΉΨ±Ψ¨ΩΨ©", |
| } |
|
|
| |
| WEEKEND_DAYS = [4, 5] |
|
|
|
|
| def is_weekend(d: date | datetime) -> bool: |
| """Check if a date falls on Friday or Saturday (Bahrain weekend).""" |
| if isinstance(d, datetime): |
| d = d.date() |
| return d.weekday() in WEEKEND_DAYS |
|
|