ai-lls-lib 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai_lls_lib/__init__.py +27 -0
- ai_lls_lib/cli/__init__.py +3 -0
- ai_lls_lib/cli/__main__.py +29 -0
- ai_lls_lib/cli/aws_client.py +115 -0
- ai_lls_lib/cli/commands/__init__.py +3 -0
- ai_lls_lib/cli/commands/admin.py +174 -0
- ai_lls_lib/cli/commands/cache.py +142 -0
- ai_lls_lib/cli/commands/test_stack.py +216 -0
- ai_lls_lib/cli/commands/verify.py +111 -0
- ai_lls_lib/core/__init__.py +3 -0
- ai_lls_lib/core/cache.py +106 -0
- ai_lls_lib/core/models.py +77 -0
- ai_lls_lib/core/processor.py +135 -0
- ai_lls_lib/core/verifier.py +95 -0
- ai_lls_lib/testing/__init__.py +3 -0
- ai_lls_lib/testing/fixtures.py +104 -0
- ai_lls_lib-1.0.0.dist-info/METADATA +220 -0
- ai_lls_lib-1.0.0.dist-info/RECORD +20 -0
- ai_lls_lib-1.0.0.dist-info/WHEEL +4 -0
- ai_lls_lib-1.0.0.dist-info/entry_points.txt +3 -0
ai_lls_lib/__init__.py
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
"""
|
2
|
+
AI LLS Library - Core business logic for Landline Scrubber
|
3
|
+
"""
|
4
|
+
from ai_lls_lib.core.models import (
|
5
|
+
PhoneVerification,
|
6
|
+
BulkJob,
|
7
|
+
BulkJobStatus,
|
8
|
+
LineType,
|
9
|
+
VerificationSource,
|
10
|
+
JobStatus
|
11
|
+
)
|
12
|
+
from ai_lls_lib.core.verifier import PhoneVerifier
|
13
|
+
from ai_lls_lib.core.processor import BulkProcessor
|
14
|
+
from ai_lls_lib.core.cache import DynamoDBCache
|
15
|
+
|
16
|
+
__version__ = "1.0.0"
|
17
|
+
__all__ = [
|
18
|
+
"PhoneVerification",
|
19
|
+
"BulkJob",
|
20
|
+
"BulkJobStatus",
|
21
|
+
"LineType",
|
22
|
+
"VerificationSource",
|
23
|
+
"JobStatus",
|
24
|
+
"PhoneVerifier",
|
25
|
+
"BulkProcessor",
|
26
|
+
"DynamoDBCache",
|
27
|
+
]
|
@@ -0,0 +1,29 @@
|
|
1
|
+
"""
|
2
|
+
Landline Scrubber CLI entry point
|
3
|
+
"""
|
4
|
+
import click
|
5
|
+
import sys
|
6
|
+
from ai_lls_lib.cli.commands import verify, cache, admin, test_stack
|
7
|
+
|
8
|
+
@click.group()
|
9
|
+
@click.version_option(version="0.1.0", prog_name="ai-lls")
|
10
|
+
def cli():
|
11
|
+
"""Landline Scrubber CLI - Administrative and debugging tools"""
|
12
|
+
pass
|
13
|
+
|
14
|
+
# Register command groups
|
15
|
+
cli.add_command(verify.verify_group)
|
16
|
+
cli.add_command(cache.cache_group)
|
17
|
+
cli.add_command(admin.admin_group)
|
18
|
+
cli.add_command(test_stack.test_stack_group)
|
19
|
+
|
20
|
+
def main():
|
21
|
+
"""Main entry point"""
|
22
|
+
try:
|
23
|
+
cli()
|
24
|
+
except Exception as e:
|
25
|
+
click.echo(f"Error: {e}", err=True)
|
26
|
+
sys.exit(1)
|
27
|
+
|
28
|
+
if __name__ == "__main__":
|
29
|
+
main()
|
@@ -0,0 +1,115 @@
|
|
1
|
+
"""
|
2
|
+
AWS client utilities for CLI operations
|
3
|
+
"""
|
4
|
+
import os
|
5
|
+
import boto3
|
6
|
+
from typing import Optional, Any, Dict
|
7
|
+
from botocore.exceptions import ClientError
|
8
|
+
import click
|
9
|
+
|
10
|
+
class AWSClient:
|
11
|
+
"""Wrapper for AWS operations with proper error handling"""
|
12
|
+
|
13
|
+
def __init__(self, region: Optional[str] = None, profile: Optional[str] = None):
|
14
|
+
"""Initialize AWS clients"""
|
15
|
+
self.region = region or os.environ.get("AWS_REGION", "us-east-1")
|
16
|
+
self.profile = profile
|
17
|
+
|
18
|
+
# Create session
|
19
|
+
if profile:
|
20
|
+
self.session = boto3.Session(profile_name=profile, region_name=self.region)
|
21
|
+
else:
|
22
|
+
self.session = boto3.Session(region_name=self.region)
|
23
|
+
|
24
|
+
# Lazy-load clients
|
25
|
+
self._dynamodb = None
|
26
|
+
self._s3 = None
|
27
|
+
self._sqs = None
|
28
|
+
self._secretsmanager = None
|
29
|
+
self._cloudformation = None
|
30
|
+
|
31
|
+
@property
|
32
|
+
def dynamodb(self):
|
33
|
+
"""Get DynamoDB client"""
|
34
|
+
if not self._dynamodb:
|
35
|
+
self._dynamodb = self.session.resource('dynamodb')
|
36
|
+
return self._dynamodb
|
37
|
+
|
38
|
+
@property
|
39
|
+
def s3(self):
|
40
|
+
"""Get S3 client"""
|
41
|
+
if not self._s3:
|
42
|
+
self._s3 = self.session.client('s3')
|
43
|
+
return self._s3
|
44
|
+
|
45
|
+
@property
|
46
|
+
def sqs(self):
|
47
|
+
"""Get SQS client"""
|
48
|
+
if not self._sqs:
|
49
|
+
self._sqs = self.session.client('sqs')
|
50
|
+
return self._sqs
|
51
|
+
|
52
|
+
@property
|
53
|
+
def secretsmanager(self):
|
54
|
+
"""Get Secrets Manager client"""
|
55
|
+
if not self._secretsmanager:
|
56
|
+
self._secretsmanager = self.session.client('secretsmanager')
|
57
|
+
return self._secretsmanager
|
58
|
+
|
59
|
+
@property
|
60
|
+
def cloudformation(self):
|
61
|
+
"""Get CloudFormation client"""
|
62
|
+
if not self._cloudformation:
|
63
|
+
self._cloudformation = self.session.client('cloudformation')
|
64
|
+
return self._cloudformation
|
65
|
+
|
66
|
+
def get_stack_outputs(self, stack_name: str) -> Dict[str, str]:
|
67
|
+
"""Get CloudFormation stack outputs"""
|
68
|
+
try:
|
69
|
+
response = self.cloudformation.describe_stacks(StackName=stack_name)
|
70
|
+
stack = response['Stacks'][0]
|
71
|
+
outputs = {}
|
72
|
+
for output in stack.get('Outputs', []):
|
73
|
+
outputs[output['OutputKey']] = output['OutputValue']
|
74
|
+
return outputs
|
75
|
+
except ClientError as e:
|
76
|
+
if e.response['Error']['Code'] == 'ValidationError':
|
77
|
+
raise click.ClickException(f"Stack '{stack_name}' not found")
|
78
|
+
raise
|
79
|
+
|
80
|
+
def get_table_name(self, stack_name: str, logical_name: str) -> str:
|
81
|
+
"""Get actual table name from stack"""
|
82
|
+
try:
|
83
|
+
response = self.cloudformation.describe_stack_resource(
|
84
|
+
StackName=stack_name,
|
85
|
+
LogicalResourceId=logical_name
|
86
|
+
)
|
87
|
+
return response['StackResourceDetail']['PhysicalResourceId']
|
88
|
+
except ClientError:
|
89
|
+
# Fallback to conventional naming
|
90
|
+
return f"{stack_name}-{logical_name.lower()}"
|
91
|
+
|
92
|
+
def scan_table(self, table_name: str, limit: int = 100) -> list:
|
93
|
+
"""Scan DynamoDB table"""
|
94
|
+
try:
|
95
|
+
table = self.dynamodb.Table(table_name)
|
96
|
+
response = table.scan(Limit=limit)
|
97
|
+
return response.get('Items', [])
|
98
|
+
except ClientError as e:
|
99
|
+
raise click.ClickException(f"Error scanning table: {e}")
|
100
|
+
|
101
|
+
def put_item(self, table_name: str, item: dict) -> None:
|
102
|
+
"""Put item to DynamoDB"""
|
103
|
+
try:
|
104
|
+
table = self.dynamodb.Table(table_name)
|
105
|
+
table.put_item(Item=item)
|
106
|
+
except ClientError as e:
|
107
|
+
raise click.ClickException(f"Error putting item: {e}")
|
108
|
+
|
109
|
+
def delete_item(self, table_name: str, key: dict) -> None:
|
110
|
+
"""Delete item from DynamoDB"""
|
111
|
+
try:
|
112
|
+
table = self.dynamodb.Table(table_name)
|
113
|
+
table.delete_item(Key=key)
|
114
|
+
except ClientError as e:
|
115
|
+
raise click.ClickException(f"Error deleting item: {e}")
|
@@ -0,0 +1,174 @@
|
|
1
|
+
"""
|
2
|
+
Administrative commands for user and credit management
|
3
|
+
"""
|
4
|
+
import click
|
5
|
+
import json
|
6
|
+
from datetime import datetime
|
7
|
+
from ai_lls_lib.cli.aws_client import AWSClient
|
8
|
+
|
9
|
+
@click.group(name="admin")
|
10
|
+
def admin_group():
|
11
|
+
"""Administrative commands"""
|
12
|
+
pass
|
13
|
+
|
14
|
+
@admin_group.command(name="user-credits")
|
15
|
+
@click.argument("user_id")
|
16
|
+
@click.option("--add", type=int, help="Add credits")
|
17
|
+
@click.option("--set", "set_credits", type=int, help="Set credits to specific value")
|
18
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
19
|
+
@click.option("--profile", help="AWS profile")
|
20
|
+
@click.option("--region", help="AWS region")
|
21
|
+
def user_credits(user_id, add, set_credits, stack, profile, region):
|
22
|
+
"""Manage user credits"""
|
23
|
+
aws = AWSClient(region=region, profile=profile)
|
24
|
+
credits_table = aws.get_table_name(stack, "CreditsTable")
|
25
|
+
|
26
|
+
try:
|
27
|
+
table = aws.dynamodb.Table(credits_table)
|
28
|
+
|
29
|
+
# Get current credits
|
30
|
+
response = table.get_item(Key={"user_id": user_id})
|
31
|
+
current = response.get('Item', {}).get('credits', 0)
|
32
|
+
|
33
|
+
click.echo(f"User {user_id} - Current credits: {current}")
|
34
|
+
|
35
|
+
if add:
|
36
|
+
new_credits = current + add
|
37
|
+
table.update_item(
|
38
|
+
Key={"user_id": user_id},
|
39
|
+
UpdateExpression="SET credits = :val, updated_at = :now",
|
40
|
+
ExpressionAttributeValues={
|
41
|
+
":val": new_credits,
|
42
|
+
":now": datetime.utcnow().isoformat()
|
43
|
+
}
|
44
|
+
)
|
45
|
+
click.echo(f"Added {add} credits. New balance: {new_credits}")
|
46
|
+
|
47
|
+
elif set_credits is not None:
|
48
|
+
table.update_item(
|
49
|
+
Key={"user_id": user_id},
|
50
|
+
UpdateExpression="SET credits = :val, updated_at = :now",
|
51
|
+
ExpressionAttributeValues={
|
52
|
+
":val": set_credits,
|
53
|
+
":now": datetime.utcnow().isoformat()
|
54
|
+
}
|
55
|
+
)
|
56
|
+
click.echo(f"Set credits to {set_credits}")
|
57
|
+
|
58
|
+
except Exception as e:
|
59
|
+
click.echo(f"Error managing credits: {e}", err=True)
|
60
|
+
|
61
|
+
@admin_group.command(name="api-keys")
|
62
|
+
@click.option("--user", help="Filter by user ID")
|
63
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
64
|
+
@click.option("--profile", help="AWS profile")
|
65
|
+
@click.option("--region", help="AWS region")
|
66
|
+
def list_api_keys(user, stack, profile, region):
|
67
|
+
"""List API keys"""
|
68
|
+
aws = AWSClient(region=region, profile=profile)
|
69
|
+
keys_table = aws.get_table_name(stack, "ApiKeysTable")
|
70
|
+
|
71
|
+
try:
|
72
|
+
if user:
|
73
|
+
# Query by user using GSI
|
74
|
+
table = aws.dynamodb.Table(keys_table)
|
75
|
+
response = table.query(
|
76
|
+
IndexName='gsi_user',
|
77
|
+
KeyConditionExpression='user_id = :uid',
|
78
|
+
ExpressionAttributeValues={':uid': user}
|
79
|
+
)
|
80
|
+
items = response.get('Items', [])
|
81
|
+
else:
|
82
|
+
# Scan all
|
83
|
+
items = aws.scan_table(keys_table)
|
84
|
+
|
85
|
+
if not items:
|
86
|
+
click.echo("No API keys found")
|
87
|
+
return
|
88
|
+
|
89
|
+
click.echo(f"\nAPI Keys ({len(items)} total):")
|
90
|
+
click.echo("=" * 60)
|
91
|
+
|
92
|
+
for item in items:
|
93
|
+
click.echo(f"Key ID: {item.get('api_key_id', 'N/A')}")
|
94
|
+
click.echo(f"User: {item.get('user_id', 'N/A')}")
|
95
|
+
click.echo(f"Created: {item.get('created_at', 'N/A')}")
|
96
|
+
click.echo(f"Last Used: {item.get('last_used', 'Never')}")
|
97
|
+
click.echo("-" * 60)
|
98
|
+
|
99
|
+
except Exception as e:
|
100
|
+
click.echo(f"Error listing API keys: {e}", err=True)
|
101
|
+
|
102
|
+
@admin_group.command(name="queue-stats")
|
103
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
104
|
+
@click.option("--profile", help="AWS profile")
|
105
|
+
@click.option("--region", help="AWS region")
|
106
|
+
def queue_stats(stack, profile, region):
|
107
|
+
"""Show SQS queue statistics"""
|
108
|
+
aws = AWSClient(region=region, profile=profile)
|
109
|
+
|
110
|
+
try:
|
111
|
+
# Get queue URLs from stack
|
112
|
+
outputs = aws.get_stack_outputs(stack)
|
113
|
+
|
114
|
+
# Find queue URLs or construct them
|
115
|
+
queue_name = f"{stack}-bulk-processing"
|
116
|
+
dlq_name = f"{stack}-bulk-processing-dlq"
|
117
|
+
|
118
|
+
# Get queue attributes
|
119
|
+
queues = [
|
120
|
+
("Processing Queue", queue_name),
|
121
|
+
("Dead Letter Queue", dlq_name)
|
122
|
+
]
|
123
|
+
|
124
|
+
for display_name, queue in queues:
|
125
|
+
try:
|
126
|
+
response = aws.sqs.get_queue_url(QueueName=queue)
|
127
|
+
queue_url = response['QueueUrl']
|
128
|
+
|
129
|
+
attrs = aws.sqs.get_queue_attributes(
|
130
|
+
QueueUrl=queue_url,
|
131
|
+
AttributeNames=['All']
|
132
|
+
)['Attributes']
|
133
|
+
|
134
|
+
click.echo(f"\n{display_name}:")
|
135
|
+
click.echo(f" Messages Available: {attrs.get('ApproximateNumberOfMessages', 0)}")
|
136
|
+
click.echo(f" Messages In Flight: {attrs.get('ApproximateNumberOfMessagesNotVisible', 0)}")
|
137
|
+
click.echo(f" Messages Delayed: {attrs.get('ApproximateNumberOfMessagesDelayed', 0)}")
|
138
|
+
|
139
|
+
except aws.sqs.exceptions.QueueDoesNotExist:
|
140
|
+
click.echo(f"\n{display_name}: Not found")
|
141
|
+
|
142
|
+
except Exception as e:
|
143
|
+
click.echo(f"Error getting queue stats: {e}", err=True)
|
144
|
+
|
145
|
+
@admin_group.command(name="secrets")
|
146
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
147
|
+
@click.option("--show", is_flag=True, help="Show secret values (CAREFUL!)")
|
148
|
+
@click.option("--profile", help="AWS profile")
|
149
|
+
@click.option("--region", help="AWS region")
|
150
|
+
def manage_secrets(stack, show, profile, region):
|
151
|
+
"""Manage secrets"""
|
152
|
+
aws = AWSClient(region=region, profile=profile)
|
153
|
+
secret_name = f"{stack}-secrets"
|
154
|
+
|
155
|
+
try:
|
156
|
+
response = aws.secretsmanager.describe_secret(SecretId=secret_name)
|
157
|
+
click.echo(f"\nSecret: {secret_name}")
|
158
|
+
click.echo(f"ARN: {response['ARN']}")
|
159
|
+
click.echo(f"Last Updated: {response.get('LastChangedDate', 'N/A')}")
|
160
|
+
|
161
|
+
if show and click.confirm("Show secret values? This will display sensitive data!"):
|
162
|
+
secret_value = aws.secretsmanager.get_secret_value(SecretId=secret_name)
|
163
|
+
secrets = json.loads(secret_value['SecretString'])
|
164
|
+
|
165
|
+
click.echo("\nSecret Values:")
|
166
|
+
for key, value in secrets.items():
|
167
|
+
# Mask most of the value
|
168
|
+
masked = value[:4] + "*" * (len(value) - 8) + value[-4:] if len(value) > 8 else "*" * len(value)
|
169
|
+
click.echo(f" {key}: {masked}")
|
170
|
+
|
171
|
+
except aws.secretsmanager.exceptions.ResourceNotFoundException:
|
172
|
+
click.echo(f"Secret '{secret_name}' not found")
|
173
|
+
except Exception as e:
|
174
|
+
click.echo(f"Error managing secrets: {e}", err=True)
|
@@ -0,0 +1,142 @@
|
|
1
|
+
"""
|
2
|
+
Cache management commands
|
3
|
+
"""
|
4
|
+
import click
|
5
|
+
import json
|
6
|
+
from datetime import datetime, timedelta
|
7
|
+
from ai_lls_lib.cli.aws_client import AWSClient
|
8
|
+
|
9
|
+
@click.group(name="cache")
|
10
|
+
def cache_group():
|
11
|
+
"""Cache management commands"""
|
12
|
+
pass
|
13
|
+
|
14
|
+
@cache_group.command(name="stats")
|
15
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
16
|
+
@click.option("--profile", help="AWS profile")
|
17
|
+
@click.option("--region", help="AWS region")
|
18
|
+
def cache_stats(stack, profile, region):
|
19
|
+
"""Show cache statistics"""
|
20
|
+
aws = AWSClient(region=region, profile=profile)
|
21
|
+
cache_table = aws.get_table_name(stack, "PhoneCacheTable")
|
22
|
+
|
23
|
+
try:
|
24
|
+
# Get table description for item count
|
25
|
+
table = aws.dynamodb.Table(cache_table)
|
26
|
+
desc = table.meta.client.describe_table(TableName=cache_table)
|
27
|
+
|
28
|
+
click.echo(f"\nCache Table: {cache_table}")
|
29
|
+
click.echo("=" * 50)
|
30
|
+
click.echo(f"Item Count: {desc['Table']['ItemCount']:,}")
|
31
|
+
click.echo(f"Table Size: {desc['Table']['TableSizeBytes']:,} bytes")
|
32
|
+
click.echo(f"Status: {desc['Table']['TableStatus']}")
|
33
|
+
|
34
|
+
# Sample some items to show age distribution
|
35
|
+
items = aws.scan_table(cache_table, limit=100)
|
36
|
+
if items:
|
37
|
+
now = datetime.utcnow()
|
38
|
+
ages = []
|
39
|
+
for item in items:
|
40
|
+
if 'verified_at' in item:
|
41
|
+
verified = datetime.fromisoformat(item['verified_at'].replace('Z', '+00:00'))
|
42
|
+
age = (now - verified.replace(tzinfo=None)).days
|
43
|
+
ages.append(age)
|
44
|
+
|
45
|
+
if ages:
|
46
|
+
click.echo(f"\nCache Age (sample of {len(ages)} items):")
|
47
|
+
click.echo(f" Newest: {min(ages)} days")
|
48
|
+
click.echo(f" Oldest: {max(ages)} days")
|
49
|
+
click.echo(f" Average: {sum(ages)/len(ages):.1f} days")
|
50
|
+
|
51
|
+
except Exception as e:
|
52
|
+
click.echo(f"Error getting cache stats: {e}", err=True)
|
53
|
+
|
54
|
+
@cache_group.command(name="get")
|
55
|
+
@click.argument("phone_number")
|
56
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
57
|
+
@click.option("--profile", help="AWS profile")
|
58
|
+
@click.option("--region", help="AWS region")
|
59
|
+
def cache_get(phone_number, stack, profile, region):
|
60
|
+
"""Get cached entry for a phone number"""
|
61
|
+
from ai_lls_lib.core.verifier import PhoneVerifier
|
62
|
+
from ai_lls_lib.core.cache import DynamoDBCache
|
63
|
+
|
64
|
+
aws = AWSClient(region=region, profile=profile)
|
65
|
+
cache_table = aws.get_table_name(stack, "PhoneCacheTable")
|
66
|
+
|
67
|
+
# Normalize phone first
|
68
|
+
verifier = PhoneVerifier(cache=DynamoDBCache(table_name=cache_table))
|
69
|
+
try:
|
70
|
+
normalized = verifier.normalize_phone(phone_number)
|
71
|
+
except ValueError as e:
|
72
|
+
click.echo(f"Invalid phone: {e}", err=True)
|
73
|
+
return
|
74
|
+
|
75
|
+
# Get from cache
|
76
|
+
cache = DynamoDBCache(table_name=cache_table)
|
77
|
+
result = cache.get(normalized)
|
78
|
+
|
79
|
+
if result:
|
80
|
+
click.echo(f"\nCached entry for {normalized}:")
|
81
|
+
click.echo(json.dumps(result.dict() if hasattr(result, 'dict') else result, indent=2, default=str))
|
82
|
+
else:
|
83
|
+
click.echo(f"No cache entry found for {normalized}")
|
84
|
+
|
85
|
+
@cache_group.command(name="invalidate")
|
86
|
+
@click.argument("phone_number")
|
87
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
88
|
+
@click.option("--profile", help="AWS profile")
|
89
|
+
@click.option("--region", help="AWS region")
|
90
|
+
@click.confirmation_option(prompt="Are you sure you want to invalidate this cache entry?")
|
91
|
+
def cache_invalidate(phone_number, stack, profile, region):
|
92
|
+
"""Invalidate cached entry for a phone number"""
|
93
|
+
from ai_lls_lib.core.verifier import PhoneVerifier
|
94
|
+
from ai_lls_lib.core.cache import DynamoDBCache
|
95
|
+
|
96
|
+
aws = AWSClient(region=region, profile=profile)
|
97
|
+
cache_table = aws.get_table_name(stack, "PhoneCacheTable")
|
98
|
+
|
99
|
+
# Normalize phone
|
100
|
+
verifier = PhoneVerifier(cache=DynamoDBCache(table_name=cache_table))
|
101
|
+
try:
|
102
|
+
normalized = verifier.normalize_phone(phone_number)
|
103
|
+
except ValueError as e:
|
104
|
+
click.echo(f"Invalid phone: {e}", err=True)
|
105
|
+
return
|
106
|
+
|
107
|
+
# Delete from cache
|
108
|
+
try:
|
109
|
+
aws.delete_item(cache_table, {"phone_number": normalized})
|
110
|
+
click.echo(f"Cache entry invalidated for {normalized}")
|
111
|
+
except Exception as e:
|
112
|
+
click.echo(f"Error invalidating cache: {e}", err=True)
|
113
|
+
|
114
|
+
@cache_group.command(name="clear")
|
115
|
+
@click.option("--stack", default="landline-api", help="CloudFormation stack name")
|
116
|
+
@click.option("--older-than", type=int, help="Clear entries older than N days")
|
117
|
+
@click.option("--profile", help="AWS profile")
|
118
|
+
@click.option("--region", help="AWS region")
|
119
|
+
@click.confirmation_option(prompt="This will clear cache entries. Continue?")
|
120
|
+
def cache_clear(stack, older_than, profile, region):
|
121
|
+
"""Clear cache entries"""
|
122
|
+
aws = AWSClient(region=region, profile=profile)
|
123
|
+
cache_table = aws.get_table_name(stack, "PhoneCacheTable")
|
124
|
+
|
125
|
+
if older_than:
|
126
|
+
click.echo(f"Clearing entries older than {older_than} days...")
|
127
|
+
cutoff = datetime.utcnow() - timedelta(days=older_than)
|
128
|
+
|
129
|
+
# Scan and delete old entries
|
130
|
+
items = aws.scan_table(cache_table, limit=1000)
|
131
|
+
deleted = 0
|
132
|
+
|
133
|
+
for item in items:
|
134
|
+
if 'verified_at' in item:
|
135
|
+
verified = datetime.fromisoformat(item['verified_at'].replace('Z', '+00:00'))
|
136
|
+
if verified.replace(tzinfo=None) < cutoff:
|
137
|
+
aws.delete_item(cache_table, {"phone_number": item['phone_number']})
|
138
|
+
deleted += 1
|
139
|
+
|
140
|
+
click.echo(f"Deleted {deleted} entries older than {older_than} days")
|
141
|
+
else:
|
142
|
+
click.echo("Full cache clear not implemented for safety. Use --older-than option.")
|