# CLAUDE.md — Website Checker: Complete Technical Knowledge Base This document is the authoritative technical reference for the **Website Checker** desktop application. Keep it current whenever code changes are made. --- ## 1. Application Overview Website Checker is a Python/Tkinter desktop application for shift-based website monitoring backed by a remote MySQL database. Regular users log in, view websites assigned to their shift, open each site, and mark it checked. Administrators manage users, websites, shifts, credentials, email reports, and AI-powered document analysis. - **UI:** Python `tkinter` + `ttk` - **Database:** Remote MySQL 5.7+ / MariaDB 10.3+ via `mysql-connector-python` - **Entry point:** `app.py` → `class App(tk.Tk)` - **Default window:** 1100×700, minimum 960×620 - **Default theme:** Light (toggleable to Dark) - **Platform:** Windows desktop (primary); macOS/Linux supported with limitations --- ## 2. Project File Structure ``` website_checker/ ├── app.py Entry point, shell, navigation, session management ├── config.py DB config, connection pool, schema DDL, migrations ├── models.py All database access (data layer) ├── requirements.txt ├── CLAUDE.md This file ├── README.md Setup and deployment guide ├── USER_MANUAL.md End-user documentation ├── utils/ │ ├── config_crypto.py Windows DPAPI encryption for config.ini credentials │ ├── crypto.py Fernet credential encryption (website passwords) │ ├── export.py CSV + Excel export │ ├── scheduler.py Daily email report background daemon + SMTP helpers │ └── ui_helpers.py ThemeManager, COLOURS proxy, DateEntry, widgets └── views/ ├── admin_dashboard_view.py Admin home — KPI cards + per-user progress table ├── admin_log_view.py Activity log treeview ├── admin_shifts_view.py Shift CRUD + PDF export + inactive filter toggle ├── admin_users_view.py User CRUD + secure password reset dialog ├── admin_websites_view.py Website CRUD + visibility + credentials (collapsible) ├── ai_summary_view.py AI document analysis via Groq API ├── change_password_view.py Self-service password change (all roles) ├── email_settings_view.py SMTP / scheduled report config (3-mode security) ├── login_view.py Login form with rate-limiting countdown ├── reports_view.py Shift Detail / Unchecked / Summary / Chart tabs ├── settings_view.py DB connection settings dialog └── user_dashboard_view.py User checklist — search, bulk check, notifications ``` --- ## 3. Runtime Files ### `config.ini` Created on first launch. Contains [database], [email], [crypto], and [groq] sections. **Sensitive fields are DPAPI-encrypted** (see §6 Security). Encrypted values have a `dpapi:` prefix. ```ini [database] host=your-mysql-host port=3306 database=website_checker user=dpapi: ; encrypted password=dpapi: ; encrypted [email] enabled=false smtp_host=smtp.example.com smtp_port=587 smtp_user=sender@example.com smtp_password=dpapi: ; encrypted security=starttls ; starttls | ssl | none use_tls=true ; legacy compat field, derived from security recipients=admin@example.com send_time=18:00 last_sent_date=2025-01-01 ; ISO date, prevents re-send after restart [groq] api_key=dpapi: ; encrypted model=llama-3.3-70b-versatile [crypto] salt= ``` ### `app.log` UTF-8 log file in working directory. All INFO/WARNING/ERROR from every module. --- ## 4. Database Schema All tables use InnoDB/utf8mb4. Created by `initialize_database()` on first launch. Safe ALTER TABLE migrations run automatically for columns added post-deployment. New tables (ai_criteria, ai_analysis_log) verified with information_schema check and logged. ### users | Column | Type | Notes | |---|---|---| | id | INT PK AUTO | | | username | VARCHAR(100) UNIQUE | | | password | VARCHAR(255) | bcrypt hash. Legacy SHA-256 (64 hex) auto-migrated on login | | role | ENUM('admin','user') | | | full_name | VARCHAR(200) | | | is_active | TINYINT(1) | | | failed_attempts | TINYINT UNSIGNED | Incremented on bad login; reset on success | | locked_until | DATETIME NULL | Set when failed_attempts >= MAX_FAILED_ATTEMPTS (5) | | created_at / updated_at | DATETIME | | ### websites | Column | Type | Notes | |---|---|---| | id | INT PK AUTO | | | name | VARCHAR(200) | | | url | TEXT | Validated by _validate_and_normalise_url (http/https only) | | check_type | ENUM('daily','weekly') | weekly = once per ISO week | | visibility | ENUM('all','assigned') | assigned = only users in website_users table | | note | TEXT | Shown on user dashboard card | | is_active | TINYINT(1) | Soft-delete | | created_by | INT FK users SET NULL | | ### website_credentials | Column | Notes | |---|---| | website_id FK | | | username | Plaintext | | password | Fernet-encrypted with `enc:` prefix | | label | e.g. "Admin", "Read-only" | ### shift_checks One row per user per site per day (upserted). `user_note` optional. ### activity_log `action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED, CREATE/UPDATE/DELETE_USER, CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD, CREATE/UPDATE/DELETE_WEBSITE, ADD/REMOVE_CREDENTIAL, CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE, EXPORT_CSV, EXPORT_EXCEL, EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS, CREATE/UPDATE/DELETE_AI_CRITERION. ### shifts `days_of_week` is a digit string using MySQL DAYOFWEEK: 1=Sun 2=Mon 3=Tue 4=Wed 5=Thu 6=Fri 7=Sat. E.g. "23456" = Mon–Fri. Queried with `LOCATE(DAYOFWEEK(CURDATE()), days_of_week) > 0`. ### shift_users (junction): (shift_id, user_id) PK, both CASCADE ### shift_websites (junction): (shift_id, website_id) PK + sort_order ### login_attempts: username, attempted_at, ip_address (indexed) ### website_users (junction): (website_id, user_id) PK — for visibility='assigned' ### ai_criteria Stores evaluation criteria used by the AI to assess solicitation alignment. | Column | Type | Notes | |---|---|---| | id | INT PK AUTO | | | title | VARCHAR(200) | Short label shown in treeview | | description | TEXT | Full criterion text sent to the AI prompt | | is_active | TINYINT(1) | Inactive criteria excluded from AI prompt | | sort_order | INT | Controls display and prompt ordering | | created_by | INT FK users SET NULL | | | created_at / updated_at | DATETIME | | ### ai_analysis_log Persists every AI analysis result for history and audit purposes. | Column | Type | Notes | |---|---|---| | id | INT PK AUTO | | | user_id | INT FK users SET NULL | | | file_names | TEXT | Comma-separated filenames analyzed | | model | VARCHAR(100) | Groq model used | | verdict | ENUM('PURSUE','PASS','UNCLEAR') NULL | NULL when no criteria active | | criteria_snapshot | TEXT NULL | Active criteria text at time of analysis | | summary_text | MEDIUMTEXT | Full AI response | | analyzed_at | DATETIME | | --- ## 5. Module Details ### app.py - App(tk.Tk) — root window; manages login state, shell, sidebar, session timeout - IDLE_TIMEOUT_MS = 1,800,000 (30 min); IDLE_WARNING_MS = 60,000 (1 min warning) - _reset_idle_timer throttled: Motion events fire at most once per _IDLE_THROTTLE_S=5.0 seconds - _idle_last_reset: monotonic timestamp used for throttle check - Admin login → scheduler.start(); logout → scheduler.stop() - Theme toggle destroys and rebuilds the entire shell (content panels re-instantiated) ### config.py - DB_CONFIG loaded from config.ini at import; placeholders if file missing - get_connection() → pool (pool_size=5) - initialize_database() → idempotent DDL + ALTER TABLE migrations + table confirmation logs - load_config() / save_config() → decrypt/encrypt DPAPI fields transparently - migrate_plaintext_config() → one-time migration; encrypts any plain-text creds on startup - config_exists() → True if host, database, user are set - reload_db_config() → re-reads config.ini, resets pool ### utils/config_crypto.py Windows DPAPI-based encryption for config.ini sensitive values. - encrypt_value(plaintext) → "dpapi:" string - decrypt_value(stored) → plaintext; plain-text pass-through for legacy values - is_encrypted(value) → True if value starts with "dpapi:" - Tied to the current Windows user account — blob is unreadable on any other machine/account - Graceful fallback: if pywin32 not available, values stored/returned as plain text ### models.py Each function opens+closes its own connection. All writes call log_action(). Password helpers: - _hash_password(pw) → bcrypt rounds=12 - _verify_password(pw, stored) → handles bcrypt + legacy SHA-256 - _needs_rehash(stored) → True for 64-char hex (SHA-256) Rate-limit constants: MAX_FAILED_ATTEMPTS=5, LOCKOUT_MINUTES=15 Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char Key functions: - get_today_checks(user_id) — shift-aware; respects visibility and check_type - get_unchecked_report(target_date, user_id) — shift-scoped; only sites user was expected to check - get_summary_report(date_from, date_to) — shift-scoped total_sites per user per day - get_all_criteria() / get_active_criteria() — no LEFT JOIN; simple SELECT from ai_criteria - save_ai_analysis() / get_ai_analysis_history() / get_ai_analysis_detail() - delete_user() — guards: self-delete prevention + last-admin prevention ### utils/crypto.py - PBKDF2-HMAC-SHA256, 100,000 iterations, 32-byte salt from config.ini [crypto] - encrypt(plaintext) → "enc:" + base64(Fernet token) - decrypt(ciphertext) → plaintext; legacy plaintext (no enc: prefix) passes through unchanged - reset_fernet() → force key reload after config.ini replacement ### utils/ui_helpers.py - ThemeManager singleton — initial="light". toggle(rebuild_callback) flips theme. - COLOURS = _ColourProxy(dict) — always delegates to ThemeManager.get(). Import once, always current. - THEMES["dark"] and THEMES["light"] each have 18 colour keys + 4 cal_* keys - DateEntry — Frame subclass; .get() → "YYYY-MM-DD", .set(str). Calendar popup with prev/next month+year. - make_scrollable_frame() — mousewheel scoped to Enter/Leave to prevent stale-widget crashes - scrolled_text(parent, height, width) → (frame, tk.Text) ### utils/scheduler.py - Daemon thread; polls every 60 seconds - Sends HTML email once per day when now >= send_time - smtp_password DPAPI-encrypted on save / decrypted on load - last_sent_date persisted to config.ini [email] last_sent_date — survives restarts - start() / stop() called from app.py on admin login/logout - start() guards against double-start: joins existing thread before spawning new one - _make_smtp_server(host, port, security) — centralised connection with explicit ehlo() calls (required on Windows for reliable STARTTLS/SSL handshake) - security field: "starttls" (port 587) | "ssl" (port 465) | "none" (port 25) - test_smtp_connection() — 4-step diagnostic: DNS → TCP → TLS → Auth - send_test_email() — sends real HTML test message through full pipeline - Email report excludes users with total_sites=0 (no shift today); adds footnote count ### views/email_settings_view.py - Security mode: 3 radio buttons (STARTTLS/SSL·TLS/None) replacing old Use STARTTLS checkbox - Selecting a security mode auto-fills the default port - Loads legacy use_tls bool from config.ini and maps to security field on first load - 🔌 Test Connection — step-by-step diagnostic output in status label - 📧 Send Test Email — real email send; all buttons disabled during operation - Save preserves last_sent_date in config.ini ### views/ai_summary_view.py AI-powered document analysis panel. Accessible from sidebar for both roles. Key internals: - Supported file types: .txt, .md, .csv, .pdf, .docx, .doc, .xlsx, .xls - Groq API key and model selection visible only to admins - Regular users use stored API key transparently - Background thread for AI calls (prevents UI freeze) - Notebook layout: ✨ Analyze tab + 🕑 History tab - Extraction prompt: 12 labeled fields per document + 4-section Overall Summary - Driving distance calculated from 2815 Hartland Rd, Falls Church VA (AI estimate) - max_tokens=4096; temperature=0.2; MAX_CHARS_PER_FILE=14,000 Evaluation Criteria panel (collapsible): - Admin: Add/Edit/Delete controls; CriterionDialog with live character counter (soft limit 500) - Users: read-only treeview - Active criteria appended to AI prompt; AI outputs RECOMMENDATION: PURSUE/PASS/UNCLEAR - Verdict parsed by _parse_verdict() and shown as colour-coded banner (green/red/amber) History tab: - Every analysis saved to ai_analysis_log with verdict + criteria snapshot - save_ai_analysis() called in _on_success() — never blocks the result display - Filterable by verdict radio buttons (All/PURSUE/PASS/UNCLEAR/none) - Double-click or View Result restores full output in Analyze tab - Admins see all users' history; regular users see only their own - Auto-refreshes on tab switch via <> .doc reading (legacy binary Word) — 3-tier fallback: 1. win32com (Word COM automation — requires MS Word installed) 2. docx2txt (pure Python) 3. Raw ASCII scrape from binary URL safety: _validate_and_normalise_url() rejects non-http/https schemes. ### views/admin_websites_view.py - Website CRUD dialog with collapsible credentials section (hidden by default) - _on_visibility_change uses before=self._cred_body (not creds_container — same parent) - _validate_and_normalise_url() called in _save() before DB write - URL validation: prepends https:// if no scheme; rejects javascript:/data:/file: etc. ### views/admin_users_view.py - 🔑 Reset Password button generates 16-char cryptographically random password - _PasswordResetDialog: masked field, 👁 reveal, 📋 copy (2s flash), 120s auto-close, 30s clipboard wipe, countdown timer - Logs RESET_PASSWORD to activity_log ### views/admin_shifts_view.py - "Show inactive" checkbox toggles visibility of inactive shifts in treeview - _load_shifts filters by _show_inactive BooleanVar; no extra DB call - _export_pdf(): reportlab A4; one section per active shift ### views/user_dashboard_view.py - _health_cache: {website_id: ("ok"|"slow"|"restricted"|"down", ms)} - ok: 2xx/3xx ≤3000ms; slow: 2xx/3xx >3000ms; restricted: 4xx; down: 5xx/network error - Notifications: single consolidated reminder per shift end-time group (not per site) - Groups unchecked sites by end_time; fires one notification listing all sites - _notified_sites key: (frozenset(names), end_t) - Keyboard shortcuts: stored in _shortcut_ids; unbound in destroy() - Credentials popup: 📋 copy (username 2s flash; password 2s flash + 15s clipboard wipe) ### views/reports_view.py - Tabs: Shift Detail / Unchecked / Summary / Chart - All date fields use DateEntry (no manual text entry) - Summary report: shift-scoped total_sites per user per day (not global count) - Unchecked report: shift-scoped; only sites user was expected to check that day - Chart: matplotlib Agg + FigureCanvasTkAgg; bars green≥100% / amber≥50% / red<50% ### views/login_view.py - check_login_allowed(username) called before authenticate() - Locked: form disabled; 1-second countdown; re-enables when timer reaches 0 - Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout" --- ## 6. Authentication & Security | Concern | Implementation | |---|---| | Password hashing | bcrypt rounds=12; SHA-256 auto-rehashed on next login | | Website credential encryption | Fernet (AES-128-CBC + HMAC) via cryptography library | | Config credential protection | Windows DPAPI (CryptProtectData) — user-account-scoped | | Encrypted fields | DB user, DB password, SMTP password, Groq API key | | Login rate limiting | 5 attempts → 15-min lockout in DB | | Session timeout | 30-min idle; 1-min warning; Motion events throttled to 1/5s | | Password policy | 8+ chars, uppercase, digit, special character | | Self-service change | Requires current password; strength meter; same-as-current guard | | Clipboard security | Password copy auto-clears clipboard after 15 seconds | | URL safety | _validate_and_normalise_url rejects non-http/https schemes | | Admin self-delete | Blocked in delete_user() | | Last-admin deletion | Blocked in delete_user() | | Password reset display | _PasswordResetDialog: 120s auto-close, 30s clipboard wipe | ### DPAPI Migration `migrate_plaintext_config()` is called automatically on every startup (in `_init_db`). It is a no-op if all sensitive fields are already encrypted (dpapi: prefix present). On first run after this feature was added, it encrypts any existing plain-text values in-place. --- ## 7. Key Business Logic ### check_type - daily: shown every shift day - weekly: hidden once checked this week (YEARWEEK ISO); reappears Monday ### visibility - all: every user in shift sees it - assigned: only users in website_users table see it (enforced in both shift and legacy paths) ### get_today_checks resolution 1. Check if user has active shifts today (DAYOFWEEK match) 2. If yes: shift websites filtered by check_type + visibility (website_users respected) 3. If no: all active websites filtered by visibility + check_type (legacy fallback) ### Unchecked report scope Uses shift membership to determine "expected" sites per user per day. CROSS JOIN replaced with shift-aware subquery — no false positives for off-shift users. ### Summary report total_sites Correlated subquery counts shift-assigned sites per user per check_date day-of-week. A user in a 3-site shift shows 3 as total_sites, not the global count. ### Soft deletes Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first). --- ## 8. Configuration Constants (edit in source) | File | Constant | Default | |---|---|---| | app.py | IDLE_TIMEOUT_MS | 1,800,000 (30 min) | | app.py | IDLE_WARNING_MS | 60,000 (1 min) | | app.py | APP_VERSION | "1.0.0" | | app.py | VERSION_CHECK_ENABLED | False | | app.py | App._IDLE_THROTTLE_S | 5.0 (seconds between Motion-event timer resets) | | models.py | MAX_FAILED_ATTEMPTS | 5 | | models.py | LOCKOUT_MINUTES | 15 | | models.py | PW_MIN_LENGTH | 8 | | user_dashboard_view.py | NOTIFY_MINUTES_BEFORE | 15 | | admin_dashboard_view.py | REFRESH_INTERVAL_MS | 60,000 | | utils/crypto.py | _ITERATIONS | 100,000 | | ai_summary_view.py | MAX_CHARS_PER_FILE | 14,000 | | ai_summary_view.py | _OFFICE_ADDRESS | "2815 Hartland Road, Falls Church, VA 22043, USA" | | ai_summary_view.py | CriterionDialog._DESC_SOFT_LIMIT | 500 (chars, soft guidance only) | | admin_users_view.py | _PasswordResetDialog._AUTO_CLOSE_S | 120 (seconds before auto-close) | | admin_users_view.py | _PasswordResetDialog._CLIP_CLEAR_S | 30 (seconds before clipboard wipe) | | utils/scheduler.py | (timeout in _make_smtp_server) | 15 seconds | --- ## 9. Critical Gotchas 1. bind_all("") is NEVER used at module level. Always Enter/Leave scoped. Every view's destroy() calls unbind_all("") and _unbind_shortcuts(). 2. COLOURS is a live proxy — delegates to ThemeManager.get() on every access. Never snapshot it into a local variable at class-creation time. 3. bcrypt is slow by design (~200-400ms at rounds=12). Expected behaviour. 4. config.ini sensitive fields are DPAPI-encrypted (dpapi: prefix). Website credential passwords are Fernet-encrypted (enc: prefix). Non-sensitive fields (host, port, db name, smtp_host, recipients) remain plain text. 5. get_today_checks GROUP BY includes sc.id to prevent row collisions when a user belongs to multiple shifts sharing the same website. 6. Weekly site logic uses YEARWEEK(..., 1) (ISO week, Monday start). 7. The email scheduler last_sent_date is persisted to config.ini [email] last_sent_date (ISO format). A restart after the configured send_time will not re-send that day. 8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent cp1252 UnicodeEncodeError on Windows consoles. 9. DPAPI encrypted blobs are tied to the Windows user account that created them. If config.ini is copied to a different machine or user account, credentials cannot be decrypted. Users must re-enter credentials via the Settings dialog. 10. Theme toggle destroys and rebuilds the entire shell. Any unsaved in-memory state in the active panel is lost. AI summary output is not preserved across theme toggles (it is in ai_analysis_log; retrieve from History tab). 11. _make_smtp_server calls ehlo() explicitly before and after starttls() — this is required on Windows. Removing these calls breaks SMTP on many servers. 12. The security field in config.ini supersedes use_tls. On existing deployments without a security field, load_email_config() derives it from use_tls for backward compatibility. 13. get_all_criteria() does NOT join users table. The creator column is excluded intentionally — the treeview doesn't show it and the join is wasted I/O. --- ## 10. Dependencies ``` mysql-connector-python>=8.0.0 bcrypt>=4.0.0 openpyxl>=3.1.0 cryptography>=41.0.0 matplotlib>=3.7.0 plyer>=2.1.0 reportlab>=4.0.0 groq>=1.0.0 pypdf>=3.0.0 python-docx>=1.0.0 pywin32>=306 docx2txt>=0.8 ``` stdlib used: tkinter, csv, smtplib, urllib.request, urllib.parse, configparser, threading, calendar, datetime, base64, re, tempfile, socket --- ## 11. First-Run Flow 1. _boot() → config_exists() → False → SettingsView (locked modal) 2. User enters DB creds → Test Connection → Save & Connect 3. reload_db_config() → initialize_database() → seeds admin/admin123 if users empty 4. migrate_plaintext_config() → encrypts any plain-text credentials in config.ini 5. Login screen shown 6. ThemeManager(root, initial="light") applied; shell built --- ## 12. Sidebar Navigation ### Admin role Dashboard · Websites · Users · Shifts · Reports · Activity Log · 🤖 AI Summary · Settings · Sign Out ### User role My Shift · 🤖 AI Summary · Change Password · Sign Out --- ## 13. SMTP Configuration Reference | Security Mode | Protocol | Default Port | Use When | |---|---|---|---| | STARTTLS | Plain → TLS upgrade | 587 | Office 365, Exchange, most corporate | | SSL/TLS | TLS from first byte | 465 | Gmail direct, some providers | | None | Unencrypted | 25 | Internal relay servers only | Connection flow in _make_smtp_server: - STARTTLS: SMTP() → ehlo() → starttls() → ehlo() - SSL: SMTP_SSL() → ehlo() - None: SMTP() → ehlo() test_smtp_connection() diagnostic steps: 1. DNS — getaddrinfo(); fails = hostname wrong or no network 2. TCP — create_connection(); fails = firewall blocking the port 3. TLS — _make_smtp_server(); fails = wrong security mode or port 4. Auth — server.login(); fails = wrong username/password or App Password needed --- ## 14. Deployment Notes - Python 3.9+ required. 3.12 tested on Windows. - tkinter bundled on Windows/macOS; Linux: apt install python3-tk - Create DB first: CREATE DATABASE website_checker CHARACTER SET utf8mb4; - MySQL port 3306 must be reachable from client - Default admin: username=admin / password=admin123 — change immediately - pywin32 required for DPAPI encryption (Windows only). Install: pip install pywin32 - Microsoft Word recommended for .doc file support in AI Summary (falls back to docx2txt) - Groq API key required for AI Summary feature — free at https://console.groq.com - Gmail users: enable 2FA, then create an App Password at myaccount.google.com/apppasswords - config.ini is machine- and user-account-specific. Do not share or copy between machines. --- ## 15. AI Summary Feature — Setup & Prompt Details ### Setup (Admin only) 1. Obtain a free Groq API key at https://console.groq.com 2. Navigate to 🤖 AI Summary in the sidebar 3. Enter the API key and select a model 4. Click 💾 Save Settings — key stored DPAPI-encrypted in config.ini [groq] ### Supported Models - llama-3.3-70b-versatile (default, recommended) - llama-3.1-8b-instant (faster, smaller) - gemma2-9b-it - mixtral-8x7b-32768 ### Prompt Strategy Extraction: 12 labeled fields per document: Solicitation Number, Type, Set-Aside, Description/Scope, Work Site, Pre-Proposal Conference, POC, Square Footage, Driving Distance from 2815 Hartland Rd Falls Church VA (AI estimate), Last Day for Questions, Due Date, Other requirements. Overall Summary: 4 sections: A. Scope of Work · B. Contract Period · C. Proposal Submission Requirements · D. Key Deadlines Alignment evaluation (when criteria active): - Each criterion assessed as MEETS / DOES NOT MEET / PARTIALLY MEETS - RECOMMENDATION: PURSUE | PASS | UNCLEAR on its own line (machine-read by _parse_verdict) - Executive Summary: 2-3 sentences ### History & Audit - Every analysis is auto-saved to ai_analysis_log (verdict + criteria snapshot at time of run) - update_criterion() logs full description text in activity_log.detail for audit trail - Admins see all users' analyses; users see only their own