mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 04:05:25 -05:00
feat: add passkey authentication and enhance user preferences - Add passkey login security event type with fingerprint icon - Include request and site context in email confirmation for backend - Add user_id exact match filter to prevent incorrect user lookups - Enable PATCH method for updating user preferences via API - Add moderation_preferences support to user settings - Optimize ticket queries with select_related and prefetch_related This commit introduces passkey authentication tracking, improves user profile filtering accuracy, and extends the preferences API to support updates. Query optimizations reduce database hits for ticket listings.
175 lines
5.5 KiB
Python
Executable File
175 lines
5.5 KiB
Python
Executable File
#!/usr/bin/env python
|
|
"""
|
|
RichChoiceField Enforcement Lint Script
|
|
|
|
This script checks for prohibited choice patterns in the codebase.
|
|
Exit code 0 = no violations found, exit code 1 = violations found.
|
|
|
|
Usage:
|
|
python scripts/lint_choices.py
|
|
python scripts/lint_choices.py --fix # Show fix suggestions
|
|
|
|
Add to CI:
|
|
python scripts/lint_choices.py || exit 1
|
|
"""
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Patterns to detect prohibited choice usage
|
|
PROHIBITED_PATTERNS = [
|
|
# TextChoices / IntegerChoices class definitions
|
|
(r'class\s+\w+\s*\(\s*models\.(TextChoices|IntegerChoices)\s*\)',
|
|
'models.TextChoices/IntegerChoices class definition'),
|
|
|
|
# Inline tuple choices in CharField
|
|
(r'choices\s*=\s*\[\s*\(\s*["\']',
|
|
'Inline tuple choices'),
|
|
|
|
# Direct reference to .choices attribute
|
|
(r'choices\s*=\s*\w+\.choices',
|
|
'Reference to inner TextChoices.choices'),
|
|
]
|
|
|
|
# Directories/files to exclude
|
|
EXCLUDE_PATTERNS = [
|
|
'*/migrations/*',
|
|
'*/.venv/*',
|
|
'*/node_modules/*',
|
|
'*/__pycache__/*',
|
|
'*.pyc',
|
|
'lint_choices.py', # Exclude this script
|
|
]
|
|
|
|
# Files allowed to define TextChoices (infrastructure only)
|
|
ALLOWED_EXCEPTION_FILES = [
|
|
# Core choice infrastructure files can reference these patterns
|
|
'apps/core/choices/',
|
|
]
|
|
|
|
|
|
def should_exclude(path: Path) -> bool:
|
|
"""Check if path should be excluded from linting."""
|
|
path_str = str(path)
|
|
for pattern in EXCLUDE_PATTERNS:
|
|
if pattern.startswith('*/'):
|
|
if pattern[2:].rstrip('/*') in path_str:
|
|
return True
|
|
elif pattern.endswith('/*'):
|
|
if path_str.startswith(pattern[:-2]):
|
|
return True
|
|
elif pattern in path_str:
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_exception_file(path: Path) -> bool:
|
|
"""Check if file is an allowed exception."""
|
|
path_str = str(path)
|
|
for exception in ALLOWED_EXCEPTION_FILES:
|
|
if exception in path_str:
|
|
return True
|
|
return False
|
|
|
|
|
|
def scan_file(filepath: Path) -> list[tuple[int, str, str]]:
|
|
"""Scan a file for prohibited patterns. Returns list of (line_num, line, pattern_name)."""
|
|
violations = []
|
|
|
|
if should_exclude(filepath) or is_exception_file(filepath):
|
|
return violations
|
|
|
|
try:
|
|
content = filepath.read_text(encoding='utf-8')
|
|
lines = content.split('\n')
|
|
|
|
for i, line in enumerate(lines, 1):
|
|
# Skip comments
|
|
stripped = line.strip()
|
|
if stripped.startswith('#'):
|
|
continue
|
|
|
|
for pattern, description in PROHIBITED_PATTERNS:
|
|
if re.search(pattern, line):
|
|
violations.append((i, line.strip(), description))
|
|
break # Only report one violation per line
|
|
|
|
except Exception as e:
|
|
print(f"Warning: Could not read {filepath}: {e}", file=sys.stderr)
|
|
|
|
return violations
|
|
|
|
|
|
def scan_directory(root_dir: Path) -> dict[Path, list]:
|
|
"""Scan all Python files in directory for violations."""
|
|
all_violations = {}
|
|
|
|
for filepath in root_dir.rglob('*.py'):
|
|
violations = scan_file(filepath)
|
|
if violations:
|
|
all_violations[filepath] = violations
|
|
|
|
return all_violations
|
|
|
|
|
|
def print_violations(violations: dict, show_fix: bool = False):
|
|
"""Print violations in a readable format."""
|
|
total = 0
|
|
|
|
for filepath, file_violations in sorted(violations.items()):
|
|
print(f"\n\033[1;31m{filepath}\033[0m")
|
|
for line_num, line_content, description in file_violations:
|
|
print(f" Line {line_num}: [{description}]")
|
|
print(f" {line_content[:80]}{'...' if len(line_content) > 80 else ''}")
|
|
total += 1
|
|
|
|
if show_fix:
|
|
print(f" \033[1;33mFix:\033[0m Use RichChoiceField(choice_group='...', domain='...')")
|
|
|
|
return total
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description='Lint for prohibited choice patterns')
|
|
parser.add_argument('--fix', action='store_true', help='Show fix suggestions')
|
|
parser.add_argument('path', nargs='?', default='apps', help='Path to scan (default: apps)')
|
|
args = parser.parse_args()
|
|
|
|
# Find backend directory
|
|
script_dir = Path(__file__).parent
|
|
backend_dir = script_dir.parent
|
|
if (backend_dir / 'apps').exists():
|
|
scan_path = backend_dir / args.path
|
|
elif (backend_dir / 'backend' / 'apps').exists():
|
|
scan_path = backend_dir / 'backend' / args.path
|
|
else:
|
|
print("Error: Could not find apps directory", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(f"Scanning {scan_path} for prohibited choice patterns...")
|
|
print("=" * 60)
|
|
|
|
violations = scan_directory(scan_path)
|
|
|
|
if violations:
|
|
total = print_violations(violations, show_fix=args.fix)
|
|
print("\n" + "=" * 60)
|
|
print(f"\033[1;31mFound {total} violation(s) in {len(violations)} file(s)\033[0m")
|
|
print("\nProhibited patterns:")
|
|
print(" - models.TextChoices / models.IntegerChoices classes")
|
|
print(" - Inline choices=[(value, label), ...]")
|
|
print(" - References to InnerClass.choices")
|
|
print("\nRequired pattern:")
|
|
print(" RichChoiceField(choice_group='group_name', domain='domain_name')")
|
|
print("\nSee: .agent/workflows/enforce-richchoice.md for migration guide")
|
|
sys.exit(1)
|
|
else:
|
|
print("\n\033[1;32m✓ No prohibited choice patterns found!\033[0m")
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|