This commit is contained in:
LockeShor
2026-03-06 00:56:43 -05:00
commit 95e03442c5
25 changed files with 3695 additions and 0 deletions

50
.gitignore vendored Normal file
View File

@@ -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

502
Scripts/Activate.ps1 Normal file
View File

@@ -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

70
Scripts/activate Normal file
View File

@@ -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

34
Scripts/activate.bat Normal file
View File

@@ -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=
)

22
Scripts/deactivate.bat Normal file
View File

@@ -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

BIN
Scripts/flask.exe Normal file

Binary file not shown.

BIN
Scripts/pip.exe Normal file

Binary file not shown.

BIN
Scripts/pip3.12.exe Normal file

Binary file not shown.

BIN
Scripts/pip3.exe Normal file

Binary file not shown.

BIN
Scripts/python.exe Normal file

Binary file not shown.

BIN
Scripts/pythonw.exe Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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
1 member_id first_name last_name age household checked_in swim_test_passed can_use_deep_end
2 4 Luis Garcia 41 Garcia 1 1 1
3 6 Mateo Garcia 11 Garcia 1 1 1
4 5 Sofia Garcia 38 Garcia 0 1 1
5 3 Emma James 29 Individual 1 1 1
6 2 Noah Patel 42 Individual 0 1 1
7 1 Avery Wilson 34 Individual 1 1 1
8 7 Helen Miller 37 Miller 1 1 1
9 8 Kai Miller 9 Miller 1 0 0
10 10 Liam Nguyen 8 Nguyen 0 0 0
11 9 Trang Nguyen 35 Nguyen 0 1 1

View File

@@ -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
1 member_id first_name last_name age household checked_in swim_test_passed can_use_deep_end
2 4 Luis Garcia 41 Garcia 0 0 1
3 6 Mateo Garcia 11 Garcia 0 0 0
4 5 Sofia Garcia 38 Garcia 0 0 1
5 3 Emma James 29 Individual 0 0 1
6 2 Noah Patel 42 Individual 0 0 1
7 1 Avery Wilson 34 Individual 0 0 1
8 7 Helen Miller 37 Miller 0 0 1
9 8 Kai Miller 9 Miller 0 0 0
10 10 Liam Nguyen 8 Nguyen 0 0 0
11 9 Trang Nguyen 35 Nguyen 0 0 1

View File

@@ -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
1 member_id first_name last_name age family_id family_name primary_phone checked_in swim_test_passed requires_swim_test
2 4 Luis Garcia 41 1 Garcia 555-555-0141 0 0 0
3 6 Mateo Garcia 11 1 Garcia 555-555-0141 0 0 1
4 5 Sofia Garcia 38 1 Garcia 555-555-0141 0 0 0
5 11 John Gracia-laquez 16 1 Garcia 555-555-0141 0 0 0
6 3 Emma James 29 Individual 0 0 0
7 2 Noah Patel 42 Individual 0 0 0
8 1 Avery Wilson 34 Individual 0 0 0
9 7 Helen Miller 37 2 Miller 555-555-0187 0 0 0
10 8 Kai Miller 9 2 Miller 555-555-0187 0 0 1
11 12 Langlostean Nguyen 11 3 Nguyen 555-555-0179 0 0 1
12 10 Liam Nguyen 8 3 Nguyen 555-555-0179 0 0 1
13 9 Trang Nguyen 35 3 Nguyen 555-555-0179 0 0 0

View File

@@ -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
1 member_id first_name last_name age family_id family_name primary_phone checked_in swim_test_passed requires_swim_test
2 4 Luis Garcia 41 1 Garcia 555-555-0141 0 0 0
3 6 Mateo Garcia 11 1 Garcia 555-555-0141 0 0 1
4 5 Sofia Garcia 38 1 Garcia 555-555-0141 0 0 0
5 11 John Gracia-laquez 16 1 Garcia 555-555-0141 0 0 0
6 3 Emma James 29 Individual 0 0 0
7 2 Noah Patel 42 Individual 0 0 0
8 1 Avery Wilson 34 Individual 0 0 0
9 7 Helen Miller 37 2 Miller 555-555-0187 0 0 0
10 8 Kai Miller 9 2 Miller 555-555-0187 0 0 1
11 12 Langlostean Nguyen 11 3 Nguyen 555-555-0179 0 0 1
12 10 Liam Nguyen 8 3 Nguyen 555-555-0179 0 0 1
13 9 Trang Nguyen 35 3 Nguyen 555-555-0179 0 0 0

View File

@@ -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
1 member_id first_name last_name age family_id family_name primary_phone checked_in swim_test_passed requires_swim_test
2 4 Luis Garcia 41 1 Garcia 555-555-0141 0 0 0
3 6 Mateo Garcia 11 1 Garcia 555-555-0141 0 0 1
4 5 Sofia Garcia 38 1 Garcia 555-555-0141 0 0 0
5 11 John Gracia-laquez 16 1 Garcia 555-555-0141 0 0 0
6 3 Emma James 29 Individual 0 0 0
7 2 Noah Patel 42 Individual 0 0 0
8 1 Avery Wilson 34 Individual 0 0 0
9 7 Helen Miller 37 2 Miller 555-555-0187 0 0 0
10 8 Kai Miller 9 2 Miller 555-555-0187 0 0 1
11 12 Langlostean Nguyen 11 3 Nguyen 555-555-0179 0 0 1
12 10 Liam Nguyen 8 3 Nguyen 555-555-0179 0 0 1
13 9 Trang Nguyen 35 3 Nguyen 555-555-0179 0 0 0

View File

@@ -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
1 member_id first_name last_name age family_id family_name primary_phone checked_in swim_test_passed requires_swim_test
2 4 Luis Garcia 41 1 Garcia 555-555-0141 0 0 0
3 6 Mateo Garcia 11 1 Garcia 555-555-0141 0 0 1
4 5 Sofia Garcia 38 1 Garcia 555-555-0141 0 0 0
5 11 John Gracia-laquez 16 1 Garcia 555-555-0141 0 0 0
6 3 Emma James 29 Individual 0 0 0
7 2 Noah Patel 42 Individual 0 0 0
8 1 Avery Wilson 34 Individual 0 0 0
9 7 Helen Miller 37 2 Miller 555-555-0187 0 0 0
10 8 Kai Miller 9 2 Miller 555-555-0187 0 0 1
11 12 Langlostean Nguyen 11 3 Nguyen 555-555-0179 0 0 1
12 10 Liam Nguyen 8 3 Nguyen 555-555-0179 0 0 1
13 9 Trang Nguyen 35 3 Nguyen 555-555-0179 0 0 0

786
family.html Normal file
View File

@@ -0,0 +1,786 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cary Swim Club Family Check-In</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #10231a;
--ink-soft: #4b5f56;
--surface: #ffffff;
--surface-soft: #f4fbef;
--line: #d8e8cf;
--good: #3e9f14;
--warn: #ba5d17;
--deep: #2b6ed4;
--deep-bg: #dbe9ff;
--safe: #9f3232;
--accent: #4cbb17;
--accent-strong: #3b9612;
--bg-a: #f6fbf2;
--bg-b: #deefd1;
--hero-a: #1f3e22;
--hero-b: #2f642d;
--focus-ring: rgba(76, 187, 23, 0.28);
--shadow: 0 16px 36px rgba(19, 45, 23, 0.14);
}
body[data-theme="dark"] {
--ink: #e7f6df;
--ink-soft: #a5c1af;
--surface: #132118;
--surface-soft: #1a2d21;
--line: #264032;
--good: #65d82c;
--warn: #f2a45b;
--deep: #7db7ff;
--deep-bg: #1b3157;
--safe: #f08181;
--accent: #65d82c;
--accent-strong: #4cbb17;
--bg-a: #0a140d;
--bg-b: #12211a;
--hero-a: #15341c;
--hero-b: #1f4c28;
--focus-ring: rgba(101, 216, 44, 0.25);
--shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at 20% 0%, rgba(76, 187, 23, 0.22), transparent 30%),
radial-gradient(circle at 95% 95%, rgba(61, 120, 67, 0.2), transparent 28%),
linear-gradient(165deg, var(--bg-a), var(--bg-b));
background-attachment: fixed;
padding: 24px;
transition: background 260ms ease, color 260ms ease;
}
.page {
max-width: 1050px;
margin: 0 auto;
display: grid;
gap: 16px;
}
.top {
background:
linear-gradient(120deg, rgba(255, 255, 255, 0.05), transparent 42%),
linear-gradient(140deg, var(--hero-a), var(--hero-b));
border-radius: 18px;
padding: 20px;
color: #efffe8;
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.top::after {
content: "";
position: absolute;
width: 210px;
height: 210px;
right: -60px;
top: -102px;
border-radius: 50%;
background: radial-gradient(circle, rgba(151, 255, 109, 0.25), transparent 70%);
}
.top h1 {
margin: 0;
font-family: "Archivo Black", "Impact", sans-serif;
letter-spacing: 0.04em;
font-size: clamp(1.4rem, 2vw + 1rem, 2.2rem);
}
.top p {
margin: 8px 0 0;
color: #d8f6c8;
}
.top-controls {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.guest-pass-panel {
background: rgba(10, 31, 10, 0.22);
border: 1px solid rgba(180, 244, 148, 0.35);
border-radius: 14px;
padding: 10px 12px;
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 260px;
}
.guest-pass-label {
display: grid;
gap: 2px;
min-width: 110px;
}
.guest-pass-label strong {
font-size: 0.8rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #d8f6c8;
}
.guest-pass-value {
font-family: "Archivo Black", "Impact", sans-serif;
font-size: 1.6rem;
line-height: 1;
color: #f7ffe9;
}
.guest-pass-actions {
margin-left: auto;
display: inline-grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.pass-btn {
border: 1px solid rgba(196, 250, 166, 0.35);
background: rgba(6, 28, 12, 0.34);
color: #f0ffe5;
border-radius: 9px;
min-width: 50px;
padding: 8px 10px;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.pass-btn:disabled {
cursor: default;
opacity: 0.5;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.btn {
border: 0;
border-radius: 12px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-main {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff;
}
.btn-ghost {
background: var(--surface-soft);
color: var(--ink);
border: 1px solid var(--line);
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
box-shadow: var(--shadow);
backdrop-filter: blur(2px);
transition: background 220ms ease, border-color 220ms ease;
}
.rule-banner {
margin-bottom: 10px;
border-radius: 12px;
border: 1px dashed var(--line);
background: var(--surface-soft);
color: var(--ink-soft);
padding: 10px 12px;
font-size: 0.9rem;
}
.section-block {
display: grid;
gap: 10px;
margin-top: 10px;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.section-title {
margin: 0;
font-size: 1rem;
letter-spacing: 0.02em;
}
.section-meta {
margin: 0;
font-size: 0.82rem;
color: var(--ink-soft);
}
.member-list {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
align-items: start;
}
.member {
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
background: linear-gradient(135deg, var(--surface), var(--surface-soft));
display: grid;
gap: 8px;
align-content: start;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
animation: card-in 260ms ease both;
align-self: start;
position: relative;
overflow: hidden;
}
.member::after {
content: "";
position: absolute;
right: -20px;
bottom: -25px;
width: 80px;
height: 80px;
border-radius: 50%;
background: radial-gradient(circle, rgba(76, 187, 23, 0.18), rgba(76, 187, 23, 0.02));
}
.member:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
box-shadow: 0 12px 24px rgba(19, 45, 79, 0.12);
}
.member-head {
display: flex;
justify-content: flex-start;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
position: relative;
z-index: 1;
}
.member-title-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.member-icon {
width: 28px;
height: 28px;
border-radius: 9px;
background: linear-gradient(135deg, var(--accent), #2e8e34);
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
flex: 0 0 auto;
align-self: center;
}
.member-icon svg {
width: 15px;
height: 15px;
fill: currentColor;
}
.member-name {
margin: 0;
display: grid;
gap: 1px;
max-width: 100%;
}
.member-first,
.member-last {
display: block;
font-size: 1.16rem;
font-weight: 700;
line-height: 1.08;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.member-sub {
margin: 4px 0 0;
color: var(--ink-soft);
font-size: 0.88rem;
overflow-wrap: anywhere;
}
.chip {
display: inline-block;
border-radius: 999px;
padding: 4px 10px;
font-size: 0.78rem;
font-weight: 700;
}
.chip-in {
background: color-mix(in srgb, var(--accent) 20%, var(--surface));
color: var(--good);
}
.chip-out {
background: #fbe8d9;
color: var(--warn);
}
.chip-deep {
background: var(--deep-bg);
color: var(--deep);
}
.chip-shallow {
background: #fde7e7;
color: var(--safe);
}
.row {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
position: relative;
z-index: 1;
}
.toggle {
background: color-mix(in srgb, var(--surface-soft) 85%, transparent);
border: 1px solid var(--line);
border-radius: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
padding: 3px;
gap: 3px;
}
.toggle button {
border: 0;
border-radius: 9px;
padding: 8px 8px;
font: inherit;
font-size: 0.83rem;
font-weight: 700;
cursor: pointer;
color: var(--ink);
background: transparent;
}
.toggle button.active {
color: #fff;
box-shadow: 0 8px 20px rgba(9, 35, 67, 0.2);
}
.state-in.active {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
}
.state-out.active {
background: linear-gradient(135deg, #ce7a32, #b75f17);
}
.state-pass.active {
background: linear-gradient(135deg, #3f87eb, #2b6ed4);
}
.state-fail.active {
background: linear-gradient(135deg, #ad3b3b, #912a2a);
}
.empty-note {
color: var(--ink-soft);
font-size: 0.86rem;
border: 1px dashed var(--line);
border-radius: 10px;
padding: 9px 10px;
background: var(--surface-soft);
}
.chip-wrap {
display: inline-flex;
gap: 5px;
flex-wrap: wrap;
justify-content: flex-end;
max-width: 100%;
}
.status {
border: 1px solid var(--line);
border-radius: 12px;
padding: 8px 10px;
font-size: 0.82rem;
color: var(--ink-soft);
background: var(--surface-soft);
opacity: 0.86;
margin-top: 12px;
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 700px) {
body { padding: 14px; }
.member-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 460px) {
.member-list { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="page">
<section class="top">
<h1 id="familyTitle">Family</h1>
<p id="familyMeta">Loading family...</p>
<div class="top-controls">
<div class="guest-pass-panel" aria-live="polite">
<div class="guest-pass-label">
<strong>Guest Passes</strong>
<span id="guestPassCount" class="guest-pass-value">0</span>
</div>
<div class="guest-pass-actions">
<button id="decreasePassBtn" class="pass-btn" type="button" aria-label="Decrease guest passes">-1</button>
<button id="increasePassBtn" class="pass-btn" type="button" aria-label="Increase guest passes">+1</button>
</div>
</div>
</div>
</section>
<section class="toolbar">
<a class="btn btn-ghost" href="/">Back To Family Search</a>
<button id="refreshBtn" class="btn btn-main" type="button">Refresh Family</button>
</section>
<section class="panel">
<div id="ruleBanner" class="rule-banner">Swim test required under age 13.</div>
<section class="section-block">
<div class="section-head">
<h2 class="section-title">Adults</h2>
<p class="section-meta" id="adultsMeta">0 members</p>
</div>
<div id="adultsList" class="member-list"></div>
<div id="adultsEmpty" class="empty-note">No adults in this family.</div>
</section>
<section class="section-block">
<div class="section-head">
<h2 class="section-title">Children</h2>
<p class="section-meta" id="childrenMeta">0 members</p>
</div>
<div id="childrenList" class="member-list"></div>
<div id="childrenEmpty" class="empty-note">No children in this family.</div>
</section>
<div id="status" class="status">Ready.</div>
</section>
</main>
<script>
function getPreferredTheme() {
const saved = window.localStorage.getItem("csc-theme");
if (saved === "dark" || saved === "light") {
return saved;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme) {
document.body.setAttribute("data-theme", theme);
}
applyTheme(getPreferredTheme());
const familyTitle = document.getElementById("familyTitle");
const familyMeta = document.getElementById("familyMeta");
const ruleBanner = document.getElementById("ruleBanner");
const adultsList = document.getElementById("adultsList");
const childrenList = document.getElementById("childrenList");
const adultsMeta = document.getElementById("adultsMeta");
const childrenMeta = document.getElementById("childrenMeta");
const adultsEmpty = document.getElementById("adultsEmpty");
const childrenEmpty = document.getElementById("childrenEmpty");
const statusBox = document.getElementById("status");
const refreshBtn = document.getElementById("refreshBtn");
const guestPassCount = document.getElementById("guestPassCount");
const decreasePassBtn = document.getElementById("decreasePassBtn");
const increasePassBtn = document.getElementById("increasePassBtn");
let currentMinSwimTestAge = 13;
let currentGuestPasses = 0;
let isUpdatingGuestPasses = false;
function currentFamilyId() {
const parts = window.location.pathname.split("/").filter(Boolean);
const maybeId = parts[parts.length - 1];
return Number.parseInt(maybeId, 10);
}
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || payload.message || "Request failed");
}
return payload;
}
function setStatus(message) {
statusBox.textContent = message;
}
function buildToggle(options, activeValue, onChoose) {
const container = document.createElement("div");
container.className = "toggle";
options.forEach((opt) => {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = opt.label;
btn.className = `${opt.className} ${opt.value === activeValue ? "active" : ""}`;
btn.addEventListener("click", () => onChoose(opt.value));
container.append(btn);
});
return container;
}
async function setCheckIn(memberId, checkedIn) {
await api(`/api/members/${memberId}/checkin`, {
method: "PATCH",
body: JSON.stringify({ checkedIn }),
});
}
async function setSwimTest(memberId, swimTestPassed) {
await api(`/api/members/${memberId}/swim-test`, {
method: "PATCH",
body: JSON.stringify({ swimTestPassed }),
});
}
function renderGuestPasses() {
guestPassCount.textContent = String(currentGuestPasses);
decreasePassBtn.disabled = isUpdatingGuestPasses || currentGuestPasses <= 0;
increasePassBtn.disabled = isUpdatingGuestPasses;
}
async function updateGuestPasses(nextCount) {
if (!Number.isInteger(nextCount) || nextCount < 0) {
return;
}
const familyId = currentFamilyId();
if (!familyId || Number.isNaN(familyId)) {
setStatus("Invalid family id.");
return;
}
isUpdatingGuestPasses = true;
renderGuestPasses();
try {
setStatus("Updating guest passes...");
const payload = await api(`/api/families/${familyId}/guest-passes`, {
method: "PATCH",
body: JSON.stringify({ guestPasses: nextCount }),
});
currentGuestPasses = payload.item.guestPasses;
setStatus(`Guest passes set to ${currentGuestPasses}.`);
} catch (error) {
setStatus(`Error: ${error.message}`);
} finally {
isUpdatingGuestPasses = false;
renderGuestPasses();
}
}
function renderMember(member, refreshFamily) {
const card = document.createElement("article");
card.className = "member";
const head = document.createElement("div");
head.className = "member-head";
const idWrap = document.createElement("div");
idWrap.className = "member-title-wrap";
const icon = document.createElement("span");
icon.className = "member-icon";
icon.innerHTML = "<svg viewBox='0 0 24 24' aria-hidden='true'><path d='M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5zm0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5z'/></svg>";
const textWrap = document.createElement("div");
const title = document.createElement("h3");
title.className = "member-name";
title.innerHTML = `<span class="member-first">${member.firstName}</span><span class="member-last">${member.lastName}</span>`;
const subtitle = document.createElement("p");
subtitle.className = "member-sub";
subtitle.textContent = `Age ${member.age}`;
textWrap.append(title, subtitle);
idWrap.append(icon, textWrap);
head.append(idWrap);
const controls = document.createElement("div");
controls.className = "row";
controls.append(
buildToggle(
[
{ label: "Checked Out", value: false, className: "state-out" },
{ label: "Checked In", value: true, className: "state-in" },
],
!!member.checkedIn,
async (value) => {
try {
setStatus(`Updating check-in for ${member.fullName}...`);
await setCheckIn(member.id, value);
await refreshFamily();
setStatus(`Updated check-in for ${member.fullName}.`);
} catch (error) {
setStatus(`Error: ${error.message}`);
}
}
)
);
if (member.requiresSwimTest) {
controls.append(
buildToggle(
[
{ label: "Not Passed", value: false, className: "state-fail" },
{ label: "Passed", value: true, className: "state-pass" },
],
!!member.swimTestPassed,
async (value) => {
try {
setStatus(`Updating swim test for ${member.fullName}...`);
await setSwimTest(member.id, value);
await refreshFamily();
setStatus(`Updated swim test for ${member.fullName}.`);
} catch (error) {
setStatus(`Error: ${error.message}`);
}
}
)
);
} else {
const notRequired = document.createElement("div");
notRequired.className = "toggle";
notRequired.innerHTML = `<button type="button" class="state-pass active">Swim Test Not Required</button><button type="button">Age ${member.age}</button>`;
controls.append(notRequired);
}
card.append(head, controls);
return card;
}
async function loadFamily() {
const familyId = currentFamilyId();
if (!familyId || Number.isNaN(familyId)) {
setStatus("Invalid family id.");
return;
}
const payload = await api(`/api/families/${familyId}`);
const family = payload.item;
currentMinSwimTestAge = family.minSwimTestAge;
currentGuestPasses = Number.isInteger(family.guestPasses) ? family.guestPasses : 0;
familyTitle.textContent = `${family.familyName} Family`;
familyMeta.textContent = `${family.members.length} members • ${family.primaryPhone || "No phone number on file"}`;
ruleBanner.textContent = `Swim test required under age ${currentMinSwimTestAge}.`;
renderGuestPasses();
const adults = family.members.filter((member) => member.age >= 18);
const children = family.members.filter((member) => member.age < 18);
adultsMeta.textContent = `${adults.length} member${adults.length === 1 ? "" : "s"}`;
childrenMeta.textContent = `${children.length} member${children.length === 1 ? "" : "s"}`;
adultsList.innerHTML = "";
childrenList.innerHTML = "";
adults.forEach((member) => {
adultsList.append(renderMember(member, refreshFamily));
});
children.forEach((member) => {
childrenList.append(renderMember(member, refreshFamily));
});
adultsEmpty.style.display = adults.length ? "none" : "block";
childrenEmpty.style.display = children.length ? "none" : "block";
}
async function refreshFamily() {
try {
await loadFamily();
} catch (error) {
setStatus(`Error: ${error.message}`);
}
}
refreshBtn.addEventListener("click", refreshFamily);
decreasePassBtn.addEventListener("click", () => updateGuestPasses(Math.max(0, currentGuestPasses - 1)));
increasePassBtn.addEventListener("click", () => updateGuestPasses(currentGuestPasses + 1));
refreshFamily();
</script>
</body>
</html>

727
index.html Normal file
View File

@@ -0,0 +1,727 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cary Swim Club Check-In</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #10231a;
--ink-soft: #4a5e56;
--surface: #ffffff;
--surface-soft: #f4fbef;
--line: #d8e8cf;
--accent: #4cbb17;
--accent-strong: #3b9612;
--accent-soft: #d8f3c6;
--bg-a: #f6fbf2;
--bg-b: #deefd1;
--hero-a: #1f3e22;
--hero-b: #2f642d;
--focus-ring: rgba(76, 187, 23, 0.28);
--shadow: 0 18px 38px rgba(20, 46, 24, 0.14);
--tile-glow: rgba(76, 187, 23, 0.2);
}
body[data-theme="dark"] {
--ink: #e7f6df;
--ink-soft: #a6c2b0;
--surface: #132118;
--surface-soft: #1a2d21;
--line: #264032;
--accent: #65d82c;
--accent-strong: #4cbb17;
--accent-soft: #24411a;
--bg-a: #0a140d;
--bg-b: #12211a;
--hero-a: #15341c;
--hero-b: #1f4c28;
--focus-ring: rgba(101, 216, 44, 0.25);
--shadow: 0 18px 42px rgba(0, 0, 0, 0.45);
--tile-glow: rgba(101, 216, 44, 0.24);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at 82% 12%, var(--tile-glow), transparent 34%),
radial-gradient(circle at 12% 88%, rgba(24, 93, 142, 0.2), transparent 36%),
linear-gradient(160deg, var(--bg-a), var(--bg-b));
background-attachment: fixed;
padding: 24px;
transition: background 260ms ease, color 260ms ease;
}
.page {
max-width: 1100px;
margin: 0 auto;
display: grid;
gap: 18px;
}
.hero {
border-radius: 20px;
padding: 24px;
color: #f4ffe8;
background:
linear-gradient(120deg, rgba(255, 255, 255, 0.06), transparent 42%),
linear-gradient(140deg, var(--hero-a), var(--hero-b));
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.hero::after {
content: "";
position: absolute;
width: 230px;
height: 230px;
right: -64px;
top: -112px;
border-radius: 50%;
background: radial-gradient(circle, rgba(151, 255, 109, 0.28), transparent 70%);
pointer-events: none;
}
.hero h1 {
margin: 0;
font-family: "Archivo Black", "Impact", sans-serif;
letter-spacing: 0.03em;
font-size: clamp(1.4rem, 2vw + 0.9rem, 2.3rem);
}
.hero p {
margin: 8px 0 0;
color: #d8f6c8;
max-width: 52ch;
}
.club-tag {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
border-radius: 999px;
border: 1px solid rgba(196, 250, 166, 0.4);
background: rgba(13, 31, 14, 0.25);
padding: 5px 11px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.stats {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.stat {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
box-shadow: var(--shadow);
transition: transform 180ms ease, border-color 180ms ease, background 220ms ease;
}
.stat:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
}
.stat .label {
margin: 0;
color: var(--ink-soft);
font-size: 0.88rem;
}
.stat .value {
margin: 6px 0 0;
font-size: 1.8rem;
font-weight: 700;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
box-shadow: var(--shadow);
backdrop-filter: blur(2px);
transition: background 220ms ease, border-color 220ms ease;
}
.panel h2 {
margin: 0;
font-size: 1.2rem;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.sub {
margin: 4px 0 14px;
color: var(--ink-soft);
font-size: 0.93rem;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
.search {
flex: 1;
min-width: 220px;
border-radius: 12px;
border: 1px solid var(--line);
padding: 11px 12px;
font: inherit;
color: var(--ink);
background: var(--surface-soft);
transition: border-color 200ms ease, box-shadow 200ms ease;
}
.search:focus {
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--focus-ring);
outline: none;
}
.btn {
border: 0;
border-radius: 12px;
padding: 10px 14px;
font: inherit;
cursor: pointer;
font-weight: 700;
}
.btn-main {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff;
transition: transform 160ms ease, filter 160ms ease;
}
.btn-main:hover,
.btn-main:focus-visible {
transform: translateY(-1px);
filter: brightness(1.04);
outline: none;
}
.btn-ghost {
background: var(--surface-soft);
border: 1px solid var(--line);
color: var(--ink);
}
.btn-small {
padding: 8px 12px;
border-radius: 10px;
font-size: 0.85rem;
}
.checked-list {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.checked-card {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px;
background: linear-gradient(135deg, var(--surface), var(--surface-soft));
display: grid;
gap: 6px;
animation: fade-up 240ms ease both;
transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
}
.checked-card.selected {
border-color: color-mix(in srgb, var(--accent) 60%, var(--line));
box-shadow: 0 0 0 4px var(--focus-ring);
transform: translateY(-1px);
}
.checked-name {
font-size: 1.08rem;
font-weight: 700;
line-height: 1.2;
display: flex;
gap: 6px;
align-items: center;
}
.checked-meta {
color: var(--ink-soft);
font-size: 0.83rem;
}
.checked-actions {
display: inline-grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 2px;
}
.mini-btn {
border: 1px solid var(--line);
border-radius: 9px;
padding: 6px 8px;
font: inherit;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
background: var(--surface-soft);
color: var(--ink);
}
.mini-btn.active-in {
background: color-mix(in srgb, var(--accent) 24%, var(--surface));
color: color-mix(in srgb, var(--accent-strong) 75%, black);
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
}
.mini-btn.active-out {
background: #fae8dc;
color: #ab5816;
border-color: #e2b390;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--accent);
display: inline-block;
}
.list {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.family-tile {
text-decoration: none;
color: inherit;
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
display: grid;
gap: 10px;
align-content: space-between;
background: linear-gradient(135deg, var(--surface), var(--surface-soft));
transition: transform 200ms ease, box-shadow 200ms ease, border-color 200ms ease;
box-shadow: 0 8px 24px rgba(14, 43, 73, 0.08);
aspect-ratio: 1 / 1;
min-height: 180px;
position: relative;
overflow: hidden;
}
.family-tile::after {
content: "";
position: absolute;
right: -26px;
bottom: -28px;
width: 92px;
height: 92px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--accent) 30%, transparent), transparent);
}
.family-tile:hover,
.family-tile:focus-visible {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
box-shadow: 0 14px 30px rgba(14, 43, 73, 0.14);
outline: none;
}
.family-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
position: relative;
z-index: 1;
}
.family-main {
display: grid;
gap: 8px;
position: relative;
z-index: 1;
}
.family-name-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.family-icon {
width: 30px;
height: 30px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--accent), #2e8e34);
color: #fff;
font-size: 0.72rem;
font-weight: 700;
}
.family-tile h3 {
margin: 0;
font-size: 1.16rem;
line-height: 1.15;
}
.arrow {
font-size: 1rem;
color: color-mix(in srgb, var(--accent) 46%, var(--ink-soft));
font-weight: 700;
}
.meta {
margin: 2px 0 0;
color: var(--ink-soft);
font-size: 0.86rem;
}
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pill {
border-radius: 999px;
padding: 4px 8px;
font-size: 0.75rem;
font-weight: 700;
display: inline-flex;
gap: 4px;
align-items: center;
}
.pill-in {
background: color-mix(in srgb, var(--accent) 20%, var(--surface));
color: color-mix(in srgb, var(--accent-strong) 78%, black);
}
.pill-kids {
background: #e4edff;
color: #2456b1;
}
.status-box {
font-size: 0.9rem;
color: var(--ink-soft);
border: 1px dashed var(--line);
border-radius: 12px;
padding: 10px 12px;
background: var(--surface-soft);
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(7px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
.hidden { display: none; }
@media (max-width: 900px) {
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
body { padding: 14px; }
.stats { grid-template-columns: 1fr; }
.list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.family-tile { min-height: 150px; }
.checked-list { grid-template-columns: 1fr; }
}
@media (max-width: 420px) {
.list { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="page">
<section class="hero">
<div class="club-tag">Cary Swim Club</div>
<h1>Family Check-In Lookup</h1>
<p>Fast and simple member management system for CSC guards and administrators.</p>
</section>
<section class="stats">
<article class="stat">
<p class="label">Families</p>
<p class="value" id="kpiFamilies">0</p>
</article>
<article class="stat">
<p class="label">Total Members</p>
<p class="value" id="kpiTotal">0</p>
</article>
<article class="stat">
<p class="label">Checked In</p>
<p class="value" id="kpiInPool">0</p>
</article>
<article class="stat">
<p class="label">Children Swim Passed</p>
<p class="value" id="kpiSwimKids">0</p>
</article>
</section>
<section class="panel">
<div class="panel-head">
<h2>Checked-In Right Now</h2>
<a class="btn btn-ghost btn-small" href="/manage">Management</a>
</div>
<p class="sub" id="checkedHelp">Search active guests, or type first/family-last names for quick check-in/out lookup.</p>
<div class="controls">
<input id="checkedSearch" class="search" type="text" placeholder="Search checked-in members..." autocomplete="off">
</div>
<div id="checkedList" class="checked-list"></div>
<div id="checkedEmpty" class="status-box hidden">No checked-in members found.</div>
</section>
<section class="panel">
<h2>Search Families</h2>
<p class="sub">Search and tap a family.</p>
<div class="controls">
<input id="familySearch" class="search" type="text" placeholder="Search Garcia, Nguyen, Mateo..." autocomplete="off">
<button id="refreshBtn" class="btn btn-ghost" type="button">Refresh</button>
</div>
<div id="familyList" class="list"></div>
<div id="empty" class="status-box hidden">No matching families found.</div>
</section>
</main>
<script>
function getPreferredTheme() {
const saved = window.localStorage.getItem("csc-theme");
if (saved === "dark" || saved === "light") {
return saved;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme) {
document.body.setAttribute("data-theme", theme);
}
applyTheme(getPreferredTheme());
const familySearch = document.getElementById("familySearch");
const checkedSearch = document.getElementById("checkedSearch");
const familyList = document.getElementById("familyList");
const checkedList = document.getElementById("checkedList");
const refreshBtn = document.getElementById("refreshBtn");
const emptyBox = document.getElementById("empty");
const checkedEmpty = document.getElementById("checkedEmpty");
const checkedHelp = document.getElementById("checkedHelp");
let latestCheckedItems = [];
let selectedCheckedIndex = 0;
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || payload.message || "Request failed");
}
return payload;
}
function renderFamilies(items) {
familyList.innerHTML = "";
emptyBox.classList.toggle("hidden", items.length > 0);
items.forEach((family) => {
const checkedInCount = family.members.filter((m) => m.checkedIn).length;
const childCount = family.members.filter((m) => m.age < 18).length;
const familyInitial = (family.familyName || "F").slice(0, 1).toUpperCase();
const node = document.createElement("a");
node.className = "family-tile";
node.href = `/family/${family.id}`;
node.innerHTML = `
<div class="family-head">
<div class="family-name-wrap">
<span class="family-icon">${familyInitial}</span>
<h3>${family.familyName} Family</h3>
</div>
<span class="arrow">View</span>
</div>
<div class="family-main">
<p class="meta">${family.members.length} members • ${family.primaryPhone || "No phone"}</p>
<div class="pill-row">
<span class="pill pill-in">In: ${checkedInCount}</span>
<span class="pill pill-kids">Kids: ${childCount}</span>
</div>
</div>
`;
familyList.append(node);
});
}
async function updateCheckStatus(memberId, checkedIn) {
try {
await api(`/api/members/${memberId}/checkin`, {
method: "PATCH",
body: JSON.stringify({ checkedIn }),
});
await Promise.all([loadStats(), loadFamilies(), loadCheckedIn()]);
} catch (error) {
console.error(error);
}
}
function renderCheckedIn(items) {
checkedList.innerHTML = "";
latestCheckedItems = items;
if (items.length === 0) {
selectedCheckedIndex = 0;
} else {
selectedCheckedIndex = Math.min(selectedCheckedIndex, items.length - 1);
}
checkedEmpty.classList.toggle("hidden", items.length > 0);
const searching = checkedSearch.value.trim().length > 0;
checkedHelp.textContent = searching
? "Search mode: fuzzy first-name and family-last-name matching. Shortcuts: ArrowUp/ArrowDown to select, Enter=In, Shift+Enter=Out."
: "Showing currently checked-in members. Shortcuts work while search box is focused.";
items.forEach((member, index) => {
const node = document.createElement("article");
node.className = `checked-card ${(searching && index === selectedCheckedIndex) ? "selected" : ""}`;
node.innerHTML = `
<div class="checked-name"><span class="dot"></span>${member.fullName}</div>
<div class="checked-meta">${member.familyName} • Age ${member.age}</div>
<div class="checked-actions">
<button class="mini-btn ${member.checkedIn ? "active-in" : ""}" data-state="in">Checked In</button>
<button class="mini-btn ${!member.checkedIn ? "active-out" : ""}" data-state="out">Checked Out</button>
</div>
`;
const inBtn = node.querySelector("[data-state='in']");
const outBtn = node.querySelector("[data-state='out']");
inBtn.disabled = member.checkedIn;
outBtn.disabled = !member.checkedIn;
inBtn.addEventListener("click", async () => {
await updateCheckStatus(member.id, true);
});
outBtn.addEventListener("click", async () => {
await updateCheckStatus(member.id, false);
});
checkedList.append(node);
});
}
async function loadStats() {
const stats = await api("/api/dashboard");
document.getElementById("kpiFamilies").textContent = stats.families;
document.getElementById("kpiTotal").textContent = stats.totalMembers;
document.getElementById("kpiInPool").textContent = stats.checkedIn;
document.getElementById("kpiSwimKids").textContent = stats.childrenSwimPassed;
}
async function loadFamilies() {
const q = familySearch.value.trim();
const path = q ? `/api/families?q=${encodeURIComponent(q)}` : "/api/families";
const payload = await api(path);
renderFamilies(payload.items || []);
}
async function loadCheckedIn() {
const q = checkedSearch.value.trim();
const path = q ? `/api/checked-in?q=${encodeURIComponent(q)}` : "/api/checked-in";
const payload = await api(path);
renderCheckedIn(payload.items || []);
}
async function refreshEverything() {
try {
await Promise.all([loadStats(), loadFamilies(), loadCheckedIn()]);
} catch (error) {
console.error(error);
}
}
familySearch.addEventListener("input", loadFamilies);
checkedSearch.addEventListener("input", loadCheckedIn);
checkedSearch.addEventListener("keydown", async (event) => {
if (latestCheckedItems.length === 0) {
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
event.preventDefault();
selectedCheckedIndex = Math.min(selectedCheckedIndex + 1, latestCheckedItems.length - 1);
renderCheckedIn(latestCheckedItems);
return;
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault();
selectedCheckedIndex = Math.max(selectedCheckedIndex - 1, 0);
renderCheckedIn(latestCheckedItems);
return;
}
if (event.key !== "Enter") {
return;
}
event.preventDefault();
const selected = latestCheckedItems[selectedCheckedIndex] || latestCheckedItems[0];
const targetCheckedIn = !event.shiftKey;
await updateCheckStatus(selected.id, targetCheckedIn);
});
refreshBtn.addEventListener("click", refreshEverything);
refreshEverything();
</script>
</body>
</html>

833
main.py Normal file
View File

@@ -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/<int:family_id>")
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/<int:family_id>", 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/<int:family_id>/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/<int:family_id>", 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/<int:member_id>/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/<int:member_id>/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)

592
manage.html Normal file
View File

@@ -0,0 +1,592 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cary Swim Club Management</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #10231a;
--ink-soft: #4b5f56;
--surface: #ffffff;
--surface-soft: #f4fbef;
--line: #d8e8cf;
--accent: #4cbb17;
--accent-strong: #3b9612;
--danger: #b63838;
--bg-a: #f6fbf2;
--bg-b: #deefd1;
--hero-a: #1f3e22;
--hero-b: #2f642d;
--focus-ring: rgba(76, 187, 23, 0.28);
--shadow: 0 14px 34px rgba(17, 43, 20, 0.14);
}
body[data-theme="dark"] {
--ink: #e7f6df;
--ink-soft: #a5c1af;
--surface: #132118;
--surface-soft: #1a2d21;
--line: #264032;
--accent: #65d82c;
--accent-strong: #4cbb17;
--danger: #dc5959;
--bg-a: #0a140d;
--bg-b: #12211a;
--hero-a: #15341c;
--hero-b: #1f4c28;
--focus-ring: rgba(101, 216, 44, 0.25);
--shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at 78% 10%, rgba(76, 187, 23, 0.22), transparent 32%),
radial-gradient(circle at 10% 85%, rgba(61, 120, 67, 0.16), transparent 36%),
linear-gradient(160deg, var(--bg-a), var(--bg-b));
background-attachment: fixed;
padding: 20px;
transition: background 260ms ease, color 260ms ease;
}
.page {
max-width: 1100px;
margin: 0 auto;
display: grid;
gap: 16px;
}
.hero {
border-radius: 18px;
padding: 20px;
background:
linear-gradient(120deg, rgba(255, 255, 255, 0.05), transparent 42%),
linear-gradient(140deg, var(--hero-a), var(--hero-b));
color: #f2ffe9;
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.hero::after {
content: "";
position: absolute;
width: 220px;
height: 220px;
right: -70px;
top: -105px;
border-radius: 50%;
background: radial-gradient(circle, rgba(151, 255, 109, 0.24), transparent 70%);
}
.hero h1 {
margin: 0;
font-family: "Archivo Black", "Impact", sans-serif;
font-size: clamp(1.3rem, 2vw + 1rem, 2rem);
}
.hero p {
margin: 8px 0 0;
color: #d8f6c8;
}
.club-tag {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
border-radius: 999px;
border: 1px solid rgba(196, 250, 166, 0.4);
background: rgba(13, 31, 14, 0.25);
padding: 5px 11px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
border: 0;
border-radius: 11px;
padding: 9px 13px;
font: inherit;
font-weight: 700;
cursor: pointer;
text-decoration: none;
display: inline-flex;
justify-content: center;
align-items: center;
}
.btn-main {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff;
}
.btn-ghost {
background: var(--surface-soft);
color: var(--ink);
border: 1px solid var(--line);
}
.btn-danger {
background: linear-gradient(135deg, #c44d4d, var(--danger));
color: #fff;
}
.grid {
display: grid;
gap: 14px;
grid-template-columns: 1fr 1fr;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
box-shadow: var(--shadow);
transition: background 220ms ease, border-color 220ms ease;
}
.panel h2 {
margin: 0;
font-size: 1.08rem;
}
.sub {
margin: 6px 0 10px;
color: var(--ink-soft);
font-size: 0.9rem;
}
.form {
display: grid;
gap: 8px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
label {
display: grid;
gap: 4px;
font-size: 0.84rem;
color: var(--ink-soft);
}
input,
select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 9px 10px;
font: inherit;
color: var(--ink);
background: var(--surface-soft);
}
input:focus,
select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--focus-ring);
}
.theme-panel {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface-soft);
padding: 10px 12px;
}
.theme-label {
display: grid;
gap: 2px;
}
.theme-label strong {
font-size: 0.92rem;
}
.theme-label span {
color: var(--ink-soft);
font-size: 0.8rem;
}
.theme-toggle {
border: 1px solid var(--line);
border-radius: 12px;
background: color-mix(in srgb, var(--surface-soft) 85%, transparent);
padding: 3px;
display: inline-grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
min-width: 170px;
}
.theme-toggle button {
border: 0;
border-radius: 9px;
padding: 8px 10px;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
color: var(--ink);
background: transparent;
}
.theme-toggle button.active {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff;
box-shadow: 0 8px 20px rgba(9, 35, 15, 0.26);
}
.action-list {
display: grid;
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.status {
border: 1px dashed var(--line);
border-radius: 11px;
padding: 10px 11px;
color: var(--ink-soft);
font-size: 0.88rem;
background: var(--surface-soft);
}
.danger-zone {
border-color: color-mix(in srgb, var(--danger) 35%, var(--line));
}
.danger-zone .sub {
color: color-mix(in srgb, var(--danger) 78%, var(--ink-soft));
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
body { padding: 12px; }
.row { grid-template-columns: 1fr; }
.action-list { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="page">
<section class="hero">
<div class="club-tag">Cary Swim Club</div>
<h1>Management</h1>
<p>Add families, add members, and run core administrative tasks.</p>
</section>
<section class="toolbar">
<a class="btn btn-ghost" href="/">Back To Family Search</a>
<a class="btn btn-ghost" id="openFirstFamily" href="#">Open First Family</a>
</section>
<section class="theme-panel" aria-label="Theme controls">
<div class="theme-label">
<strong>Display Theme</strong>
<span>Switch between daylight deck mode and evening mode.</span>
</div>
<div class="theme-toggle" role="group" aria-label="Color theme selection">
<button id="themeLightBtn" type="button">Light</button>
<button id="themeDarkBtn" type="button">Dark</button>
</div>
</section>
<section class="grid">
<article class="panel">
<h2>Add Family</h2>
<p class="sub">Create a new household record.</p>
<form id="familyForm" class="form">
<label>
Family Name
<input id="familyName" type="text" required placeholder="Garcia">
</label>
<label>
Primary Phone
<input id="familyPhone" type="tel" placeholder="555-201-0141">
</label>
<button class="btn btn-main" type="submit">Add Family</button>
</form>
</article>
<article class="panel">
<h2>Add Member To Family</h2>
<p class="sub">Create an individual member attached to a family.</p>
<form id="memberForm" class="form">
<div class="row">
<label>
First Name
<input id="memberFirst" type="text" required placeholder="Sofia">
</label>
<label>
Last Name
<input id="memberLast" type="text" required placeholder="Garcia">
</label>
</div>
<div class="row">
<label>
Age
<input id="memberAge" type="number" min="0" required>
</label>
<label>
Family
<select id="memberFamily" required></select>
</label>
</div>
<button class="btn btn-main" type="submit">Add Member</button>
</form>
</article>
<article class="panel danger-zone">
<h2>Family Danger Zone</h2>
<p class="sub">Delete a family and all members inside it.</p>
<div class="form">
<label>
Family To Delete
<select id="deleteFamilySelect"></select>
</label>
<button id="deleteFamilyBtn" class="btn btn-danger" type="button">Delete Family + Members</button>
</div>
</article>
<article class="panel" style="grid-column: 1 / -1;">
<h2>Administrative Actions</h2>
<p class="sub">Database maintenance and operational actions.</p>
<div class="action-list">
<button class="btn btn-ghost" data-action="/api/admin/backup">Create Backup</button>
<button class="btn btn-ghost" data-action="/api/admin/export">Export CSV</button>
<button class="btn btn-ghost" data-action="/api/admin/reindex">Rebuild Indexes</button>
<button class="btn btn-ghost" data-action="/api/admin/vacuum">Run VACUUM</button>
<button class="btn btn-danger" data-action="/api/admin/cleanup-orphan-members" data-confirm="Delete all members with null or missing family ids? This cannot be undone.">Cleanup Orphan Members</button>
<button class="btn btn-danger" data-action="/api/admin/reset-checkins" data-confirm="Check out all members right now?">Check Out Everyone</button>
<button class="btn btn-danger" data-action="/api/admin/reset-swim-tests" data-confirm="Reset all swim tests to not passed?">Reset Swim Tests</button>
</div>
<div id="status" class="status" style="margin-top: 10px;">Ready.</div>
</article>
</section>
</main>
<script>
const THEME_KEY = "csc-theme";
function getPreferredTheme() {
const saved = window.localStorage.getItem(THEME_KEY);
if (saved === "dark" || saved === "light") {
return saved;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme) {
document.body.setAttribute("data-theme", theme);
const lightBtn = document.getElementById("themeLightBtn");
const darkBtn = document.getElementById("themeDarkBtn");
if (lightBtn && darkBtn) {
lightBtn.classList.toggle("active", theme === "light");
darkBtn.classList.toggle("active", theme === "dark");
lightBtn.setAttribute("aria-pressed", String(theme === "light"));
darkBtn.setAttribute("aria-pressed", String(theme === "dark"));
}
}
function setTheme(theme) {
window.localStorage.setItem(THEME_KEY, theme);
applyTheme(theme);
}
applyTheme(getPreferredTheme());
const familyForm = document.getElementById("familyForm");
const memberForm = document.getElementById("memberForm");
const memberFamily = document.getElementById("memberFamily");
const deleteFamilySelect = document.getElementById("deleteFamilySelect");
const deleteFamilyBtn = document.getElementById("deleteFamilyBtn");
const statusBox = document.getElementById("status");
const openFirstFamily = document.getElementById("openFirstFamily");
const themeLightBtn = document.getElementById("themeLightBtn");
const themeDarkBtn = document.getElementById("themeDarkBtn");
function setStatus(message) {
const stamp = new Date().toLocaleTimeString();
statusBox.textContent = `${stamp}: ${message}`;
}
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || payload.message || "Request failed");
}
return payload;
}
async function loadFamiliesForSelect() {
const payload = await api("/api/families");
const families = payload.items || [];
memberFamily.innerHTML = "";
deleteFamilySelect.innerHTML = "";
families.forEach((family) => {
const option = document.createElement("option");
option.value = String(family.id);
option.textContent = `${family.familyName} Family`;
memberFamily.append(option);
const deleteOption = document.createElement("option");
deleteOption.value = String(family.id);
deleteOption.textContent = `${family.familyName} Family`;
deleteFamilySelect.append(deleteOption);
});
if (families.length > 0) {
openFirstFamily.href = `/family/${families[0].id}`;
memberFamily.disabled = false;
deleteFamilySelect.disabled = false;
deleteFamilyBtn.disabled = false;
} else {
openFirstFamily.href = "/";
memberFamily.disabled = true;
deleteFamilySelect.disabled = true;
deleteFamilyBtn.disabled = true;
}
}
familyForm.addEventListener("submit", async (event) => {
event.preventDefault();
const familyName = document.getElementById("familyName").value.trim();
const primaryPhone = document.getElementById("familyPhone").value.trim();
try {
await api("/api/families", {
method: "POST",
body: JSON.stringify({ familyName, primaryPhone }),
});
familyForm.reset();
await loadFamiliesForSelect();
setStatus("Family added.");
} catch (error) {
setStatus(`Error: ${error.message}`);
}
});
memberForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!memberFamily.value) {
setStatus("Error: Add a family before adding members.");
return;
}
const firstName = document.getElementById("memberFirst").value.trim();
const lastName = document.getElementById("memberLast").value.trim();
const age = Number.parseInt(document.getElementById("memberAge").value, 10);
const familyId = Number.parseInt(memberFamily.value, 10);
try {
await api("/api/members", {
method: "POST",
body: JSON.stringify({ firstName, lastName, age, familyId }),
});
memberForm.reset();
setStatus("Member added to family.");
} catch (error) {
setStatus(`Error: ${error.message}`);
}
});
deleteFamilyBtn.addEventListener("click", async () => {
const selectedId = Number.parseInt(deleteFamilySelect.value, 10);
if (!Number.isInteger(selectedId)) {
setStatus("Error: Select a family to delete.");
return;
}
const selectedLabel = deleteFamilySelect.options[deleteFamilySelect.selectedIndex]?.textContent || "this family";
const confirmed = window.confirm(
`Delete ${selectedLabel} and all members in it? This cannot be undone.`
);
if (!confirmed) {
return;
}
deleteFamilyBtn.disabled = true;
try {
const payload = await api(`/api/families/${selectedId}`, { method: "DELETE" });
await loadFamiliesForSelect();
setStatus(payload.message || "Family deleted.");
} catch (error) {
setStatus(`Error: ${error.message}`);
} finally {
deleteFamilyBtn.disabled = false;
}
});
document.querySelectorAll("[data-action]").forEach((button) => {
button.addEventListener("click", async () => {
const path = button.getAttribute("data-action");
const confirmText = button.getAttribute("data-confirm");
if (confirmText && !window.confirm(confirmText)) {
return;
}
button.disabled = true;
try {
const payload = await api(path, { method: "POST" });
setStatus(payload.message || "Action completed.");
if (path === "/api/admin/cleanup-orphan-members") {
await loadFamiliesForSelect();
}
} catch (error) {
setStatus(`Error: ${error.message}`);
} finally {
button.disabled = false;
}
});
});
themeLightBtn.addEventListener("click", () => setTheme("light"));
themeDarkBtn.addEventListener("click", () => setTheme("dark"));
loadFamiliesForSelect();
</script>
</body>
</html>

BIN
pool_checkin.sqlite3 Normal file

Binary file not shown.

5
pyvenv.cfg Normal file
View File

@@ -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