commit 95e03442c5411ca727f2efc177933d31dad231aa Author: LockeShor <75901583+LockeShor@users.noreply.github.com> Date: Fri Mar 6 00:56:43 2026 -0500 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d3f1e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Virtual Environment +csc-checkin.venv/ +venv/ +env/ +ENV/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment variables +.env +.env.local + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Logs +*.log \ No newline at end of file diff --git a/Scripts/Activate.ps1 b/Scripts/Activate.ps1 new file mode 100644 index 0000000..b63e7b7 --- /dev/null +++ b/Scripts/Activate.ps1 @@ -0,0 +1,502 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" + +# SIG # Begin signature block +# MIIvIwYJKoZIhvcNAQcCoIIvFDCCLxACAQExDzANBglghkgBZQMEAgEFADB5Bgor +# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG +# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBnL745ElCYk8vk +# dBtMuQhLeWJ3ZGfzKW4DHCYzAn+QB6CCE8MwggWQMIIDeKADAgECAhAFmxtXno4h +# MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK +# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV +# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z +# ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ +# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 +# IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB +# AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z +# G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ +# anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s +# Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL +# 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb +# BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3 +# JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c +# AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx +# YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0 +# viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL +# T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud +# EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf +# Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk +# aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS +# PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK +# 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB +# cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp +# 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg +# dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri +# RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7 +# 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5 +# nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3 +# i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H +# EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G +# CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ +# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 +# IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla +# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE +# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz +# ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C +# 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce +# 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da +# E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T +# SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA +# FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh +# D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM +# 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z +# 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05 +# huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY +# mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP +# /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T +# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD +# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG +# A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY +# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj +# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV +# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU +# cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN +# BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry +# sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL +# IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf +# Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh +# OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh +# dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV +# 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j +# wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH +# Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC +# XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l +# /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW +# eE4wggd3MIIFX6ADAgECAhAHHxQbizANJfMU6yMM0NHdMA0GCSqGSIb3DQEBCwUA +# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE +# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz +# ODQgMjAyMSBDQTEwHhcNMjIwMTE3MDAwMDAwWhcNMjUwMTE1MjM1OTU5WjB8MQsw +# CQYDVQQGEwJVUzEPMA0GA1UECBMGT3JlZ29uMRIwEAYDVQQHEwlCZWF2ZXJ0b24x +# IzAhBgNVBAoTGlB5dGhvbiBTb2Z0d2FyZSBGb3VuZGF0aW9uMSMwIQYDVQQDExpQ +# eXRob24gU29mdHdhcmUgRm91bmRhdGlvbjCCAiIwDQYJKoZIhvcNAQEBBQADggIP +# ADCCAgoCggIBAKgc0BTT+iKbtK6f2mr9pNMUTcAJxKdsuOiSYgDFfwhjQy89koM7 +# uP+QV/gwx8MzEt3c9tLJvDccVWQ8H7mVsk/K+X+IufBLCgUi0GGAZUegEAeRlSXx +# xhYScr818ma8EvGIZdiSOhqjYc4KnfgfIS4RLtZSrDFG2tN16yS8skFa3IHyvWdb +# D9PvZ4iYNAS4pjYDRjT/9uzPZ4Pan+53xZIcDgjiTwOh8VGuppxcia6a7xCyKoOA +# GjvCyQsj5223v1/Ig7Dp9mGI+nh1E3IwmyTIIuVHyK6Lqu352diDY+iCMpk9Zanm +# SjmB+GMVs+H/gOiofjjtf6oz0ki3rb7sQ8fTnonIL9dyGTJ0ZFYKeb6BLA66d2GA +# LwxZhLe5WH4Np9HcyXHACkppsE6ynYjTOd7+jN1PRJahN1oERzTzEiV6nCO1M3U1 +# HbPTGyq52IMFSBM2/07WTJSbOeXjvYR7aUxK9/ZkJiacl2iZI7IWe7JKhHohqKuc +# eQNyOzxTakLcRkzynvIrk33R9YVqtB4L6wtFxhUjvDnQg16xot2KVPdfyPAWd81w +# tZADmrUtsZ9qG79x1hBdyOl4vUtVPECuyhCxaw+faVjumapPUnwo8ygflJJ74J+B +# Yxf6UuD7m8yzsfXWkdv52DjL74TxzuFTLHPyARWCSCAbzn3ZIly+qIqDAgMBAAGj +# ggIGMIICAjAfBgNVHSMEGDAWgBRoN+Drtjv4XxGG+/5hewiIZfROQjAdBgNVHQ4E +# FgQUt/1Teh2XDuUj2WW3siYWJgkZHA8wDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQM +# MAoGCCsGAQUFBwMDMIG1BgNVHR8Ega0wgaowU6BRoE+GTWh0dHA6Ly9jcmwzLmRp +# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI +# QTM4NDIwMjFDQTEuY3JsMFOgUaBPhk1odHRwOi8vY3JsNC5kaWdpY2VydC5jb20v +# RGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0Ex +# LmNybDA+BgNVHSAENzA1MDMGBmeBDAEEATApMCcGCCsGAQUFBwIBFhtodHRwOi8v +# d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwgZQGCCsGAQUFBwEBBIGHMIGEMCQGCCsGAQUF +# BzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wXAYIKwYBBQUHMAKGUGh0dHA6 +# Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWdu +# aW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3J0MAwGA1UdEwEB/wQCMAAwDQYJKoZI +# hvcNAQELBQADggIBABxv4AeV/5ltkELHSC63fXAFYS5tadcWTiNc2rskrNLrfH1N +# s0vgSZFoQxYBFKI159E8oQQ1SKbTEubZ/B9kmHPhprHya08+VVzxC88pOEvz68nA +# 82oEM09584aILqYmj8Pj7h/kmZNzuEL7WiwFa/U1hX+XiWfLIJQsAHBla0i7QRF2 +# de8/VSF0XXFa2kBQ6aiTsiLyKPNbaNtbcucaUdn6vVUS5izWOXM95BSkFSKdE45O +# q3FForNJXjBvSCpwcP36WklaHL+aHu1upIhCTUkzTHMh8b86WmjRUqbrnvdyR2yd +# I5l1OqcMBjkpPpIV6wcc+KY/RH2xvVuuoHjlUjwq2bHiNoX+W1scCpnA8YTs2d50 +# jDHUgwUo+ciwpffH0Riq132NFmrH3r67VaN3TuBxjI8SIZM58WEDkbeoriDk3hxU +# 8ZWV7b8AW6oyVBGfM06UgkfMb58h+tJPrFx8VI/WLq1dTqMfZOm5cuclMnUHs2uq +# rRNtnV8UfidPBL4ZHkTcClQbCoz0UbLhkiDvIS00Dn+BBcxw/TKqVL4Oaz3bkMSs +# M46LciTeucHY9ExRVt3zy7i149sd+F4QozPqn7FrSVHXmem3r7bjyHTxOgqxRCVa +# 18Vtx7P/8bYSBeS+WHCKcliFCecspusCDSlnRUjZwyPdP0VHxaZg2unjHY3rMYIa +# tjCCGrICAQEwfTBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu +# Yy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJT +# QTQwOTYgU0hBMzg0IDIwMjEgQ0ExAhAHHxQbizANJfMU6yMM0NHdMA0GCWCGSAFl +# AwQCAQUAoIHIMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcC +# AQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCBnAZ6P7YvTwq0fbF62 +# o7E75R0LxsW5OtyYiFESQckLhjBcBgorBgEEAYI3AgEMMU4wTKBGgEQAQgB1AGkA +# bAB0ADoAIABSAGUAbABlAGEAcwBlAF8AdgAzAC4AMQAyAC4ANABfADIAMAAyADQA +# MAA2ADAANgAuADAAMaECgAAwDQYJKoZIhvcNAQEBBQAEggIAV29hYhi09QNyGtav +# HZIo33y/iqXsIa4o88S5gzBa7Nnkwra0QLitSjvRfVbcFvq54Id+VIn00di4Nde0 +# maAUKPGXtTQL48esG/F/TLDOWd/jb9qCYHyNZYpJjKdXqI8IbyG6Pl05IMSas7wX +# DHsK19ZEGuGrmKCAxh6JbFXADgeUbftg3i9UxpMnfSugZjjdKIdyVWlzUnpYkKuI +# fpafwvNHfIYzfxOeV9CWsdqe34D6fRrEs8ZDEZSQl+Mw9aGaT39vuryFE1iKOzj0 +# uqrX/wN/wwu8oLWNC7JWE8SDG3eD0QLy+x7zEnlPkWsRV9nGOgrP9Khge0LgL+jP +# Km8iDs7fSGEOB/7PPxAl8yshEULOZAhBhcsGeGs+kQrVzlqZ9WlrU1Z1cylpLWzX +# Kkvs2DXD+zrplhpiVv6Gnn3YMBr4BKf0mXESTX9/BzIwvxlkhpv/BT0OWwrDlgPM +# hNj8jA5r2/WSqCg15DYjJ0RlnCerC/ORhSbs7v/HjpmH3DhaICJF7tdyFSIFXgNV +# W0GyQJMulQDEPd2+o+PNyAPElvGC3SYTjVnRLPcJTGhAt+VuHfnMG4HNkmyeU+nk +# OAMShxEax6NLeRsjKqqABUgZb2g4FSmXzHy7HgQOPmCQMv8xH4m8u992YMLyxh5U +# gGRUOUiAhrHXNZ6wG6T52NGQppehghc/MIIXOwYKKwYBBAGCNwMDATGCFyswghcn +# BgkqhkiG9w0BBwKgghcYMIIXFAIBAzEPMA0GCWCGSAFlAwQCAQUAMHcGCyqGSIb3 +# DQEJEAEEoGgEZjBkAgEBBglghkgBhv1sBwEwMTANBglghkgBZQMEAgEFAAQg+eJt +# Pwl5Hz89rrpf2qbsjNAUNlBq9SGjVuw+Erci2HcCEDPjoeI//+uRP30fqUoeHIAY +# DzIwMjQwNjA2MTk1MDE0WqCCEwkwggbCMIIEqqADAgECAhAFRK/zlJ0IOaa/2z9f +# 5WEWMA0GCSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdp +# Q2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2 +# IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwHhcNMjMwNzE0MDAwMDAwWhcNMzQxMDEz +# MjM1OTU5WjBIMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4x +# IDAeBgNVBAMTF0RpZ2lDZXJ0IFRpbWVzdGFtcCAyMDIzMIICIjANBgkqhkiG9w0B +# AQEFAAOCAg8AMIICCgKCAgEAo1NFhx2DjlusPlSzI+DPn9fl0uddoQ4J3C9Io5d6 +# OyqcZ9xiFVjBqZMRp82qsmrdECmKHmJjadNYnDVxvzqX65RQjxwg6seaOy+WZuNp +# 52n+W8PWKyAcwZeUtKVQgfLPywemMGjKg0La/H8JJJSkghraarrYO8pd3hkYhftF +# 6g1hbJ3+cV7EBpo88MUueQ8bZlLjyNY+X9pD04T10Mf2SC1eRXWWdf7dEKEbg8G4 +# 5lKVtUfXeCk5a+B4WZfjRCtK1ZXO7wgX6oJkTf8j48qG7rSkIWRw69XloNpjsy7p +# Be6q9iT1HbybHLK3X9/w7nZ9MZllR1WdSiQvrCuXvp/k/XtzPjLuUjT71Lvr1KAs +# NJvj3m5kGQc3AZEPHLVRzapMZoOIaGK7vEEbeBlt5NkP4FhB+9ixLOFRr7StFQYU +# 6mIIE9NpHnxkTZ0P387RXoyqq1AVybPKvNfEO2hEo6U7Qv1zfe7dCv95NBB+plwK +# WEwAPoVpdceDZNZ1zY8SdlalJPrXxGshuugfNJgvOuprAbD3+yqG7HtSOKmYCaFx +# smxxrz64b5bV4RAT/mFHCoz+8LbH1cfebCTwv0KCyqBxPZySkwS0aXAnDU+3tTbR +# yV8IpHCj7ArxES5k4MsiK8rxKBMhSVF+BmbTO77665E42FEHypS34lCh8zrTioPL +# QHsCAwEAAaOCAYswggGHMA4GA1UdDwEB/wQEAwIHgDAMBgNVHRMBAf8EAjAAMBYG +# A1UdJQEB/wQMMAoGCCsGAQUFBwMIMCAGA1UdIAQZMBcwCAYGZ4EMAQQCMAsGCWCG +# SAGG/WwHATAfBgNVHSMEGDAWgBS6FtltTYUvcyl2mi91jGogj57IbzAdBgNVHQ4E +# FgQUpbbvE+fvzdBkodVWqWUxo97V40kwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDov +# L2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5NlNIQTI1 +# NlRpbWVTdGFtcGluZ0NBLmNybDCBkAYIKwYBBQUHAQEEgYMwgYAwJAYIKwYBBQUH +# MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBYBggrBgEFBQcwAoZMaHR0cDov +# L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5NlNI +# QTI1NlRpbWVTdGFtcGluZ0NBLmNydDANBgkqhkiG9w0BAQsFAAOCAgEAgRrW3qCp +# tZgXvHCNT4o8aJzYJf/LLOTN6l0ikuyMIgKpuM+AqNnn48XtJoKKcS8Y3U623mzX +# 4WCcK+3tPUiOuGu6fF29wmE3aEl3o+uQqhLXJ4Xzjh6S2sJAOJ9dyKAuJXglnSoF +# eoQpmLZXeY/bJlYrsPOnvTcM2Jh2T1a5UsK2nTipgedtQVyMadG5K8TGe8+c+nji +# kxp2oml101DkRBK+IA2eqUTQ+OVJdwhaIcW0z5iVGlS6ubzBaRm6zxbygzc0brBB +# Jt3eWpdPM43UjXd9dUWhpVgmagNF3tlQtVCMr1a9TMXhRsUo063nQwBw3syYnhmJ +# A+rUkTfvTVLzyWAhxFZH7doRS4wyw4jmWOK22z75X7BC1o/jF5HRqsBV44a/rCcs +# QdCaM0qoNtS5cpZ+l3k4SF/Kwtw9Mt911jZnWon49qfH5U81PAC9vpwqbHkB3NpE +# 5jreODsHXjlY9HxzMVWggBHLFAx+rrz+pOt5Zapo1iLKO+uagjVXKBbLafIymrLS +# 2Dq4sUaGa7oX/cR3bBVsrquvczroSUa31X/MtjjA2Owc9bahuEMs305MfR5ocMB3 +# CtQC4Fxguyj/OOVSWtasFyIjTvTs0xf7UGv/B3cfcZdEQcm4RtNsMnxYL2dHZeUb +# c7aZ+WssBkbvQR7w8F/g29mtkIBEr4AQQYowggauMIIElqADAgECAhAHNje3JFR8 +# 2Ees/ShmKl5bMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK +# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV +# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0yMjAzMjMwMDAwMDBaFw0z +# NzAzMjIyMzU5NTlaMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwg +# SW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2IFNIQTI1 +# NiBUaW1lU3RhbXBpbmcgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC +# AQDGhjUGSbPBPXJJUVXHJQPE8pE3qZdRodbSg9GeTKJtoLDMg/la9hGhRBVCX6SI +# 82j6ffOciQt/nR+eDzMfUBMLJnOWbfhXqAJ9/UO0hNoR8XOxs+4rgISKIhjf69o9 +# xBd/qxkrPkLcZ47qUT3w1lbU5ygt69OxtXXnHwZljZQp09nsad/ZkIdGAHvbREGJ +# 3HxqV3rwN3mfXazL6IRktFLydkf3YYMZ3V+0VAshaG43IbtArF+y3kp9zvU5Emfv +# DqVjbOSmxR3NNg1c1eYbqMFkdECnwHLFuk4fsbVYTXn+149zk6wsOeKlSNbwsDET +# qVcplicu9Yemj052FVUmcJgmf6AaRyBD40NjgHt1biclkJg6OBGz9vae5jtb7IHe +# IhTZgirHkr+g3uM+onP65x9abJTyUpURK1h0QCirc0PO30qhHGs4xSnzyqqWc0Jo +# n7ZGs506o9UD4L/wojzKQtwYSH8UNM/STKvvmz3+DrhkKvp1KCRB7UK/BZxmSVJQ +# 9FHzNklNiyDSLFc1eSuo80VgvCONWPfcYd6T/jnA+bIwpUzX6ZhKWD7TA4j+s4/T +# Xkt2ElGTyYwMO1uKIqjBJgj5FBASA31fI7tk42PgpuE+9sJ0sj8eCXbsq11GdeJg +# o1gJASgADoRU7s7pXcheMBK9Rp6103a50g5rmQzSM7TNsQIDAQABo4IBXTCCAVkw +# EgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUuhbZbU2FL3MpdpovdYxqII+e +# yG8wHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQD +# AgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEF +# BQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRw +# Oi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy +# dDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln +# aUNlcnRUcnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglg +# hkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAH1ZjsCTtm+YqUQiAX5m1tghQuGw +# GC4QTRPPMFPOvxj7x1Bd4ksp+3CKDaopafxpwc8dB+k+YMjYC+VcW9dth/qEICU0 +# MWfNthKWb8RQTGIdDAiCqBa9qVbPFXONASIlzpVpP0d3+3J0FNf/q0+KLHqrhc1D +# X+1gtqpPkWaeLJ7giqzl/Yy8ZCaHbJK9nXzQcAp876i8dU+6WvepELJd6f8oVInw +# 1YpxdmXazPByoyP6wCeCRK6ZJxurJB4mwbfeKuv2nrF5mYGjVoarCkXJ38SNoOeY +# +/umnXKvxMfBwWpx2cYTgAnEtp/Nh4cku0+jSbl3ZpHxcpzpSwJSpzd+k1OsOx0I +# SQ+UzTl63f8lY5knLD0/a6fxZsNBzU+2QJshIUDQtxMkzdwdeDrknq3lNHGS1yZr +# 5Dhzq6YBT70/O3itTK37xJV77QpfMzmHQXh6OOmc4d0j/R0o08f56PGYX/sr2H7y +# Rp11LB4nLCbbbxV7HhmLNriT1ObyF5lZynDwN7+YAN8gFk8n+2BnFqFmut1VwDop +# hrCYoCvtlUG3OtUVmDG0YgkPCr2B2RP+v6TR81fZvAT6gt4y3wSJ8ADNXcL50CN/ +# AAvkdgIm2fBldkKmKYcJRyvmfxqkhQ/8mJb2VVQrH4D6wPIOK+XW+6kvRBVK5xMO +# Hds3OBqhK/bt1nz8MIIFjTCCBHWgAwIBAgIQDpsYjvnQLefv21DiCEAYWjANBgkq +# hkiG9w0BAQwFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5j +# MRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBB +# c3N1cmVkIElEIFJvb3QgQ0EwHhcNMjIwODAxMDAwMDAwWhcNMzExMTA5MjM1OTU5 +# WjBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL +# ExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJv +# b3QgRzQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1K +# PDAiMGkz7MKnJS7JIT3yithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2r +# snnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C +# 8weE5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBf +# sXpm7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGY +# QJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8 +# rhsDdV14Ztk6MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaY +# dj1ZXUJ2h4mXaXpI8OCiEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+ +# wJS00mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw +# ++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+N +# P8m800ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7F +# wI+isX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo4IBOjCCATYwDwYDVR0TAQH/BAUw +# AwEB/zAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wHwYDVR0jBBgwFoAU +# Reuir/SSy4IxLVGLp6chnfNtyA8wDgYDVR0PAQH/BAQDAgGGMHkGCCsGAQUFBwEB +# BG0wazAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEMGCCsG +# AQUFBzAChjdodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRBc3N1 +# cmVkSURSb290Q0EuY3J0MEUGA1UdHwQ+MDwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRp +# Z2ljZXJ0LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcmwwEQYDVR0gBAow +# CDAGBgRVHSAAMA0GCSqGSIb3DQEBDAUAA4IBAQBwoL9DXFXnOF+go3QbPbYW1/e/ +# Vwe9mqyhhyzshV6pGrsi+IcaaVQi7aSId229GhT0E0p6Ly23OO/0/4C5+KH38nLe +# JLxSA8hO0Cre+i1Wz/n096wwepqLsl7Uz9FDRJtDIeuWcqFItJnLnU+nBgMTdydE +# 1Od/6Fmo8L8vC6bp8jQ87PcDx4eo0kxAGTVGamlUsLihVo7spNU96LHc/RzY9Hda +# XFSMb++hUD38dglohJ9vytsgjTVgHAIDyyCwrFigDkBjxZgiwbJZ9VVrzyerbHbO +# byMt9H5xaiNrIv8SuFQtJ37YOtnwtoeW/VvRXKwYw02fc7cBqZ9Xql4o4rmUMYID +# djCCA3ICAQEwdzBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu +# Yy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5NiBTSEEyNTYg +# VGltZVN0YW1waW5nIENBAhAFRK/zlJ0IOaa/2z9f5WEWMA0GCWCGSAFlAwQCAQUA +# oIHRMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcN +# MjQwNjA2MTk1MDE0WjArBgsqhkiG9w0BCRACDDEcMBowGDAWBBRm8CsywsLJD4Jd +# zqqKycZPGZzPQDAvBgkqhkiG9w0BCQQxIgQgUvswt0fWRoofHUAuTE0/8V9tLmHP +# zr/l2RTobZjBdqYwNwYLKoZIhvcNAQkQAi8xKDAmMCQwIgQg0vbkbe10IszR1EBX +# aEE2b4KK2lWarjMWr00amtQMeCgwDQYJKoZIhvcNAQEBBQAEggIAc7/uG/S8kf0i +# 2kaDQkE8NSfiXCYfN7z/2sgi6RNrkipvs/KTWfEKuMbhu9qWjjusZFgywn/IrZqw +# td4Js1kmaN+HJ02t/HXYUCr+KTJye4mDaBGvaXXHllCqsK7bhsJxJYE0uYiL03MP +# g64jyu9WdJD3N26MW/DkO6HTVhYzRzjafbAKbrr8KCvaFan1KZERzYwbA8XVjm88 +# HOodLCA9h+91Iqdc+uSz3Sg9/+Ns4zCp4BonvnsPYTlWTitiB5cpfPe/v4lBvCNu +# x0ha6whvKMdRLZJgXsiDXo2NwwB55kkWEBwD3a1RnBJQmyJxFEGpSXOrhmdcEWPg +# fjoHVIfowKBrIgINdWJbvIu+pLzQRMkVhuJzB32xpiZBIvbzkPETYQMOmKIu40I9 +# 5EAL0xNakPxYiT3nTkncn6woLOhiOXFm7crE+gO4IzDNauYuT9Vfe36K1CqtuYSy +# JesLIey9Z81OQqOo6n2/lW110MKMEV2PkPU7YW/bYO2uKsZ3OAjUWr63nMT+M2wk +# VdUAcqm0QdZsELY75Q3ekRxHje/B9ePP4Q4RMQGOZvmgqdtEeFhsmRwufR4fzfqx +# WMttmOHelTd8Sc0sfA9B+1dxtiC9GFn3de5/o+T2s/jQn6eNp2hvlCqGV0iFzSQp +# InPTBa9Na/+5UeXZ3NBWRvarfZ62TVM= +# SIG # End signature block diff --git a/Scripts/activate b/Scripts/activate new file mode 100644 index 0000000..84afec4 --- /dev/null +++ b/Scripts/activate @@ -0,0 +1,70 @@ +# This file must be used with "source bin/activate" *from bash* +# You cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # Call hash to forget past commands. Without forgetting + # past commands the $PATH changes we made may not be respected + hash -r 2> /dev/null + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +# on Windows, a path can contain colons and backslashes and has to be converted: +if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then + # transform D:\path\to\venv to /d/path/to/venv on MSYS + # and to /cygdrive/d/path/to/venv on Cygwin + export VIRTUAL_ENV=$(cygpath "c:\Users\Lockeshor\Documents\Code\csc-checkin\csc-checkin.venv") +else + # use the path as-is + export VIRTUAL_ENV="c:\Users\Lockeshor\Documents\Code\csc-checkin\csc-checkin.venv" +fi + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/Scripts:$PATH" +export PATH + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1="(csc-checkin.venv) ${PS1:-}" + export PS1 + VIRTUAL_ENV_PROMPT="(csc-checkin.venv) " + export VIRTUAL_ENV_PROMPT +fi + +# Call hash to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +hash -r 2> /dev/null diff --git a/Scripts/activate.bat b/Scripts/activate.bat new file mode 100644 index 0000000..1904b76 --- /dev/null +++ b/Scripts/activate.bat @@ -0,0 +1,34 @@ +@echo off + +rem This file is UTF-8 encoded, so we need to update the current code page while executing it +for /f "tokens=2 delims=:." %%a in ('"%SystemRoot%\System32\chcp.com"') do ( + set _OLD_CODEPAGE=%%a +) +if defined _OLD_CODEPAGE ( + "%SystemRoot%\System32\chcp.com" 65001 > nul +) + +set VIRTUAL_ENV=c:\Users\Lockeshor\Documents\Code\csc-checkin\csc-checkin.venv + +if not defined PROMPT set PROMPT=$P$G + +if defined _OLD_VIRTUAL_PROMPT set PROMPT=%_OLD_VIRTUAL_PROMPT% +if defined _OLD_VIRTUAL_PYTHONHOME set PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME% + +set _OLD_VIRTUAL_PROMPT=%PROMPT% +set PROMPT=(csc-checkin.venv) %PROMPT% + +if defined PYTHONHOME set _OLD_VIRTUAL_PYTHONHOME=%PYTHONHOME% +set PYTHONHOME= + +if defined _OLD_VIRTUAL_PATH set PATH=%_OLD_VIRTUAL_PATH% +if not defined _OLD_VIRTUAL_PATH set _OLD_VIRTUAL_PATH=%PATH% + +set PATH=%VIRTUAL_ENV%\Scripts;%PATH% +set VIRTUAL_ENV_PROMPT=(csc-checkin.venv) + +:END +if defined _OLD_CODEPAGE ( + "%SystemRoot%\System32\chcp.com" %_OLD_CODEPAGE% > nul + set _OLD_CODEPAGE= +) diff --git a/Scripts/deactivate.bat b/Scripts/deactivate.bat new file mode 100644 index 0000000..62a39a7 --- /dev/null +++ b/Scripts/deactivate.bat @@ -0,0 +1,22 @@ +@echo off + +if defined _OLD_VIRTUAL_PROMPT ( + set "PROMPT=%_OLD_VIRTUAL_PROMPT%" +) +set _OLD_VIRTUAL_PROMPT= + +if defined _OLD_VIRTUAL_PYTHONHOME ( + set "PYTHONHOME=%_OLD_VIRTUAL_PYTHONHOME%" + set _OLD_VIRTUAL_PYTHONHOME= +) + +if defined _OLD_VIRTUAL_PATH ( + set "PATH=%_OLD_VIRTUAL_PATH%" +) + +set _OLD_VIRTUAL_PATH= + +set VIRTUAL_ENV= +set VIRTUAL_ENV_PROMPT= + +:END diff --git a/Scripts/flask.exe b/Scripts/flask.exe new file mode 100644 index 0000000..2732dac Binary files /dev/null and b/Scripts/flask.exe differ diff --git a/Scripts/pip.exe b/Scripts/pip.exe new file mode 100644 index 0000000..52cfd61 Binary files /dev/null and b/Scripts/pip.exe differ diff --git a/Scripts/pip3.12.exe b/Scripts/pip3.12.exe new file mode 100644 index 0000000..52cfd61 Binary files /dev/null and b/Scripts/pip3.12.exe differ diff --git a/Scripts/pip3.exe b/Scripts/pip3.exe new file mode 100644 index 0000000..52cfd61 Binary files /dev/null and b/Scripts/pip3.exe differ diff --git a/Scripts/python.exe b/Scripts/python.exe new file mode 100644 index 0000000..53121ae Binary files /dev/null and b/Scripts/python.exe differ diff --git a/Scripts/pythonw.exe b/Scripts/pythonw.exe new file mode 100644 index 0000000..a09f6e9 Binary files /dev/null and b/Scripts/pythonw.exe differ diff --git a/backups/pool_backup_20260306_000244.sqlite3 b/backups/pool_backup_20260306_000244.sqlite3 new file mode 100644 index 0000000..c94e8fb Binary files /dev/null and b/backups/pool_backup_20260306_000244.sqlite3 differ diff --git a/backups/pool_backup_20260306_001656.sqlite3 b/backups/pool_backup_20260306_001656.sqlite3 new file mode 100644 index 0000000..c832eaa Binary files /dev/null and b/backups/pool_backup_20260306_001656.sqlite3 differ diff --git a/exports/pool_members_20260305_233159.csv b/exports/pool_members_20260305_233159.csv new file mode 100644 index 0000000..eacd5c7 --- /dev/null +++ b/exports/pool_members_20260305_233159.csv @@ -0,0 +1,11 @@ +member_id,first_name,last_name,age,household,checked_in,swim_test_passed,can_use_deep_end +4,Luis,Garcia,41,Garcia,1,1,1 +6,Mateo,Garcia,11,Garcia,1,1,1 +5,Sofia,Garcia,38,Garcia,0,1,1 +3,Emma,James,29,Individual,1,1,1 +2,Noah,Patel,42,Individual,0,1,1 +1,Avery,Wilson,34,Individual,1,1,1 +7,Helen,Miller,37,Miller,1,1,1 +8,Kai,Miller,9,Miller,1,0,0 +10,Liam,Nguyen,8,Nguyen,0,0,0 +9,Trang,Nguyen,35,Nguyen,0,1,1 diff --git a/exports/pool_members_20260306_000251.csv b/exports/pool_members_20260306_000251.csv new file mode 100644 index 0000000..65118ba --- /dev/null +++ b/exports/pool_members_20260306_000251.csv @@ -0,0 +1,11 @@ +member_id,first_name,last_name,age,household,checked_in,swim_test_passed,can_use_deep_end +4,Luis,Garcia,41,Garcia,0,0,1 +6,Mateo,Garcia,11,Garcia,0,0,0 +5,Sofia,Garcia,38,Garcia,0,0,1 +3,Emma,James,29,Individual,0,0,1 +2,Noah,Patel,42,Individual,0,0,1 +1,Avery,Wilson,34,Individual,0,0,1 +7,Helen,Miller,37,Miller,0,0,1 +8,Kai,Miller,9,Miller,0,0,0 +10,Liam,Nguyen,8,Nguyen,0,0,0 +9,Trang,Nguyen,35,Nguyen,0,0,1 diff --git a/exports/pool_members_20260306_000949.csv b/exports/pool_members_20260306_000949.csv new file mode 100644 index 0000000..49f3467 --- /dev/null +++ b/exports/pool_members_20260306_000949.csv @@ -0,0 +1,13 @@ +member_id,first_name,last_name,age,family_id,family_name,primary_phone,checked_in,swim_test_passed,requires_swim_test +4,Luis,Garcia,41,1,Garcia,555-555-0141,0,0,0 +6,Mateo,Garcia,11,1,Garcia,555-555-0141,0,0,1 +5,Sofia,Garcia,38,1,Garcia,555-555-0141,0,0,0 +11,John,Gracia-laquez,16,1,Garcia,555-555-0141,0,0,0 +3,Emma,James,29,,Individual,,0,0,0 +2,Noah,Patel,42,,Individual,,0,0,0 +1,Avery,Wilson,34,,Individual,,0,0,0 +7,Helen,Miller,37,2,Miller,555-555-0187,0,0,0 +8,Kai,Miller,9,2,Miller,555-555-0187,0,0,1 +12,Langlostean,Nguyen,11,3,Nguyen,555-555-0179,0,0,1 +10,Liam,Nguyen,8,3,Nguyen,555-555-0179,0,0,1 +9,Trang,Nguyen,35,3,Nguyen,555-555-0179,0,0,0 diff --git a/exports/pool_members_20260306_001657.csv b/exports/pool_members_20260306_001657.csv new file mode 100644 index 0000000..49f3467 --- /dev/null +++ b/exports/pool_members_20260306_001657.csv @@ -0,0 +1,13 @@ +member_id,first_name,last_name,age,family_id,family_name,primary_phone,checked_in,swim_test_passed,requires_swim_test +4,Luis,Garcia,41,1,Garcia,555-555-0141,0,0,0 +6,Mateo,Garcia,11,1,Garcia,555-555-0141,0,0,1 +5,Sofia,Garcia,38,1,Garcia,555-555-0141,0,0,0 +11,John,Gracia-laquez,16,1,Garcia,555-555-0141,0,0,0 +3,Emma,James,29,,Individual,,0,0,0 +2,Noah,Patel,42,,Individual,,0,0,0 +1,Avery,Wilson,34,,Individual,,0,0,0 +7,Helen,Miller,37,2,Miller,555-555-0187,0,0,0 +8,Kai,Miller,9,2,Miller,555-555-0187,0,0,1 +12,Langlostean,Nguyen,11,3,Nguyen,555-555-0179,0,0,1 +10,Liam,Nguyen,8,3,Nguyen,555-555-0179,0,0,1 +9,Trang,Nguyen,35,3,Nguyen,555-555-0179,0,0,0 diff --git a/exports/pool_members_20260306_003607.csv b/exports/pool_members_20260306_003607.csv new file mode 100644 index 0000000..49f3467 --- /dev/null +++ b/exports/pool_members_20260306_003607.csv @@ -0,0 +1,13 @@ +member_id,first_name,last_name,age,family_id,family_name,primary_phone,checked_in,swim_test_passed,requires_swim_test +4,Luis,Garcia,41,1,Garcia,555-555-0141,0,0,0 +6,Mateo,Garcia,11,1,Garcia,555-555-0141,0,0,1 +5,Sofia,Garcia,38,1,Garcia,555-555-0141,0,0,0 +11,John,Gracia-laquez,16,1,Garcia,555-555-0141,0,0,0 +3,Emma,James,29,,Individual,,0,0,0 +2,Noah,Patel,42,,Individual,,0,0,0 +1,Avery,Wilson,34,,Individual,,0,0,0 +7,Helen,Miller,37,2,Miller,555-555-0187,0,0,0 +8,Kai,Miller,9,2,Miller,555-555-0187,0,0,1 +12,Langlostean,Nguyen,11,3,Nguyen,555-555-0179,0,0,1 +10,Liam,Nguyen,8,3,Nguyen,555-555-0179,0,0,1 +9,Trang,Nguyen,35,3,Nguyen,555-555-0179,0,0,0 diff --git a/exports/pool_members_20260306_004335.csv b/exports/pool_members_20260306_004335.csv new file mode 100644 index 0000000..49f3467 --- /dev/null +++ b/exports/pool_members_20260306_004335.csv @@ -0,0 +1,13 @@ +member_id,first_name,last_name,age,family_id,family_name,primary_phone,checked_in,swim_test_passed,requires_swim_test +4,Luis,Garcia,41,1,Garcia,555-555-0141,0,0,0 +6,Mateo,Garcia,11,1,Garcia,555-555-0141,0,0,1 +5,Sofia,Garcia,38,1,Garcia,555-555-0141,0,0,0 +11,John,Gracia-laquez,16,1,Garcia,555-555-0141,0,0,0 +3,Emma,James,29,,Individual,,0,0,0 +2,Noah,Patel,42,,Individual,,0,0,0 +1,Avery,Wilson,34,,Individual,,0,0,0 +7,Helen,Miller,37,2,Miller,555-555-0187,0,0,0 +8,Kai,Miller,9,2,Miller,555-555-0187,0,0,1 +12,Langlostean,Nguyen,11,3,Nguyen,555-555-0179,0,0,1 +10,Liam,Nguyen,8,3,Nguyen,555-555-0179,0,0,1 +9,Trang,Nguyen,35,3,Nguyen,555-555-0179,0,0,0 diff --git a/family.html b/family.html new file mode 100644 index 0000000..a0ade95 --- /dev/null +++ b/family.html @@ -0,0 +1,786 @@ + + + + + + Cary Swim Club Family Check-In + + + + + + +
+
+

Family

+

Loading family...

+
+
+
+ Guest Passes + 0 +
+
+ + +
+
+
+
+ +
+ Back To Family Search + +
+ +
+
Swim test required under age 13.
+ +
+
+

Adults

+ +
+
+
No adults in this family.
+
+ +
+
+

Children

+ +
+
+
No children in this family.
+
+ +
Ready.
+
+
+ + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..fe4dd95 --- /dev/null +++ b/index.html @@ -0,0 +1,727 @@ + + + + + + Cary Swim Club Check-In + + + + + + +
+
+
Cary Swim Club
+

Family Check-In Lookup

+

Fast and simple member management system for CSC guards and administrators.

+
+ +
+
+

Families

+

0

+
+
+

Total Members

+

0

+
+
+

Checked In

+

0

+
+
+

Children Swim Passed

+

0

+
+
+ +
+
+

Checked-In Right Now

+ Management +
+

Search active guests, or type first/family-last names for quick check-in/out lookup.

+
+ +
+
+ +
+ +
+

Search Families

+

Search and tap a family.

+
+ + +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..7ff26a2 --- /dev/null +++ b/main.py @@ -0,0 +1,833 @@ +import csv +import datetime as dt +import difflib +import re +import shutil +import sqlite3 +from pathlib import Path + +from flask import Flask, jsonify, request, send_file + + +BASE_DIR = Path(__file__).resolve().parent +DB_PATH = BASE_DIR / "pool_checkin.sqlite3" +BACKUP_DIR = BASE_DIR / "backups" +EXPORT_DIR = BASE_DIR / "exports" + +app = Flask(__name__) +# Code-only configuration: change this constant to update the swim-test age rule. +DEFAULT_MIN_SWIM_TEST_AGE = 13 + + +def dict_row_factory(cursor, row): + return {col[0]: row[idx] for idx, col in enumerate(cursor.description)} + + +def get_connection() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = dict_row_factory + conn.execute("PRAGMA foreign_keys = ON;") + return conn + + +def normalize_text(value: str) -> str: + return re.sub(r"[^a-z0-9 ]+", "", (value or "").lower()).strip() + + +def fuzzy_score(query: str, candidate: str) -> float: + q = normalize_text(query) + c = normalize_text(candidate) + if not q or not c: + return 0.0 + + if q in c: + return 1.0 + (len(q) / max(len(c), 1)) + + base = difflib.SequenceMatcher(None, q, c).ratio() + token_scores = [difflib.SequenceMatcher(None, q, token).ratio() for token in c.split()] + token_max = max(token_scores) if token_scores else 0.0 + return max(base, token_max) + + +def normalize_phone_for_storage(phone_raw: str) -> str | None: + digits = re.sub(r"\D", "", phone_raw or "") + if not digits: + return None + + if len(digits) == 10: + return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}" + + if len(digits) == 11 and digits.startswith("1"): + d = digits[1:] + return f"+1 {d[:3]}-{d[3:6]}-{d[6:]}" + + raise ValueError("Phone number must include area code (10 digits).") + + +def initialize_database() -> None: + BACKUP_DIR.mkdir(exist_ok=True) + EXPORT_DIR.mkdir(exist_ok=True) + + with get_connection() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS families ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + family_name TEXT NOT NULL, + primary_phone TEXT, + guest_passes INTEGER NOT NULL DEFAULT 0 CHECK(guest_passes >= 0), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS app_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + age INTEGER NOT NULL CHECK(age >= 0), + family_id INTEGER, + is_checked_in INTEGER NOT NULL DEFAULT 0 CHECK(is_checked_in IN (0, 1)), + swim_test_passed INTEGER NOT NULL DEFAULT 0 CHECK(swim_test_passed IN (0, 1)), + can_use_deep_end INTEGER NOT NULL DEFAULT 0 CHECK(can_use_deep_end IN (0, 1)), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (family_id) REFERENCES families(id) ON DELETE SET NULL + ); + + CREATE INDEX IF NOT EXISTS idx_members_name ON members(last_name, first_name); + CREATE INDEX IF NOT EXISTS idx_members_family ON members(family_id); + CREATE INDEX IF NOT EXISTS idx_members_checked_in ON members(is_checked_in); + """ + ) + + # Seed demo data once to make first run usable. + total_rows = conn.execute("SELECT COUNT(*) AS count FROM members;").fetchone()["count"] + if total_rows == 0: + conn.executemany( + "INSERT INTO families (family_name, primary_phone) VALUES (?, ?);", + [ + ("Garcia", "555-201-0141"), + ("Miller", "555-201-0187"), + ("Nguyen", "555-201-0179"), + ], + ) + + family_map = { + row["family_name"]: row["id"] + for row in conn.execute("SELECT id, family_name FROM families;").fetchall() + } + + conn.executemany( + """ + INSERT INTO members + (first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end) + VALUES (?, ?, ?, ?, ?, ?, ?); + """, + [ + ("Avery", "Wilson", 34, None, 0, 0, 1), + ("Noah", "Patel", 42, None, 0, 0, 1), + ("Emma", "James", 29, None, 0, 0, 1), + ("Luis", "Garcia", 41, family_map["Garcia"], 0, 0, 1), + ("Sofia", "Garcia", 38, family_map["Garcia"], 0, 0, 1), + ("Mateo", "Garcia", 11, family_map["Garcia"], 0, 0, 0), + ("Helen", "Miller", 37, family_map["Miller"], 0, 0, 1), + ("Kai", "Miller", 9, family_map["Miller"], 0, 0, 0), + ("Trang", "Nguyen", 35, family_map["Nguyen"], 0, 0, 1), + ("Liam", "Nguyen", 8, family_map["Nguyen"], 0, 0, 0), + ], + ) + + schema_version = conn.execute( + "SELECT value FROM app_meta WHERE key = 'schema_version';" + ).fetchone() + current_schema_version = 0 + if schema_version is not None: + try: + current_schema_version = int(schema_version["value"]) + except (TypeError, ValueError): + current_schema_version = 0 + + # One-time migration for older local databases created before defaults were reset. + if current_schema_version < 2: + conn.execute("UPDATE members SET is_checked_in = 0, swim_test_passed = 0;") + conn.execute( + "UPDATE members SET can_use_deep_end = CASE WHEN age >= 13 THEN 1 ELSE 0 END;" + ) + + family_columns = { + row["name"] + for row in conn.execute("PRAGMA table_info(families);").fetchall() + } + if "guest_passes" not in family_columns: + conn.execute( + "ALTER TABLE families " + "ADD COLUMN guest_passes INTEGER NOT NULL DEFAULT 0 CHECK(guest_passes >= 0);" + ) + + if current_schema_version < 3: + conn.execute( + "INSERT INTO app_meta (key, value) VALUES ('schema_version', '3') " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value;" + ) + + # Normalize older short phone records to full numbers when possible. + old_phones = conn.execute( + "SELECT id, primary_phone FROM families WHERE primary_phone IS NOT NULL;" + ).fetchall() + for row in old_phones: + phone = row["primary_phone"] + digits = re.sub(r"\D", "", phone) + if len(digits) == 7: + normalized = normalize_phone_for_storage(f"555{digits}") + conn.execute( + "UPDATE families SET primary_phone = ? WHERE id = ?;", + (normalized, row["id"]), + ) + + +def to_member_payload(row: dict, min_swim_test_age: int) -> dict: + requires_swim_test = row["age"] < min_swim_test_age + return { + "id": row["id"], + "firstName": row["first_name"], + "lastName": row["last_name"], + "fullName": f"{row['first_name']} {row['last_name']}", + "age": row["age"], + "familyId": row["family_id"], + "checkedIn": bool(row["is_checked_in"]), + "swimTestPassed": bool(row["swim_test_passed"]), + "requiresSwimTest": requires_swim_test, + } + + +def recalculate_deep_end(conn: sqlite3.Connection, member_id: int) -> None: + member = conn.execute( + "SELECT age, swim_test_passed FROM members WHERE id = ?;", + (member_id,), + ).fetchone() + if member is None: + return + + can_use = int(member["age"] >= 13 or bool(member["swim_test_passed"])) + conn.execute( + "UPDATE members SET can_use_deep_end = ? WHERE id = ?;", + (can_use, member_id), + ) + + +@app.route("/") +def homepage(): + return send_file(BASE_DIR / "index.html") + + +@app.route("/family/") +def family_page(family_id: int): + # The family id is consumed client-side from the URL path. + return send_file(BASE_DIR / "family.html") + + +@app.route("/manage") +def manage_page(): + return send_file(BASE_DIR / "manage.html") + + +@app.route("/api/dashboard", methods=["GET"]) +def api_dashboard(): + with get_connection() as conn: + totals = conn.execute( + """ + SELECT + COUNT(*) AS total_members, + SUM(CASE WHEN family_id IS NULL THEN 1 ELSE 0 END) AS individual_members, + SUM(CASE WHEN is_checked_in = 1 THEN 1 ELSE 0 END) AS checked_in_members, + SUM(CASE WHEN age < ? THEN 1 ELSE 0 END) AS children_count, + SUM(CASE WHEN age < ? AND swim_test_passed = 1 THEN 1 ELSE 0 END) AS children_swim_test_passed + FROM members; + """ + , + (DEFAULT_MIN_SWIM_TEST_AGE, DEFAULT_MIN_SWIM_TEST_AGE), + ).fetchone() + + family_count = conn.execute("SELECT COUNT(*) AS count FROM families;").fetchone()["count"] + + return jsonify( + { + "totalMembers": totals["total_members"] or 0, + "individuals": totals["individual_members"] or 0, + "families": family_count, + "checkedIn": totals["checked_in_members"] or 0, + "children": totals["children_count"] or 0, + "childrenSwimPassed": totals["children_swim_test_passed"] or 0, + "minSwimTestAge": DEFAULT_MIN_SWIM_TEST_AGE, + } + ) + + +@app.route("/api/checked-in", methods=["GET"]) +def api_checked_in_members(): + query = request.args.get("q", "").strip() + has_query = bool(query) + threshold = 0.62 + query_norm = normalize_text(query) + + with get_connection() as conn: + rows = conn.execute( + """ + SELECT + m.id, + m.first_name, + m.last_name, + m.age, + m.family_id, + m.is_checked_in, + m.swim_test_passed, + f.family_name + FROM members m + LEFT JOIN families f ON m.family_id = f.id + ORDER BY m.last_name, m.first_name; + """ + ).fetchall() + + family_last_name_score = {} + if has_query: + for row in rows: + family_id = row["family_id"] + if family_id is None: + continue + score = fuzzy_score(query, row["last_name"]) + current = family_last_name_score.get(family_id, 0.0) + family_last_name_score[family_id] = max(current, score) + + candidates = [] + for row in rows: + checked_in = bool(row["is_checked_in"]) + if not has_query and not checked_in: + continue + + first_score = fuzzy_score(query, row["first_name"]) if has_query else 1.0 + family_score = family_last_name_score.get(row["family_id"], 0.0) if has_query else 0.0 + last_score = fuzzy_score(query, row["last_name"]) if has_query else 0.0 + + matches_query = ( + not has_query + or first_score >= threshold + or family_score >= threshold + or (row["family_id"] is None and last_score >= threshold) + ) + if not matches_query: + continue + + full_name = f"{row['first_name']} {row['last_name']}" + family_name = row["family_name"] if row["family_name"] else "Individual" + first_norm = normalize_text(row["first_name"]) + last_norm = normalize_text(row["last_name"]) + full_norm = normalize_text(full_name) + + # Ordered matching boosts: exact > prefix > early-position > fuzzy. + prefix_bonus = 0.0 + position_bonus = 0.0 + if has_query and query_norm: + if first_norm == query_norm: + prefix_bonus = 1.6 + elif first_norm.startswith(query_norm): + prefix_bonus = 1.25 + elif full_norm.startswith(query_norm): + prefix_bonus = 1.05 + + if query_norm in first_norm: + position_bonus = max(position_bonus, 0.35 - (first_norm.find(query_norm) * 0.03)) + if query_norm in full_norm: + position_bonus = max(position_bonus, 0.2 - (full_norm.find(query_norm) * 0.015)) + + family_boost = family_score * 0.85 + match_score = max(first_score + prefix_bonus + position_bonus, last_score, family_boost) + candidates.append( + { + "id": row["id"], + "firstName": row["first_name"], + "lastName": row["last_name"], + "fullName": full_name, + "age": row["age"], + "checkedIn": checked_in, + "familyName": family_name, + "requiresSwimTest": row["age"] < DEFAULT_MIN_SWIM_TEST_AGE, + "swimTestPassed": bool(row["swim_test_passed"]), + "matchScore": round(match_score, 4), + "firstScore": round(first_score, 4), + "familyScore": round(family_score, 4), + "lastScore": round(last_score, 4), + } + ) + + if has_query: + candidates.sort( + key=lambda item: ( + -item["matchScore"], + -item["firstScore"], + -item["familyScore"], + not item["checkedIn"], + item["firstName"].lower(), + item["lastName"].lower(), + ) + ) + else: + candidates.sort( + key=lambda item: ( + not item["checkedIn"], + item["lastName"].lower(), + item["firstName"].lower(), + ) + ) + + return jsonify({"items": candidates}) + + +@app.route("/api/individuals", methods=["GET"]) +def api_individuals(): + query = request.args.get("q", "").strip().lower() + + with get_connection() as conn: + rows = conn.execute( + """ + SELECT id, first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end + FROM members + WHERE family_id IS NULL + ORDER BY last_name, first_name; + """ + ).fetchall() + + filtered = [] + for row in rows: + payload = to_member_payload(row, DEFAULT_MIN_SWIM_TEST_AGE) + searchable = f"{payload['fullName']} {payload['id']}".lower() + if query and query not in searchable: + continue + filtered.append(payload) + + return jsonify({"items": filtered}) + + +@app.route("/api/families", methods=["GET", "POST"]) +def api_families(): + if request.method == "POST": + body = request.get_json(silent=True) or {} + family_name = (body.get("familyName") or "").strip() + primary_phone = (body.get("primaryPhone") or "").strip() + + if not family_name: + return jsonify({"error": "`familyName` is required."}), 400 + + try: + normalized_phone = normalize_phone_for_storage(primary_phone) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + with get_connection() as conn: + result = conn.execute( + "INSERT INTO families (family_name, primary_phone, guest_passes) VALUES (?, ?, 0);", + (family_name, normalized_phone), + ) + + family = conn.execute( + "SELECT id, family_name, primary_phone, guest_passes FROM families WHERE id = ?;", + (result.lastrowid,), + ).fetchone() + + return ( + jsonify( + { + "item": { + "id": family["id"], + "familyName": family["family_name"], + "primaryPhone": family["primary_phone"], + "guestPasses": family["guest_passes"], + } + } + ), + 201, + ) + + query = request.args.get("q", "").strip().lower() + + with get_connection() as conn: + families = conn.execute( + "SELECT id, family_name, primary_phone, guest_passes FROM families ORDER BY family_name;" + ).fetchall() + + members = conn.execute( + """ + SELECT id, first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end + FROM members + WHERE family_id IS NOT NULL + ORDER BY age DESC, last_name, first_name; + """ + ).fetchall() + + members_by_family = {} + for row in members: + item = to_member_payload(row, DEFAULT_MIN_SWIM_TEST_AGE) + members_by_family.setdefault(item["familyId"], []).append(item) + + payload = [] + for family in families: + family_members = members_by_family.get(family["id"], []) + family_name = family["family_name"] + score = 0.0 + if query: + candidates = [family_name] + [m["fullName"] for m in family_members] + score = max((fuzzy_score(query, candidate) for candidate in candidates), default=0.0) + if score < 0.42: + continue + else: + score = 1.0 + + payload.append( + { + "id": family["id"], + "familyName": family_name, + "primaryPhone": family["primary_phone"], + "guestPasses": family["guest_passes"], + "members": family_members, + "matchScore": round(score, 4), + } + ) + + payload.sort(key=lambda item: (-item["matchScore"], item["familyName"].lower())) + + return jsonify({"items": payload}) + + +@app.route("/api/members", methods=["POST"]) +def api_create_member(): + body = request.get_json(silent=True) or {} + first_name = (body.get("firstName") or "").strip() + last_name = (body.get("lastName") or "").strip() + age = body.get("age") + family_id = body.get("familyId") + + if not first_name or not last_name: + return jsonify({"error": "`firstName` and `lastName` are required."}), 400 + + if not isinstance(age, int) or age < 0: + return jsonify({"error": "`age` must be a non-negative integer."}), 400 + + if family_id is None: + return jsonify({"error": "`familyId` is required for this form."}), 400 + + if not isinstance(family_id, int): + return jsonify({"error": "`familyId` must be an integer."}), 400 + + with get_connection() as conn: + family = conn.execute( + "SELECT id FROM families WHERE id = ?;", + (family_id,), + ).fetchone() + if family is None: + return jsonify({"error": "Family not found."}), 404 + + seed_can_use_deep_end = int(age >= DEFAULT_MIN_SWIM_TEST_AGE) + result = conn.execute( + """ + INSERT INTO members + (first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end) + VALUES (?, ?, ?, ?, 0, 0, ?); + """, + (first_name, last_name, age, family_id, seed_can_use_deep_end), + ) + + member = conn.execute( + """ + SELECT id, first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end + FROM members + WHERE id = ?; + """, + (result.lastrowid,), + ).fetchone() + + return jsonify({"member": to_member_payload(member, DEFAULT_MIN_SWIM_TEST_AGE)}), 201 + + +@app.route("/api/families/", methods=["GET"]) +def api_family_detail(family_id: int): + with get_connection() as conn: + family = conn.execute( + "SELECT id, family_name, primary_phone, guest_passes FROM families WHERE id = ?;", + (family_id,), + ).fetchone() + if family is None: + return jsonify({"error": "Family not found."}), 404 + + member_rows = conn.execute( + """ + SELECT id, first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end + FROM members + WHERE family_id = ? + ORDER BY age DESC, last_name, first_name; + """, + (family_id,), + ).fetchall() + + return jsonify( + { + "item": { + "id": family["id"], + "familyName": family["family_name"], + "primaryPhone": family["primary_phone"], + "guestPasses": family["guest_passes"], + "minSwimTestAge": DEFAULT_MIN_SWIM_TEST_AGE, + "members": [to_member_payload(row, DEFAULT_MIN_SWIM_TEST_AGE) for row in member_rows], + } + } + ) + + +@app.route("/api/families//guest-passes", methods=["PATCH"]) +def api_update_family_guest_passes(family_id: int): + body = request.get_json(silent=True) or {} + guest_passes = body.get("guestPasses") + + if not isinstance(guest_passes, int) or guest_passes < 0: + return jsonify({"error": "`guestPasses` must be a non-negative integer."}), 400 + + with get_connection() as conn: + result = conn.execute( + "UPDATE families SET guest_passes = ? WHERE id = ?;", + (guest_passes, family_id), + ) + if result.rowcount == 0: + return jsonify({"error": "Family not found."}), 404 + + family = conn.execute( + "SELECT id, family_name, primary_phone, guest_passes FROM families WHERE id = ?;", + (family_id,), + ).fetchone() + + return jsonify( + { + "item": { + "id": family["id"], + "familyName": family["family_name"], + "primaryPhone": family["primary_phone"], + "guestPasses": family["guest_passes"], + }, + "message": "Guest passes updated.", + } + ) + + +@app.route("/api/families/", methods=["DELETE"]) +def api_delete_family(family_id: int): + with get_connection() as conn: + family = conn.execute( + "SELECT id, family_name FROM families WHERE id = ?;", + (family_id,), + ).fetchone() + if family is None: + return jsonify({"error": "Family not found."}), 404 + + deleted_members = conn.execute( + "DELETE FROM members WHERE family_id = ?;", + (family_id,), + ).rowcount + + conn.execute("DELETE FROM families WHERE id = ?;", (family_id,)) + + return jsonify( + { + "message": ( + f"Deleted {family['family_name']} family and {deleted_members} member" + f"{'s' if deleted_members != 1 else ''}." + ), + "deletedFamilyId": family_id, + "deletedMembers": deleted_members, + } + ) + + +@app.route("/api/members//checkin", methods=["PATCH"]) +def api_update_checkin(member_id: int): + body = request.get_json(silent=True) or {} + checked_in = body.get("checkedIn") + if not isinstance(checked_in, bool): + return jsonify({"error": "`checkedIn` must be a boolean."}), 400 + + with get_connection() as conn: + result = conn.execute( + "UPDATE members SET is_checked_in = ? WHERE id = ?;", + (int(checked_in), member_id), + ) + if result.rowcount == 0: + return jsonify({"error": "Member not found."}), 404 + + member = conn.execute( + """ + SELECT id, first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end + FROM members + WHERE id = ?; + """, + (member_id,), + ).fetchone() + + return jsonify({"member": to_member_payload(member, DEFAULT_MIN_SWIM_TEST_AGE)}) + + +@app.route("/api/members//swim-test", methods=["PATCH"]) +def api_update_swim_test(member_id: int): + body = request.get_json(silent=True) or {} + passed = body.get("swimTestPassed") + if not isinstance(passed, bool): + return jsonify({"error": "`swimTestPassed` must be a boolean."}), 400 + + with get_connection() as conn: + result = conn.execute( + "UPDATE members SET swim_test_passed = ? WHERE id = ?;", + (int(passed), member_id), + ) + if result.rowcount == 0: + return jsonify({"error": "Member not found."}), 404 + + member = conn.execute( + """ + SELECT id, first_name, last_name, age, family_id, is_checked_in, swim_test_passed, can_use_deep_end + FROM members + WHERE id = ?; + """, + (member_id,), + ).fetchone() + + return jsonify({"member": to_member_payload(member, DEFAULT_MIN_SWIM_TEST_AGE)}) + + +@app.route("/api/admin/backup", methods=["POST"]) +def api_admin_backup(): + stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = BACKUP_DIR / f"pool_backup_{stamp}.sqlite3" + shutil.copy2(DB_PATH, backup_path) + return jsonify({"message": f"Backup created at {backup_path.name}"}) + + +@app.route("/api/admin/export", methods=["POST"]) +def api_admin_export(): + stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") + csv_path = EXPORT_DIR / f"pool_members_{stamp}.csv" + + with get_connection() as conn: + rows = conn.execute( + """ + SELECT + m.id, + m.first_name, + m.last_name, + m.age, + m.family_id, + COALESCE(f.family_name, 'Individual') AS family_name, + COALESCE(f.primary_phone, '') AS primary_phone, + m.is_checked_in, + m.swim_test_passed + FROM members m + LEFT JOIN families f ON m.family_id = f.id + ORDER BY family_name, m.last_name, m.first_name; + """ + ).fetchall() + + with csv_path.open("w", newline="", encoding="utf-8") as csv_file: + writer = csv.writer(csv_file) + writer.writerow( + [ + "member_id", + "first_name", + "last_name", + "age", + "family_id", + "family_name", + "primary_phone", + "checked_in", + "swim_test_passed", + "requires_swim_test", + ] + ) + for row in rows: + writer.writerow( + [ + row["id"], + row["first_name"], + row["last_name"], + row["age"], + row["family_id"], + row["family_name"], + row["primary_phone"], + row["is_checked_in"], + row["swim_test_passed"], + int(row["age"] < DEFAULT_MIN_SWIM_TEST_AGE), + ] + ) + + return jsonify({"message": f"Export generated: {csv_path.name}"}) + + +@app.route("/api/admin/reindex", methods=["POST"]) +def api_admin_reindex(): + with get_connection() as conn: + conn.execute("REINDEX;") + return jsonify({"message": "SQLite indexes rebuilt successfully."}) + + +@app.route("/api/admin/vacuum", methods=["POST"]) +def api_admin_vacuum(): + # VACUUM requires autocommit mode in SQLite. + conn = sqlite3.connect(DB_PATH, isolation_level=None) + try: + conn.execute("VACUUM;") + finally: + conn.close() + return jsonify({"message": "VACUUM completed successfully."}) + + +@app.route("/api/admin/reset-checkins", methods=["POST"]) +def api_admin_reset_checkins(): + with get_connection() as conn: + conn.execute("UPDATE members SET is_checked_in = 0;") + return jsonify({"message": "All members were checked out."}) + + +@app.route("/api/admin/reset-swim-tests", methods=["POST"]) +def api_admin_reset_swim_tests(): + with get_connection() as conn: + conn.execute("UPDATE members SET swim_test_passed = 0;") + conn.execute( + "UPDATE members SET can_use_deep_end = CASE WHEN age >= ? THEN 1 ELSE 0 END;", + (DEFAULT_MIN_SWIM_TEST_AGE,), + ) + return jsonify({"message": "All swim tests were reset to not passed."}) + + +@app.route("/api/admin/cleanup-orphan-members", methods=["POST"]) +def api_admin_cleanup_orphan_members(): + with get_connection() as conn: + deleted = conn.execute( + """ + DELETE FROM members + WHERE family_id IS NULL + OR family_id NOT IN (SELECT id FROM families); + """ + ).rowcount + + return jsonify( + { + "message": ( + f"Removed {deleted} member" + f"{'s' if deleted != 1 else ''} with null or missing family ids." + ), + "deletedMembers": deleted, + } + ) + + +initialize_database() + + +if __name__ == "__main__": + app.run(debug=False) \ No newline at end of file diff --git a/manage.html b/manage.html new file mode 100644 index 0000000..3c777da --- /dev/null +++ b/manage.html @@ -0,0 +1,592 @@ + + + + + + Cary Swim Club Management + + + + + + +
+
+
Cary Swim Club
+

Management

+

Add families, add members, and run core administrative tasks.

+
+ +
+ Back To Family Search + Open First Family +
+ +
+
+ Display Theme + Switch between daylight deck mode and evening mode. +
+
+ + +
+
+ +
+
+

Add Family

+

Create a new household record.

+
+ + + +
+
+ +
+

Add Member To Family

+

Create an individual member attached to a family.

+
+
+ + +
+ +
+ + +
+ + +
+
+ +
+

Family Danger Zone

+

Delete a family and all members inside it.

+
+ + +
+
+ +
+

Administrative Actions

+

Database maintenance and operational actions.

+
+ + + + + + + +
+
Ready.
+
+
+
+ + + + diff --git a/pool_checkin.sqlite3 b/pool_checkin.sqlite3 new file mode 100644 index 0000000..f0c2574 Binary files /dev/null and b/pool_checkin.sqlite3 differ diff --git a/pyvenv.cfg b/pyvenv.cfg new file mode 100644 index 0000000..8e4fcfc --- /dev/null +++ b/pyvenv.cfg @@ -0,0 +1,5 @@ +home = C:\Users\Lockeshor\AppData\Local\Programs\Python\Python312 +include-system-site-packages = false +version = 3.12.4 +executable = C:\Users\Lockeshor\AppData\Local\Programs\Python\Python312\python.exe +command = C:\Users\Lockeshor\AppData\Local\Programs\Python\Python312\python.exe -m venv c:\Users\Lockeshor\Documents\Code\csc-checkin\csc-checkin.venv