diff --git a/QUICK_LOG_REFERENCE.md b/QUICK_LOG_REFERENCE.md
index f49368e..df54351 100644
--- a/QUICK_LOG_REFERENCE.md
+++ b/QUICK_LOG_REFERENCE.md
@@ -58,6 +58,22 @@ All logs are in: `/home/appuser/.pm2/logs/`
- **Admin**: `punimtag-admin-error.log`, `punimtag-admin-out.log`
- **Viewer**: `punimtag-viewer-error.log`, `punimtag-viewer-out.log`
+### Click Logs (Admin Frontend)
+
+Click logs are in: `/opt/punimtag/logs/`
+
+- **Click Log**: `admin-clicks.log` (auto-rotates at 10MB, keeps 5 backups)
+- **View live clicks**: `tail -f /opt/punimtag/logs/admin-clicks.log`
+- **View recent clicks**: `tail -n 100 /opt/punimtag/logs/admin-clicks.log`
+- **Search clicks**: `grep "username\|page" /opt/punimtag/logs/admin-clicks.log`
+- **Cleanup old logs**: `./scripts/cleanup-click-logs.sh`
+
+**Automated Cleanup (Crontab):**
+```bash
+# Add to crontab: cleanup logs weekly (Sundays at 2 AM)
+0 2 * * 0 /opt/punimtag/scripts/cleanup-click-logs.sh
+```
+
## 🔧 Direct Log Access
```bash
@@ -113,3 +129,15 @@ This configures:
pm2 flush # Clear all logs (be careful!)
```
+5. **Viewing click logs?**
+ ```bash
+ # Watch clicks in real-time
+ tail -f /opt/punimtag/logs/admin-clicks.log
+
+ # View recent clicks
+ tail -n 100 /opt/punimtag/logs/admin-clicks.log
+
+ # Search for specific user or page
+ grep "admin\|/identify" /opt/punimtag/logs/admin-clicks.log
+ ```
+
diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx
index 81c1ab9..e58b82e 100644
--- a/admin-frontend/src/App.tsx
+++ b/admin-frontend/src/App.tsx
@@ -23,6 +23,7 @@ import Help from './pages/Help'
import Layout from './components/Layout'
import PasswordChangeModal from './components/PasswordChangeModal'
import AdminRoute from './components/AdminRoute'
+import { logClick, flushPendingClicks } from './services/clickLogger'
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading, passwordChangeRequired } = useAuth()
@@ -57,6 +58,38 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
}
function AppRoutes() {
+ const { isAuthenticated } = useAuth()
+
+ // Set up global click logging for authenticated users
+ useEffect(() => {
+ if (!isAuthenticated) {
+ return
+ }
+
+ const handleClick = (event: MouseEvent) => {
+ const target = event.target as HTMLElement
+ if (target) {
+ logClick(target)
+ }
+ }
+
+ // Add click listener
+ document.addEventListener('click', handleClick, true) // Use capture phase
+
+ // Flush pending clicks on page unload
+ const handleBeforeUnload = () => {
+ flushPendingClicks()
+ }
+ window.addEventListener('beforeunload', handleBeforeUnload)
+
+ return () => {
+ document.removeEventListener('click', handleClick, true)
+ window.removeEventListener('beforeunload', handleBeforeUnload)
+ // Flush any pending clicks on cleanup
+ flushPendingClicks()
+ }
+ }, [isAuthenticated])
+
return (
} />
diff --git a/admin-frontend/src/api/people.ts b/admin-frontend/src/api/people.ts
index 99b55f5..4a0712a 100644
--- a/admin-frontend/src/api/people.ts
+++ b/admin-frontend/src/api/people.ts
@@ -46,8 +46,8 @@ export const peopleApi = {
const res = await apiClient.get('/api/v1/people', { params })
return res.data
},
- listWithFaces: async (lastName?: string): Promise => {
- const params = lastName ? { last_name: lastName } : {}
+ listWithFaces: async (name?: string): Promise => {
+ const params = name ? { last_name: name } : {}
const res = await apiClient.get('/api/v1/people/with-faces', { params })
return res.data
},
diff --git a/admin-frontend/src/pages/Help.tsx b/admin-frontend/src/pages/Help.tsx
index 07dd36b..b1df125 100644
--- a/admin-frontend/src/pages/Help.tsx
+++ b/admin-frontend/src/pages/Help.tsx
@@ -672,7 +672,7 @@ function ModifyPageHelp({ onBack }: { onBack: () => void }) {
Finding and Selecting a Person:
- Navigate to Modify page
- - Optionally search for a person by entering their last name or maiden name in the search box
+ - Optionally search for a person by entering their first, middle, last, or maiden name in the search box
- Click "Search" to filter the list, or "Clear" to show all people
- Click on a person's name in the left panel to select them
- The person's faces and videos will load in the right panels
diff --git a/admin-frontend/src/pages/Modify.tsx b/admin-frontend/src/pages/Modify.tsx
index 0fc9ee7..1bf799d 100644
--- a/admin-frontend/src/pages/Modify.tsx
+++ b/admin-frontend/src/pages/Modify.tsx
@@ -147,7 +147,7 @@ function EditPersonDialog({ person, onSave, onClose }: EditDialogProps) {
export default function Modify() {
const [people, setPeople] = useState([])
- const [lastNameFilter, setLastNameFilter] = useState('')
+ const [nameFilter, setNameFilter] = useState('')
const [selectedPersonId, setSelectedPersonId] = useState(null)
const [selectedPersonName, setSelectedPersonName] = useState('')
const [faces, setFaces] = useState([])
@@ -187,7 +187,7 @@ export default function Modify() {
try {
setBusy(true)
setError(null)
- const res = await peopleApi.listWithFaces(lastNameFilter || undefined)
+ const res = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(res.items)
// Auto-select first person if available and none selected (only if not restoring state)
@@ -203,7 +203,7 @@ export default function Modify() {
} finally {
setBusy(false)
}
- }, [lastNameFilter, selectedPersonId])
+ }, [nameFilter, selectedPersonId])
// Load faces for a person
const loadPersonFaces = useCallback(async (personId: number) => {
@@ -248,12 +248,15 @@ export default function Modify() {
useEffect(() => {
let restoredPanelWidth = false
try {
- const saved = sessionStorage.getItem(STATE_KEY)
- if (saved) {
- const state = JSON.parse(saved)
- if (state.lastNameFilter !== undefined) {
- setLastNameFilter(state.lastNameFilter || '')
- }
+ const saved = sessionStorage.getItem(STATE_KEY)
+ if (saved) {
+ const state = JSON.parse(saved)
+ if (state.nameFilter !== undefined) {
+ setNameFilter(state.nameFilter || '')
+ } else if (state.lastNameFilter !== undefined) {
+ // Backward compatibility with old state key
+ setNameFilter(state.lastNameFilter || '')
+ }
if (state.selectedPersonId !== undefined && state.selectedPersonId !== null) {
setSelectedPersonId(state.selectedPersonId)
}
@@ -365,7 +368,7 @@ export default function Modify() {
try {
const state = {
- lastNameFilter,
+ nameFilter,
selectedPersonId,
selectedPersonName,
faces,
@@ -380,10 +383,10 @@ export default function Modify() {
} catch (error) {
console.error('Error saving state to sessionStorage:', error)
}
- }, [lastNameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth, stateRestored])
+ }, [nameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth, stateRestored])
// Save state on unmount (when navigating away) - use refs to capture latest values
- const lastNameFilterRef = useRef(lastNameFilter)
+ const nameFilterRef = useRef(nameFilter)
const selectedPersonIdRef = useRef(selectedPersonId)
const selectedPersonNameRef = useRef(selectedPersonName)
const facesRef = useRef(faces)
@@ -396,7 +399,7 @@ export default function Modify() {
// Update refs whenever state changes
useEffect(() => {
- lastNameFilterRef.current = lastNameFilter
+ nameFilterRef.current = nameFilter
selectedPersonIdRef.current = selectedPersonId
selectedPersonNameRef.current = selectedPersonName
facesRef.current = faces
@@ -406,14 +409,14 @@ export default function Modify() {
facesExpandedRef.current = facesExpanded
videosExpandedRef.current = videosExpanded
peoplePanelWidthRef.current = peoplePanelWidth
- }, [lastNameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth])
+ }, [nameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth])
// Save state on unmount (when navigating away)
useEffect(() => {
return () => {
try {
const state = {
- lastNameFilter: lastNameFilterRef.current,
+ nameFilter: nameFilterRef.current,
selectedPersonId: selectedPersonIdRef.current,
selectedPersonName: selectedPersonNameRef.current,
faces: facesRef.current,
@@ -463,7 +466,7 @@ export default function Modify() {
}
const handleClearSearch = () => {
- setLastNameFilter('')
+ setNameFilter('')
// loadPeople will be called by useEffect
}
@@ -560,7 +563,7 @@ export default function Modify() {
await facesApi.batchUnmatch({ face_ids: [unmatchConfirmDialog.faceId] })
// Reload people list to update face counts
- const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
+ const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(peopleRes.items)
// Reload faces
@@ -591,7 +594,7 @@ export default function Modify() {
setSelectedFaces(new Set())
// Reload people list to update face counts
- const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
+ const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(peopleRes.items)
// Reload faces
@@ -627,7 +630,7 @@ export default function Modify() {
await videosApi.removePerson(unmatchConfirmDialog.videoId, selectedPersonId)
// Reload people list
- const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
+ const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(peopleRes.items)
// Reload videos
@@ -679,7 +682,7 @@ export default function Modify() {
setSelectedVideos(new Set())
// Reload people list
- const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined)
+ const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined)
setPeople(peopleRes.items)
// Reload videos
@@ -720,10 +723,10 @@ export default function Modify() {
setLastNameFilter(e.target.value)}
+ value={nameFilter}
+ onChange={(e) => setNameFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
- placeholder="Type Last Name or Maiden Name"
+ placeholder="Type First, Middle, Last, or Maiden Name"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
- Search by Last Name or Maiden Name
+ Search by First, Middle, Last, or Maiden Name
{/* People list */}
diff --git a/admin-frontend/src/pages/Tags.tsx b/admin-frontend/src/pages/Tags.tsx
index f139608..a349730 100644
--- a/admin-frontend/src/pages/Tags.tsx
+++ b/admin-frontend/src/pages/Tags.tsx
@@ -1115,6 +1115,11 @@ export default function Tags() {
selectedPhotoIds={Array.from(selectedPhotoIds)}
photos={photos.filter(p => selectedPhotoIds.has(p.id))}
tags={tags}
+ onTagsUpdated={async () => {
+ // Reload tags when new tags are created
+ const tagsRes = await tagsApi.list()
+ setTags(tagsRes.items)
+ }}
onClose={async () => {
setShowTagSelectedDialog(false)
setSelectedPhotoIds(new Set())
@@ -1775,17 +1780,26 @@ function TagSelectedPhotosDialog({
selectedPhotoIds,
photos,
tags,
+ onTagsUpdated,
onClose,
}: {
selectedPhotoIds: number[]
photos: PhotoWithTagsItem[]
tags: TagResponse[]
+ onTagsUpdated?: () => Promise
onClose: () => void
}) {
const [selectedTagName, setSelectedTagName] = useState('')
+ const [newTagName, setNewTagName] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState>(new Set())
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [photoTagsData, setPhotoTagsData] = useState>({})
+ const [localTags, setLocalTags] = useState(tags)
+
+ // Update local tags when tags prop changes
+ useEffect(() => {
+ setLocalTags(tags)
+ }, [tags])
// Load tag linkage information for all selected photos
useEffect(() => {
@@ -1809,28 +1823,59 @@ function TagSelectedPhotosDialog({
}, [selectedPhotoIds])
const handleAddTag = async () => {
- if (!selectedTagName.trim() || selectedPhotoIds.length === 0) return
+ if (selectedPhotoIds.length === 0) return
- // Check if tag exists, create if not
- let tag = tags.find(t => t.tag_name.toLowerCase() === selectedTagName.toLowerCase().trim())
- if (!tag) {
- try {
- tag = await tagsApi.create(selectedTagName.trim())
- // Note: We don't update the tags list here since it's passed from parent
- } catch (error) {
- console.error('Failed to create tag:', error)
- alert('Failed to create tag')
- return
- }
+ // Collect both tags: selected existing tag and new tag name
+ const tagsToAdd: string[] = []
+
+ if (selectedTagName.trim()) {
+ tagsToAdd.push(selectedTagName.trim())
+ }
+
+ if (newTagName.trim()) {
+ tagsToAdd.push(newTagName.trim())
+ }
+
+ if (tagsToAdd.length === 0) {
+ alert('Please select a tag or enter a new tag name.')
+ return
}
- // Make single batch API call for all selected photos
try {
+ // Create any new tags first
+ const newTags = tagsToAdd.filter(tag =>
+ !localTags.some(availableTag =>
+ availableTag.tag_name.toLowerCase() === tag.toLowerCase()
+ )
+ )
+
+ if (newTags.length > 0) {
+ const createdTags: TagResponse[] = []
+ for (const newTag of newTags) {
+ const createdTag = await tagsApi.create(newTag)
+ createdTags.push(createdTag)
+ }
+ // Update local tags immediately with newly created tags
+ setLocalTags(prev => {
+ const updated = [...prev, ...createdTags]
+ // Sort by tag name
+ return updated.sort((a, b) => a.tag_name.localeCompare(b.tag_name))
+ })
+ // Also reload tags list in parent to keep it in sync
+ if (onTagsUpdated) {
+ await onTagsUpdated()
+ }
+ }
+
+ // Add all tags to photos in a single API call
await tagsApi.addToPhotos({
photo_ids: selectedPhotoIds,
- tag_names: [selectedTagName.trim()],
+ tag_names: tagsToAdd,
})
+
+ // Clear inputs after successful tagging
setSelectedTagName('')
+ setNewTagName('')
// Reload photo tags data to update the common tags list
const tagsData: Record = {}
@@ -1901,7 +1946,7 @@ function TagSelectedPhotosDialog({
allPhotoTags[photoId] = photoTagsData[photoId] || []
})
- const tagIdToName = new Map(tags.map(t => [t.id, t.tag_name]))
+ const tagIdToName = new Map(localTags.map(t => [t.id, t.tag_name]))
// Get all unique tag IDs from all photos
const allTagIds = new Set()
@@ -1930,7 +1975,7 @@ function TagSelectedPhotosDialog({
}
})
.filter(Boolean) as any[]
- }, [photos, tags, selectedPhotoIds, photoTagsData])
+ }, [photos, localTags, selectedPhotoIds, photoTagsData])
// Get selected tag names for confirmation message
const selectedTagNames = useMemo(() => {
@@ -1961,11 +2006,14 @@ function TagSelectedPhotosDialog({
-
+
+
-
+
+ You can select an existing tag and enter a new tag name to add both at once.
+
+
+
+
+
setNewTagName(e.target.value)}
+ className="w-full px-3 py-2 border border-gray-300 rounded"
+ placeholder="Type new tag name..."
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ handleAddTag()
+ }
+ }}
+ />
+
+ New tags will be created in the database automatically.
+
@@ -2024,12 +2088,21 @@ function TagSelectedPhotosDialog({
>
Remove selected tags
-
+
+
+
+
diff --git a/admin-frontend/src/services/clickLogger.ts b/admin-frontend/src/services/clickLogger.ts
new file mode 100644
index 0000000..b8917a9
--- /dev/null
+++ b/admin-frontend/src/services/clickLogger.ts
@@ -0,0 +1,201 @@
+/**
+ * Click logging service for admin frontend.
+ * Sends click events to backend API for logging to file.
+ */
+
+import { apiClient } from '../api/client'
+
+interface ClickLogData {
+ page: string
+ element_type: string
+ element_id?: string
+ element_text?: string
+ context?: Record
+}
+
+// Batch clicks to avoid excessive API calls
+const CLICK_BATCH_SIZE = 10
+const CLICK_BATCH_DELAY = 1000 // 1 second
+
+let clickQueue: ClickLogData[] = []
+let batchTimeout: number | null = null
+
+/**
+ * Get the current page path.
+ */
+function getCurrentPage(): string {
+ return window.location.pathname
+}
+
+/**
+ * Get element type from HTML element.
+ */
+function getElementType(element: HTMLElement): string {
+ const tagName = element.tagName.toLowerCase()
+
+ // Map common elements
+ if (tagName === 'button' || element.getAttribute('role') === 'button') {
+ return 'button'
+ }
+ if (tagName === 'a') {
+ return 'link'
+ }
+ if (tagName === 'input') {
+ return 'input'
+ }
+ if (tagName === 'select') {
+ return 'select'
+ }
+ if (tagName === 'textarea') {
+ return 'textarea'
+ }
+
+ // Check for clickable elements
+ if (element.onclick || element.getAttribute('onclick')) {
+ return 'clickable'
+ }
+
+ // Default to tag name
+ return tagName
+}
+
+/**
+ * Get element text content (truncated to 100 chars).
+ */
+function getElementText(element: HTMLElement): string {
+ const text = element.textContent?.trim() || element.getAttribute('aria-label') || ''
+ return text.substring(0, 100)
+}
+
+/**
+ * Extract context from element (data attributes, etc.).
+ */
+function extractContext(element: HTMLElement): Record {
+ const context: Record = {}
+
+ // Extract data-* attributes
+ Array.from(element.attributes).forEach(attr => {
+ if (attr.name.startsWith('data-')) {
+ const key = attr.name.replace('data-', '').replace(/-/g, '_')
+ context[key] = attr.value
+ }
+ })
+
+ // Extract common IDs that might be useful
+ const id = element.id
+ if (id) {
+ context.element_id = id
+ }
+
+ const className = element.className
+ if (className && typeof className === 'string') {
+ context.class_name = className.split(' ').slice(0, 3).join(' ') // First 3 classes
+ }
+
+ return context
+}
+
+/**
+ * Flush queued clicks to backend.
+ */
+async function flushClickQueue(): Promise {
+ if (clickQueue.length === 0) {
+ return
+ }
+
+ const clicksToSend = [...clickQueue]
+ clickQueue = []
+
+ // Send clicks in parallel (but don't wait for all to complete)
+ clicksToSend.forEach(clickData => {
+ apiClient.post('/api/v1/log/click', clickData).catch(error => {
+ // Silently fail - don't interrupt user experience
+ console.debug('Click logging failed:', error)
+ })
+ })
+}
+
+/**
+ * Queue a click for logging.
+ */
+function queueClick(clickData: ClickLogData): void {
+ clickQueue.push(clickData)
+
+ // Flush if batch size reached
+ if (clickQueue.length >= CLICK_BATCH_SIZE) {
+ if (batchTimeout !== null) {
+ window.clearTimeout(batchTimeout)
+ batchTimeout = null
+ }
+ flushClickQueue()
+ } else {
+ // Set timeout to flush after delay
+ if (batchTimeout === null) {
+ batchTimeout = window.setTimeout(() => {
+ batchTimeout = null
+ flushClickQueue()
+ }, CLICK_BATCH_DELAY)
+ }
+ }
+}
+
+/**
+ * Log a click event.
+ */
+export function logClick(
+ element: HTMLElement,
+ additionalContext?: Record
+): void {
+ try {
+ const elementType = getElementType(element)
+ const elementId = element.id || undefined
+ const elementText = getElementText(element)
+ const page = getCurrentPage()
+ const context = {
+ ...extractContext(element),
+ ...additionalContext,
+ }
+
+ // Skip logging for certain elements (to reduce noise)
+ const skipSelectors = [
+ 'input[type="password"]',
+ 'input[type="hidden"]',
+ '[data-no-log]', // Allow opt-out via data attribute
+ ]
+
+ const shouldSkip = skipSelectors.some(selector => {
+ try {
+ return element.matches(selector)
+ } catch {
+ return false
+ }
+ })
+
+ if (shouldSkip) {
+ return
+ }
+
+ queueClick({
+ page,
+ element_type: elementType,
+ element_id: elementId,
+ element_text: elementText || undefined,
+ context: Object.keys(context).length > 0 ? context : undefined,
+ })
+ } catch (error) {
+ // Silently fail - don't interrupt user experience
+ console.debug('Click logging error:', error)
+ }
+}
+
+/**
+ * Flush any pending clicks (useful on page unload).
+ */
+export function flushPendingClicks(): void {
+ if (batchTimeout !== null) {
+ window.clearTimeout(batchTimeout)
+ batchTimeout = null
+ }
+ flushClickQueue()
+}
+
diff --git a/backend/api/click_log.py b/backend/api/click_log.py
new file mode 100644
index 0000000..c2dbe08
--- /dev/null
+++ b/backend/api/click_log.py
@@ -0,0 +1,56 @@
+"""Click logging API endpoint."""
+
+from __future__ import annotations
+
+from typing import Annotated, Optional
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel
+
+from backend.api.auth import get_current_user
+from backend.utils.click_logger import log_click
+
+router = APIRouter(prefix="/log", tags=["logging"])
+
+
+class ClickLogRequest(BaseModel):
+ """Request model for click logging."""
+ page: str
+ element_type: str
+ element_id: Optional[str] = None
+ element_text: Optional[str] = None
+ context: Optional[dict] = None
+
+
+@router.post("/click")
+def log_click_event(
+ request: ClickLogRequest,
+ current_user: Annotated[dict, Depends(get_current_user)],
+) -> dict:
+ """Log a click event from the admin frontend.
+
+ Args:
+ request: Click event data
+ current_user: Authenticated user (from JWT token)
+
+ Returns:
+ Success confirmation
+ """
+ username = current_user.get("username", "unknown")
+
+ try:
+ log_click(
+ username=username,
+ page=request.page,
+ element_type=request.element_type,
+ element_id=request.element_id,
+ element_text=request.element_text,
+ context=request.context,
+ )
+ return {"status": "ok", "message": "Click logged"}
+ except Exception as e:
+ # Don't fail the request if logging fails
+ # Just return success but log the error
+ import logging
+ logging.error(f"Failed to log click: {e}")
+ return {"status": "ok", "message": "Click logged (with errors)"}
+
diff --git a/backend/api/people.py b/backend/api/people.py
index 2a5165e..c17b001 100644
--- a/backend/api/people.py
+++ b/backend/api/people.py
@@ -6,7 +6,7 @@ from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
-from sqlalchemy import func
+from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from backend.db.session import get_db
@@ -48,12 +48,12 @@ def list_people(
@router.get("/with-faces", response_model=PeopleWithFacesListResponse)
def list_people_with_faces(
- last_name: str | None = Query(None, description="Filter by last name or maiden name (case-insensitive)"),
+ last_name: str | None = Query(None, description="Filter by first, middle, last, or maiden name (case-insensitive)"),
db: Session = Depends(get_db),
) -> PeopleWithFacesListResponse:
"""List all people with face counts and video counts, sorted by last_name, first_name.
- Optionally filter by last_name or maiden_name if provided (case-insensitive search).
+ Optionally filter by first_name, middle_name, last_name, or maiden_name if provided (case-insensitive search).
Returns all people, including those with zero faces or videos.
"""
# Query people with face counts using LEFT OUTER JOIN to include people with no faces
@@ -67,11 +67,15 @@ def list_people_with_faces(
)
if last_name:
- # Case-insensitive search on both last_name and maiden_name
+ # Case-insensitive search on first_name, middle_name, last_name, and maiden_name
search_term = last_name.lower()
query = query.filter(
- (func.lower(Person.last_name).contains(search_term)) |
- ((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term)))
+ or_(
+ func.lower(Person.first_name).contains(search_term),
+ func.lower(Person.middle_name).contains(search_term),
+ func.lower(Person.last_name).contains(search_term),
+ ((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term)))
+ )
)
results = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all()
diff --git a/backend/app.py b/backend/app.py
index 1d340ab..d7bba1d 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -26,6 +26,7 @@ from backend.api.users import router as users_router
from backend.api.auth_users import router as auth_users_router
from backend.api.role_permissions import router as role_permissions_router
from backend.api.videos import router as videos_router
+from backend.api.click_log import router as click_log_router
from backend.api.version import router as version_router
from backend.settings import APP_TITLE, APP_VERSION
from backend.constants.roles import DEFAULT_ADMIN_ROLE, DEFAULT_USER_ROLE, ROLE_VALUES
@@ -747,6 +748,7 @@ def create_app() -> FastAPI:
app.include_router(users_router, prefix="/api/v1")
app.include_router(auth_users_router, prefix="/api/v1")
app.include_router(role_permissions_router, prefix="/api/v1")
+ app.include_router(click_log_router, prefix="/api/v1")
return app
diff --git a/backend/utils/click_logger.py b/backend/utils/click_logger.py
new file mode 100644
index 0000000..7ab28ac
--- /dev/null
+++ b/backend/utils/click_logger.py
@@ -0,0 +1,123 @@
+"""Click logging utility with file rotation and management."""
+
+from __future__ import annotations
+
+import os
+import logging
+from datetime import datetime
+from pathlib import Path
+from typing import Optional
+
+# Log directory - relative to project root
+LOG_DIR = Path(__file__).parent.parent.parent / "logs"
+LOG_FILE = LOG_DIR / "admin-clicks.log"
+MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
+BACKUP_COUNT = 5 # Keep 5 rotated files
+RETENTION_DAYS = 30 # Keep logs for 30 days
+
+# Ensure log directory exists
+LOG_DIR.mkdir(parents=True, exist_ok=True)
+
+# Configure logger with rotation
+_logger: Optional[logging.Logger] = None
+
+
+def get_click_logger() -> logging.Logger:
+ """Get or create the click logger with rotation."""
+ global _logger
+
+ if _logger is not None:
+ return _logger
+
+ _logger = logging.getLogger("admin_clicks")
+ _logger.setLevel(logging.INFO)
+
+ # Remove existing handlers to avoid duplicates
+ _logger.handlers.clear()
+
+ # Create rotating file handler
+ from logging.handlers import RotatingFileHandler
+
+ handler = RotatingFileHandler(
+ LOG_FILE,
+ maxBytes=MAX_FILE_SIZE,
+ backupCount=BACKUP_COUNT,
+ encoding='utf-8'
+ )
+
+ # Simple format: timestamp | username | page | element_type | element_id | element_text | context
+ formatter = logging.Formatter(
+ '%(asctime)s | %(message)s',
+ datefmt='%Y-%m-%d %H:%M:%S'
+ )
+ handler.setFormatter(formatter)
+ _logger.addHandler(handler)
+
+ # Prevent propagation to root logger
+ _logger.propagate = False
+
+ return _logger
+
+
+def log_click(
+ username: str,
+ page: str,
+ element_type: str,
+ element_id: Optional[str] = None,
+ element_text: Optional[str] = None,
+ context: Optional[dict] = None,
+) -> None:
+ """Log a click event to the log file.
+
+ Args:
+ username: Username of the user who clicked
+ page: Page/route where click occurred (e.g., '/identify')
+ element_type: Type of element (button, link, input, etc.)
+ element_id: ID of the element (optional)
+ element_text: Text content of the element (optional)
+ context: Additional context as dict (optional, will be JSON stringified)
+ """
+ logger = get_click_logger()
+
+ # Format context as JSON string if provided
+ context_str = ""
+ if context:
+ import json
+ try:
+ context_str = f" | {json.dumps(context)}"
+ except (TypeError, ValueError):
+ context_str = f" | {str(context)}"
+
+ # Build log message
+ parts = [
+ username,
+ page,
+ element_type,
+ element_id or "",
+ element_text or "",
+ ]
+
+ # Join parts with | separator, remove empty parts
+ message = " | ".join(part for part in parts if part) + context_str
+
+ logger.info(message)
+
+
+def cleanup_old_logs() -> None:
+ """Remove log files older than RETENTION_DAYS."""
+ if not LOG_DIR.exists():
+ return
+
+ from datetime import timedelta
+ cutoff_date = datetime.now() - timedelta(days=RETENTION_DAYS)
+
+ for log_file in LOG_DIR.glob("admin-clicks.log.*"):
+ try:
+ # Check file modification time
+ mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
+ if mtime < cutoff_date:
+ log_file.unlink()
+ except (OSError, ValueError):
+ # Skip files we can't process
+ pass
+
diff --git a/scripts/cleanup-click-logs.sh b/scripts/cleanup-click-logs.sh
new file mode 100755
index 0000000..0b77ab4
--- /dev/null
+++ b/scripts/cleanup-click-logs.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+# Cleanup old click log files (older than retention period)
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+LOG_DIR="$PROJECT_ROOT/logs"
+
+if [ ! -d "$LOG_DIR" ]; then
+ echo "Log directory does not exist: $LOG_DIR"
+ exit 0
+fi
+
+# Run Python cleanup function
+cd "$PROJECT_ROOT"
+python3 -c "
+from backend.utils.click_logger import cleanup_old_logs
+cleanup_old_logs()
+print('✅ Old click logs cleaned up')
+" 2>&1
+
diff --git a/scripts/start-api.sh b/scripts/start-api.sh
new file mode 100755
index 0000000..f322b0d
--- /dev/null
+++ b/scripts/start-api.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+# Wrapper script to start the API with port cleanup
+
+# Kill any processes using port 8000 (except our own if we're restarting)
+PORT=8000
+lsof -ti :${PORT} | xargs -r kill -9 2>/dev/null || true
+
+# Wait a moment for port to be released
+sleep 2
+
+# Start uvicorn
+exec /opt/punimtag/venv/bin/uvicorn backend.app:app --host 0.0.0.0 --port 8000
+