Compare commits

...

181 Commits

Author SHA1 Message Date
713584dc04 docs: Update project documentation for web-based architecture and development environment
This commit enhances the project documentation by updating the `.cursorrules` file to reflect the transition to a modern web-based photo management application. It includes detailed sections on the development environment, specifying the PostgreSQL server and development server configurations. Additionally, the README.md is updated to include development database information and environment setup instructions, ensuring clarity for new developers and contributors. These changes improve the overall documentation and support for the project's new architecture.
2026-01-06 13:13:00 -05:00
845b3f3b87 docs: Update architecture documentation and add auto-match load analysis
This commit updates the `ARCHITECTURE.md` file to reflect the transition to a web-based application, including new features and system overview. Additionally, it introduces `AUTOMATCH_LOAD_ANALYSIS.md`, detailing performance issues with the Auto-Match page and recommendations for optimizations. A new document, `CONFIDENCE_CALIBRATION_SUMMARY.md`, is also added to explain the implementation of a confidence calibration system for face recognition, ensuring more accurate match probabilities. These updates enhance the project's documentation and provide insights for future improvements.
2026-01-06 13:11:30 -05:00
3ec0da1573 chore: Remove archived GUI files and demo resources
This commit deletes obsolete GUI files from the archive, including various panel implementations and the main dashboard GUI, as the project has migrated to a web-based interface. Additionally, demo photos and related instructions have been removed to streamline the repository and eliminate outdated resources. This cleanup enhances project maintainability and clarity.
2026-01-06 12:45:23 -05:00
906e2cbe19 feat: Enhance installation script and documentation for Python tkinter support
This commit updates the `install.sh` script to include the installation of Python tkinter, which is required for the native folder picker functionality. Additionally, the README.md is modified to reflect this new requirement, providing installation instructions for various operating systems. The documentation is further enhanced with troubleshooting tips for users encountering issues with the folder picker, ensuring a smoother setup experience.
2026-01-06 12:29:40 -05:00
1f3f35d535 docs: Update README.md for PostgreSQL requirement and remove SQLite references
This commit updates the README.md to reflect the requirement of PostgreSQL for both development and production environments. It clarifies the database setup instructions, removes references to SQLite, and ensures consistency in the documentation regarding database configurations. Additionally, it enhances the clarity of environment variable settings and database schema compatibility between the web and desktop versions.
2026-01-06 11:56:08 -05:00
b104dcba71 feat: Add comprehensive database architecture review document
This commit introduces a new document, `DATABASE_ARCHITECTURE_REVIEW.md`, providing a detailed overview of the main and auth database architectures, configurations, and production deployment options. It includes sections on database schemas, connection management, and deployment strategies, enhancing documentation for better understanding and future reference.
2026-01-05 15:22:09 -05:00
fe01ff51b8 feat: Add PrismaCompatibleDate type and enhance date validation in photo extraction
This commit introduces a new `PrismaCompatibleDate` type to ensure compatibility with Prisma's SQLite driver by storing dates in a DateTime format. Additionally, the `extract_exif_date`, `extract_video_date`, and `extract_photo_date` functions are updated to include validation checks that reject future dates and dates prior to 1900, enhancing data integrity during photo and video metadata extraction.
2026-01-05 15:05:03 -05:00
c69604573d feat: Improve face identification process with validation and error handling
This commit enhances the face identification process by adding validation checks for person ID and names, ensuring that users provide necessary information before proceeding. It also introduces detailed logging for better debugging and user feedback during the identification process. Additionally, error handling is improved to provide user-friendly messages in case of failures, enhancing the overall user experience.
2026-01-05 13:37:45 -05:00
0b95cd2492 feat: Add job cancellation support and update job status handling
This commit introduces a new `CANCELLED` status to the job management system, allowing users to cancel ongoing jobs. The frontend is updated to handle job cancellation requests, providing user feedback during the cancellation process. Additionally, the backend is enhanced to manage job statuses more effectively, ensuring that jobs can be marked as cancelled and that appropriate messages are displayed to users. This improvement enhances the overall user experience by providing better control over job processing.
2026-01-05 13:09:32 -05:00
03d3a28b21 feat: Add date validation for photo import process
This commit introduces a new function, `validate_date_taken`, to ensure that the date taken for photos is valid before being saved. The function checks for valid date objects, prevents future dates, and filters out dates that are too old. The photo import process is updated to utilize this validation, enhancing data integrity and preventing corrupted date data from being stored. Additionally, the `date_added` field is explicitly set to the current UTC time to ensure validity.
2026-01-02 14:46:57 -05:00
e624d203d5 feat: Add DeepFace model weights download functionality to installation script
This commit introduces a new function in the `install.sh` script to download DeepFace model weights, enhancing the setup process for users. The function checks for the presence of DeepFace and attempts to download the ArcFace model weights, providing fallback options and user-friendly messages for manual download if automatic attempts fail. This improvement streamlines the initial configuration for facial recognition capabilities in the application.
2026-01-02 14:16:08 -05:00
32be5c7f23 feat: Enhance auth database setup and environment variable loading
This commit improves the setup of the authentication database by adding a new function to create necessary tables for both frontends. It also ensures that environment variables are loaded from a `.env` file before any database operations, enhancing configuration management. Additionally, minor updates are made to related scripts for better clarity and functionality.
2026-01-02 13:28:07 -05:00
68d280e8f5 feat: Add new analysis documents and update installation scripts for backend integration
This commit introduces several new analysis documents, including Auto-Match Load Performance Analysis, Folder Picker Analysis, Monorepo Migration Summary, and various performance analysis documents. Additionally, the installation scripts are updated to reflect changes in backend service paths, ensuring proper integration with the new backend structure. These enhancements provide better documentation and streamline the setup process for users.
2025-12-30 15:04:32 -05:00
12c62f1deb feat: Add comprehensive installation script for PunimTag Web
This commit introduces an `install.sh` script that automates the installation of system dependencies, Python packages, and frontend dependencies for the PunimTag Web application. The script checks for required software versions, installs PostgreSQL and Redis on Ubuntu/Debian systems, sets up databases, creates a Python virtual environment, and installs necessary dependencies. Additionally, the README.md is updated to include installation instructions and prerequisites, highlighting the new automated installation option. This enhancement simplifies the setup process for users.
2025-12-12 13:49:51 -05:00
a8fd0568c9 feat: Enhance Search component with date taken filters and tag management
This commit adds new date taken filters to the Search component, allowing users to specify a date range for photos based on when they were taken. Additionally, the tagging functionality is improved with options to filter by tags during searches, including a match mode for tag selection. The UI is updated to accommodate these features, enhancing user experience and search capabilities. Documentation has been updated to reflect these changes.
2025-12-11 13:39:36 -05:00
bca01a5ac3 feat: Enhance getSimilar API and UI with excluded faces functionality
This commit updates the `getSimilar` API to include an optional parameter for excluding faces in the results. The Identify component is modified to utilize this new parameter, allowing users to filter out unwanted faces during identification. Additionally, the Help documentation is updated to reflect changes in the identification process, including new filtering options and user instructions for managing excluded faces. Overall, these enhancements improve the user experience and provide more control over face identification.
2025-12-11 13:16:58 -05:00
10f777f3cc feat: Add pagination and setup area toggle in Identify component for improved navigation
This commit introduces pagination controls in the Identify component, allowing users to navigate through faces more efficiently with "Previous" and "Next" buttons. Additionally, a setup area toggle is added to collapse or expand the filters section, enhancing the user interface. The state management for the current page is updated to persist across sessions, improving overall user experience. Documentation has been updated to reflect these changes.
2025-12-11 11:42:23 -05:00
a9b4510d08 feat: Enhance ManageUsers and Modify components with password visibility toggle and session state management
This commit introduces a password visibility toggle in the ManageUsers component, allowing users to show or hide their password input. Additionally, the Modify component is updated to manage session state more effectively, persisting user selections and filters across page reloads. The implementation includes restoring state from sessionStorage and saving state on unmount, improving user experience. Documentation has been updated to reflect these changes.
2025-12-09 14:35:30 -05:00
6e196ff859 feat: Enhance UI with emoji page titles and improve tagging functionality
This commit updates the Layout component to include emojis in page titles for better visual cues. The Login component removes default credential placeholders for improved security. Additionally, the Search component is enhanced to allow immediate tagging of selected photos with existing tags, and it supports adding multiple tags at once, improving user experience. Documentation has been updated to reflect these changes.
2025-12-09 13:01:35 -05:00
0a109b198a feat: Add password field to AuthUser schema and update user management logic
This commit introduces a new optional `password` field to the `AuthUserUpdateRequest` schema, allowing users to update their passwords. The ManageUsers component is updated to handle password input, including validation for minimum length and an option to keep the current password. Additionally, the backend logic is modified to hash and store the new password when provided. Documentation has been updated to reflect these changes.
2025-12-05 15:08:26 -05:00
8f31e1942f feat: Update UI components with logo integration and styling enhancements
This commit enhances the Layout, Dashboard, Login, and PendingPhotos components by integrating a logo with fallback support and updating various UI styles for improved aesthetics. The Dashboard section's background gradient is replaced with a linear gradient, and text colors are adjusted for better visibility. Additionally, the PendingPhotos component's confirmation messages are clarified, and the cleanup functionality is refined to specify which records are affected. Documentation has been updated to reflect these changes.
2025-12-05 15:00:24 -05:00
e9e8fbf3f5 feat: Add is_active and role fields to AuthUser schema and update user management logic
This commit introduces new fields `is_active` and `role` to the `AuthUserResponse` and `AuthUserUpdateRequest` schemas, enhancing user management capabilities. The `deleteUser` and `updateUser` functions are updated to handle user deactivation instead of deletion when linked data exists. Additionally, the ManageUsers component is enhanced with filtering options for active status and roles, improving user experience. Documentation has been updated to reflect these changes.
2025-12-05 14:20:45 -05:00
0e65eac206 feat: Add pagination controls to Search component for improved navigation
This commit introduces pagination functionality in the Search component, allowing users to navigate through search results more efficiently. The UI now includes "Previous" and "Next" buttons, enhancing the overall user experience. Documentation has been updated to reflect these changes.
2025-12-05 12:48:39 -05:00
30f8a36e57 feat: Optimize photo retrieval with tags and face counts using efficient queries
This commit enhances the `get_photos_with_tags` function by optimizing database queries through the use of JOINs and aggregations. The new implementation reduces the number of queries from 4N+1 to just 3, improving performance. Additionally, the function now returns a comprehensive list of photos with associated tags and identified individuals, enhancing the overall data retrieval process. Documentation has been updated to reflect these changes.
2025-12-05 12:46:35 -05:00
7973dfadd2 feat: Enhance Search component with session state management and UI improvements
This commit introduces session storage functionality in the Search component, allowing users to persist their search state across page reloads. The UI has been updated for better clarity, including improved labels and placeholders for input fields. Additionally, the search options have been reorganized for a more intuitive user experience. Documentation has been updated to reflect these changes.
2025-12-05 12:38:34 -05:00
d2852fbf1e feat: Implement excluded and identified filters in FacesMaintenance component
This commit adds functionality to filter faces based on their excluded and identified statuses in the FacesMaintenance component. New state variables and API parameters are introduced to manage these filters, enhancing the user experience. The UI is updated with dropdowns for selecting filter options, and the backend is modified to support these filters in the face listing API. Documentation has been updated to reflect these changes.
2025-12-05 11:57:02 -05:00
47505249ce feat: Add excluded face management and filtering capabilities in Identify component
This commit introduces functionality to manage excluded faces within the Identify component. A new state variable is added to toggle the inclusion of excluded faces in the displayed results. The API is updated to support setting and retrieving the excluded status of faces, including a new endpoint for toggling the excluded state. The UI is enhanced with a checkbox for users to include or exclude blocked faces from identification, improving user experience. Additionally, the database schema is updated to include an 'excluded' column in the faces table, ensuring proper data handling. Documentation has been updated to reflect these changes.
2025-12-04 16:18:32 -05:00
2f2e44c933 feat: Add sorting and filtering capabilities to Tags component with people names integration
This commit enhances the Tags component by introducing sorting functionality for various columns, including ID, filename, media type, and more. A filter option is added to display only photos with unidentified faces. Additionally, the API and data models are updated to include a new field for people names, allowing users to see identified individuals in the photo. The UI is improved with dropdowns for sorting and checkboxes for filtering, enhancing user experience. Documentation has been updated to reflect these changes.
2025-12-04 15:44:48 -05:00
a41e30b101 feat: Enhance Search component with person autocomplete and improved search functionality
This commit adds a person autocomplete feature to the Search component, allowing users to select individuals from a dropdown or type names manually. The search functionality is enhanced to combine selected people names with free text input, improving the accuracy of search results. Additionally, the layout is updated to include a collapsible configuration area for better organization of search options. Documentation has been updated to reflect these changes.
2025-12-04 14:26:32 -05:00
6cc359f25a feat: Add video player modal and enhance search filters in Modify and Search components
This commit introduces a video player modal in the Modify component, allowing users to play selected videos directly within the application. Additionally, the Search component has been updated to include a collapsible filters section for media type selection, improving user experience when searching for images or videos. The layout adjustments ensure better responsiveness and usability across various screen sizes. Documentation has been updated to reflect these changes.
2025-12-04 13:13:55 -05:00
e48b614b23 feat: Update Layout and AutoMatch components with enhanced functionality and UI improvements
This commit modifies the Layout component to change the page title from 'Dashboard' to 'Home Page' for better clarity. In the AutoMatch component, new state variables and effects are added to manage a dropdown for selecting people, improving user interaction. The search functionality is enhanced to filter people based on the search query, and the save button now reflects the action of saving matches instead of changes. Additionally, the Scan component's input field is adjusted for better responsiveness, and the Search component's dropdowns are resized for improved usability. Documentation has been updated to reflect these changes.
2025-12-04 12:29:38 -05:00
84c4f7ca73 feat: Enhance Help page with new user management and photo review features
This commit updates the Help page to include new sections for managing user accounts and reviewing user-reported photos. It introduces detailed guidance on handling user uploads, tag suggestions, and user roles within the application. The layout has been improved for better organization, and additional tips have been added to assist users in navigating the new features. Documentation has been updated to reflect these changes.
2025-12-03 16:12:34 -05:00
0c212348f6 feat: Add photo_media_type field to API responses and enhance media handling in frontend
This commit introduces a new `photo_media_type` field in the `PendingLinkageResponse` and `ReportedPhotoResponse` interfaces, allowing differentiation between image and video files. The frontend has been updated to handle video links appropriately, including opening video files directly and displaying video thumbnails. Additionally, the search functionality has been enhanced to exclude videos when searching for "Photos without faces." Documentation has been updated to reflect these changes.
2025-12-02 16:19:02 -05:00
e0e5aae2ff feat: Add video count to person API and frontend for enhanced media management
This commit introduces a new `video_count` field in the `PersonWithFaces` interface and updates the API to return video counts alongside face counts. The frontend has been modified to display video counts in the people list and includes functionality for selecting and unmatching videos. Additionally, the layout has been enhanced to support resizing of the people panel, improving user experience when managing faces and videos. Documentation has been updated to reflect these changes.
2025-12-02 16:03:15 -05:00
9d40f9772e feat: Add video management features and API endpoints for person identification
This commit introduces several enhancements related to video management, including the addition of a new API for handling video-person identifications. The frontend has been updated to support video listing, person identification in videos, and the ability to remove person identifications. A new database table, photo_person_linkage, has been created to manage the relationships between videos and identified persons. Additionally, video thumbnail generation has been implemented, improving the user experience when interacting with video content. Documentation has been updated to reflect these changes.
2025-12-02 15:14:18 -05:00
c6055737fb feat: Enhance Layout and Search components with dynamic page titles and media type filtering
This commit introduces a new function in the Layout component to dynamically set page titles based on the current route, improving user navigation. Additionally, the Search component has been updated to include a media type filter, allowing users to filter results by images or videos. The UI has been enhanced with collapsible filters for better organization. Documentation has been updated to reflect these changes.
2025-12-02 13:30:51 -05:00
9c6a2ff05e feat: Add media_type column to photos table and enhance video handling
This commit introduces a new column, media_type, to the photos table to differentiate between image and video files. The ensure_photo_media_type_column function has been added to manage the database schema changes. Additionally, the photo and video processing logic has been updated to skip videos during face detection and to extract metadata from videos, including the date taken. The find_photos_in_folder function now supports both image and video formats, improving the overall media management capabilities. Documentation has been updated to reflect these changes.
2025-12-01 12:21:24 -05:00
a888968a97 feat: Implement identification statistics modal and sorting for user-tagged photos
This commit introduces a new modal for displaying identification statistics, allowing admins to filter reports by date range. The Identify component has been updated to include state management for the modal and loading logic for the statistics data. Additionally, sorting functionality has been added to the User Tagged Photos page, enabling users to sort by various fields such as photo, tag, and submitted date. The UI has been enhanced with buttons for selecting all pending decisions, improving the user experience. Documentation has been updated to reflect these changes.
2025-12-01 10:30:10 -05:00
d5d6dc82b1 feat: Add pending linkages management API and user interface for tag approvals
This commit introduces a new API for managing pending tag linkages, allowing admins to review and approve or deny user-suggested tags. The frontend has been updated with a new User Tagged Photos page for displaying pending linkages, including options for filtering and submitting decisions. Additionally, the Layout component has been modified to include navigation to the new page. Documentation has been updated to reflect these changes.
2025-11-27 14:40:43 -05:00
999e79f859 refactor: Improve person creation and identification logic with optional fields handling
This commit refactors the person creation and identification logic to handle optional fields more effectively. The `date_of_birth` field in the `PersonCreateRequest` schema is now optional, and the frontend has been updated to trim whitespace from name fields before submission. Additionally, the identification logic has been enhanced to ensure that only non-empty names are considered valid. Documentation has been updated to reflect these changes.
2025-11-27 13:02:02 -05:00
709be7555a refactor: Simplify footer navigation and conditionally display developer mode information
This commit refactors the Layout component to simplify the footer navigation by removing the Settings item. Additionally, it updates the AutoMatch and Identify components to conditionally display user information and face location details based on the developer mode status, enhancing the clarity of the UI. Documentation has been updated to reflect these changes.
2025-11-26 15:14:22 -05:00
7c35e4d8ec chore: Update .gitignore and add role permissions management API
This commit updates the .gitignore file to include Node.js related directories and files. Additionally, it introduces a new API for managing role-to-feature permissions, allowing for better control over user access levels. The API includes endpoints for listing and updating role permissions, ensuring that the permissions matrix is initialized and maintained. Documentation has been updated to reflect these changes.
2025-11-26 15:00:28 -05:00
eed3b36dad not needed to be tracked 2025-11-26 14:59:58 -05:00
638ed18033 feat: Update Layout and ApproveIdentified components with improved navigation and labeling
This commit enhances the Layout component by introducing a state for managing the visibility of maintenance navigation items and refactoring the navigation rendering logic for better clarity. The primary and maintenance navigation items have been separated for improved organization, and labels for navigation items have been updated for better user understanding. Additionally, the ApproveIdentified component has been updated to change the button label from "Report" to "Statistics," providing clearer context for the action. Documentation has been updated to reflect these changes.
2025-11-25 13:35:27 -05:00
a0cc3a985a feat: Implement bulk delete functionality for photos in API and frontend
This commit introduces a new feature for bulk deleting photos, allowing admins to permanently remove multiple photos at once. The backend has been updated with a new API endpoint for handling bulk delete requests, including response handling for missing photo IDs. The frontend has been enhanced with a confirmation dialog and a button to trigger the bulk delete action, improving the user experience. Documentation has been updated to reflect these changes.
2025-11-25 13:21:16 -05:00
f9e8c476bc feat: Enhance reported photos management with cleanup functionality and report comment
This commit introduces a new cleanup feature for reported photos, allowing admins to delete records based on their review status. The API has been updated with a new endpoint for cleanup operations, and the frontend now includes a button to trigger this action. Additionally, a report comment field has been added to the reported photo response model, improving the detail available for each reported photo. The user interface has been updated to display report comments and provide a confirmation dialog for the cleanup action. Documentation has been updated to reflect these changes.
2025-11-25 13:08:40 -05:00
51eaf6a52b feat: Add Manage Photos page and inactivity timeout hook
This commit introduces a new Manage Photos page in the frontend, allowing users to manage their photos effectively. The Layout component has been updated to include navigation to the new page. Additionally, a custom hook for handling user inactivity timeouts has been implemented, enhancing security by logging users out after a specified period of inactivity. The user management functionality has also been improved with new sorting options and validation for frontend permissions. Documentation has been updated to reflect these changes.
2025-11-25 11:59:29 -05:00
a036169b0f feat: Add identification report and clear denied records functionality
This commit introduces new API endpoints for generating identification reports and clearing denied records from the database. The frontend has been updated to include a report button that fetches user identification statistics, allowing admins to view how many faces each user identified over a specified date range. Additionally, a clear denied records button has been added to permanently remove all denied identifications. The necessary data models and response structures have been implemented to support these features. Documentation has been updated to reflect these changes.
2025-11-24 14:58:11 -05:00
dbffaef298 feat: Add frontend permission option for user creation and enhance validation error handling
This commit introduces a new `give_frontend_permission` field in the user creation request, allowing admins to create users with frontend access. The frontend has been updated to include validation for required fields and improved error messaging for Pydantic validation errors. Additionally, the backend has been modified to handle the creation of users in the auth database if frontend permission is granted. Documentation has been updated to reflect these changes.
2025-11-24 14:21:56 -05:00
100d39c556 feat: Add cleanup functionality for pending photos and database management
This commit introduces new API endpoints for cleaning up files and records related to pending photos. The frontend has been updated to include buttons for admins to trigger cleanup operations, allowing for the deletion of files from shared space and records from the pending_photos table. Additionally, the README has been updated with instructions for granting DELETE permissions on auth database tables, and a script has been added to automate this process. Documentation has been updated to reflect these changes.
2025-11-24 13:57:27 -05:00
661e812193 feat: Enhance API startup script and add file hash management for photos
This commit improves the `run_api_with_worker.sh` script by ensuring the virtual environment is created if it doesn't exist and dependencies are installed. It also adds a check to ensure the database schema is up to date. Additionally, new functionality has been introduced to calculate and store file hashes for uploaded photos, preventing duplicates. The database schema has been updated to include a `file_hash` column in the `photos` table, along with an index for efficient querying. The frontend has been updated to handle warnings for duplicate photos during the review process. Documentation has been updated to reflect these changes.
2025-11-24 13:16:41 -05:00
93cb4eda5b feat: Implement auth user management API and UI for admin users
This commit introduces a new Auth User management feature, allowing admins to create, update, delete, and list users in the auth database. A dedicated API has been implemented with endpoints for managing auth users, including validation for unique email addresses. The frontend has been updated to include a Manage Users page with tabs for backend and frontend users, enhancing the user experience. Additionally, modals for creating and editing auth users have been added, along with appropriate error handling and loading states. Documentation has been updated to reflect these changes.
2025-11-21 13:33:13 -05:00
e6c66e564e feat: Add Pending Photos management with API integration and UI updates
This commit introduces a new Pending Photos feature, allowing admins to manage user-uploaded photos awaiting review. A dedicated PendingPhotos page has been created in the frontend, which fetches and displays pending photos with options to approve or reject them. The backend has been updated with new API endpoints for listing and reviewing pending photos, ensuring seamless integration with the frontend. The Layout component has been modified to include navigation to the new Pending Photos page, enhancing the overall user experience. Documentation has been updated to reflect these changes.
2025-11-21 11:00:38 -05:00
2c3b2d7a08 feat: Enhance reported photos handling with detailed confirmation dialogs
This commit improves the user experience in the ReportedPhotos component by adding specific confirmation dialogs for photo removal decisions. It ensures users are aware of the consequences of their actions, particularly when permanently deleting photos. Additionally, the backend has been updated to handle deletion of tag linkages associated with photos, ensuring data integrity. Documentation has been updated to reflect these changes.
2025-11-20 14:06:07 -05:00
87146b1356 feat: Add user management features with password change and reported photos handling
This commit introduces several user management functionalities, including the ability to create, update, and delete users through a new API. The frontend has been updated to include a Manage Users page, allowing admins to manage user accounts effectively. Additionally, a password change feature has been implemented, requiring users to change their passwords upon first login. The reported photos functionality has been added, enabling admins to review and manage reported content. Documentation has been updated to reflect these changes.
2025-11-20 13:18:58 -05:00
926e738a13 feat: Implement approve/deny functionality for pending identifications
This commit adds the ability to approve or deny pending identifications through a new API endpoint and updates the frontend to support this feature. The `PendingIdentification` interface has been extended to include an optional `photo_id`, and new request/response models for approval decisions have been introduced. The ApproveIdentified page now allows users to submit their decisions, with UI updates for better user interaction. Documentation has been updated to reflect these changes.
2025-11-19 13:48:26 -05:00
1d8ca7e592 feat: Add Approve Identified page and API for pending identifications
This commit introduces a new Approve Identified page in the frontend, allowing users to view and manage pending identifications. The page fetches data from a new API endpoint that lists pending identifications from the auth database. Additionally, the necessary API routes and database session management for handling pending identifications have been implemented. The Layout component has been updated to include navigation to the new page, enhancing the user experience. Documentation has been updated to reflect these changes.
2025-11-18 15:14:16 -05:00
7f48d48b80 feat: Add comprehensive documentation for PunimTag Photo Viewer
This commit introduces several new documentation files for the PunimTag Photo Viewer project, including an Executive Summary, Quick Start Guide, Complete Plan, and Architecture Overview. These documents provide a high-level overview, setup instructions, detailed project plans, and architectural diagrams to assist developers and decision-makers. The README has also been updated to include links to these new resources, ensuring easy navigation and access to essential information for users and contributors.
2025-11-14 13:33:55 -05:00
8caa9e192b feat: Add PostgreSQL support and configuration setup for PunimTag
This commit introduces PostgreSQL as the default database for the PunimTag application, along with a new `.env.example` file for configuration. A setup script for PostgreSQL has been added to automate the installation and database creation process. The README has been updated to reflect these changes, including instructions for setting up PostgreSQL and using the `.env` file for configuration. Additionally, the database session management has been enhanced to support PostgreSQL connection pooling. Documentation has been updated accordingly.
2025-11-14 12:44:12 -05:00
c661aeeda6 feat: Implement auto-match people and person matches API with frontend integration
This commit introduces new API endpoints for retrieving a list of people for auto-matching and fetching matches for specific individuals. The frontend has been updated to utilize these endpoints, allowing for lazy loading of matches and improved state management. The AutoMatch component now supports caching of matches and session storage for user settings, enhancing performance and user experience. Documentation has been updated to reflect these changes.
2025-11-13 15:19:16 -05:00
4f0b72ee5f refactor: Simplify tag management by removing linkage type from API and UI
This commit refactors the tag management system by removing the linkage type parameter from various components, including the API and frontend. The changes streamline the process of adding and removing tags, allowing for both single and bulk tags to be handled uniformly. The UI has been updated to reflect these changes, enhancing user experience and simplifying the codebase. Documentation has been updated accordingly.
2025-11-13 14:10:55 -05:00
5ca130f8bd feat: Implement favorites functionality for photos with API and UI updates
This commit introduces a favorites feature for photos, allowing users to mark and manage their favorite images. The API has been updated with new endpoints for toggling favorite status, checking if a photo is a favorite, and bulk adding/removing favorites. The frontend has been enhanced to include favorite management in the PhotoViewer and Search components, with UI elements for adding/removing favorites and displaying favorite status. Documentation has been updated to reflect these changes.
2025-11-13 12:58:17 -05:00
cd72913cd5 feat: Add zoom and slideshow functionality to PhotoViewer component
This commit enhances the PhotoViewer component by introducing zoom and pan capabilities, allowing users to adjust the view of photos interactively. Additionally, a slideshow feature has been implemented, enabling automatic photo transitions with adjustable intervals. The user interface has been updated to include controls for zooming and starting/stopping the slideshow, improving the overall photo browsing experience. Documentation has been updated to reflect these changes.
2025-11-13 12:07:30 -05:00
72d18ead8c feat: Implement PhotoViewer component for enhanced photo browsing experience
This commit introduces a new PhotoViewer component that allows users to view photos in a full-screen mode with navigation controls. The component supports preloading adjacent images for smoother transitions and includes keyboard navigation for improved accessibility. Additionally, the Search page has been updated to integrate the PhotoViewer, enabling users to load and display all photos seamlessly. Documentation has been updated to reflect these changes.
2025-11-13 11:54:37 -05:00
cfb94900ef feat: Enhance photo identification and tagging features with new filters and counts
This commit introduces several enhancements to the photo identification and tagging functionalities. The Identify component now supports filtering by photo IDs, allowing users to view faces from specific photos. Additionally, the Tags component has been updated to include an unidentified face count for each photo, improving user awareness of untagged faces. The API has been modified to accommodate these new parameters, ensuring seamless integration with the frontend. Documentation has been updated to reflect these changes.
2025-11-12 14:17:16 -05:00
89a63cbf57 feat: Add Help page and enhance user navigation in PunimTag application
This commit introduces a new Help page to the PunimTag application, providing users with detailed guidance on various features and workflows. The navigation has been updated to include the Help page, improving accessibility to support resources. Additionally, the user guide has been refined to remove outdated workflow examples, ensuring clarity and relevance. The Dashboard page has also been streamlined for a cleaner interface. Documentation has been updated to reflect these changes.
2025-11-12 12:13:19 -05:00
842f588f19 docs: Add comprehensive user guide for PunimTag web application
This commit introduces a new user guide in the documentation, providing detailed instructions on using the PunimTag web application. The guide includes sections on getting started, navigation overview, page-by-page usage, workflow examples, and tips for best practices. This addition aims to enhance user experience by offering clear guidance on application features and functionalities.
2025-11-11 15:31:36 -05:00
049f9de4f8 readme 2025-11-11 14:45:03 -05:00
52344febad feat: Enhance tag management in TagSelectedPhotosDialog with improved logic for removable tags
This commit updates the TagSelectedPhotosDialog to allow both single and bulk tags to be managed more effectively. The logic for determining removable tags has been refined, ensuring that users can see which tags are common across selected photos and whether they can be removed. Additionally, the photo date extraction method in PhotoManager has been improved to include a fallback to file modification time, enhancing reliability. The photo service now also utilizes this updated method for date extraction, ensuring consistency across the application. Documentation has been updated to reflect these changes.
2025-11-11 14:35:50 -05:00
f7accb925d feat: Add Faces Maintenance page and API for managing face items
This commit introduces a new Faces Maintenance page in the frontend, allowing users to view, sort, and delete face items based on quality and person information. The API has been updated to include endpoints for retrieving and deleting faces, enhancing the management capabilities of the application. Additionally, new data models and schemas for maintenance face items have been added to support these features. Documentation has been updated to reflect these changes.
2025-11-11 14:09:47 -05:00
20f1a4207f feat: Add processed and unprocessed photo search options and sessionStorage management
This commit introduces new search options for processed and unprocessed photos in the Search component, enhancing the photo management capabilities. The Identify component has been updated to clear sessionStorage settings on logout and authentication failure, improving user experience by ensuring a clean state. Additionally, the API has been modified to support these new search parameters, ensuring seamless integration with the frontend. Documentation has been updated to reflect these changes.
2025-11-11 13:45:43 -05:00
85dd6a68b3 feat: Add date processed filter to Identify component and API
This commit introduces a new date processed filter in the Identify component, allowing users to filter faces based on the date they were processed. The API has been updated to support this new parameter, ensuring seamless integration with the frontend. Additionally, the date filters for date taken have been renamed for clarity. Documentation has been updated to reflect these changes.
2025-11-11 13:05:19 -05:00
17aeb5b823 feat: Enhance AutoMatch and Identify components with quality criteria for face matching
This commit updates the AutoMatch component to include a new criterion for auto-matching faces based on picture quality, requiring a minimum quality score of 50%. The Identify component has been modified to persist user settings in localStorage, improving user experience by retaining preferences across sessions. Additionally, the Modify component introduces functionality for selecting and unmatching faces in bulk, enhancing the management of face items. Documentation has been updated to reflect these changes.
2025-11-11 12:48:26 -05:00
20a8e4df5d feat: Add tag filtering and developer mode options in Identify and AutoMatch components
This commit introduces new features for tag filtering in the Identify and AutoMatch components. Users can now filter unidentified faces by tags, with options to match all or any specified tags. The UI has been updated to include tag selection and management, enhancing user experience. Additionally, developer mode options have been added to display tolerance and auto-accept threshold settings conditionally. The API has been updated to support these new parameters, ensuring seamless integration. Documentation has been updated to reflect these changes.
2025-11-11 12:17:23 -05:00
21c138a339 feat: Improve UI components for batch processing and tag management
This commit enhances the user interface of the Process, Scan, Search, and Tags components. The input fields for batch size and button styles have been adjusted for better usability and consistency. The Search component now includes improved logic for handling removable tags, ensuring only single tags can be deleted. Additionally, a new dialog for tagging selected photos has been introduced, allowing users to manage tags more effectively. Documentation has been updated to reflect these changes.
2025-11-10 15:05:00 -05:00
8d668a9658 feat: Add browse folder API and enhance folder selection in Scan component
This commit introduces a new API endpoint for browsing folders, utilizing tkinter for a native folder picker dialog. The Scan component has been updated to integrate this functionality, allowing users to select folders more easily. If the native picker is unavailable, a browser-based fallback is implemented, ensuring a seamless user experience. Additionally, the input field for batch size has been modified to restrict input to numeric values only, improving data validation. Documentation has been updated to reflect these changes.
2025-11-10 14:12:51 -05:00
ac07932e14 chore: Remove Alembic migration files and configuration
This commit deletes the Alembic migration files and configuration, including the alembic.ini file, env.py, and various migration scripts. This cleanup is part of the transition to a new database management approach, ensuring that outdated migration artifacts do not interfere with future development. The requirements.txt file has also been updated to remove the Alembic dependency. No functional changes to the application are introduced in this commit.
2025-11-10 13:36:51 -05:00
ea3d06a3d5 feat: Refactor Layout and Search components for improved UI and tag management
This commit enhances the Layout component by fixing the sidebar position for better usability and adjusting the main content layout accordingly. The Search component has been updated to improve tag selection and management, including the addition of new state variables for handling selected tags and loading photo tags. A confirmation dialog for tag deletion has been introduced, along with improved error handling and user feedback. The overall user interface has been refined for a more cohesive experience. Documentation has been updated to reflect these changes.
2025-11-10 12:43:44 -05:00
8d11ac415e feat: Enhance Modify and Tags components with improved state management and confirmation dialogs
This commit refines the Modify and Tags components by implementing better state management for person selection and tag handling. In the Modify component, the logic for checking if a selected person still exists has been optimized to prevent unnecessary state updates. The Tags component has been updated to support immediate saving of tags and includes confirmation dialogs for tag removals, enhancing user experience. Additionally, error handling for tag creation and deletion has been improved, ensuring a more robust interaction with the API. Documentation has been updated to reflect these changes.
2025-11-10 11:41:45 -05:00
3e78e90061 feat: Enhance Identify component with loading progress indicators and date filter updates
This commit improves the Identify component by adding a loading progress bar to provide user feedback during face loading and similarity calculations. The date filters have been updated for consistency, simplifying the date selection process. Additionally, the API has been adjusted to support the new date parameters, ensuring a seamless user experience. The CSS has been modified to style the scrollbar for the similar faces container, enhancing the overall UI. Documentation has been updated to reflect these changes.
2025-11-07 15:31:09 -05:00
b1cb9decb5 feat: Add date filters for face identification and enhance API for improved querying
This commit introduces new date filters for the face identification process, allowing users to filter faces based on the date taken and date processed. The API has been updated to support these new parameters, ensuring backward compatibility with legacy date filters. Additionally, the Identify component has been modified to incorporate these new filters in the user interface, enhancing the overall functionality and user experience. Documentation has been updated to reflect these changes.
2025-11-07 14:45:51 -05:00
81b845c98f feat: Add batch similarity endpoint and update Identify component for improved face comparison
This commit introduces a new batch similarity API endpoint to efficiently calculate similarities between multiple faces in a single request. The frontend has been updated to utilize this endpoint, enhancing the Identify component by replacing individual similarity checks with a batch processing approach. Progress indicators have been added to provide user feedback during similarity calculations, improving the overall user experience. Additionally, new data models for batch similarity requests and responses have been defined, ensuring a structured and efficient data flow. Documentation has been updated to reflect these changes.
2025-11-07 12:56:23 -05:00
e4a5ff8a57 feat: Add landmarks column to faces and update face processing for pose detection
This commit introduces a new column for storing facial landmarks in the database schema, enhancing the face processing capabilities. The FaceProcessor class has been updated to extract and serialize landmarks during face detection, improving pose classification accuracy. Additionally, the Identify and AutoMatch components have been modified to support loading progress indicators and provide user feedback during face loading operations. Documentation has been updated to reflect these changes, ensuring a better user experience and improved functionality.
2025-11-07 11:58:57 -05:00
f4f6223cd0 feat: Introduce CLI and GUI components for PunimTag desktop version
This commit adds a new command-line interface (CLI) and graphical user interface (GUI) components for the PunimTag desktop application. The `photo_tagger.py` script serves as the CLI entry point, enabling users to scan, process, and manage photos with facial recognition capabilities. The GUI includes a unified dashboard with various panels for identifying, modifying, and managing tags for photos. Additionally, comprehensive documentation has been created to guide users through installation and usage, ensuring a smooth experience. This update enhances the overall functionality and accessibility of the PunimTag application.
2025-11-06 13:33:16 -05:00
e74ade9278 feat: Add pose mode analysis and face width detection for improved profile classification
This commit introduces a comprehensive analysis of pose modes and face width detection to enhance profile classification accuracy. New scripts have been added to analyze pose data in the database, check identified faces for pose information, and validate yaw angles. The PoseDetector class has been updated to calculate face width from landmarks, which serves as an additional indicator for profile detection. The frontend and API have been modified to include pose mode in responses, ensuring better integration with existing functionalities. Documentation has been updated to reflect these changes, improving user experience and accuracy in face processing.
2025-11-06 13:26:25 -05:00
a70637feff feat: Improve photo processing cancellation handling in face service
This commit enhances the photo processing workflow in the FaceService by ensuring that cancellation checks occur after completing the current photo's processing, including pose detection and database commits. It introduces robust error handling during progress updates, allowing for graceful cancellation and improved user feedback. Documentation has been updated to reflect these changes, enhancing the overall user experience and reliability of the photo processing feature.
2025-11-04 15:05:43 -05:00
e2cadf3232 feat: Implement auto-match automation plan with enhanced API and frontend support
This commit introduces a comprehensive auto-match automation plan that automates the face matching process in the application. Key features include the ability to automatically identify faces based on pose and similarity thresholds, with configurable options for auto-acceptance. The API has been updated to support new parameters for auto-acceptance and pose filtering, while the frontend has been enhanced to allow users to set an auto-accept threshold and view results. Documentation has been updated to reflect these changes, improving user experience and functionality.
2025-11-04 14:55:05 -05:00
0dcfe327cd feat: Auto-start auto-match on component mount and tolerance change
This commit enhances the AutoMatch component by implementing an auto-start feature for the auto-match process when the component mounts or when the tolerance value changes. Additionally, it improves the user interface by allowing users to click on face images to open the full photo in a new tab, enhancing the overall user experience. Documentation has been updated to reflect these changes.
2025-11-04 14:11:29 -05:00
0e69677d54 feat: Implement face pose detection using RetinaFace for enhanced face processing
This commit introduces a comprehensive face pose detection system utilizing the RetinaFace library to automatically classify face poses (yaw, pitch, roll) during image processing. The database schema has been updated to store pose information, including pose mode and angles. The face processing pipeline has been modified to integrate pose detection with graceful fallback mechanisms, ensuring compatibility with existing functionality. Additionally, new utility functions for pose detection have been added, along with unit tests to validate the implementation. Documentation has been updated to reflect these changes, enhancing the overall user experience and accuracy in face matching.
2025-11-04 13:58:08 -05:00
7945b084a4 feat: Enhance navigation and filtering in AutoMatch and Identify components
This commit introduces new navigation buttons in the AutoMatch component, allowing users to easily move between persons being matched. Additionally, the Identify component has been updated to include a collapsible filters section, improving the user interface for managing face identification. The unique faces filter is now enabled by default, enhancing the identification process. Documentation and tests have been updated to reflect these changes, ensuring a reliable user experience.
2025-11-04 13:30:40 -05:00
0a960a99ce feat: Enhance tag management with new API endpoints and frontend components
This commit introduces several new features for tag management, including the ability to retrieve tags for specific photos, update tag names, and delete tags through new API endpoints. The frontend has been updated to support these functionalities, allowing users to manage tags more effectively with a user-friendly interface. Additionally, new components for managing photo tags and bulk tagging have been added, improving overall usability. Documentation and tests have been updated to reflect these changes, ensuring reliability and user satisfaction.
2025-11-04 12:16:22 -05:00
91ee2ce8ab feat: Implement Modify Identified workflow for person management
This commit introduces the Modify Identified workflow, allowing users to edit person information, view associated faces, and unmatch faces from identified people. The API has been updated with new endpoints for unmatching faces and retrieving faces for specific persons. The frontend includes a new Modify page with a user-friendly interface for managing identified persons, including search and edit functionalities. Documentation and tests have been updated to reflect these changes, ensuring reliability and usability.
2025-11-04 12:00:39 -05:00
bb42478c8f feat: Add open folder functionality for photos in file manager
This commit introduces a new feature that allows users to open the folder containing a selected photo in their system's file manager. The API has been updated with a new endpoint to handle folder opening requests, supporting various operating systems. The frontend has been enhanced to include a button for this action, providing user feedback and error handling. Documentation and tests have been updated to reflect these changes, ensuring reliability and usability.
2025-11-03 14:50:10 -05:00
c0f9d19368 feat: Implement photo search functionality with filtering and tagging support
This commit introduces a comprehensive photo search feature in the application, allowing users to search photos by various criteria including name, date, and tags. The API has been updated to support these search parameters, returning results that match the specified filters. Additionally, new endpoints for managing tags have been added, enabling users to add and remove tags from multiple photos. The frontend has been enhanced with a user-friendly interface for search inputs and results display, improving overall usability. Documentation and tests have been updated to reflect these new features and ensure reliability.
2025-11-03 14:27:27 -05:00
59dc01118e feat: Enhance face identification with unique faces filter and improved API logging
This commit introduces a new feature in the Identify component that allows users to filter for unique faces only, hiding duplicates with ≥60% match confidence. The API has been updated to log calls to the get_similar_faces endpoint, including warnings for non-existent faces and information on the number of results returned. Additionally, the SimilarFaceItem schema has been updated to include the filename, improving data handling and user experience. Documentation and tests have been updated accordingly.
2025-11-03 14:00:25 -05:00
817e95337f feat: Update documentation and API for face identification and people management
This commit enhances the README with detailed instructions on the automatic database initialization and schema compatibility between the web and desktop versions. It also introduces new API endpoints for managing unidentified faces and people, including listing, creating, and identifying faces. The schemas for these operations have been updated to reflect the new data structures. Additionally, tests have been added to ensure the functionality of the new API features, improving overall coverage and reliability.
2025-11-03 12:49:48 -05:00
5174fe0d54 feat: Add database migration for processed column in photos and new utility scripts
This commit introduces a new Alembic migration to add a 'processed' column to the 'photos' table, enhancing the database schema to track photo processing status. Additionally, it includes new utility scripts for dropping and recreating all tables in the web database, as well as a script to display all tables and their structures. These changes improve database management and facilitate a fresh start for the web application, ensuring alignment with the updated schema.
2025-11-03 11:46:48 -05:00
dd92d1ec14 feat: Integrate DeepFace for face processing with configurable options
This commit introduces the DeepFace integration for face processing, allowing users to configure detector backends and models through the new Process tab in the GUI. Key features include batch processing, job cancellation support, and real-time progress tracking. The README has been updated to reflect these enhancements, including instructions for automatic model downloads and handling of processing-intensive tasks. Additionally, the API has been expanded to support job management for face processing tasks, ensuring a robust user experience.
2025-10-31 14:06:40 -04:00
2f039a1d48 docs: Update README and add run script for Redis and RQ worker integration
This commit enhances the README with detailed instructions for installing and starting Redis, including commands for various operating systems. It clarifies the automatic startup of the RQ worker with the FastAPI server and updates the project status for Phase 2 features. Additionally, a new script `run_api_with_worker.sh` is introduced to streamline the process of starting the FastAPI server alongside the RQ worker, ensuring a smoother setup for users. The worker now has a unique name to prevent conflicts during execution.
2025-10-31 13:01:58 -04:00
4c2148f7fc migration to web 2025-10-31 12:23:19 -04:00
94385e3dcc migration to web 2025-10-31 12:10:44 -04:00
d6b1e85998 feat: Implement empirical confidence calibration for face matching
This commit introduces a new confidence calibration system that converts DeepFace distance values into actual match probabilities, addressing previous misleading confidence percentages. Key changes include the addition of calibration methods in `FaceProcessor`, updates to the `IdentifyPanel` and `AutoMatchPanel` to utilize calibrated confidence, and new configuration settings in `config.py`. The README has been updated to document these enhancements, ensuring users see more realistic match probabilities throughout the application.
2025-10-27 13:31:19 -04:00
f44cb8b777 refactor: Update AutoMatchPanel to enable search controls dynamically
This commit modifies the `AutoMatchPanel` class to enable search entry and buttons when the auto-match process starts. The search controls are disabled when there is only one matched person, and the help label text is updated accordingly. Additionally, the search controls are disabled when clearing the search, improving user experience by providing clear feedback on search functionality status.
2025-10-21 13:21:54 -04:00
f8fefd2983 feat: Enhance Identify Panel with responsive face canvas sizing and dynamic updates
This commit introduces a new method `_calculate_face_canvas_size` in the `IdentifyPanel` class to calculate a responsive size for the face canvas based on the available window space. Additionally, the `_update_face_canvas_size` method has been implemented to dynamically adjust the canvas size during window resize events. The dashboard GUI has also been updated to improve layout consistency by fixing the width of the folder input text box. These changes enhance the user experience by ensuring that the face canvas adapts to different screen sizes and resolutions.
2025-10-17 14:43:34 -04:00
4816925a3d refactor: Update face processing methods to accept optional limits for photo processing
This commit modifies the `process_faces` method in both the `PhotoTagger` and `FaceProcessor` classes to accept an optional `limit` parameter. If `None`, all unprocessed photos will be processed, enhancing flexibility in face processing. Additionally, the `get_unprocessed_photos` method in `DatabaseManager` is updated to handle the optional limit, ensuring consistent behavior across the application. Docstrings have been updated to reflect these changes, improving code documentation and clarity.
2025-10-17 14:10:00 -04:00
5db41b63ef feat: Enhance face processing with EXIF orientation handling and database updates
This commit introduces a comprehensive EXIF orientation handling system to improve face processing accuracy. Key changes include the addition of an `exif_orientation` field in the database schema, updates to the `FaceProcessor` class for applying orientation corrections before face detection, and the implementation of a new `EXIFOrientationHandler` utility for managing image orientation. The README has been updated to document these enhancements, including recent fixes for face orientation issues and improved face extraction logic. Additionally, tests for EXIF orientation handling have been added to ensure functionality and reliability.
2025-10-17 13:50:47 -04:00
2828b9966b refactor: Update face location handling to DeepFace format across the codebase
This commit refactors the handling of face location data to exclusively use the DeepFace format ({x, y, w, h}) instead of the legacy tuple format (top, right, bottom, left). Key changes include updating method signatures, modifying internal logic for face quality score calculations, and ensuring compatibility in the GUI components. Additionally, configuration settings for face detection have been adjusted to allow for smaller face sizes and lower confidence thresholds, enhancing the system's ability to detect faces in various conditions. All relevant tests have been updated to reflect these changes, ensuring continued functionality and performance.
2025-10-17 12:55:11 -04:00
68673ccdbe feat: Implement face detection improvements and cleanup script
This commit introduces significant enhancements to the face detection system, addressing false positives by updating configuration settings and validation logic. Key changes include stricter confidence thresholds, increased minimum face size, and improved aspect ratio requirements. A new script for cleaning up existing false positives from the database has also been added, successfully removing 199 false positive faces. Documentation has been updated to reflect these changes and provide usage instructions for the cleanup process.
2025-10-16 15:56:17 -04:00
d398b139f5 chore: Update migration documentation and quickstart notes for DeepFace integration
This commit adds final notes to the migration documentation and quickstart files, confirming readiness for DeepFace implementation across all phases. The updates include completion confirmations in `DEEPFACE_MIGRATION_COMPLETE.md`, `PHASE1_COMPLETE.md`, `PHASE2_COMPLETE.md`, and `PHASE3_COMPLETE.md`, as well as quickstart notes in `.notes/phase1_quickstart.md` and `.notes/phase2_quickstart.md`. These changes ensure clarity on the project's progress and readiness for the next steps in the DeepFace integration.
2025-10-16 15:20:14 -04:00
ddb156520b feat: Implement quality filtering in Identify Panel for enhanced face identification
This commit adds a quality filtering feature to the Identify Panel, allowing users to filter faces based on a quality score (0-100%). The `_get_unidentified_faces()` method has been updated to accept a `min_quality_score` parameter, and the SQL query now includes a WHERE clause for quality filtering. All relevant call sites have been modified to utilize this new feature, improving the user experience during face identification. The unique checkbox default state has also been confirmed to be unchecked, ensuring consistency in the UI behavior.
2025-10-16 15:02:43 -04:00
986fc81005 feat: Enhance Identify Panel with quality filtering and navigation improvements
This commit introduces a quality filtering feature in the Identify Panel, allowing users to filter faces based on a quality score (0-100%). The panel now includes a slider for adjusting the quality threshold and displays the current quality percentage. Additionally, navigation functions have been updated to skip to the next or previous face that meets the quality criteria, improving the user experience during identification. The README has been updated to reflect these new features and enhancements.
2025-10-16 14:49:00 -04:00
b2847a066e docs: Add comprehensive documentation for Phase 6 testing and validation
This commit introduces several new documents summarizing the completion of Phase 6, which focused on testing and validation of the DeepFace integration. Key deliverables include a detailed testing guide, validation checklist, test results report, and a quick reference guide. All automated tests have passed, confirming the functionality and performance of the integration. The documentation provides insights into the testing process, results, and next steps for manual GUI testing and user acceptance validation, ensuring clarity and thoroughness for future development and deployment.
2025-10-16 13:30:40 -04:00
ef7a296a9b feat: Complete migration to DeepFace with full integration and testing
This commit finalizes the migration from face_recognition to DeepFace across all phases. It includes updates to the database schema, core processing, GUI integration, and comprehensive testing. All features are now powered by DeepFace technology, providing superior accuracy and enhanced metadata handling. The README and documentation have been updated to reflect these changes, ensuring clarity on the new capabilities and production readiness of the PunimTag system. All tests are passing, confirming the successful integration.
2025-10-16 13:17:41 -04:00
d300eb1122 chore: Add configuration and documentation files for project structure and guidelines
This commit introduces several new files to enhance project organization and developer onboarding. The `.cursorignore` and `.cursorrules` files provide guidelines for Cursor AI, while `CONTRIBUTING.md` outlines contribution procedures. Additionally, `IMPORT_FIX_SUMMARY.md`, `RESTRUCTURE_SUMMARY.md`, and `STATUS.md` summarize recent changes and project status. The `README.md` has been updated to reflect the new project focus and structure, ensuring clarity for contributors and users. These additions aim to improve maintainability and facilitate collaboration within the PunimTag project.
2025-10-15 14:43:18 -04:00
e49b567afa Remove deprecated files and refactor codebase for improved maintainability
This commit deletes the `photo_tagger_refactored.py`, `run.sh`, and test files (`test_basic.py`, `test_deepface_gui.py`, `test_face_recognition.py`) that are no longer in use. The removal of these files streamlines the project structure and eliminates legacy code, paving the way for future enhancements and a cleaner codebase. The README has been updated to reflect these changes, ensuring clarity on the current state of the project.
2025-10-15 12:44:02 -04:00
ac5507c560 Add cancel functionality to Dashboard GUI processing 2025-10-15 11:40:09 -04:00
507b09b764 Implement progress tracking and status updates in Dashboard GUI processing
This commit enhances the Dashboard GUI by adding a progress bar and status label to provide real-time feedback during photo processing. The processing button is now disabled during the operation, and the progress updates are simulated to reflect various stages of the process. Additionally, error handling has been improved to reset the progress state in case of failures, ensuring a smoother user experience. The README has been updated to include these new features, emphasizing the improved interactivity and user feedback during processing tasks.
2025-10-14 14:53:22 -04:00
3e88e2cd2c Enhance Dashboard GUI with smart navigation and unified exit behavior
This commit introduces a compact home icon for quick navigation to the welcome screen, improving user experience across all panels. Additionally, all exit buttons now navigate to the home screen instead of closing the application, ensuring a consistent exit behavior. The README has been updated to reflect these enhancements, emphasizing the improved navigation and user experience in the unified dashboard.
2025-10-10 14:47:38 -04:00
cbc29a9429 Add Tag Manager Panel to Dashboard GUI for comprehensive tag management functionality
This commit introduces the TagManagerPanel class into the Dashboard GUI, providing a fully integrated interface for managing photo tags. Users can now view, add, edit, and delete tags directly within the dashboard, enhancing the overall tagging experience. The panel includes features for bulk operations, responsive layout, and a detailed preview of tag management capabilities. The README has been updated to reflect these new functionalities, emphasizing the improved user experience in managing photo tags and their associations.
2025-10-10 14:02:26 -04:00
f40b3db868 Integrate Modify Panel into Dashboard GUI for enhanced face editing functionality
This commit introduces the ModifyPanel class into the Dashboard GUI, providing a fully integrated interface for editing identified faces. Users can now view and modify person details, unmatch faces, and perform bulk operations with visual confirmation. The panel includes a responsive layout, search functionality for filtering people by last name, and a calendar interface for date selection. The README has been updated to reflect the new capabilities of the Modify Panel, emphasizing its full functionality and improved user experience in managing photo identifications.
2025-10-10 13:37:42 -04:00
8ce538c508 Add Auto-Match Panel to Dashboard GUI for enhanced face matching functionality
This commit introduces the AutoMatchPanel class into the Dashboard GUI, providing a fully integrated interface for automatic face matching. The new panel allows users to start the auto-match process, configure tolerance settings, and visually confirm matches between identified and unidentified faces. It includes features for bulk selection of matches, smart navigation through matched individuals, and a search filter for large databases. The README has been updated to reflect the new functionality and improvements in the auto-match workflow, enhancing the overall user experience in managing photo identifications.
2025-10-10 12:40:24 -04:00
e5ec0e4aea Enhance Dashboard GUI with full screen and responsive design features
This commit updates the Dashboard GUI to support automatic full screen mode across platforms, ensuring optimal viewing experiences. It introduces a responsive layout that dynamically adjusts components during window resizing, improving usability. Additionally, typography has been enhanced with larger fonts for better readability. The README has been updated to reflect these new features, emphasizing the unified dashboard's capabilities and user experience improvements.
2025-10-10 11:11:58 -04:00
de23fccf6a Integrate Identify Panel into Dashboard GUI for enhanced face identification functionality
This commit introduces the IdentifyPanel class into the Dashboard GUI, allowing for a fully integrated face identification interface. The Dashboard now requires a database manager and face processor to create the Identify panel, which includes features for face browsing, identification, and management. Additionally, the DatabaseManager has been updated to support case-insensitive person additions, improving data consistency. The PhotoTagger class has also been modified to accommodate these changes, ensuring seamless interaction between components.
2025-10-09 15:36:44 -04:00
34c7998ce9 Revamp Dashboard GUI with unified interface and menu navigation
This commit transforms the Dashboard GUI into a unified interface designed for web migration, featuring a single window with a menu bar for easy access to all functionalities. Key enhancements include the addition of a content area for seamless panel switching, improved panel management, and real-time status updates. The README has also been updated to reflect these changes, providing a comprehensive overview of the new dashboard features and system requirements.
2025-10-09 14:19:05 -04:00
aa67f12a20 Enhance Search GUI with processed status and improved results display
This commit updates the SearchGUI class to include a new "Processed" column, displaying the processing status of photos. The results area now features a header with item count, and sorting functionality has been added for the processed status. Additionally, the treeview has been enhanced with a vertical scrollbar for better navigation. The logic for displaying and sorting results has been updated to accommodate these changes, improving the overall user experience in managing photo collections.
2025-10-09 14:00:30 -04:00
18e65e88fc Update README to enhance path handling and dashboard features
This commit improves the README documentation by detailing the new path handling capabilities, including support for both absolute and relative paths, and automatic path normalization. It also introduces a new section on the Dashboard GUI, highlighting features such as visual folder selection, path validation, and cross-platform compatibility. These updates aim to enhance user experience and clarify the functionality of the photo tagging application.
2025-10-09 12:45:18 -04:00
36aaadca1d Add folder browsing and path validation features to Dashboard GUI and photo management
This commit introduces a folder browsing button in the Dashboard GUI, allowing users to select a folder for photo scanning. It also implements path normalization and validation using new utility functions from the path_utils module, ensuring that folder paths are absolute and accessible before scanning. Additionally, the PhotoManager class has been updated to utilize these path utilities, enhancing the robustness of folder scanning operations. This improves user experience by preventing errors related to invalid paths and streamlining folder management across the application.
2025-10-09 12:43:28 -04:00
150ae5fd3f Enhance Search GUI with multiple search types and interactive features
This commit updates the README to detail new functionalities in the Search GUI, including the ability to search for photos by name, date, tags, and to identify photos without faces or tags. A collapsible filters area has been added, featuring a folder location filter, browse and clear buttons, and tooltips for user guidance. The results display now includes sortable columns and additional interactive features such as a tag help icon and calendar picker, improving the overall user experience and search capabilities.
2025-10-08 15:21:09 -04:00
29a02ceae3 Implement folder filter functionality in Search GUI
This commit enhances the SearchGUI class by introducing a folder filter feature, allowing users to filter search results based on a specified folder path. The GUI now includes an input field for folder location, along with 'Browse' and 'Clear' buttons for easier folder selection and management. Additionally, the search logic has been updated to apply the folder filter across various search types, improving the overall search experience and result accuracy. Tooltip functionality has also been added for better user guidance on available tags and filter options.
2025-10-08 15:20:39 -04:00
6fd5fe3e44 Implement date search functionality in Search GUI and SearchStats
This commit enhances the SearchGUI and SearchStats classes by introducing the ability to search for photos within a specified date range. The SearchGUI now includes input fields for users to enter 'From' and 'To' dates, along with calendar buttons for easier date selection. The SearchStats class has been updated to execute database queries that retrieve photos based on the provided date criteria, returning results that include the date taken. This addition improves the overall search capabilities and user experience in managing photo collections.
2025-10-08 14:47:58 -04:00
1c8856209a Enhance Search GUI and SearchStats with photos without tags feature
This commit introduces functionality to search for and display photos that lack associated tags in the SearchGUI and SearchStats classes. The SearchGUI has been updated to manage the visibility of relevant columns based on the selected search type, ensuring a seamless user experience. Additionally, the SearchStats class now retrieves photos without tags from the database, improving the overall search capabilities of the application.
2025-10-08 14:31:04 -04:00
8a9834b056 Implement photos without faces feature in Search GUI and SearchStats
This commit enhances the SearchGUI and SearchStats classes by adding functionality to search for and display photos that do not have detected faces. The SearchGUI now includes logic to handle the visibility of relevant columns based on the selected search type, ensuring a streamlined user experience. Additionally, the SearchStats class has been updated to retrieve photos without faces from the database, improving the overall search capabilities of the application.
2025-10-08 14:19:58 -04:00
40ffc0692e Enhance database and GUI for case-insensitive tag management
This commit updates the DatabaseManager to support case-insensitive tag lookups and additions, ensuring consistent tag handling. The SearchGUI and TagManagerGUI have been modified to reflect these changes, allowing for improved user experience when managing tags. Additionally, the search logic in SearchStats and TagManagement has been adjusted for case-insensitive tag ID retrieval, enhancing overall functionality and reliability in tag management across the application.
2025-10-08 14:03:41 -04:00
d92f5750b7 Enhance Search GUI with tag search functionality and improved result display
This commit updates the SearchGUI class to include a new feature for searching photos by tags, allowing users to input multiple tags and choose match modes (ANY or ALL). The results now display associated tags for each photo, and the GUI has been adjusted to accommodate these changes, including sorting capabilities for the tags column. Additionally, the search logic in the SearchStats class has been implemented to retrieve photos based on the specified tags, enhancing the overall search experience.
2025-10-08 13:44:04 -04:00
1972a69685 Refactor IdentifyGUI for improved face identification and management
This commit enhances the IdentifyGUI class by updating the face identification process to handle similar faces more effectively. The logic for updating the current face index has been streamlined, allowing for better flow when identifying faces. Additionally, new methods have been introduced to manage selected similar faces, ensuring that all identified faces are properly marked and displayed. The form clearing functionality has also been updated to include similar face selections, improving user experience during the identification process.
2025-10-08 12:55:56 -04:00
69150b2025 Enhance Search GUI with detailed features and improved workflow
This commit updates the README to reflect significant enhancements in the Search GUI, including new functionalities for photo selection, tagging, and sorting. Users can now view and manage tags through a dedicated popup, select multiple photos for bulk tagging, and utilize sortable columns for better navigation. The workflow for searching and managing photos has been clarified, providing a comprehensive guide for users to efficiently utilize the updated features.
2025-10-07 15:04:32 -04:00
9ec8b78b05 Enhance Search GUI with photo selection and tagging features
This commit updates the SearchGUI class to include a checkbox for selecting photos, allowing users to tag multiple photos at once. New buttons for tagging selected photos and clearing selections have been added, improving the user experience in managing photo tags. Additionally, the GUI now displays tags associated with each photo, enhancing the functionality of the search interface. The PhotoTagger class is updated to accommodate these changes, streamlining the tagging process.
2025-10-07 15:03:20 -04:00
55cd82943a Add sorting functionality to Search GUI for improved result management
This commit enhances the SearchGUI class by introducing sorting capabilities for search results. Users can now sort results by 'Person' and 'Photo path' columns, with visual indicators for sort direction. The sorting state is maintained, allowing for toggling between ascending and descending orders. Additionally, the default sorting is set to 'Person' when results are first loaded, improving the overall user experience in navigating search results.
2025-10-07 13:31:49 -04:00
0883a47914 Add Dashboard GUI for core feature management
This commit introduces the DashboardGUI class, providing a user-friendly interface for launching core features of the PunimTag application. The dashboard includes placeholder buttons for scanning, processing, and identifying faces, along with options for folder input and batch processing. The PhotoTagger class is updated to integrate the new dashboard functionality, and the README is revised to include usage instructions for the new dashboard command. This enhancement aims to streamline user interactions and improve overall accessibility to key features.
2025-10-07 12:27:57 -04:00
d4504ee81a Add Search GUI for enhanced photo searching capabilities
This commit introduces the SearchGUI class, allowing users to search for photos by name through a user-friendly interface. The new functionality includes options to view search results, open photo locations, and display relevant information about matched individuals. The PhotoTagger class is updated to integrate this feature, and the README is revised to include usage instructions for the new search-gui command. Additionally, the search_faces method in SearchStats is enhanced to return detailed results, improving the overall search experience.
2025-10-07 11:45:12 -04:00
b9a0637035 Enhance database schema with photo-tag linkage type and remove redundant migrations
This commit adds a new column, linkage_type, to the phototaglinkage table to distinguish between single and bulk tag additions. Additionally, the previous migration attempts to add date_taken and date_added columns to the photos table have been removed, streamlining the database initialization process. These changes improve the database structure for better tag management.
2025-10-06 14:42:22 -04:00
01404418f7 Enhance TagManagerGUI with bulk tagging features and linkage type management
This commit introduces significant enhancements to the TagManagerGUI, including the ability to add and manage bulk tags for all photos in a folder. Users can now link tags in bulk, with clear distinctions between single and bulk linkage types. The GUI also features improved handling of pending tag changes, allowing for better tracking and management of tag states. Additionally, the README has been updated to reflect these new functionalities and provide usage instructions.
2025-10-06 14:36:19 -04:00
64c29f24de Add TagManagerGUI for enhanced tag management in PhotoTagger
This commit introduces the TagManagerGUI class, which provides a comprehensive interface for managing photo tags within the PhotoTagger application. The new GUI preserves the legacy functionality while integrating into the refactored architecture, allowing users to add, edit, and delete tags efficiently. The PhotoTagger class is updated to utilize this new feature, streamlining the tag management process. Additionally, relevant documentation in the README has been updated to reflect these changes and provide usage instructions.
2025-10-06 12:43:30 -04:00
70cb11adbd Refactor AutoMatchGUI and ModifyIdentifiedGUI for improved unsaved changes handling
This commit enhances the AutoMatchGUI and ModifyIdentifiedGUI classes by refining the logic for handling unsaved changes when quitting. The AutoMatchGUI now uses a simplified messagebox for unsaved changes, while the ModifyIdentifiedGUI introduces a similar prompt to warn users about pending changes. Additionally, the logic for managing matched IDs has been updated to ensure consistency in face identification. These improvements aim to enhance user experience by providing clearer warnings and preserving user actions more effectively.
2025-10-06 12:25:44 -04:00
ac546a09e0 Add ModifyIdentifiedGUI for face modification in PhotoTagger
This commit introduces the ModifyIdentifiedGUI class, enabling users to view and modify identified faces within the PhotoTagger application. The new GUI provides a comprehensive interface for searching, displaying, and managing faces associated with individuals, allowing for easy unmatching and editing of person details. The PhotoTagger class is updated to integrate this functionality, streamlining the face modification process. Additionally, relevant documentation has been updated to reflect these changes.
2025-10-06 12:14:12 -04:00
b75e12816c Refactor AutoMatchGUI layout and enhance confidence display
This commit refines the layout of the AutoMatchGUI by adjusting the positioning of elements for better usability. The photo icon placement is now calculated based on the actual image dimensions, ensuring accurate positioning. Additionally, a new confidence badge feature is introduced, providing a visual representation of confidence levels alongside filenames. The layout adjustments improve the overall user experience by ensuring that images and related information are displayed more intuitively. The IdentifyGUI is also updated to reflect similar layout enhancements for consistency across the application.
2025-10-06 11:53:35 -04:00
38f931a7a7 Add AutoMatchGUI for face identification in PhotoTagger
This commit introduces the AutoMatchGUI class, enabling users to automatically identify and match unidentified faces against already identified ones within the PhotoTagger application. The new GUI provides a user-friendly interface for displaying potential matches, selecting identified faces, and saving changes. It integrates seamlessly with existing components, enhancing the overall functionality of the application. The PhotoTagger class is updated to utilize this new feature, streamlining the face identification process. Additionally, relevant documentation has been updated to reflect these changes.
2025-10-03 15:39:09 -04:00
5c1d5584a3 Implement auto-identification of faces with GUI in PhotoTagger
This commit introduces a comprehensive auto-identification feature within the PhotoTagger application, allowing users to automatically match unidentified faces against identified ones using a graphical user interface. The implementation includes database queries to fetch identified faces, a user-friendly display of potential matches, and options for selecting and saving identified faces. The GUI is designed for optimal performance and usability, ensuring a seamless experience. Additionally, the README has been updated to reflect this new functionality and provide usage instructions.
2025-10-03 15:24:23 -04:00
a51ffcfaa0 Add face identification GUI and related features to PhotoTagger
This commit introduces a new IdentifyGUI class for handling face identification within the PunimTag application. The GUI allows users to identify faces interactively, with features such as batch processing, date filtering, and a user-friendly interface for entering person details. The PhotoTagger class is updated to integrate this new functionality, enabling seamless face identification directly from the tagging interface. Additionally, enhancements to the calendar dialog and improved quit validation are included, ensuring a smoother user experience. The README is updated to reflect these new features and usage instructions.
2025-10-03 14:49:08 -04:00
f410e60e66 Add core functionality for PunimTag with modular architecture
This commit introduces a comprehensive set of modules for the PunimTag application, including configuration management, database operations, face processing, photo management, and tag management. Each module is designed to encapsulate specific functionalities, enhancing maintainability and scalability. The GUI components are also integrated, allowing for a cohesive user experience. This foundational work sets the stage for future enhancements and features, ensuring a robust framework for photo tagging and face recognition tasks.
2025-10-03 12:25:41 -04:00
b910be9fe7 Add photo icon feature to PhotoTagger for easy access to original photos
This update introduces a new photo icon in the PhotoTagger interface, allowing users to click the camera icon on face images to open the original photo in their default image viewer. The feature includes cross-platform support for Windows, macOS, and Linux, ensuring proper window sizing and multiple viewer options. Tooltips are added for enhanced user guidance, and the README has been updated to reflect this new functionality and its usage.
2025-10-02 14:16:41 -04:00
a14a8a4231 Add quit button with unsaved changes warning in PhotoTagger
This update introduces a new quit button that prompts users with a warning if there are unsaved changes before exiting the tagging dialog. The warning summarizes pending tag additions and removals, allowing users to choose to save changes, quit without saving, or cancel the action. Additionally, the tag selection combobox is now set to 'readonly' to prevent user modifications, enhancing the overall user experience in managing photo tags.
2025-10-02 13:31:33 -04:00
31691d7c47 Implement enhanced tag management features in PhotoTagger
This update introduces a comprehensive system for managing photo tags, including the ability to add and remove tags with pending changes tracked until saved. A new tag management dialog allows users to link tags intuitively, featuring a linkage icon for easy access. The interface now supports real-time updates and visual indicators for saved and pending tags, improving user experience. Additionally, the README has been updated to reflect these enhancements and provide clear documentation on the new functionalities.
2025-10-02 13:07:08 -04:00
639b283c0c Enhance tag management in PhotoTagger by implementing shared tag linking functions and improving tag deletion handling. Introduce a unified tag button frame for various view modes, allowing users to add tags seamlessly. Update pending tag changes cleanup logic to ensure accurate tag displays after deletions. This update streamlines the tagging process and enhances user experience across different photo views. 2025-10-02 12:41:16 -04:00
4602c252e8 Implement comprehensive tag management features in PhotoTagger
Add functionality for deduplicating tags, parsing tag strings, and managing tag IDs within the PhotoTagger GUI. Introduce a tag management dialog for adding, editing, and deleting tags, ensuring a user-friendly interface for tag operations. Update the internal logic to utilize tag IDs for improved performance and reliability, while enhancing the README to reflect these significant changes in tag handling and management.
2025-10-02 12:24:15 -04:00
7f89c2a825 Add tagging functionality to PhotoTagger GUI
Introduce a tagging system that allows users to manage tags for photos directly within the interface. Implement a tagging widget for each photo, enabling users to add and remove tags dynamically. Include a save button to persist tag changes to the database, enhancing the overall tagging experience. Update the layout to accommodate the new tagging feature and ensure existing tags are loaded for user convenience.
2025-10-01 15:24:48 -04:00
6bfc44a6c9 Refactor database schema for tag management in PhotoTagger
Enhance the tagging system by introducing a normalized structure with a separate `tags` table for unique tag definitions and a `phototaglinkage` table to manage the many-to-many relationship between photos and tags. Update the logic for inserting and retrieving tags to improve data integrity and prevent duplicates. Additionally, update the README to reflect these changes and document the new folder view features.
2025-10-01 15:10:23 -04:00
0f599d3d16 Implement folder grouping and collapsible sections in PhotoTagger GUI
Enhance the photo display functionality by introducing folder grouping, allowing users to view photos organized by their respective folders. Implement collapsible sections for each folder, enabling users to expand or collapse folder contents. This update improves the organization and accessibility of photos within the interface, enhancing overall user experience.
2025-10-01 14:52:15 -04:00
b6e6b38a76 Add Tag Management GUI to PhotoTagger
Introduce a new tag management interface with a file explorer-like design, allowing users to manage photo tags efficiently. Features include multiple view modes (list, icons, compact), resizable columns, and column visibility management through a right-click context menu. Update README to document the new functionality and usage instructions.
2025-10-01 13:57:45 -04:00
68ec18b822 Enhance PhotoTagger GUI by standardizing canvas configurations and improving the unmatch face button. Set consistent background colors for canvases, and implement a new clickable 'X' button with hover effects for better user interaction. This update aims to improve visual consistency and usability across the interface. 2025-10-01 12:04:53 -04:00
199a75098d Refactor PhotoTagger GUI to standardize canvas background colors and improve layout configurations. Update canvas elements to inherit background color from the theme, ensuring visual consistency. Adjust grid settings for match frames to enhance alignment and responsiveness, contributing to a more cohesive user experience. 2025-09-30 15:55:16 -04:00
15b7c10056 Refactor PhotoTagger GUI to enhance image display and layout. Update canvas background color to match the theme, improve image resizing logic for better aspect ratio handling, and adjust grid configurations for match frames to ensure proper alignment and responsiveness. This enhances overall user experience and visual consistency across the interface. 2025-09-30 15:39:51 -04:00
360fcb0881 Enhance PhotoTagger with last name autocomplete and required field indicators. Implement live filtering for last names during input, improving user experience. Add red asterisks to indicate required fields for first name, last name, and date of birth, ensuring clarity in form completion. Update README to document these new features. 2025-09-30 15:19:37 -04:00
0fb6a19624 Refactor PhotoTagger GUI to improve search functionality. Update search controls for last name filtering by introducing a helper label and reorganizing layout for better usability. Adjust grid placements for matched person info and image to enhance visual clarity and consistency across the interface. 2025-09-30 13:24:58 -04:00
da6f810b5b Refactor PhotoTagger GUI to enhance filtering capabilities. Introduce unique faces only and compare similar faces checkboxes, allowing users to filter displayed faces based on uniqueness and similarity. Update layout for better organization of date filters and controls, improving overall user experience. Adjust row configurations to minimize spacing and ensure proper expansion of panels. 2025-09-30 13:06:25 -04:00
4c0a1a3b38 Add unique faces only filter to PhotoTagger. Introduce a checkbox in the Date Filters section to hide duplicates with high/medium confidence matches, enhancing face identification accuracy. Implement filtering logic that groups faces with ≥60% confidence, ensuring only one representative is displayed in the main list while keeping the Similar Faces panel unfiltered. Update README to document this new feature and its behavior. 2025-09-29 16:01:06 -04:00
e1bed343b6 Implement date handling features in PhotoTagger. Add 'date_taken' and 'date_added' columns to the photos database, along with EXIF data extraction for photo dates. Enhance the GUI with date filter options for face identification, allowing users to filter by date taken and processed dates. Introduce a calendar dialog for easier date selection, improving user experience and data management. 2025-09-29 15:26:16 -04:00
34aba85fc6 Enhance PhotoTagger README to include new last name search and filter-aware navigation features. Update descriptions for the left panel and modify identified interface to reflect case-insensitive search capabilities and auto-selection of matches, improving user experience and interface clarity. 2025-09-29 13:08:02 -04:00
2394afb5ee Add last name search functionality to PhotoTagger GUI. Implement search and clear buttons for filtering people by last name, enhancing user experience. Update navigation and display logic to reflect filtered results, ensuring proper handling of UI states and feedback when no matches are found. 2025-09-29 13:07:46 -04:00
347a597927 Enhance PhotoTagger GUI to update the right panel based on filtered results. Implement logic to load and display faces for the first person in the list after filtering or clearing the last name search, improving user experience and visual feedback. Ensure proper handling of UI states and clear faces panel when no matches are found. 2025-09-29 12:51:03 -04:00
62bb0dc31f Add last name search functionality in PhotoTagger GUI. Implement search and clear buttons for filtering people by last name, enhancing user experience. Update population logic to reflect filtered results and ensure smooth integration with existing data handling. 2025-09-29 12:46:01 -04:00
267519a034 Implement tracking of original checkbox states in PhotoTagger to enhance unsaved changes detection. Update logic to compare current and original states, ensuring accurate identification of unsaved changes. Refactor save functionality to avoid modifying original states, improving user experience and data integrity. 2025-09-29 12:24:34 -04:00
8c9da7362b Refactor PhotoTagger to enhance data handling by storing person information in separate fields for first name, last name, middle name, maiden name, and date of birth. Improve identification logic to ensure data integrity and streamline user experience. Update README to reflect changes in data storage and new features. 2025-09-26 15:22:24 -04:00
9f11a1a647 Enhance PhotoTagger GUI and database to support comprehensive person information, including first, last, middle, and maiden names, along with date of birth. Implement smart validation for required fields and improve user experience with a visual calendar picker for date selection. Update README to reflect these changes and provide detailed guidance on new features and interface enhancements. 2025-09-26 14:07:14 -04:00
f3338f0097 Add unsaved changes warning dialog in PhotoTagger before quitting. Implement functionality to check for unsaved changes and prompt the user with options to save, discard, or cancel the quit action, enhancing user experience and preventing data loss. 2025-09-26 13:58:47 -04:00
5ecfe1121e Enhance PhotoTagger to include comprehensive person details in the database and GUI. Update data handling to support middle names, maiden names, and date of birth, improving user experience and data integrity. Revise database queries and UI components for better data entry and display, ensuring all relevant information is captured during identification. 2025-09-26 13:10:30 -04:00
ee3638b929 Enhance PhotoTagger by adding support for middle and maiden names in the database and GUI. Update data handling to accommodate new input fields, ensuring comprehensive data capture during identification. Revise database queries and improve user interface for better data entry experience. 2025-09-26 12:38:46 -04:00
2fcd200cd0 Enhance PhotoTagger by adding date of birth support in the database and GUI. Update data handling to accommodate new input format, including validation for date selection. Revise identification logic to ensure complete data is saved, improving user experience and data integrity. 2025-09-25 14:39:05 -04:00
52b9d37d8c Refactor PhotoTagger to support separate first and last name fields in the database. Update GUI to include dedicated input fields for first and last names, enhancing user experience with smart name parsing and improved dropdown functionality. Revise README to reflect these changes and highlight new features. 2025-09-22 15:56:55 -04:00
6a5bafef50 Implement 'modifyidentified' feature for viewing and modifying identified faces in the PhotoTagger GUI. Enhance user experience with new controls for selecting and unselecting faces, and improve navigation and state management. Update README to reflect new functionality and installation requirements, including system dependencies. 2025-09-22 13:57:43 -04:00
2c67b2216d Enhance PhotoTagger functionality with improved database management, caching, and GUI features. Introduce context management for database connections, add face quality scoring, and implement a new auto-match interface for better user experience. Update README for clarity on new features and installation requirements. 2025-09-19 15:32:50 -04:00
228 changed files with 72342 additions and 1672 deletions

127
.cursorignore Normal file
View File

@ -0,0 +1,127 @@
# Cursor AI Ignore File
# Files and directories that Cursor should not index or analyze
# Virtual Environment
venv/
env/
.venv/
# Python Cache
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Distribution / packaging
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Database Files
*.db
*.sqlite
*.sqlite3
data/photos.db
photos.db
# Logs
logs/
*.log
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Git
.git/
.gitignore
# Temporary Files
tmp/
temp/
*.tmp
# Test Coverage
.coverage
htmlcov/
.pytest_cache/
.tox/
# Archives
archive/
*.backup
*_backup.py
*_original.py
# Demo/Sample Files
demo_photos/
*.jpg
*.jpeg
*.png
*.gif
*.bmp
*.tiff
*.tif
# Compiled Files
*.pyc
*.pyo
*.so
# Documentation Build
docs/_build/
docs/_static/
docs/_templates/
# OS Files
.DS_Store
Thumbs.db
desktop.ini
# Large Files
*.zip
*.tar.gz
*.rar
# Config Files (may contain sensitive data)
gui_config.json
.env
.env.local
# Scripts output
*.out
*.err
# Jupyter Notebooks
.ipynb_checkpoints/
*.ipynb
# MyPy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyenv
.python-version
# Package Manager
pip-log.txt
pip-delete-this-directory.txt

240
.cursorrules Normal file
View File

@ -0,0 +1,240 @@
# Cursor AI Rules for PunimTag
## Project Context
This is a modern web-based photo management application with facial recognition capabilities. The project uses a monorepo structure with FastAPI backend, React admin frontend, and Next.js viewer frontend.
## Code Style
### Python (Backend)
- Follow PEP 8 strictly
- Use type hints for all function signatures
- Maximum line length: 100 characters
- Use 4 spaces for indentation (never tabs)
- Add docstrings to all public classes and methods
### TypeScript/JavaScript (Frontend)
- Use TypeScript for all new code
- Follow ESLint rules
- Use functional components with hooks
- Prefer named exports over default exports
- Maximum line length: 100 characters
## Import Organization
### Python
```python
# Standard library imports
import os
import sys
# Third-party imports
import numpy as np
from PIL import Image
from fastapi import APIRouter
# Local imports
from backend.db.session import get_db
from backend.services.face_service import FaceService
```
### TypeScript
```typescript
// Third-party imports
import React from 'react';
import { useQuery } from '@tanstack/react-query';
// Local imports
import { apiClient } from '@/api/client';
import { Layout } from '@/components/Layout';
```
## Naming Conventions
- **Python Classes**: PascalCase (e.g., `FaceProcessor`)
- **Python Functions/methods**: snake_case (e.g., `process_faces`)
- **Python Constants**: UPPER_SNAKE_CASE (e.g., `DEFAULT_TOLERANCE`)
- **Python Private members**: prefix with underscore (e.g., `_internal_method`)
- **TypeScript/React Components**: PascalCase (e.g., `PhotoViewer`)
- **TypeScript Functions**: camelCase (e.g., `processFaces`)
- **TypeScript Constants**: UPPER_SNAKE_CASE or camelCase (e.g., `API_URL` or `defaultTolerance`)
## Project Structure (Monorepo)
```
punimtag/
├── backend/ # FastAPI backend
│ ├── api/ # API routers
│ ├── db/ # Database models and session
│ ├── schemas/ # Pydantic models
│ ├── services/ # Business logic services
│ ├── constants/ # Constants and configuration
│ ├── utils/ # Utility functions
│ ├── app.py # FastAPI application
│ └── worker.py # RQ worker for background jobs
├── admin-frontend/ # React admin interface
│ ├── src/
│ │ ├── api/ # API client
│ │ ├── components/ # React components
│ │ ├── context/ # React contexts (Auth)
│ │ ├── hooks/ # Custom hooks
│ │ └── pages/ # Page components
│ └── package.json
├── viewer-frontend/ # Next.js viewer interface
│ ├── app/ # Next.js app router
│ ├── components/ # React components
│ ├── lib/ # Utilities and database
│ └── prisma/ # Prisma schemas
├── src/ # Legacy utilities (if any)
├── tests/ # Test suite
├── docs/ # Documentation
├── scripts/ # Utility scripts
└── deploy/ # Deployment configurations
```
## Key Technologies
### Backend
- **Python 3.12+**
- **FastAPI** - Web framework
- **PostgreSQL** - Database (required, not SQLite)
- **SQLAlchemy 2.0** - ORM
- **DeepFace** - Face recognition (migrated from face_recognition)
- **Redis + RQ** - Background job processing
- **JWT** - Authentication
### Frontend
- **React 18 + TypeScript** - Admin interface
- **Next.js 16** - Viewer interface
- **Vite** - Build tool for admin
- **Tailwind CSS** - Styling
- **React Query** - Data fetching
- **Prisma** - Database client for viewer
## Import Paths
### Python Backend
Always use absolute imports from backend:
```python
from backend.db.session import get_db
from backend.services.face_service import FaceService
from backend.api.photos import router as photos_router
```
### TypeScript Frontend
Use path aliases configured in tsconfig.json:
```typescript
import { apiClient } from '@/api/client';
import { Layout } from '@/components/Layout';
```
## Database
- **PostgreSQL** is required (not SQLite)
- Use SQLAlchemy ORM for all database operations
- Use context managers for database sessions
- Always use prepared statements (SQLAlchemy handles this)
- Two databases: `punimtag` (main) and `punimtag_auth` (auth)
## Error Handling
- Use specific exception types
- Log errors appropriately
- Provide user-friendly error messages in API responses
- Never silently catch exceptions
- Use FastAPI HTTPException for API errors
- Use try-catch in React components with proper error boundaries
## Testing
- Write tests for all new features
- Use pytest for Python backend tests
- Use Vitest/Jest for frontend tests
- Place tests in `tests/` directory
- Aim for >80% code coverage
## Documentation
- Update docstrings when changing code
- Keep README.md current
- Update docs/ARCHITECTURE.md for architectural changes
- Document complex algorithms inline
- Update API documentation (FastAPI auto-generates from docstrings)
## Git Commit Messages
Format: `<type>: <subject>`
Types:
- feat: New feature
- fix: Bug fix
- docs: Documentation
- style: Formatting
- refactor: Code restructuring
- test: Tests
- chore: Maintenance
## Current Focus
- Web-based architecture (migrated from desktop)
- DeepFace integration (completed)
- PostgreSQL migration (completed)
- Monorepo structure (completed)
- Production deployment preparation
## Avoid
- Circular imports
- Global state (except configuration)
- Hard-coded file paths
- SQL injection vulnerabilities (use SQLAlchemy ORM)
- Mixing business logic with API/UI code
- Storing sensitive data in code (use environment variables)
## Prefer
- Type hints (Python) and TypeScript
- List/dict comprehensions over loops
- Context managers for resources
- f-strings for formatting (Python)
- Template literals for formatting (TypeScript)
- Pathlib over os.path (Python)
- Environment variables for configuration
## When Adding Features
1. **Design**: Plan the feature and update architecture docs
2. **Database**: Update schema if needed (SQLAlchemy models)
3. **Backend**: Implement API endpoints and services
4. **Frontend**: Add UI components and API integration
5. **Tests**: Write test cases for backend and frontend
6. **Docs**: Update documentation
## Performance Considerations
- Cache face encodings in database
- Use database indices
- Batch database operations
- Lazy load large datasets
- Use React Query caching for frontend
- Profile before optimizing
- Use background jobs (RQ) for long-running tasks
## Security
- Validate all user inputs (Pydantic schemas for API)
- Sanitize file paths
- Use SQLAlchemy ORM (prevents SQL injection)
- Don't store sensitive data in plain text
- Use bcrypt for password hashing
- JWT tokens for authentication
- CORS configuration for production
- Environment variables for secrets
## Development Environment
### Dev Server
- **Host**: 10.0.10.121
- **User**: appuser
- **Password**: C0caC0la
### Dev PostgreSQL
- **Host**: 10.0.10.181
- **Port**: 5432
- **User**: ladmin
- **Password**: C0caC0la
## Deployment
- Use deployment scripts in package.json
- Build frontends before deployment
- Set up environment variables on server
- Configure PostgreSQL connection strings
- Set up Redis for background jobs
- Use process managers (systemd, PM2) for production

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
# Database Configuration
# PostgreSQL (for network database)
DATABASE_URL=postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag
# Or use SQLite for local development (default if DATABASE_URL not set)
# DATABASE_URL=sqlite:///data/punimtag.db
# Photo Storage
PHOTO_STORAGE_DIR=data/uploads
# JWT Secrets (change in production!)
SECRET_KEY=your-secret-key-here-change-in-production
# Single-user credentials (change in production!)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin

16
.gitignore vendored
View File

@ -24,7 +24,7 @@ wheels/
venv/
env/
ENV/
``````````
# Database files (keep structure, ignore content)
*.db
*.sqlite
@ -37,7 +37,7 @@ data/*.sqlite
*.temp
temp_face_crop_*.jpg
# IDE
# IDE``````````
.vscode/
.idea/
*.swp
@ -67,4 +67,14 @@ photos/
*.webp
dlib/
*.dat
*.model
*.model
# Node.js
node_modules/
frontend/node_modules/
frontend/.parcel-cache/
# Archive and demo files
archive/
demo_photos/
data/uploads/
data/thumbnails/

View File

@ -0,0 +1,805 @@
# Migration Plan: Replace face_recognition with DeepFace in PunimTag
**Version:** 1.0
**Created:** October 15, 2025
**Status:** Planning Phase
---
## Executive Summary
This plan outlines the complete migration from `face_recognition` library to `DeepFace` library for the PunimTag photo tagging application. Based on testing in `test_deepface_gui.py`, DeepFace provides superior accuracy using the ArcFace model with configurable detector backends.
**Key Changes:**
- Face encoding dimensions: 128 → 512 (ArcFace model)
- Detection method: HOG/CNN → RetinaFace/MTCNN/OpenCV/SSD (configurable)
- Similarity metric: Euclidean distance → Cosine similarity
- Face location format: (top, right, bottom, left) → {x, y, w, h}
- No backward compatibility - fresh start with new database
---
## PHASE 1: Database Schema Updates
### Step 1.1: Update Database Schema
**File:** `src/core/database.py`
**Actions:**
1. **Modify `faces` table** to add DeepFace-specific columns:
```sql
ALTER TABLE faces ADD COLUMN detector_backend TEXT DEFAULT 'retinaface';
ALTER TABLE faces ADD COLUMN model_name TEXT DEFAULT 'ArcFace';
ALTER TABLE faces ADD COLUMN face_confidence REAL DEFAULT 0.0;
```
2. **Update `person_encodings` table** similarly:
```sql
ALTER TABLE person_encodings ADD COLUMN detector_backend TEXT DEFAULT 'retinaface';
ALTER TABLE person_encodings ADD COLUMN model_name TEXT DEFAULT 'ArcFace';
```
3. **Update `init_database()` method** in `DatabaseManager` class:
- Add new columns to CREATE TABLE statements for `faces` and `person_encodings`
- Add indices for new columns if needed
**Expected encoding size change:**
- face_recognition: 128 floats × 8 bytes = 1,024 bytes per encoding
- DeepFace ArcFace: 512 floats × 8 bytes = 4,096 bytes per encoding
### Step 1.2: Update Database Methods
**File:** `src/core/database.py`
**Actions:**
1. **Modify `add_face()` method signature:**
```python
def add_face(self, photo_id: int, encoding: bytes, location: str,
confidence: float = 0.0, quality_score: float = 0.0,
person_id: Optional[int] = None,
detector_backend: str = 'retinaface',
model_name: str = 'ArcFace',
face_confidence: float = 0.0) -> int:
```
2. **Modify `add_person_encoding()` method signature:**
```python
def add_person_encoding(self, person_id: int, face_id: int, encoding: bytes,
quality_score: float,
detector_backend: str = 'retinaface',
model_name: str = 'ArcFace'):
```
3. **Update all database queries** that insert/update faces to include new fields
---
## PHASE 2: Configuration Updates
### Step 2.1: Update Configuration Constants
**File:** `src/core/config.py`
**Actions:**
1. **Replace face_recognition settings:**
```python
# OLD - Remove these:
# DEFAULT_FACE_DETECTION_MODEL = "hog"
# DEFAULT_FACE_TOLERANCE = 0.6
# NEW - Add these:
DEEPFACE_DETECTOR_BACKEND = "retinaface" # Options: retinaface, mtcnn, opencv, ssd
DEEPFACE_MODEL_NAME = "ArcFace" # Best accuracy model
DEEPFACE_DISTANCE_METRIC = "cosine" # For similarity calculation
DEEPFACE_ENFORCE_DETECTION = False # Don't fail if no faces found
DEEPFACE_ALIGN_FACES = True # Face alignment for better accuracy
# Tolerance/threshold adjustments for DeepFace
DEFAULT_FACE_TOLERANCE = 0.4 # Lower for DeepFace (was 0.6 for face_recognition)
DEEPFACE_SIMILARITY_THRESHOLD = 60 # Minimum similarity percentage (0-100)
# Environment settings for TensorFlow (DeepFace uses TensorFlow backend)
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' # Suppress TensorFlow warnings
```
2. **Add detector backend options:**
```python
DEEPFACE_DETECTOR_OPTIONS = ["retinaface", "mtcnn", "opencv", "ssd"]
DEEPFACE_MODEL_OPTIONS = ["ArcFace", "Facenet", "Facenet512", "VGG-Face"]
```
### Step 2.2: Add TensorFlow Suppression
**File:** Add to all main entry points (dashboard_gui.py, photo_tagger.py, etc.)
**Actions:**
```python
# At the top of file, before other imports
import os
import warnings
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
warnings.filterwarnings('ignore')
# Then import DeepFace
from deepface import DeepFace
```
---
## PHASE 3: Face Processing Core Migration
### Step 3.1: Replace Face Detection and Encoding
**File:** `src/core/face_processing.py``FaceProcessor` class
**Method:** `process_faces()`
**Current Implementation (lines 59-144):**
```python
# OLD CODE:
image = face_recognition.load_image_file(photo_path)
face_locations = face_recognition.face_locations(image, model=model)
face_encodings = face_recognition.face_encodings(image, face_locations)
```
**New Implementation:**
```python
# NEW CODE:
try:
# Use DeepFace.represent() to get face detection and encodings
results = DeepFace.represent(
img_path=photo_path,
model_name=DEEPFACE_MODEL_NAME, # 'ArcFace'
detector_backend=DEEPFACE_DETECTOR_BACKEND, # 'retinaface'
enforce_detection=DEEPFACE_ENFORCE_DETECTION, # False
align=DEEPFACE_ALIGN_FACES # True
)
if not results:
if self.verbose >= 1:
print(f" 👤 No faces found")
# Mark as processed even with no faces
self.db.mark_photo_processed(photo_id)
continue
if self.verbose >= 1:
print(f" 👤 Found {len(results)} faces")
# Process each detected face
for i, result in enumerate(results):
# Extract face region info from DeepFace result
facial_area = result.get('facial_area', {})
face_confidence = result.get('face_confidence', 0.0)
embedding = np.array(result['embedding'])
# Convert DeepFace facial_area {x, y, w, h} to our location format
# Store as dict for consistency
location = {
'x': facial_area.get('x', 0),
'y': facial_area.get('y', 0),
'w': facial_area.get('w', 0),
'h': facial_area.get('h', 0)
}
# Calculate face quality score (reuse existing method)
# Convert facial_area to (top, right, bottom, left) for quality calculation
face_location_tuple = (
facial_area.get('y', 0), # top
facial_area.get('x', 0) + facial_area.get('w', 0), # right
facial_area.get('y', 0) + facial_area.get('h', 0), # bottom
facial_area.get('x', 0) # left
)
# Load image for quality calculation
image = Image.open(photo_path)
image_np = np.array(image)
quality_score = self._calculate_face_quality_score(image_np, face_location_tuple)
# Store in database with new format
self.db.add_face(
photo_id=photo_id,
encoding=embedding.tobytes(),
location=str(location), # Store as string representation of dict
confidence=0.0, # Legacy field, keep for compatibility
quality_score=quality_score,
person_id=None,
detector_backend=DEEPFACE_DETECTOR_BACKEND,
model_name=DEEPFACE_MODEL_NAME,
face_confidence=face_confidence
)
if self.verbose >= 3:
print(f" Face {i+1}: {location} (quality: {quality_score:.2f}, confidence: {face_confidence:.2f})")
# Mark as processed
self.db.mark_photo_processed(photo_id)
processed_count += 1
except Exception as e:
print(f"❌ Error processing {filename}: {e}")
self.db.mark_photo_processed(photo_id)
```
### Step 3.2: Update Face Location Handling
**File:** `src/core/face_processing.py`
**Method:** `_extract_face_crop()` (appears twice, lines 212-271 and 541-600)
**Current Implementation:**
```python
# OLD: face_recognition format (top, right, bottom, left)
top, right, bottom, left = location
```
**New Implementation:**
```python
# NEW: DeepFace format {x, y, w, h}
# Parse location from string if needed
if isinstance(location, str):
location = eval(location) # Convert string to dict
# Handle both formats for compatibility during migration
if isinstance(location, dict):
# DeepFace format
left = location.get('x', 0)
top = location.get('y', 0)
width = location.get('w', 0)
height = location.get('h', 0)
right = left + width
bottom = top + height
else:
# Legacy face_recognition format (top, right, bottom, left)
top, right, bottom, left = location
# Rest of the method remains the same
face_width = right - left
face_height = bottom - top
# ... etc
```
### Step 3.3: Replace Similarity Calculation
**File:** `src/core/face_processing.py`
**Method:** `find_similar_faces()` and helper methods
**Current Implementation (line 457):**
```python
# OLD:
distance = face_recognition.face_distance([target_encoding], other_enc)[0]
```
**New Implementation:**
```python
# NEW: Use cosine similarity (same as test_deepface_gui.py)
def _calculate_cosine_similarity(self, encoding1: np.ndarray, encoding2: np.ndarray) -> float:
"""Calculate cosine similarity between two face encodings"""
try:
# Ensure encodings are numpy arrays
enc1 = np.array(encoding1).flatten()
enc2 = np.array(encoding2).flatten()
# Check if encodings have the same length
if len(enc1) != len(enc2):
print(f"Warning: Encoding length mismatch: {len(enc1)} vs {len(enc2)}")
return 0.0
# Normalize encodings
enc1_norm = enc1 / (np.linalg.norm(enc1) + 1e-8)
enc2_norm = enc2 / (np.linalg.norm(enc2) + 1e-8)
# Calculate cosine similarity
cosine_sim = np.dot(enc1_norm, enc2_norm)
# Clamp to valid range [-1, 1]
cosine_sim = np.clip(cosine_sim, -1.0, 1.0)
# Convert to distance (0 = identical, 2 = opposite)
# For consistency with face_recognition's distance metric
distance = 1.0 - cosine_sim # Range [0, 2], where 0 is perfect match
return distance
except Exception as e:
print(f"Error calculating similarity: {e}")
return 2.0 # Maximum distance on error
# Replace in find_similar_faces():
distance = self._calculate_cosine_similarity(target_encoding, other_enc)
```
**Update adaptive tolerance calculation (line 333-351):**
```python
def _calculate_adaptive_tolerance(self, base_tolerance: float, face_quality: float, match_confidence: float = None) -> float:
"""Calculate adaptive tolerance based on face quality and match confidence
Note: For DeepFace, tolerance values are generally lower than face_recognition
"""
# Start with base tolerance (e.g., 0.4 instead of 0.6)
tolerance = base_tolerance
# Adjust based on face quality
quality_factor = 0.9 + (face_quality * 0.2) # Range: 0.9 to 1.1
tolerance *= quality_factor
# Adjust based on match confidence if provided
if match_confidence is not None:
confidence_factor = 0.95 + (match_confidence * 0.1)
tolerance *= confidence_factor
# Ensure tolerance stays within reasonable bounds for DeepFace
return max(0.2, min(0.6, tolerance)) # Lower range for DeepFace
```
---
## PHASE 4: GUI Integration Updates
### Step 4.1: Add DeepFace Settings to Dashboard
**File:** `src/gui/dashboard_gui.py`
**Actions:**
1. **Add detector selection to menu or settings:**
```python
# Add to settings menu or control panel
detector_frame = ttk.Frame(settings_panel)
detector_frame.pack(fill=tk.X, padx=5, pady=5)
ttk.Label(detector_frame, text="Face Detector:").pack(side=tk.LEFT, padx=5)
self.detector_var = tk.StringVar(value=DEEPFACE_DETECTOR_BACKEND)
detector_combo = ttk.Combobox(detector_frame, textvariable=self.detector_var,
values=DEEPFACE_DETECTOR_OPTIONS,
state="readonly", width=12)
detector_combo.pack(side=tk.LEFT, padx=5)
ttk.Label(detector_frame, text="Model:").pack(side=tk.LEFT, padx=5)
self.model_var = tk.StringVar(value=DEEPFACE_MODEL_NAME)
model_combo = ttk.Combobox(detector_frame, textvariable=self.model_var,
values=DEEPFACE_MODEL_OPTIONS,
state="readonly", width=12)
model_combo.pack(side=tk.LEFT, padx=5)
```
2. **Update process_faces calls to use selected settings:**
```python
# When calling face processor
detector = self.detector_var.get()
model = self.model_var.get()
# Pass to FaceProcessor (need to update FaceProcessor.__init__ to accept these)
self.face_processor = FaceProcessor(
db_manager=self.db,
verbose=self.verbose,
detector_backend=detector,
model_name=model
)
```
### Step 4.2: Update Face Processor Initialization
**File:** `src/core/face_processing.py`
**Class:** `FaceProcessor.__init__()`
**Actions:**
```python
def __init__(self, db_manager: DatabaseManager, verbose: int = 0,
detector_backend: str = None, model_name: str = None):
"""Initialize face processor with DeepFace settings"""
self.db = db_manager
self.verbose = verbose
self.detector_backend = detector_backend or DEEPFACE_DETECTOR_BACKEND
self.model_name = model_name or DEEPFACE_MODEL_NAME
self._face_encoding_cache = {}
self._image_cache = {}
```
### Step 4.3: Update GUI Display Methods
**File:** Multiple panel files (identify_panel.py, auto_match_panel.py, modify_panel.py)
**Actions:**
1. **Update all face thumbnail extraction** to handle new location format
2. **Update confidence display** to use cosine similarity percentages
3. **Update any hardcoded face_recognition references**
**Example for identify_panel.py:**
```python
# In display methods, convert distance to percentage
confidence_pct = (1 - distance) * 100 # Already done correctly
# But ensure distance calculation uses cosine similarity
```
---
## PHASE 5: Dependencies and Installation
### Step 5.1: Update requirements.txt
**File:** `requirements.txt`
**Actions:**
```python
# REMOVE these:
# face-recognition==1.3.0
# face-recognition-models==0.3.0
# dlib>=20.0.0
# ADD these:
deepface>=0.0.79
tensorflow>=2.13.0 # Required by DeepFace
opencv-python>=4.8.0 # Required by DeepFace
retina-face>=0.0.13 # For RetinaFace detector (best accuracy)
# KEEP these:
numpy>=1.21.0
pillow>=8.0.0
click>=8.0.0
setuptools>=40.0.0
```
### Step 5.2: Create Migration Script
**File:** `scripts/migrate_to_deepface.py` (new file)
**Purpose:** Drop all tables and reinitialize database for fresh start
**Actions:**
```python
#!/usr/bin/env python3
"""
Migration script to prepare database for DeepFace
Drops all existing tables and recreates with new schema
"""
import sqlite3
import sys
from pathlib import Path
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.core.database import DatabaseManager
from src.core.config import DEFAULT_DB_PATH
def migrate_database():
"""Drop all tables and reinitialize with DeepFace schema"""
print("⚠️ WARNING: This will delete all existing data!")
response = input("Type 'DELETE ALL DATA' to confirm: ")
if response != "DELETE ALL DATA":
print("Migration cancelled.")
return
print("\n🗑 Dropping all existing tables...")
# Connect directly to database
conn = sqlite3.connect(DEFAULT_DB_PATH)
cursor = conn.cursor()
# Drop all tables
tables = ['phototaglinkage', 'person_encodings', 'faces', 'tags', 'people', 'photos']
for table in tables:
cursor.execute(f'DROP TABLE IF EXISTS {table}')
print(f" Dropped table: {table}")
conn.commit()
conn.close()
print("\n✅ All tables dropped successfully")
print("\n🔄 Reinitializing database with DeepFace schema...")
# Reinitialize with new schema
db = DatabaseManager(DEFAULT_DB_PATH, verbose=1)
print("\n✅ Database migration complete!")
print("\nNext steps:")
print("1. Add photos using the dashboard")
print("2. Process faces with DeepFace")
print("3. Identify people")
if __name__ == "__main__":
migrate_database()
```
---
## PHASE 6: Testing and Validation
### Step 6.1: Create Test Suite
**File:** `tests/test_deepface_integration.py` (new file)
**Actions:**
```python
#!/usr/bin/env python3
"""
Test DeepFace integration in PunimTag
"""
import os
import sys
from pathlib import Path
# Suppress TensorFlow warnings
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
import warnings
warnings.filterwarnings('ignore')
from src.core.database import DatabaseManager
from src.core.face_processing import FaceProcessor
from src.core.config import DEEPFACE_DETECTOR_BACKEND, DEEPFACE_MODEL_NAME
def test_face_detection():
"""Test face detection with DeepFace"""
print("🧪 Testing DeepFace face detection...")
db = DatabaseManager(":memory:", verbose=0) # In-memory database for testing
processor = FaceProcessor(db, verbose=1)
# Test with a sample image
test_image = "demo_photos/2019-11-22_0011.jpg"
if not os.path.exists(test_image):
print(f"❌ Test image not found: {test_image}")
return False
# Add photo to database
photo_id = db.add_photo(test_image, Path(test_image).name, None)
# Process faces
count = processor.process_faces(limit=1)
# Verify results
stats = db.get_statistics()
print(f"✅ Processed {count} photos, found {stats['total_faces']} faces")
return stats['total_faces'] > 0
def test_face_matching():
"""Test face matching with DeepFace"""
print("\n🧪 Testing DeepFace face matching...")
db = DatabaseManager(":memory:", verbose=0)
processor = FaceProcessor(db, verbose=1)
# Test with multiple images
test_images = [
"demo_photos/2019-11-22_0011.jpg",
"demo_photos/2019-11-22_0012.jpg"
]
for img in test_images:
if os.path.exists(img):
photo_id = db.add_photo(img, Path(img).name, None)
# Process all faces
processor.process_faces(limit=10)
# Find similar faces
faces = db.get_all_face_encodings()
if len(faces) >= 2:
matches = processor.find_similar_faces(faces[0][0])
print(f"✅ Found {len(matches)} similar faces")
return len(matches) >= 0
return False
def run_all_tests():
"""Run all tests"""
print("=" * 60)
print("DeepFace Integration Test Suite")
print("=" * 60)
tests = [
test_face_detection,
test_face_matching
]
results = []
for test in tests:
try:
result = test()
results.append(result)
except Exception as e:
print(f"❌ Test failed with error: {e}")
results.append(False)
print("\n" + "=" * 60)
print(f"Tests passed: {sum(results)}/{len(results)}")
print("=" * 60)
return all(results)
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)
```
### Step 6.2: Validation Checklist
1. **Face Detection:**
- [ ] DeepFace successfully detects faces in test images
- [ ] Face locations are correctly stored in new format
- [ ] Face encodings are 512-dimensional (ArcFace)
- [ ] Multiple detector backends work (retinaface, mtcnn, etc.)
2. **Face Matching:**
- [ ] Similar faces are correctly identified
- [ ] Cosine similarity produces reasonable confidence scores
- [ ] Adaptive tolerance works with new metric
- [ ] No false positives at default threshold
3. **GUI Integration:**
- [ ] All panels display faces correctly
- [ ] Face thumbnails extract properly with new location format
- [ ] Confidence scores display correctly
- [ ] Detector/model selection works in settings
4. **Database:**
- [ ] New columns are created correctly
- [ ] Encodings are stored as 4096-byte BLOBs
- [ ] Queries work with new schema
- [ ] Indices improve performance
---
## PHASE 7: Implementation Order
**Execute in this order to minimize issues:**
1. **Day 1: Database & Configuration**
- Update `requirements.txt`
- Install DeepFace: `pip install deepface tensorflow opencv-python retina-face`
- Update `src/core/config.py` with DeepFace settings
- Update `src/core/database.py` schema and methods
- Create and run `scripts/migrate_to_deepface.py`
2. **Day 2: Core Face Processing**
- Update `src/core/face_processing.py` `process_faces()` method
- Update `_extract_face_crop()` to handle new location format
- Implement `_calculate_cosine_similarity()` method
- Update `find_similar_faces()` to use new similarity
- Update `_calculate_adaptive_tolerance()` for DeepFace ranges
3. **Day 3: GUI Updates**
- Add detector/model selection to dashboard
- Update `FaceProcessor.__init__()` to accept settings
- Test face processing with GUI
4. **Day 4: Panel Updates**
- Update `src/gui/identify_panel.py` for new format
- Update `src/gui/auto_match_panel.py` for new format
- Update `src/gui/modify_panel.py` for new format
- Verify all face displays work correctly
5. **Day 5: Testing & Refinement**
- Create `tests/test_deepface_integration.py`
- Run all tests and fix issues
- Process test photos from `demo_photos/testdeepface/`
- Validate matching accuracy
- Adjust thresholds if needed
6. **Day 6: Documentation & Cleanup**
- Update README.md with DeepFace information
- Document detector backend options
- Document model options and trade-offs
- Remove old face_recognition references
- Final testing
---
## PHASE 8: Key Differences and Gotchas
### Encoding Size Change
- **face_recognition:** 128 floats = 1,024 bytes
- **DeepFace ArcFace:** 512 floats = 4,096 bytes
- **Impact:** Database size will be ~4x larger for encodings
- **Action:** Ensure sufficient disk space
### Location Format Change
- **face_recognition:** tuple `(top, right, bottom, left)`
- **DeepFace:** dict `{'x': x, 'y': y, 'w': w, 'h': h}`
- **Impact:** All location parsing code must be updated
- **Action:** Create helper function to convert between formats
### Tolerance/Threshold Adjustments
- **face_recognition:** Default 0.6 works well
- **DeepFace:** Lower tolerance needed (0.4 recommended)
- **Impact:** Matching sensitivity changes
- **Action:** Test and adjust `DEFAULT_FACE_TOLERANCE` in config
### Performance Considerations
- **DeepFace:** Slower than face_recognition (uses deep learning)
- **Mitigation:** Use GPU if available, cache results, process in batches
- **Action:** Add progress indicators, allow cancellation
### Dependencies
- **DeepFace requires:** TensorFlow, OpenCV, specific detectors
- **Size:** ~500MB+ of additional packages and models
- **Action:** Warn users about download size during installation
---
## PHASE 9: Rollback Plan (If Needed)
Since we're starting fresh (no backward compatibility), rollback is simple:
1. **Restore database:**
```bash
rm data/photos.db
cp data/photos.db.backup data/photos.db # If backup exists
```
2. **Restore code:**
```bash
git checkout HEAD -- src/core/face_processing.py src/core/database.py src/core/config.py requirements.txt
```
3. **Reinstall dependencies:**
```bash
pip uninstall deepface tensorflow
pip install face-recognition
```
---
## PHASE 10: Success Criteria
Migration is complete when:
- [ ] All face_recognition imports removed
- [ ] DeepFace successfully detects faces in test images
- [ ] Face matching produces accurate results
- [ ] All GUI panels work with new format
- [ ] Database stores DeepFace encodings correctly
- [ ] Test suite passes all tests
- [ ] Documentation updated
- [ ] No regression in core functionality
- [ ] Performance is acceptable (may be slower but accurate)
---
## Additional Notes for Agent
1. **Import Order Matters:**
```python
# ALWAYS import in this order:
import os
import warnings
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
warnings.filterwarnings('ignore')
# THEN import DeepFace
from deepface import DeepFace
```
2. **Error Handling:**
- DeepFace can throw various TensorFlow errors
- Always wrap DeepFace calls in try-except
- Use `enforce_detection=False` to avoid crashes on no-face images
3. **Model Downloads:**
- First run will download models (~100MB+)
- Store in `~/.deepface/weights/`
- Plan for initial download time
4. **Testing Strategy:**
- Use `demo_photos/testdeepface/` for initial testing
- These were already tested in `test_deepface_gui.py`
- Known good results for comparison
5. **Code Quality:**
- Maintain existing code style
- Keep verbose levels consistent
- Preserve all existing functionality
- Add comments explaining DeepFace-specific code
---
## Files to Modify (Summary)
1. **`requirements.txt`** - Update dependencies
2. **`src/core/config.py`** - Add DeepFace configuration
3. **`src/core/database.py`** - Update schema and methods
4. **`src/core/face_processing.py`** - Replace all face_recognition code
5. **`src/gui/dashboard_gui.py`** - Add detector/model selection
6. **`src/gui/identify_panel.py`** - Update for new formats
7. **`src/gui/auto_match_panel.py`** - Update for new formats
8. **`src/gui/modify_panel.py`** - Update for new formats
9. **`scripts/migrate_to_deepface.py`** - New migration script
10. **`tests/test_deepface_integration.py`** - New test suite
---
**END OF MIGRATION PLAN**
This plan provides a complete, step-by-step guide for migrating from face_recognition to DeepFace. Execute phases in order, test thoroughly, and refer to `tests/test_deepface_gui.py` for working DeepFace implementation examples.

View File

@ -0,0 +1,127 @@
# Directory Structure
## Overview
```
punimtag/
├── .notes/ # Project notes and planning
│ ├── project_overview.md # High-level project info
│ ├── task_list.md # Task tracking
│ ├── directory_structure.md # This file
│ └── meeting_notes.md # Meeting records
├── src/ # Source code
│ ├── __init__.py
│ ├── photo_tagger.py # CLI entry point
│ ├── setup.py # Package setup
│ │
│ ├── core/ # Business logic
│ │ ├── __init__.py
│ │ ├── config.py # Configuration
│ │ ├── database.py # Database manager
│ │ ├── face_processing.py # Face recognition
│ │ ├── photo_management.py # Photo operations
│ │ ├── tag_management.py # Tag operations
│ │ └── search_stats.py # Search & analytics
│ │
│ ├── gui/ # GUI components
│ │ ├── __init__.py
│ │ ├── dashboard_gui.py # Main dashboard
│ │ ├── gui_core.py # Common utilities
│ │ ├── identify_panel.py # Identification UI
│ │ ├── auto_match_panel.py # Auto-matching UI
│ │ ├── modify_panel.py # Person editing UI
│ │ └── tag_manager_panel.py # Tag management UI
│ │
│ └── utils/ # Utility functions
│ ├── __init__.py
│ └── path_utils.py # Path operations
├── tests/ # Test suite
│ ├── __init__.py
│ ├── test_deepface_gui.py # DeepFace testing
│ ├── test_face_recognition.py # Face rec tests
│ ├── test_simple_gui.py # GUI tests
│ ├── test_thumbnail_sizes.py # UI tests
│ ├── debug_face_detection.py # Debug tools
│ └── show_large_thumbnails.py # Debug tools
├── docs/ # Documentation
│ ├── README.md # Main documentation
│ ├── ARCHITECTURE.md # System architecture
│ ├── DEMO.md # Demo guide
│ └── README_UNIFIED_DASHBOARD.md
├── data/ # Application data
│ └── photos.db # SQLite database
├── demo_photos/ # Sample photos for testing
│ ├── events/
│ ├── more_photos/
│ └── testdeepface/
├── scripts/ # Utility scripts
│ └── drop_all_tables.py # Database utilities
├── archive/ # Legacy/backup files
│ ├── *_backup.py # Old versions
│ └── *_gui.py # Legacy GUIs
├── logs/ # Application logs
├── venv/ # Virtual environment
├── .git/ # Git repository
├── .gitignore # Git ignore rules
├── .cursorrules # Cursor AI rules
├── .cursorignore # Cursor ignore rules
├── requirements.txt # Python dependencies
├── gui_config.json # GUI preferences
├── demo.sh # Demo script
└── run_deepface_gui.sh # Run script
```
## Import Path Examples
### From core modules:
```python
from src.core.database import DatabaseManager
from src.core.face_processing import FaceProcessor
from src.core.config import DEFAULT_DB_PATH
```
### From GUI modules:
```python
from src.gui.dashboard_gui import DashboardGUI
from src.gui.gui_core import GUICore
```
### From utils:
```python
from src.utils.path_utils import normalize_path
```
## Entry Points
### GUI Application
```bash
python src/gui/dashboard_gui.py
```
### CLI Application
```bash
python src/photo_tagger.py
```
### Tests
```bash
python -m pytest tests/
```
## Notes
- All source code in `src/` directory
- Tests separate from source code
- Documentation in `docs/`
- Project notes in `.notes/`
- Legacy code archived in `archive/`

73
.notes/meeting_notes.md Normal file
View File

@ -0,0 +1,73 @@
# Meeting Notes
## 2025-10-15: Project Restructuring
### Attendees
- Development Team
### Discussion
- Agreed to restructure project for better organization
- Adopted standard Python project layout
- Separated concerns: core, gui, utils, tests
- Created .notes directory for project management
### Decisions
1. Move all business logic to `src/core/`
2. Move all GUI components to `src/gui/`
3. Move utilities to `src/utils/`
4. Consolidate tests in `tests/`
5. Move documentation to `docs/`
6. Archive legacy code instead of deleting
### Action Items
- [x] Create new directory structure
- [x] Move files to appropriate locations
- [x] Create __init__.py files for packages
- [x] Create project notes
- [ ] Update import statements
- [ ] Test all functionality
- [ ] Update documentation
---
## 2025-10-15: DeepFace Migration Planning
### Attendees
- Development Team
### Discussion
- Analyzed test_deepface_gui.py results
- DeepFace shows better accuracy than face_recognition
- ArcFace model recommended for best results
- RetinaFace detector provides best face detection
### Decisions
1. Migrate from face_recognition to DeepFace
2. Use ArcFace model (512-dim encodings)
3. Use RetinaFace detector as default
4. Support multiple detector backends
5. No backward compatibility - fresh start
### Action Items
- [x] Document migration plan
- [x] Create architecture document
- [ ] Update database schema
- [ ] Implement DeepFace integration
- [ ] Create migration script
- [ ] Test with demo photos
### Technical Notes
- Encoding size: 128 → 512 dimensions
- Similarity metric: Euclidean → Cosine
- Location format: tuple → dict
- Tolerance adjustment: 0.6 → 0.4
---
## Future Topics
- Web interface design
- Cloud storage integration
- Performance optimization
- Multi-user support
- Mobile app development

View File

@ -0,0 +1,88 @@
# Phase 1 Quick Start Guide
## What Was Done
Phase 1: Database Schema Updates ✅ COMPLETE
All database tables and methods updated to support DeepFace:
- New columns for detector backend and model name
- Support for 512-dimensional encodings (ArcFace)
- Enhanced face confidence tracking
- Migration script ready to use
## Quick Commands
### Run Tests
```bash
cd /home/ladmin/Code/punimtag
source venv/bin/activate
python3 tests/test_phase1_schema.py
```
### Migrate Existing Database (⚠️ DELETES ALL DATA)
```bash
cd /home/ladmin/Code/punimtag
source venv/bin/activate
python3 scripts/migrate_to_deepface.py
```
### Install New Dependencies (for Phase 2+)
```bash
cd /home/ladmin/Code/punimtag
source venv/bin/activate
pip install -r requirements.txt
```
## Files Modified
1. **requirements.txt** - DeepFace dependencies
2. **src/core/config.py** - DeepFace configuration
3. **src/core/database.py** - Schema + method updates
## Files Created
1. **scripts/migrate_to_deepface.py** - Migration script
2. **tests/test_phase1_schema.py** - Test suite
3. **PHASE1_COMPLETE.md** - Full documentation
## Next Steps
Ready to proceed to **Phase 2** or **Phase 3**:
### Phase 2: Configuration Updates
- Add TensorFlow suppression to entry points
- Update GUI with detector/model selection
### Phase 3: Core Face Processing
- Replace face_recognition with DeepFace
- Update process_faces() method
- Implement cosine similarity
## Quick Verification
```bash
# Check schema has new columns
cd /home/ladmin/Code/punimtag
source venv/bin/activate
python3 -c "
from src.core.database import DatabaseManager
import tempfile
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = DatabaseManager(tmp.name, verbose=0)
print('✅ Database initialized with DeepFace schema')
"
```
## Test Results
```
Tests passed: 4/4
✅ PASS: Schema Columns
✅ PASS: add_face() Method
✅ PASS: add_person_encoding() Method
✅ PASS: Config Constants
```
All systems ready for DeepFace implementation!

132
.notes/phase2_quickstart.md Normal file
View File

@ -0,0 +1,132 @@
# Phase 2 Quick Start Guide
## What Was Done
Phase 2: Configuration Updates ✅ COMPLETE
Added GUI controls for DeepFace settings and updated FaceProcessor to accept them.
## Quick Commands
### Run Tests
```bash
cd /home/ladmin/Code/punimtag
source venv/bin/activate
python3 tests/test_phase2_config.py
```
Expected: **5/5 tests passing**
### Test GUI (Visual Check)
```bash
cd /home/ladmin/Code/punimtag
source venv/bin/activate
python3 run_dashboard.py
```
Then:
1. Click "🔍 Process" button
2. Look for "DeepFace Settings" section
3. Verify two dropdowns:
- Face Detector: [retinaface ▼]
- Recognition Model: [ArcFace ▼]
## Files Modified
1. **run_dashboard.py** - Callback passes detector/model
2. **src/gui/dashboard_gui.py** - GUI controls added
3. **src/photo_tagger.py** - TF suppression
4. **src/core/face_processing.py** - Accepts detector/model params
## Files Created
1. **tests/test_phase2_config.py** - 5 tests
2. **PHASE2_COMPLETE.md** - Full documentation
## What Changed
### Before Phase 2:
```python
processor = FaceProcessor(db_manager)
```
### After Phase 2:
```python
processor = FaceProcessor(db_manager,
detector_backend='retinaface',
model_name='ArcFace')
```
### GUI Process Panel:
Now includes DeepFace Settings section with:
- Detector selection dropdown (4 options)
- Model selection dropdown (4 options)
- Help text for each option
## Test Results
```
✅ PASS: TensorFlow Suppression
✅ PASS: FaceProcessor Initialization
✅ PASS: Config Imports
✅ PASS: Entry Point Imports
✅ PASS: GUI Config Constants
Tests passed: 5/5
```
## Available Options
### Detectors:
- retinaface (default, best accuracy)
- mtcnn
- opencv
- ssd
### Models:
- ArcFace (default, 512-dim, best accuracy)
- Facenet (128-dim)
- Facenet512 (512-dim)
- VGG-Face (2622-dim)
## Important Note
⚠️ **Phase 2 adds UI/config only**
The GUI captures settings, but actual DeepFace processing happens in **Phase 3**. Currently still using face_recognition for processing.
## Next Steps
Ready for **Phase 3: Core Face Processing**
- Replace face_recognition with DeepFace
- Implement actual detector/model usage
- Update face location handling
- Implement cosine similarity
## Quick Verification
```bash
# Test FaceProcessor accepts params
cd /home/ladmin/Code/punimtag
source venv/bin/activate
python3 -c "
from src.core.database import DatabaseManager
from src.core.face_processing import FaceProcessor
db = DatabaseManager(':memory:', verbose=0)
p = FaceProcessor(db, verbose=0, detector_backend='mtcnn', model_name='Facenet')
print(f'Detector: {p.detector_backend}')
print(f'Model: {p.model_name}')
print('✅ Phase 2 working!')
"
```
Expected output:
```
Detector: mtcnn
Model: Facenet
✅ Phase 2 working!
```
All systems ready for Phase 3!

View File

@ -0,0 +1,43 @@
# PunimTag - Project Overview
## Mission Statement
PunimTag is a desktop photo management application that leverages facial recognition AI to help users organize, tag, and search their photo collections efficiently.
## Core Capabilities
- Automated face detection and recognition
- Person identification and management
- Custom tagging system
- Advanced search functionality
- Batch processing
## Current Status
- **Version**: 1.0 (Development)
- **Stage**: Active Development
- **Next Major Feature**: DeepFace Migration
## Key Technologies
- Python 3.12+
- Tkinter (GUI)
- SQLite (Database)
- face_recognition (Current - to be replaced)
- DeepFace (Planned migration)
## Project Goals
1. Make photo organization effortless
2. Provide accurate face recognition
3. Enable powerful search capabilities
4. Maintain user privacy (local-only by default)
5. Scale to large photo collections (50K+ photos)
## Success Metrics
- Face recognition accuracy > 95%
- Process 1000+ photos per hour
- Search response time < 1 second
- Zero data loss
- User-friendly interface
## Links
- Architecture: `docs/ARCHITECTURE.md`
- Main README: `docs/README.md`
- Demo Guide: `docs/DEMO.md`

View File

@ -0,0 +1,342 @@
# Project Restructure Migration Guide
## Overview
The project has been restructured to follow Python best practices with a clean separation of concerns.
---
## Directory Changes
### Before → After
```
Root Directory Files → Organized Structure
```
| Old Location | New Location | Type |
|-------------|--------------|------|
| `config.py` | `src/core/config.py` | Core |
| `database.py` | `src/core/database.py` | Core |
| `face_processing.py` | `src/core/face_processing.py` | Core |
| `photo_management.py` | `src/core/photo_management.py` | Core |
| `tag_management.py` | `src/core/tag_management.py` | Core |
| `search_stats.py` | `src/core/search_stats.py` | Core |
| `dashboard_gui.py` | `src/gui/dashboard_gui.py` | GUI |
| `gui_core.py` | `src/gui/gui_core.py` | GUI |
| `identify_panel.py` | `src/gui/identify_panel.py` | GUI |
| `auto_match_panel.py` | `src/gui/auto_match_panel.py` | GUI |
| `modify_panel.py` | `src/gui/modify_panel.py` | GUI |
| `tag_manager_panel.py` | `src/gui/tag_manager_panel.py` | GUI |
| `path_utils.py` | `src/utils/path_utils.py` | Utils |
| `photo_tagger.py` | `src/photo_tagger.py` | Entry |
| `test_*.py` | `tests/test_*.py` | Tests |
| `README.md` | `docs/README.md` | Docs |
| `ARCHITECTURE.md` | `docs/ARCHITECTURE.md` | Docs |
---
## Import Path Changes
### Core Modules
**Before:**
```python
from config import DEFAULT_DB_PATH
from database import DatabaseManager
from face_processing import FaceProcessor
from photo_management import PhotoManager
from tag_management import TagManager
from search_stats import SearchStats
```
**After:**
```python
from src.core.config import DEFAULT_DB_PATH
from src.core.database import DatabaseManager
from src.core.face_processing import FaceProcessor
from src.core.photo_management import PhotoManager
from src.core.tag_management import TagManager
from src.core.search_stats import SearchStats
```
### GUI Modules
**Before:**
```python
from gui_core import GUICore
from identify_panel import IdentifyPanel
from auto_match_panel import AutoMatchPanel
from modify_panel import ModifyPanel
from tag_manager_panel import TagManagerPanel
```
**After:**
```python
from src.gui.gui_core import GUICore
from src.gui.identify_panel import IdentifyPanel
from src.gui.auto_match_panel import AutoMatchPanel
from src.gui.modify_panel import ModifyPanel
from src.gui.tag_manager_panel import TagManagerPanel
```
### Utility Modules
**Before:**
```python
from path_utils import normalize_path, validate_path_exists
```
**After:**
```python
from src.utils.path_utils import normalize_path, validate_path_exists
```
---
## Files Requiring Import Updates
### Priority 1 - Core Files
- [ ] `src/core/face_processing.py`
- [ ] `src/core/photo_management.py`
- [ ] `src/core/tag_management.py`
- [ ] `src/core/search_stats.py`
- [ ] `src/core/database.py`
### Priority 2 - GUI Files
- [ ] `src/gui/dashboard_gui.py`
- [ ] `src/gui/identify_panel.py`
- [ ] `src/gui/auto_match_panel.py`
- [ ] `src/gui/modify_panel.py`
- [ ] `src/gui/tag_manager_panel.py`
- [ ] `src/gui/gui_core.py`
### Priority 3 - Entry Points
- [ ] `src/photo_tagger.py`
- [ ] `src/setup.py`
### Priority 4 - Tests
- [ ] `tests/test_deepface_gui.py`
- [ ] `tests/test_face_recognition.py`
- [ ] `tests/test_simple_gui.py`
---
## Search & Replace Patterns
Use these patterns to update imports systematically:
### Pattern 1: Core imports
```bash
# Find
from config import
from database import
from face_processing import
from photo_management import
from tag_management import
from search_stats import
# Replace with
from src.core.config import
from src.core.database import
from src.core.face_processing import
from src.core.photo_management import
from src.core.tag_management import
from src.core.search_stats import
```
### Pattern 2: GUI imports
```bash
# Find
from gui_core import
from identify_panel import
from auto_match_panel import
from modify_panel import
from tag_manager_panel import
# Replace with
from src.gui.gui_core import
from src.gui.identify_panel import
from src.gui.auto_match_panel import
from src.gui.modify_panel import
from src.gui.tag_manager_panel import
```
### Pattern 3: Utils imports
```bash
# Find
from path_utils import
# Replace with
from src.utils.path_utils import
```
---
## Running the Application After Restructure
### GUI Dashboard
```bash
# Old
python dashboard_gui.py
# New
python src/gui/dashboard_gui.py
# OR
python -m src.gui.dashboard_gui
```
### CLI Tool
```bash
# Old
python photo_tagger.py
# New
python src/photo_tagger.py
# OR
python -m src.photo_tagger
```
### Tests
```bash
# Old
python test_deepface_gui.py
# New
python tests/test_deepface_gui.py
# OR
python -m pytest tests/
```
---
## Verification Steps
### 1. Check Import Errors
```bash
cd /home/ladmin/Code/punimtag
python -c "from src.core import DatabaseManager; print('Core imports OK')"
python -c "from src.gui import GUICore; print('GUI imports OK')"
python -c "from src.utils import normalize_path; print('Utils imports OK')"
```
### 2. Test Each Module
```bash
# Test core modules
python -c "from src.core.database import DatabaseManager; db = DatabaseManager(':memory:'); print('Database OK')"
# Test GUI modules (may need display)
python -c "from src.gui.gui_core import GUICore; print('GUI Core OK')"
```
### 3. Run Application
```bash
# Try to launch dashboard
python src/gui/dashboard_gui.py
```
### 4. Run Tests
```bash
# Run test suite
python -m pytest tests/ -v
```
---
## Common Issues and Solutions
### Issue 1: ModuleNotFoundError
```
ModuleNotFoundError: No module named 'config'
```
**Solution:** Update import from `from config import` to `from src.core.config import`
### Issue 2: Relative Import Error
```
ImportError: attempted relative import with no known parent package
```
**Solution:** Use absolute imports with `src.` prefix
### Issue 3: Circular Import
```
ImportError: cannot import name 'X' from partially initialized module
```
**Solution:** Check for circular dependencies, may need to refactor
### Issue 4: sys.path Issues
If imports still fail, add to top of file:
```python
import sys
from pathlib import Path
# Add project root to path
project_root = Path(__file__).parent.parent # Adjust based on file location
sys.path.insert(0, str(project_root))
```
---
## Rollback Plan
If issues arise, you can rollback:
```bash
# Revert git changes
git checkout HEAD -- .
# Or manually move files back
mv src/core/*.py .
mv src/gui/*.py .
mv src/utils/*.py .
mv tests/*.py .
mv docs/*.md .
```
---
## Benefits of New Structure
**Better Organization**: Clear separation of concerns
**Easier Navigation**: Files grouped by function
**Professional**: Follows Python community standards
**Scalable**: Easy to add new modules
**Testable**: Tests separate from source
**Maintainable**: Clear dependencies
---
## Next Steps
1. ✅ Directory structure created
2. ✅ Files moved to new locations
3. ✅ __init__.py files created
4. ✅ Documentation updated
5. ⏳ Update import statements (NEXT)
6. ⏳ Test all functionality
7. ⏳ Update scripts and launchers
8. ⏳ Commit changes
---
## Testing Checklist
After updating imports, verify:
- [ ] Dashboard GUI launches
- [ ] Can scan for photos
- [ ] Face processing works
- [ ] Face identification works
- [ ] Auto-matching works
- [ ] Tag management works
- [ ] Search functionality works
- [ ] Database operations work
- [ ] All tests pass
---
**Status**: Files moved, imports need updating
**Last Updated**: 2025-10-15
**Next Action**: Update import statements in all files

65
.notes/task_list.md Normal file
View File

@ -0,0 +1,65 @@
# Task List
## High Priority
### DeepFace Migration
- [ ] Update requirements.txt with DeepFace dependencies
- [ ] Modify database schema for DeepFace support
- [ ] Implement DeepFace face detection
- [ ] Implement cosine similarity matching
- [ ] Update GUI for detector selection
- [ ] Create migration script
- [ ] Test with demo photos
- [ ] Update documentation
### Bug Fixes
- [ ] Fix import paths after restructuring
- [ ] Update all relative imports to absolute imports
- [ ] Test all GUI panels after restructure
- [ ] Verify database connections work
## Medium Priority
### Code Quality
- [ ] Add type hints throughout codebase
- [ ] Improve error handling consistency
- [ ] Add logging framework
- [ ] Increase test coverage
- [ ] Document all public APIs
### Features
- [ ] Add batch face identification
- [ ] Implement face clustering
- [ ] Add photo timeline view
- [ ] Implement advanced filters
- [ ] Add keyboard shortcuts
## Low Priority
### Performance
- [ ] Optimize database queries
- [ ] Implement result caching
- [ ] Add lazy loading for large datasets
- [ ] Profile and optimize slow operations
### Documentation
- [ ] Create developer guide
- [ ] Add API documentation
- [ ] Create video tutorials
- [ ] Write troubleshooting guide
## Completed
- [x] Restructure project to organized layout
- [x] Create architecture documentation
- [x] Unified dashboard interface
- [x] Auto-matching functionality
- [x] Tag management system
- [x] Search and statistics
## Backlog
- Web interface migration
- Cloud storage integration
- Mobile app
- Video face detection
- Multi-user support

522
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,522 @@
# Contributing to PunimTag
Thank you for your interest in contributing to PunimTag! This document provides guidelines and instructions for contributing to the project.
---
## 📋 Table of Contents
1. [Code of Conduct](#code-of-conduct)
2. [Getting Started](#getting-started)
3. [Development Workflow](#development-workflow)
4. [Coding Standards](#coding-standards)
5. [Testing](#testing)
6. [Documentation](#documentation)
7. [Pull Request Process](#pull-request-process)
8. [Project Structure](#project-structure)
---
## 🤝 Code of Conduct
### Our Pledge
We are committed to providing a welcoming and inclusive environment for all contributors.
### Expected Behavior
- Be respectful and considerate
- Welcome newcomers and help them learn
- Accept constructive criticism gracefully
- Focus on what's best for the project
- Show empathy towards other contributors
### Unacceptable Behavior
- Harassment or discriminatory language
- Trolling or insulting comments
- Public or private harassment
- Publishing others' private information
- Other unprofessional conduct
---
## 🚀 Getting Started
### Prerequisites
- Python 3.12+
- Git
- Basic understanding of Python and Tkinter
- Familiarity with face recognition concepts (helpful)
### Setting Up Development Environment
1. **Fork and Clone**
```bash
git fork <repository-url>
git clone <your-fork-url>
cd punimtag
```
2. **Create Virtual Environment**
```bash
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
```
3. **Install Dependencies**
```bash
pip install -r requirements.txt
pip install -r requirements-dev.txt # If available
```
4. **Verify Installation**
```bash
python src/gui/dashboard_gui.py
```
---
## 🔄 Development Workflow
### Branch Strategy
```
main/master - Stable releases
develop - Integration branch
feature/* - New features
bugfix/* - Bug fixes
hotfix/* - Urgent fixes
release/* - Release preparation
```
### Creating a Feature Branch
```bash
git checkout develop
git pull origin develop
git checkout -b feature/your-feature-name
```
### Making Changes
1. Make your changes in the appropriate directory:
- Business logic: `src/core/`
- GUI components: `src/gui/`
- Utilities: `src/utils/`
- Tests: `tests/`
2. Follow coding standards (see below)
3. Add/update tests
4. Update documentation
5. Test your changes thoroughly
### Committing Changes
Use clear, descriptive commit messages:
```bash
git add .
git commit -m "feat: add face clustering algorithm"
```
**Commit Message Format:**
```
<type>: <subject>
<body>
<footer>
```
**Types:**
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `style`: Code style changes (formatting)
- `refactor`: Code refactoring
- `test`: Adding or updating tests
- `chore`: Maintenance tasks
**Examples:**
```
feat: add DeepFace integration
- Replace face_recognition with DeepFace
- Implement ArcFace model
- Add cosine similarity matching
- Update database schema
Closes #123
```
---
## 📏 Coding Standards
### Python Style Guide
Follow **PEP 8** with these specifics:
#### Formatting
- **Indentation**: 4 spaces (no tabs)
- **Line Length**: 100 characters max (120 for comments)
- **Imports**: Grouped and sorted
```python
# Standard library
import os
import sys
# Third-party
import numpy as np
from PIL import Image
# Local
from src.core.database import DatabaseManager
```
#### Naming Conventions
- **Classes**: `PascalCase` (e.g., `FaceProcessor`)
- **Functions/Methods**: `snake_case` (e.g., `process_faces`)
- **Constants**: `UPPER_SNAKE_CASE` (e.g., `DEFAULT_TOLERANCE`)
- **Private**: Prefix with `_` (e.g., `_internal_method`)
#### Documentation
All public classes and functions must have docstrings:
```python
def process_faces(self, limit: int = 50) -> int:
"""Process unprocessed photos for faces.
Args:
limit: Maximum number of photos to process
Returns:
Number of photos successfully processed
Raises:
DatabaseError: If database connection fails
"""
pass
```
#### Type Hints
Use type hints for all function signatures:
```python
from typing import List, Dict, Optional
def get_similar_faces(
self,
face_id: int,
tolerance: float = 0.6
) -> List[Dict[str, Any]]:
pass
```
### Code Organization
#### File Structure
```python
#!/usr/bin/env python3
"""
Module description
"""
# Imports
import os
from typing import List
# Constants
DEFAULT_VALUE = 42
# Classes
class MyClass:
"""Class description"""
pass
# Functions
def my_function():
"""Function description"""
pass
# Main execution
if __name__ == "__main__":
main()
```
#### Error Handling
Always use specific exception types:
```python
try:
result = risky_operation()
except FileNotFoundError as e:
logger.error(f"File not found: {e}")
raise
except ValueError as e:
logger.warning(f"Invalid value: {e}")
return default_value
```
---
## 🧪 Testing
### Writing Tests
Create tests in `tests/` directory:
```python
import pytest
from src.core.face_processing import FaceProcessor
def test_face_detection():
"""Test face detection on sample image"""
processor = FaceProcessor(db_manager, verbose=0)
result = processor.process_faces(limit=1)
assert result > 0
def test_similarity_calculation():
"""Test face similarity metric"""
processor = FaceProcessor(db_manager, verbose=0)
similarity = processor._calculate_cosine_similarity(enc1, enc2)
assert 0.0 <= similarity <= 1.0
```
### Running Tests
```bash
# All tests
python -m pytest tests/
# Specific test file
python tests/test_face_recognition.py
# With coverage
pytest --cov=src tests/
# Verbose output
pytest -v tests/
```
### Test Guidelines
1. **Test Coverage**: Aim for >80% code coverage
2. **Test Names**: Descriptive names starting with `test_`
3. **Assertions**: Use clear assertion messages
4. **Fixtures**: Use pytest fixtures for setup/teardown
5. **Isolation**: Tests should not depend on each other
---
## 📚 Documentation
### What to Document
1. **Code Changes**: Update docstrings
2. **API Changes**: Update API documentation
3. **New Features**: Add to README and docs
4. **Breaking Changes**: Clearly mark in changelog
5. **Architecture**: Update ARCHITECTURE.md if needed
### Documentation Style
- Use Markdown for all documentation
- Include code examples
- Add diagrams where helpful
- Keep language clear and concise
- Update table of contents
### Files to Update
- `README.md`: User-facing documentation
- `docs/ARCHITECTURE.md`: Technical architecture
- `.notes/task_list.md`: Task tracking
- Inline comments: Complex logic explanation
---
## 🔀 Pull Request Process
### Before Submitting
- [ ] Code follows style guidelines
- [ ] All tests pass
- [ ] New tests added for new features
- [ ] Documentation updated
- [ ] No linting errors
- [ ] Commit messages are clear
- [ ] Branch is up to date with develop
### Submitting PR
1. **Push your branch**
```bash
git push origin feature/your-feature-name
```
2. **Create Pull Request**
- Go to GitHub/GitLab
- Click "New Pull Request"
- Select your feature branch
- Fill out PR template
3. **PR Title Format**
```
[Type] Short description
Examples:
[Feature] Add DeepFace integration
[Bug Fix] Fix face detection on rotated images
[Docs] Update architecture documentation
```
4. **PR Description Template**
```markdown
## Description
Brief description of changes
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Related Issues
Closes #123
## Testing
Describe testing performed
## Screenshots (if applicable)
Add screenshots
## Checklist
- [ ] Code follows style guidelines
- [ ] Tests added/updated
- [ ] Documentation updated
- [ ] All tests pass
```
### Review Process
1. **Automated Checks**: Must pass all CI/CD checks
2. **Code Review**: At least one approval required
3. **Discussion**: Address all review comments
4. **Updates**: Make requested changes
5. **Approval**: Merge after approval
### After Merge
1. Delete feature branch
2. Pull latest develop
3. Update local repository
---
## 🏗️ Project Structure
### Key Directories
```
src/
├── core/ # Business logic - most changes here
├── gui/ # GUI components - UI changes here
└── utils/ # Utilities - helper functions
tests/ # All tests go here
docs/ # User documentation
.notes/ # Developer notes
```
### Module Dependencies
```
gui → core → database
gui → utils
core → utils
```
**Rules:**
- Core modules should not import GUI modules
- Utils should not import core or GUI
- Avoid circular dependencies
---
## 💡 Tips for Contributors
### Finding Issues to Work On
- Look for `good first issue` label
- Check `.notes/task_list.md`
- Ask in discussions
### Getting Help
- Read documentation first
- Check existing issues
- Ask in discussions
- Contact maintainers
### Best Practices
1. **Start Small**: Begin with small changes
2. **One Feature**: One PR = one feature
3. **Test Early**: Write tests as you code
4. **Ask Questions**: Better to ask than assume
5. **Be Patient**: Reviews take time
---
## 🎯 Areas Needing Contribution
### High Priority
- DeepFace integration
- Test coverage improvement
- Performance optimization
- Documentation updates
### Medium Priority
- GUI improvements
- Additional search filters
- Export functionality
- Backup/restore features
### Low Priority
- Code refactoring
- Style improvements
- Additional themes
- Internationalization
---
## 📞 Contact
- **Issues**: GitHub Issues
- **Discussions**: GitHub Discussions
- **Email**: [Add email]
---
## 🙏 Recognition
Contributors will be:
- Listed in AUTHORS file
- Mentioned in release notes
- Thanked in documentation
---
## 📄 License
By contributing, you agree that your contributions will be licensed under the same license as the project.
---
**Thank you for contributing to PunimTag! 🎉**
Every contribution, no matter how small, makes a difference!

374
MERGE_REQUEST.md Normal file
View File

@ -0,0 +1,374 @@
`1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111# Merge Request: PunimTag Web Application - Major Feature Release
## Overview
This merge request contains a comprehensive set of changes that transform PunimTag from a desktop GUI application into a modern web-based photo management system with advanced facial recognition capabilities. The changes span from September 2025 to January 2026 and include migration to DeepFace, PostgreSQL support, web frontend implementation, and extensive feature additions.
## Summary Statistics
- **Total Commits**: 200+ commits
- **Files Changed**: 226 files
- **Lines Added**: ~71,189 insertions
- **Lines Removed**: ~1,670 deletions
- **Net Change**: +69,519 lines
- **Date Range**: September 19, 2025 - January 6, 2026
## Key Changes
### 1. Architecture Migration
#### Desktop to Web Migration
- **Removed**: Complete desktop GUI application (Tkinter-based)
- Archive folder with 22+ desktop GUI files removed
- Old photo_tagger.py desktop application removed
- All desktop-specific components archived
- **Added**: Modern web application architecture
- FastAPI backend with RESTful API
- React-based admin frontend
- Next.js-based viewer frontend
- Monorepo structure for unified development
#### Database Migration
- **From**: SQLite database
- **To**: PostgreSQL database
- Dual database architecture (main + auth databases)
- Comprehensive migration scripts
- Database architecture review documentation
- Enhanced data validation and type safety
### 2. Face Recognition Engine Upgrade
#### DeepFace Integration
- **Replaced**: face_recognition library
- **New**: DeepFace with ArcFace model
- 512-dimensional embeddings (4x more detailed)
- Multiple detector options (RetinaFace, MTCNN, OpenCV, SSD)
- Multiple recognition models (ArcFace, Facenet, Facenet512, VGG-Face)
- Improved accuracy and performance
- Pose detection using RetinaFace
- Face quality scoring and filtering
#### Face Processing Enhancements
- EXIF orientation handling`
- Face width detection for profile classification
- Landmarks column for pose detection
- Quality filtering in identification process
- Batch similarity endpoint for efficient face comparison
- Unique faces filter to hide duplicates
- Confidence calibration for realistic match probabilities
### 3. Backend API Development
#### Core API Endpoints
- **Authentication & Authorization**
- JWT-based authentication
- Role-based access control (RBAC)
- User management API
- Password change functionality
- Session management
- **Photo Management**
- Photo upload and import
- Photo search with advanced filters
- Photo tagging and organization
- Bulk operations (delete, tag)
- Favorites functionality
- Media type support (images and videos)
- Date validation and EXIF extraction
- **Face Management**
- Face processing with job queue
- Face identification workflow
- Face similarity matching
- Excluded faces management
- Face quality filtering
- Batch processing support
- **People Management**
- Person creation and identification
- Person search and filtering
- Person modification
- Auto-match functionality
- Pending identifications workflow
- Person statistics and counts
- **Tag Management**
- Tag creation and management
- Photo-tag linkages
- Tag filtering and search
- Bulk tagging operations
- **Video Support**
- Video upload and processing
- Video player modal
- Video metadata extraction
- Video person identification
- **Job Management**
- Background job processing with RQ
- Job status tracking
- Job cancellation support
- Progress updates
- **User Management**
- Admin user management
- Role and permission management
- User activity tracking
- Inactivity timeout
- **Reporting & Moderation**
- Reported photos management
- Pending photos review
- Pending linkages approval
- Identification statistics
### 4. Frontend Development
#### Admin Frontend (React)
- **Scan Page**: Photo import and processing
- Native folder picker integration
- Network path support
- Progress tracking
- Job management
- **Search Page**: Advanced photo search
- Multiple search types (name, date, tags, no_faces, no_tags, processed, unprocessed, favorites)
- Person autocomplete
- Date range filters
- Tag filtering
- Media type filtering
- Pagination
- Session state management
- **Identify Page**: Face identification
- Unidentified faces display
- Person creation and matching
- Quality filtering
- Date filters
- Excluded faces management
- Pagination and navigation
- Setup area toggle
- **AutoMatch Page**: Automated face matching
- Auto-start on mount
- Tolerance configuration
- Quality criteria
- Tag filtering
- Developer mode options
- **Modify Page**: Person modification
- Face selection and unselection
- Person information editing
- Video player modal
- Search filters
- **Tags Page**: Tag management
- Tag creation and editing
- People names integration
- Sorting and filtering
- Tag statistics
- **Faces Maintenance Page**: Face management
- Excluded and identified filters
- Face quality display
- Face deletion
- **User Management Pages**
- User creation and editing
- Role assignment
- Permission management
- Password management
- User activity tracking
- **Reporting & Moderation Pages**
- Pending identifications approval
- Reported photos review
- Pending photos management
- Pending linkages approval
- **UI Enhancements**
- Logo integration
- Emoji page titles
- Password visibility toggle
- Loading progress indicators
- Confirmation dialogs
- Responsive design
- Developer mode features
#### Viewer Frontend (Next.js)
- Photo viewer component with zoom and slideshow
- Photo browsing and navigation
- Tag management interface
- Person identification display
- Favorites functionality
### 5. Infrastructure & DevOps
#### Installation & Setup
- Comprehensive installation script (`install.sh`)
- Automated system dependency installation
- PostgreSQL and Redis setup
- Python virtual environment creation
- Frontend dependency installation
- Environment configuration
- Database initialization
#### Scripts & Utilities
- Database management scripts
- Table creation and migration
- Database backup and restore
- SQLite to PostgreSQL migration
- Auth database setup
- Development utilities
- Face detection debugging
- Pose analysis scripts
- Database diagnostics
- Frontend issue diagnosis
#### Deployment
- Docker Compose configuration
- Backend startup scripts
- Worker process management
- Health check endpoints
### 6. Documentation
#### Technical Documentation
- Architecture documentation
- Database architecture review
- API documentation
- Phase completion summaries
- Migration guides
#### User Documentation
- Comprehensive user guide
- Quick start guides
- Feature documentation
- Installation instructions
#### Analysis Documents
- Video support analysis
- Portrait detection plan
- Auto-match automation plan
- Resource requirements
- Performance analysis
- Client deployment questions
### 7. Testing & Quality Assurance
#### Test Suite
- Face recognition tests
- EXIF extraction tests
- API endpoint tests
- Database migration tests
- Integration tests
#### Code Quality
- Type hints throughout codebase
- Comprehensive error handling
- Input validation
- Security best practices
- Code organization and structure
### 8. Cleanup & Maintenance
#### Repository Cleanup
- Removed archived desktop GUI files (22 files)
- Removed demo photos and resources
- Removed uploaded test files
- Updated .gitignore to prevent re-adding unnecessary files
- Removed obsolete migration files
#### Code Refactoring
- Improved database connection management
- Enhanced error handling
- Better code organization
- Improved type safety
- Performance optimizations
## Breaking Changes
1. **Database**: Migration from SQLite to PostgreSQL is required
2. **API**: New RESTful API replaces desktop GUI
3. **Dependencies**: New system requirements (PostgreSQL, Redis, Node.js)
4. **Configuration**: New environment variables and configuration files
## Migration Path
1. **Database Migration**
- Run PostgreSQL setup script
- Execute SQLite to PostgreSQL migration script
- Verify data integrity
2. **Environment Setup**
- Install system dependencies (PostgreSQL, Redis)
- Run installation script
- Configure environment variables
- Generate Prisma clients
3. **Application Deployment**
- Start PostgreSQL and Redis services
- Run database migrations
- Start backend API
- Start frontend applications
## Testing Checklist
- [x] Database migration scripts tested
- [x] API endpoints functional
- [x] Face recognition accuracy verified
- [x] Frontend components working
- [x] Authentication and authorization tested
- [x] Job processing verified
- [x] Video support tested
- [x] Search functionality validated
- [x] Tag management verified
- [x] User management tested
## Known Issues & Limitations
1. **Performance**: Large photo collections may require optimization
2. **Memory**: DeepFace models require significant memory
3. **Network**: Network path support may vary by OS
4. **Browser**: Some features require modern browsers
## Future Enhancements
- Enhanced video processing
- Advanced analytics and reporting
- Mobile app support
- Cloud storage integration
- Advanced AI features
- Performance optimizations
## Contributors
- Tanya (tatiana.romlit@gmail.com) - Primary developer
- tanyar09 - Initial development
## Related Documentation
- `README.md` - Main project documentation
- `docs/ARCHITECTURE.md` - System architecture
- `docs/DATABASE_ARCHITECTURE_REVIEW.md` - Database design
- `docs/USER_GUIDE.md` - User documentation
- `MONOREPO_MIGRATION.md` - Migration details
## Approval Checklist
- [ ] Code review completed
- [ ] Tests passing
- [ ] Documentation updated
- [ ] Migration scripts tested
- [ ] Performance validated
- [ ] Security review completed
- [ ] Deployment plan reviewed
---
**Merge Request Created**: January 6, 2026
**Base Branch**: `origin/master`
**Target Branch**: `master`
**Status**: Ready for Review

1205
README.md

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json'],
},
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:@typescript-eslint/recommended',
],
settings: {
react: {
version: 'detect',
},
},
rules: {
'max-len': [
'error',
{
code: 100,
tabWidth: 2,
ignoreUrls: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
},
],
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
}

57
admin-frontend/README.md Normal file
View File

@ -0,0 +1,57 @@
# PunimTag Frontend
React + Vite + TypeScript frontend for PunimTag.
## Setup
```bash
cd frontend
npm install
```
## Development
Start the dev server:
```bash
npm run dev
```
The frontend will run on http://localhost:3000
Make sure the backend API is running on http://127.0.0.1:8000
## Default Login
- Username: `admin`
- Password: `admin`
## Features (Phase 1)
- ✅ Login page with JWT authentication
- ✅ Protected routes with auth check
- ✅ Navigation layout (left sidebar + top bar)
- ✅ Dashboard page (placeholder)
- ✅ Search page (placeholder)
- ✅ Identify page (placeholder)
- ✅ Auto-Match page (placeholder)
- ✅ Tags page (placeholder)
- ✅ Settings page (placeholder)
## Project Structure
```
frontend/
├── src/
│ ├── api/ # API client and endpoints
│ ├── components/ # React components
│ ├── hooks/ # Custom React hooks
│ ├── pages/ # Page components
│ ├── App.tsx # Main app component
│ ├── main.tsx # Entry point
│ └── index.css # Tailwind CSS
├── index.html
├── package.json
├── vite.config.ts
└── tailwind.config.js
```

14
admin-frontend/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PunimTag</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6182
admin-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
{
"name": "punimtag-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@tanstack/react-query": "^5.8.4",
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.4.0"
}
}

View File

@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

141
admin-frontend/src/App.tsx Normal file
View File

@ -0,0 +1,141 @@
import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { DeveloperModeProvider } from './context/DeveloperModeContext'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Search from './pages/Search'
import Scan from './pages/Scan'
import Process from './pages/Process'
import Identify from './pages/Identify'
import AutoMatch from './pages/AutoMatch'
import Modify from './pages/Modify'
import Tags from './pages/Tags'
import FacesMaintenance from './pages/FacesMaintenance'
import ApproveIdentified from './pages/ApproveIdentified'
import ManageUsers from './pages/ManageUsers'
import ReportedPhotos from './pages/ReportedPhotos'
import PendingPhotos from './pages/PendingPhotos'
import UserTaggedPhotos from './pages/UserTaggedPhotos'
import ManagePhotos from './pages/ManagePhotos'
import Settings from './pages/Settings'
import Help from './pages/Help'
import Layout from './components/Layout'
import PasswordChangeModal from './components/PasswordChangeModal'
import AdminRoute from './components/AdminRoute'
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading, passwordChangeRequired } = useAuth()
const [showPasswordModal, setShowPasswordModal] = useState(false)
useEffect(() => {
if (isAuthenticated && passwordChangeRequired) {
setShowPasswordModal(true)
}
}, [isAuthenticated, passwordChangeRequired])
if (isLoading) {
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return (
<>
{showPasswordModal && (
<PasswordChangeModal
onSuccess={() => {
setShowPasswordModal(false)
}}
/>
)}
{children}
</>
)
}
function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<PrivateRoute>
<Layout />
</PrivateRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="scan" element={<Scan />} />
<Route path="process" element={<Process />} />
<Route path="search" element={<Search />} />
<Route path="identify" element={<Identify />} />
<Route path="auto-match" element={<AutoMatch />} />
<Route path="modify" element={<Modify />} />
<Route path="tags" element={<Tags />} />
<Route path="manage-photos" element={<ManagePhotos />} />
<Route path="faces-maintenance" element={<FacesMaintenance />} />
<Route
path="approve-identified"
element={
<AdminRoute featureKey="user_identified">
<ApproveIdentified />
</AdminRoute>
}
/>
<Route
path="manage-users"
element={
<AdminRoute featureKey="manage_users">
<ManageUsers />
</AdminRoute>
}
/>
<Route
path="reported-photos"
element={
<AdminRoute featureKey="user_reported">
<ReportedPhotos />
</AdminRoute>
}
/>
<Route
path="pending-linkages"
element={
<AdminRoute featureKey="user_tagged">
<UserTaggedPhotos />
</AdminRoute>
}
/>
<Route
path="pending-photos"
element={
<AdminRoute featureKey="user_uploaded">
<PendingPhotos />
</AdminRoute>
}
/>
<Route path="settings" element={<Settings />} />
<Route path="help" element={<Help />} />
</Route>
</Routes>
)
}
function App() {
return (
<AuthProvider>
<DeveloperModeProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</DeveloperModeProvider>
</AuthProvider>
)
}
export default App

View File

@ -0,0 +1,65 @@
import apiClient from './client'
import { UserRoleValue } from './users'
export interface LoginRequest {
username: string
password: string
}
export interface TokenResponse {
access_token: string
refresh_token: string
token_type?: string
password_change_required?: boolean
}
export interface PasswordChangeRequest {
current_password: string
new_password: string
}
export interface PasswordChangeResponse {
success: boolean
message: string
}
export interface UserResponse {
username: string
is_admin?: boolean
role?: UserRoleValue
permissions?: Record<string, boolean>
}
export const authApi = {
login: async (credentials: LoginRequest): Promise<TokenResponse> => {
const { data } = await apiClient.post<TokenResponse>(
'/api/v1/auth/login',
credentials
)
return data
},
refresh: async (refreshToken: string): Promise<TokenResponse> => {
const { data } = await apiClient.post<TokenResponse>(
'/api/v1/auth/refresh',
{ refresh_token: refreshToken }
)
return data
},
me: async (): Promise<UserResponse> => {
const { data } = await apiClient.get<UserResponse>('/api/v1/auth/me')
return data
},
changePassword: async (
request: PasswordChangeRequest
): Promise<PasswordChangeResponse> => {
const { data } = await apiClient.post<PasswordChangeResponse>(
'/api/v1/auth/change-password',
request
)
return data
},
}

View File

@ -0,0 +1,72 @@
import apiClient from './client'
export interface AuthUserResponse {
id: number
name: string | null
email: string
is_admin: boolean | null
has_write_access: boolean | null
is_active: boolean | null
role: string | null
created_at: string | null
updated_at: string | null
}
export interface AuthUserCreateRequest {
email: string
name: string
password: string
is_admin: boolean
has_write_access: boolean
}
export interface AuthUserUpdateRequest {
email: string
name: string
is_admin: boolean
has_write_access: boolean
is_active?: boolean
role?: string
password?: string
}
export interface AuthUsersListResponse {
items: AuthUserResponse[]
total: number
}
export const authUsersApi = {
listUsers: async (): Promise<AuthUsersListResponse> => {
const { data } = await apiClient.get<AuthUsersListResponse>('/api/v1/auth-users')
return data
},
getUser: async (userId: number): Promise<AuthUserResponse> => {
const { data } = await apiClient.get<AuthUserResponse>(`/api/v1/auth-users/${userId}`)
return data
},
createUser: async (request: AuthUserCreateRequest): Promise<AuthUserResponse> => {
const { data } = await apiClient.post<AuthUserResponse>('/api/v1/auth-users', request)
return data
},
updateUser: async (
userId: number,
request: AuthUserUpdateRequest
): Promise<AuthUserResponse> => {
const { data } = await apiClient.put<AuthUserResponse>(
`/api/v1/auth-users/${userId}`,
request
)
return data
},
deleteUser: async (userId: number): Promise<{ message?: string; deactivated?: boolean }> => {
const response = await apiClient.delete(`/api/v1/auth-users/${userId}`)
// Return data if present (200 OK with deactivation message), otherwise empty object (204 No Content)
return response.data || {}
},
}

View File

@ -0,0 +1,66 @@
import axios from 'axios'
// Get API base URL from environment variable or use default
// The .env file should contain: VITE_API_URL=http://127.0.0.1:8000
// Alternatively, Vite proxy can be used (configured in vite.config.ts) by setting VITE_API_URL to empty string
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
// Add token to requests
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Handle 401 errors and network errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// Handle network errors (no response from server)
if (!error.response && (error.message === 'Network Error' || error.code === 'ERR_NETWORK')) {
// Check if user is logged in
const token = localStorage.getItem('access_token')
if (!token) {
// Not logged in - redirect to login
const isLoginPage = window.location.pathname === '/login'
if (!isLoginPage) {
window.location.href = '/login'
return Promise.reject(error)
}
}
// If logged in but network error, it's a connection issue
console.error('Network Error:', error)
}
// Handle 401 Unauthorized
if (error.response?.status === 401) {
// Don't redirect if we're already on the login page (prevents clearing error messages)
const isLoginPage = window.location.pathname === '/login'
// Always clear tokens on 401, but only redirect if not already on login page
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
// Clear sessionStorage settings on authentication failure
sessionStorage.removeItem('identify_settings')
// Only redirect if not already on login page
if (!isLoginPage) {
window.location.href = '/login'
}
// If on login page, just reject the error so the login component can handle it
}
return Promise.reject(error)
}
)
export default apiClient

View File

@ -0,0 +1,293 @@
import apiClient from './client'
export interface ProcessFacesRequest {
batch_size?: number
detector_backend: string
model_name: string
}
export interface ProcessFacesResponse {
job_id: string
message: string
batch_size?: number
detector_backend: string
model_name: string
}
export interface FaceItem {
id: number
photo_id: number
quality_score: number
face_confidence: number
location: string
pose_mode?: string
excluded?: boolean
}
export interface UnidentifiedFacesResponse {
items: FaceItem[]
page: number
page_size: number
total: number
}
export interface SimilarFaceItem {
id: number
photo_id: number
similarity: number
location: string
quality_score: number
filename: string
pose_mode?: string
}
export interface SimilarFacesResponse {
base_face_id: number
items: SimilarFaceItem[]
}
export interface FaceSimilarityPair {
face_id_1: number
face_id_2: number
similarity: number // 0-1 range
confidence_pct: number // 0-100 range
}
export interface BatchSimilarityRequest {
face_ids: number[]
min_confidence?: number // 0-100, default 60
}
export interface BatchSimilarityResponse {
pairs: FaceSimilarityPair[]
}
export interface IdentifyFaceRequest {
person_id?: number
first_name?: string
last_name?: string
middle_name?: string
maiden_name?: string
date_of_birth?: string
additional_face_ids?: number[]
}
export interface IdentifyFaceResponse {
identified_face_ids: number[]
person_id: number
created_person: boolean
}
export interface FaceUnmatchResponse {
face_id: number
message: string
}
export interface BatchUnmatchRequest {
face_ids: number[]
}
export interface BatchUnmatchResponse {
unmatched_face_ids: number[]
count: number
message: string
}
export interface AutoMatchRequest {
tolerance: number
auto_accept?: boolean
auto_accept_threshold?: number
}
export interface AutoMatchFaceItem {
id: number
photo_id: number
photo_filename: string
location: string
quality_score: number
similarity: number // Confidence percentage (0-100)
distance: number
pose_mode?: string
}
export interface AutoMatchPersonItem {
person_id: number
person_name: string
reference_face_id: number
reference_photo_id: number
reference_photo_filename: string
reference_location: string
reference_pose_mode?: string
face_count: number
matches: AutoMatchFaceItem[]
total_matches: number
}
export interface AutoMatchPersonSummary {
person_id: number
person_name: string
reference_face_id: number
reference_photo_id: number
reference_photo_filename: string
reference_location: string
reference_pose_mode?: string
face_count: number
total_matches: number
}
export interface AutoMatchPeopleResponse {
people: AutoMatchPersonSummary[]
total_people: number
}
export interface AutoMatchPersonMatchesResponse {
person_id: number
matches: AutoMatchFaceItem[]
total_matches: number
}
export interface AutoMatchResponse {
people: AutoMatchPersonItem[]
total_people: number
total_matches: number
auto_accepted?: boolean
auto_accepted_faces?: number
skipped_persons?: number
skipped_matches?: number
}
export interface AcceptMatchesRequest {
face_ids: number[]
}
export interface MaintenanceFaceItem {
id: number
photo_id: number
photo_path: string
photo_filename: string
quality_score: number
person_id: number | null
person_name: string | null
excluded: boolean
}
export interface MaintenanceFacesResponse {
items: MaintenanceFaceItem[]
total: number
}
export interface DeleteFacesRequest {
face_ids: number[]
}
export interface DeleteFacesResponse {
deleted_face_ids: number[]
count: number
message: string
}
export const facesApi = {
/**
* Start face processing job
*/
processFaces: async (request: ProcessFacesRequest): Promise<ProcessFacesResponse> => {
const response = await apiClient.post<ProcessFacesResponse>('/api/v1/faces/process', request)
return response.data
},
getUnidentified: async (params: {
page?: number
page_size?: number
min_quality?: number
date_from?: string
date_to?: string
date_taken_from?: string
date_taken_to?: string
date_processed?: string
date_processed_from?: string
date_processed_to?: string
sort_by?: 'quality' | 'date_taken' | 'date_added'
sort_dir?: 'asc' | 'desc'
tag_names?: string
match_all?: boolean
photo_ids?: string
include_excluded?: boolean
}): Promise<UnidentifiedFacesResponse> => {
const response = await apiClient.get<UnidentifiedFacesResponse>('/api/v1/faces/unidentified', {
params,
})
return response.data
},
getSimilar: async (faceId: number, includeExcluded?: boolean): Promise<SimilarFacesResponse> => {
const response = await apiClient.get<SimilarFacesResponse>(`/api/v1/faces/${faceId}/similar`, {
params: { include_excluded: includeExcluded || false },
})
return response.data
},
batchSimilarity: async (request: BatchSimilarityRequest): Promise<BatchSimilarityResponse> => {
const response = await apiClient.post<BatchSimilarityResponse>('/api/v1/faces/batch-similarity', request)
return response.data
},
identify: async (faceId: number, payload: IdentifyFaceRequest): Promise<IdentifyFaceResponse> => {
const response = await apiClient.post<IdentifyFaceResponse>(`/api/v1/faces/${faceId}/identify`, payload)
return response.data
},
setExcluded: async (faceId: number, excluded: boolean): Promise<{ face_id: number; excluded: boolean; message: string }> => {
const response = await apiClient.put<{ face_id: number; excluded: boolean; message: string }>(
`/api/v1/faces/${faceId}/excluded?excluded=${excluded}`
)
return response.data
},
unmatch: async (faceId: number): Promise<FaceUnmatchResponse> => {
const response = await apiClient.post<FaceUnmatchResponse>(`/api/v1/faces/${faceId}/unmatch`)
return response.data
},
batchUnmatch: async (payload: BatchUnmatchRequest): Promise<BatchUnmatchResponse> => {
const response = await apiClient.post<BatchUnmatchResponse>('/api/v1/faces/batch-unmatch', payload)
return response.data
},
autoMatch: async (request: AutoMatchRequest): Promise<AutoMatchResponse> => {
const response = await apiClient.post<AutoMatchResponse>('/api/v1/faces/auto-match', request)
return response.data
},
getAutoMatchPeople: async (params?: {
filter_frontal_only?: boolean
}): Promise<AutoMatchPeopleResponse> => {
const response = await apiClient.get<AutoMatchPeopleResponse>('/api/v1/faces/auto-match/people', {
params,
})
return response.data
},
getAutoMatchPersonMatches: async (
personId: number,
params?: {
tolerance?: number
filter_frontal_only?: boolean
}
): Promise<AutoMatchPersonMatchesResponse> => {
const response = await apiClient.get<AutoMatchPersonMatchesResponse>(
`/api/v1/faces/auto-match/people/${personId}/matches`,
{ params }
)
return response.data
},
getMaintenanceFaces: async (params: {
page?: number
page_size?: number
min_quality?: number
max_quality?: number
excluded_filter?: 'all' | 'excluded' | 'included'
identified_filter?: 'all' | 'identified' | 'unidentified'
}): Promise<MaintenanceFacesResponse> => {
const response = await apiClient.get<MaintenanceFacesResponse>('/api/v1/faces/maintenance', {
params,
})
return response.data
},
deleteFaces: async (request: DeleteFacesRequest): Promise<DeleteFacesResponse> => {
const response = await apiClient.post<DeleteFacesResponse>('/api/v1/faces/delete', request)
return response.data
},
}
export default facesApi

View File

@ -0,0 +1,42 @@
import apiClient from './client'
export enum JobStatus {
PENDING = 'pending',
STARTED = 'started',
PROGRESS = 'progress',
SUCCESS = 'success',
FAILURE = 'failure',
CANCELLED = 'cancelled',
}
export interface JobResponse {
id: string
status: JobStatus
progress: number
message: string
created_at: string
updated_at: string
}
export const jobsApi = {
getJob: async (jobId: string): Promise<JobResponse> => {
const { data } = await apiClient.get<JobResponse>(
`/api/v1/jobs/${jobId}`
)
return data
},
streamJobProgress: (jobId: string): EventSource => {
// EventSource needs absolute URL - use VITE_API_URL or fallback to direct backend URL
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}`)
},
cancelJob: async (jobId: string): Promise<{ message: string; status: string }> => {
const { data } = await apiClient.delete<{ message: string; status: string }>(
`/api/v1/jobs/${jobId}`
)
return data
},
}

View File

@ -0,0 +1,95 @@
import apiClient from './client'
export interface PendingIdentification {
id: number
face_id: number
photo_id?: number | null
user_id: number
user_name?: string | null
user_email: string
first_name: string
last_name: string
middle_name?: string | null
maiden_name?: string | null
date_of_birth?: string | null
status: string
created_at: string
updated_at: string
}
export interface PendingIdentificationsListResponse {
items: PendingIdentification[]
total: number
}
export interface ApproveDenyDecision {
id: number
decision: 'approve' | 'deny'
}
export interface ApproveDenyRequest {
decisions: ApproveDenyDecision[]
}
export interface ApproveDenyResponse {
approved: number
denied: number
errors: string[]
}
export interface UserIdentificationStats {
user_id: number
username: string
full_name: string
email: string
face_count: number
first_identification_date: string | null
last_identification_date: string | null
}
export interface IdentificationReportResponse {
items: UserIdentificationStats[]
total_faces: number
total_users: number
}
export interface ClearDatabaseResponse {
deleted_records: number
errors: string[]
}
export const pendingIdentificationsApi = {
list: async (includeDenied: boolean = false): Promise<PendingIdentificationsListResponse> => {
const res = await apiClient.get<PendingIdentificationsListResponse>(
'/api/v1/pending-identifications',
{ params: { include_denied: includeDenied } }
)
return res.data
},
approveDeny: async (request: ApproveDenyRequest): Promise<ApproveDenyResponse> => {
const res = await apiClient.post<ApproveDenyResponse>(
'/api/v1/pending-identifications/approve-deny',
request
)
return res.data
},
getReport: async (dateFrom?: string, dateTo?: string): Promise<IdentificationReportResponse> => {
const params: Record<string, string> = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const res = await apiClient.get<IdentificationReportResponse>(
'/api/v1/pending-identifications/report',
{ params }
)
return res.data
},
clearDenied: async (): Promise<ClearDatabaseResponse> => {
const res = await apiClient.post<ClearDatabaseResponse>(
'/api/v1/pending-identifications/clear-denied'
)
return res.data
},
}
export default pendingIdentificationsApi

View File

@ -0,0 +1,71 @@
import apiClient from './client'
export interface PendingLinkageResponse {
id: number
photo_id: number
tag_id: number | null
proposed_tag_name: string | null
resolved_tag_name: string | null
user_id: number
user_name: string | null
user_email: string | null
status: string
notes: string | null
created_at: string
updated_at: string | null
photo_filename: string | null
photo_path: string | null
photo_media_type: string | null
photo_tags: string[]
}
export interface PendingLinkagesListResponse {
items: PendingLinkageResponse[]
total: number
}
export interface ReviewDecision {
id: number
decision: 'approve' | 'deny'
}
export interface ReviewRequest {
decisions: ReviewDecision[]
}
export interface ReviewResponse {
approved: number
denied: number
tags_created: number
linkages_created: number
errors: string[]
}
export interface CleanupResponse {
deleted_records: number
errors: string[]
warnings?: string[]
}
export const pendingLinkagesApi = {
async listPendingLinkages(statusFilter?: string): Promise<PendingLinkagesListResponse> {
const { data } = await apiClient.get<PendingLinkagesListResponse>('/api/v1/pending-linkages', {
params: statusFilter ? { status_filter: statusFilter } : undefined,
})
return data
},
async reviewPendingLinkages(request: ReviewRequest): Promise<ReviewResponse> {
const { data } = await apiClient.post<ReviewResponse>('/api/v1/pending-linkages/review', request)
return data
},
async cleanupPendingLinkages(): Promise<CleanupResponse> {
const { data } = await apiClient.post<CleanupResponse>('/api/v1/pending-linkages/cleanup', {})
return data
},
}
export default pendingLinkagesApi

View File

@ -0,0 +1,106 @@
import apiClient from './client'
export interface PendingPhotoResponse {
id: number
user_id: number
user_name: string | null
user_email: string | null
filename: string
original_filename: string
file_path: string
file_size: number
mime_type: string
status: string
submitted_at: string
reviewed_at: string | null
reviewed_by: number | null
rejection_reason: string | null
}
export interface PendingPhotosListResponse {
items: PendingPhotoResponse[]
total: number
}
export interface ReviewDecision {
id: number
decision: 'approve' | 'reject'
rejection_reason?: string | null
}
export interface ReviewRequest {
decisions: ReviewDecision[]
}
export interface ReviewResponse {
approved: number
rejected: number
errors: string[]
warnings?: string[] // Informational messages (e.g., duplicates)
}
export interface CleanupResponse {
deleted_files: number
deleted_records: number
errors: string[]
warnings?: string[] // Informational messages (e.g., files already deleted)
}
export const pendingPhotosApi = {
listPendingPhotos: async (statusFilter?: string): Promise<PendingPhotosListResponse> => {
const { data } = await apiClient.get<PendingPhotosListResponse>(
'/api/v1/pending-photos',
{
params: statusFilter ? { status_filter: statusFilter } : undefined,
}
)
return data
},
getPendingPhotoImage: (photoId: number): string => {
return `${apiClient.defaults.baseURL}/api/v1/pending-photos/${photoId}/image`
},
getPendingPhotoImageBlob: async (photoId: number): Promise<string> => {
// Fetch image as blob with authentication
const response = await apiClient.get(
`/api/v1/pending-photos/${photoId}/image`,
{
responseType: 'blob',
}
)
// Create object URL from blob
return URL.createObjectURL(response.data)
},
reviewPendingPhotos: async (request: ReviewRequest): Promise<ReviewResponse> => {
const { data } = await apiClient.post<ReviewResponse>(
'/api/v1/pending-photos/review',
request
)
return data
},
cleanupFiles: async (statusFilter?: string): Promise<CleanupResponse> => {
const { data } = await apiClient.post<CleanupResponse>(
'/api/v1/pending-photos/cleanup-files',
{},
{
params: statusFilter ? { status_filter: statusFilter } : undefined,
}
)
return data
},
cleanupDatabase: async (statusFilter?: string): Promise<CleanupResponse> => {
const { data } = await apiClient.post<CleanupResponse>(
'/api/v1/pending-photos/cleanup-database',
{},
{
params: statusFilter ? { status_filter: statusFilter } : undefined,
}
)
return data
},
}

View File

@ -0,0 +1,121 @@
import apiClient from './client'
export interface Person {
id: number
first_name: string
last_name: string
middle_name?: string | null
maiden_name?: string | null
date_of_birth?: string | null
}
export interface PeopleListResponse {
items: Person[]
total: number
}
export interface PersonWithFaces extends Person {
face_count: number
video_count: number
}
export interface PeopleWithFacesListResponse {
items: PersonWithFaces[]
total: number
}
export interface PersonCreateRequest {
first_name: string
last_name: string
middle_name?: string
maiden_name?: string
date_of_birth?: string | null
}
export interface PersonUpdateRequest {
first_name: string
last_name: string
middle_name?: string
maiden_name?: string
date_of_birth?: string | null
}
export const peopleApi = {
list: async (lastName?: string): Promise<PeopleListResponse> => {
const params = lastName ? { last_name: lastName } : {}
const res = await apiClient.get<PeopleListResponse>('/api/v1/people', { params })
return res.data
},
listWithFaces: async (lastName?: string): Promise<PeopleWithFacesListResponse> => {
const params = lastName ? { last_name: lastName } : {}
const res = await apiClient.get<PeopleWithFacesListResponse>('/api/v1/people/with-faces', { params })
return res.data
},
create: async (payload: PersonCreateRequest): Promise<Person> => {
const res = await apiClient.post<Person>('/api/v1/people', payload)
return res.data
},
update: async (personId: number, payload: PersonUpdateRequest): Promise<Person> => {
const res = await apiClient.put<Person>(`/api/v1/people/${personId}`, payload)
return res.data
},
getFaces: async (personId: number): Promise<PersonFacesResponse> => {
const res = await apiClient.get<PersonFacesResponse>(`/api/v1/people/${personId}/faces`)
return res.data
},
getVideos: async (personId: number): Promise<PersonVideosResponse> => {
const res = await apiClient.get<PersonVideosResponse>(`/api/v1/people/${personId}/videos`)
return res.data
},
acceptMatches: async (personId: number, faceIds: number[]): Promise<IdentifyFaceResponse> => {
const res = await apiClient.post<IdentifyFaceResponse>(`/api/v1/people/${personId}/accept-matches`, { face_ids: faceIds })
return res.data
},
delete: async (personId: number): Promise<void> => {
await apiClient.delete(`/api/v1/people/${personId}`)
},
}
export interface IdentifyFaceResponse {
identified_face_ids: number[]
person_id: number
created_person: boolean
}
export interface PersonFaceItem {
id: number
photo_id: number
photo_path: string
photo_filename: string
location: string
face_confidence: number
quality_score: number
detector_backend: string
model_name: string
}
export interface PersonFacesResponse {
person_id: number
items: PersonFaceItem[]
total: number
}
export interface PersonVideoItem {
id: number
filename: string
path: string
date_taken: string | null
date_added: string
linkage_id: number
}
export interface PersonVideosResponse {
person_id: number
items: PersonVideoItem[]
total: number
}
export default peopleApi

View File

@ -0,0 +1,168 @@
import apiClient from './client'
export interface PhotoImportRequest {
folder_path: string
recursive?: boolean
}
export interface PhotoImportResponse {
job_id: string
message: string
folder_path?: string
estimated_photos?: number
}
export interface PhotoResponse {
id: number
path: string
filename: string
checksum?: string
date_added: string
date_taken?: string
width?: number
height?: number
mime_type?: string
}
export interface UploadResponse {
message: string
added: number
existing: number
errors: string[]
}
export interface BulkDeletePhotosResponse {
message: string
deleted_count: number
missing_photo_ids: number[]
}
export const photosApi = {
importPhotos: async (
request: PhotoImportRequest
): Promise<PhotoImportResponse> => {
const { data } = await apiClient.post<PhotoImportResponse>(
'/api/v1/photos/import',
request
)
return data
},
uploadPhotos: async (files: File[]): Promise<UploadResponse> => {
const formData = new FormData()
files.forEach((file) => {
formData.append('files', file)
})
// Don't set Content-Type header manually - let the browser set it with boundary
const { data } = await apiClient.post<UploadResponse>(
'/api/v1/photos/import/upload',
formData
)
return data
},
getPhoto: async (photoId: number): Promise<PhotoResponse> => {
const { data } = await apiClient.get<PhotoResponse>(
`/api/v1/photos/${photoId}`
)
return data
},
streamJobProgress: (jobId: string): EventSource => {
// EventSource needs absolute URL - use VITE_API_URL or fallback to direct backend URL
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}`)
},
searchPhotos: async (params: {
search_type: 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' | 'favorites'
person_name?: string
tag_names?: string
match_all?: boolean
date_from?: string
date_to?: string
folder_path?: string
page?: number
page_size?: number
}): Promise<SearchPhotosResponse> => {
const { data } = await apiClient.get<SearchPhotosResponse>('/api/v1/photos', {
params,
})
return data
},
toggleFavorite: async (photoId: number): Promise<{ photo_id: number; is_favorite: boolean; message: string }> => {
const { data } = await apiClient.post(
`/api/v1/photos/${photoId}/toggle-favorite`
)
return data
},
checkFavorite: async (photoId: number): Promise<{ photo_id: number; is_favorite: boolean }> => {
const { data } = await apiClient.get(
`/api/v1/photos/${photoId}/is-favorite`
)
return data
},
bulkAddFavorites: async (photoIds: number[]): Promise<{ message: string; added_count: number; already_favorite_count: number; total_requested: number }> => {
const { data } = await apiClient.post(
'/api/v1/photos/bulk-add-favorites',
{ photo_ids: photoIds }
)
return data
},
bulkRemoveFavorites: async (photoIds: number[]): Promise<{ message: string; removed_count: number; not_favorite_count: number; total_requested: number }> => {
const { data } = await apiClient.post(
'/api/v1/photos/bulk-remove-favorites',
{ photo_ids: photoIds }
)
return data
},
bulkDeletePhotos: async (photoIds: number[]): Promise<BulkDeletePhotosResponse> => {
const { data } = await apiClient.post<BulkDeletePhotosResponse>(
'/api/v1/photos/bulk-delete',
{ photo_ids: photoIds }
)
return data
},
openFolder: async (photoId: number): Promise<{ message: string; folder: string }> => {
const { data } = await apiClient.post<{ message: string; folder: string }>(
`/api/v1/photos/${photoId}/open-folder`
)
return data
},
browseFolder: async (): Promise<{ path: string; success: boolean; message?: string }> => {
const { data } = await apiClient.post<{ path: string; success: boolean; message?: string }>(
'/api/v1/photos/browse-folder'
)
return data
},
}
export interface PhotoSearchResult {
id: number
path: string
filename: string
date_taken?: string
date_added: string
processed: boolean
person_name?: string
tags: string[]
has_faces: boolean
face_count: number
is_favorite?: boolean
}
export interface SearchPhotosResponse {
items: PhotoSearchResult[]
page: number
page_size: number
total: number
}

View File

@ -0,0 +1,74 @@
import apiClient from './client'
export interface ReportedPhotoResponse {
id: number
photo_id: number
user_id: number
user_name: string | null
user_email: string | null
status: string
reported_at: string
reviewed_at: string | null
reviewed_by: number | null
review_notes: string | null
report_comment: string | null
photo_path: string | null
photo_filename: string | null
photo_media_type: string | null
}
export interface ReportedPhotosListResponse {
items: ReportedPhotoResponse[]
total: number
}
export interface ReviewDecision {
id: number
decision: 'keep' | 'remove'
review_notes?: string | null
}
export interface ReviewRequest {
decisions: ReviewDecision[]
}
export interface ReviewResponse {
kept: number
removed: number
errors: string[]
}
export interface ReportedCleanupResponse {
deleted_records: number
errors: string[]
warnings?: string[]
}
export const reportedPhotosApi = {
listReportedPhotos: async (statusFilter?: string): Promise<ReportedPhotosListResponse> => {
const { data } = await apiClient.get<ReportedPhotosListResponse>(
'/api/v1/reported-photos',
{
params: statusFilter ? { status_filter: statusFilter } : undefined,
}
)
return data
},
reviewReportedPhotos: async (request: ReviewRequest): Promise<ReviewResponse> => {
const { data } = await apiClient.post<ReviewResponse>(
'/api/v1/reported-photos/review',
request
)
return data
},
cleanupReportedPhotos: async (): Promise<ReportedCleanupResponse> => {
const { data } = await apiClient.post<ReportedCleanupResponse>(
'/api/v1/reported-photos/cleanup',
{},
)
return data
},
}

View File

@ -0,0 +1,42 @@
import apiClient from './client'
import { UserRoleValue } from './users'
export interface RoleFeature {
key: string
label: string
}
export type RolePermissionsMap = Record<UserRoleValue, Record<string, boolean>>
export interface RolePermissionsResponse {
features: RoleFeature[]
permissions: RolePermissionsMap
}
export interface RolePermissionsUpdateRequest {
permissions: RolePermissionsMap
}
export const rolePermissionsApi = {
async listPermissions(): Promise<RolePermissionsResponse> {
const { data } = await apiClient.get<RolePermissionsResponse>('/api/v1/role-permissions')
return data
},
async updatePermissions(
request: RolePermissionsUpdateRequest
): Promise<RolePermissionsResponse> {
const { data } = await apiClient.put<RolePermissionsResponse>(
'/api/v1/role-permissions',
request
)
return data
},
}

View File

@ -0,0 +1,118 @@
import apiClient from './client'
export interface TagResponse {
id: number
tag_name: string
created_date: string
}
export interface TagsResponse {
items: TagResponse[]
total: number
}
export interface PhotoTagsRequest {
photo_ids: number[]
tag_names: string[]
}
export interface PhotoTagsResponse {
message: string
photos_updated: number
tags_added: number
tags_removed: number
}
export interface PhotoTagItem {
tag_id: number
tag_name: string
}
export interface PhotoTagsListResponse {
photo_id: number
tags: PhotoTagItem[]
total: number
}
export interface TagUpdateRequest {
tag_name: string
}
export interface TagDeleteRequest {
tag_ids: number[]
}
export interface PhotoWithTagsItem {
id: number
filename: string
path: string
processed: boolean
date_taken?: string | null
date_added?: string | null
face_count: number
unidentified_face_count: number // Count of faces with person_id IS NULL
tags: string // Comma-separated tags string
people_names: string // Comma-separated people names string
media_type?: string | null // 'image' or 'video'
}
export interface PhotosWithTagsResponse {
items: PhotoWithTagsItem[]
total: number
}
export const tagsApi = {
list: async (): Promise<TagsResponse> => {
const { data } = await apiClient.get<TagsResponse>('/api/v1/tags')
return data
},
create: async (tagName: string): Promise<TagResponse> => {
const { data } = await apiClient.post<TagResponse>('/api/v1/tags', {
tag_name: tagName,
})
return data
},
addToPhotos: async (request: PhotoTagsRequest): Promise<PhotoTagsResponse> => {
const { data } = await apiClient.post<PhotoTagsResponse>(
'/api/v1/tags/photos/add',
request
)
return data
},
removeFromPhotos: async (request: PhotoTagsRequest): Promise<PhotoTagsResponse> => {
const { data } = await apiClient.post<PhotoTagsResponse>(
'/api/v1/tags/photos/remove',
request
)
return data
},
getPhotoTags: async (photoId: number): Promise<PhotoTagsListResponse> => {
const { data } = await apiClient.get<PhotoTagsListResponse>(
`/api/v1/tags/photos/${photoId}`
)
return data
},
update: async (tagId: number, tagName: string): Promise<TagResponse> => {
const { data } = await apiClient.put<TagResponse>(`/api/v1/tags/${tagId}`, {
tag_name: tagName,
})
return data
},
delete: async (tagIds: number[]): Promise<{ message: string; deleted_count: number }> => {
const { data } = await apiClient.post<{ message: string; deleted_count: number }>(
'/api/v1/tags/delete',
{ tag_ids: tagIds }
)
return data
},
getPhotosWithTags: async (): Promise<PhotosWithTagsResponse> => {
const { data } = await apiClient.get<PhotosWithTagsResponse>('/api/v1/tags/photos')
return data
},
}
export default tagsApi

View File

@ -0,0 +1,88 @@
import apiClient from './client'
export type UserRoleValue =
| 'admin'
| 'manager'
| 'moderator'
| 'reviewer'
| 'editor'
| 'importer'
| 'viewer'
export interface UserResponse {
id: number
username: string
email: string | null
full_name: string | null
is_active: boolean
is_admin: boolean
role?: UserRoleValue | null
created_date: string
last_login: string | null
}
export interface UserCreateRequest {
username: string
password: string
email: string
full_name: string
is_active?: boolean
is_admin?: boolean
role: UserRoleValue
give_frontend_permission?: boolean
}
export interface UserUpdateRequest {
password?: string | null
email: string
full_name: string
is_active?: boolean
is_admin?: boolean
role?: UserRoleValue
give_frontend_permission?: boolean
}
export interface UsersListResponse {
items: UserResponse[]
total: number
}
export const usersApi = {
listUsers: async (params?: {
is_active?: boolean
is_admin?: boolean
}): Promise<UsersListResponse> => {
const { data } = await apiClient.get<UsersListResponse>('/api/v1/users', {
params,
})
return data
},
getUser: async (userId: number): Promise<UserResponse> => {
const { data } = await apiClient.get<UserResponse>(`/api/v1/users/${userId}`)
return data
},
createUser: async (request: UserCreateRequest): Promise<UserResponse> => {
const { data } = await apiClient.post<UserResponse>('/api/v1/users', request)
return data
},
updateUser: async (
userId: number,
request: UserUpdateRequest
): Promise<UserResponse> => {
const { data } = await apiClient.put<UserResponse>(
`/api/v1/users/${userId}`,
request
)
return data
},
deleteUser: async (userId: number): Promise<{ message?: string; deactivated?: boolean }> => {
const response = await apiClient.delete(`/api/v1/users/${userId}`)
// Return data if present (200 OK with deactivation message), otherwise empty object (204 No Content)
return response.data || {}
},
}

View File

@ -0,0 +1,128 @@
import apiClient from './client'
export interface PersonInfo {
id: number
first_name: string
last_name: string
middle_name?: string | null
maiden_name?: string | null
date_of_birth?: string | null
}
export interface VideoListItem {
id: number
filename: string
path: string
date_taken: string | null
date_added: string
identified_people: PersonInfo[]
identified_people_count: number
}
export interface ListVideosResponse {
items: VideoListItem[]
page: number
page_size: number
total: number
}
export interface VideoPersonInfo {
person_id: number
first_name: string
last_name: string
middle_name?: string | null
maiden_name?: string | null
date_of_birth?: string | null
identified_by: string | null
identified_date: string
}
export interface VideoPeopleResponse {
video_id: number
people: VideoPersonInfo[]
}
export interface IdentifyVideoRequest {
person_id?: number
first_name?: string
last_name?: string
middle_name?: string
maiden_name?: string
date_of_birth?: string | null
}
export interface IdentifyVideoResponse {
video_id: number
person_id: number
created_person: boolean
message: string
}
export interface RemoveVideoPersonResponse {
video_id: number
person_id: number
removed: boolean
message: string
}
export const videosApi = {
listVideos: async (params: {
page?: number
page_size?: number
folder_path?: string
date_from?: string
date_to?: string
has_people?: boolean
person_name?: string
sort_by?: string
sort_dir?: string
}): Promise<ListVideosResponse> => {
const res = await apiClient.get<ListVideosResponse>('/api/v1/videos', { params })
return res.data
},
getVideoPeople: async (videoId: number): Promise<VideoPeopleResponse> => {
const res = await apiClient.get<VideoPeopleResponse>(`/api/v1/videos/${videoId}/people`)
return res.data
},
identifyPerson: async (
videoId: number,
request: IdentifyVideoRequest
): Promise<IdentifyVideoResponse> => {
const res = await apiClient.post<IdentifyVideoResponse>(
`/api/v1/videos/${videoId}/identify`,
request
)
return res.data
},
removePerson: async (
videoId: number,
personId: number
): Promise<RemoveVideoPersonResponse> => {
const res = await apiClient.delete<RemoveVideoPersonResponse>(
`/api/v1/videos/${videoId}/people/${personId}`
)
return res.data
},
getThumbnailUrl: (videoId: number): string => {
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return `${baseURL}/api/v1/videos/${videoId}/thumbnail`
},
getVideoUrl: (videoId: number): string => {
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return `${baseURL}/api/v1/videos/${videoId}/video`
},
}
export default videosApi

View File

@ -0,0 +1,30 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
interface AdminRouteProps {
children: React.ReactNode
featureKey?: string
}
export default function AdminRoute({ children, featureKey }: AdminRouteProps) {
const { isAuthenticated, isLoading, isAdmin, hasPermission } = useAuth()
if (isLoading) {
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
if (featureKey) {
if (!hasPermission(featureKey)) {
return <Navigate to="/" replace />
}
} else if (!isAdmin) {
return <Navigate to="/" replace />
}
return <>{children}</>
}

View File

@ -0,0 +1,182 @@
import { useCallback, useState } from 'react'
import { Outlet, Link, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { useInactivityTimeout } from '../hooks/useInactivityTimeout'
const INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000
type NavItem = {
path: string
label: string
icon: string
featureKey?: string
}
export default function Layout() {
const location = useLocation()
const { username, logout, isAuthenticated, hasPermission } = useAuth()
const [maintenanceExpanded, setMaintenanceExpanded] = useState(true)
const handleInactivityLogout = useCallback(() => {
logout()
}, [logout])
useInactivityTimeout({
timeoutMs: INACTIVITY_TIMEOUT_MS,
onTimeout: handleInactivityLogout,
isEnabled: isAuthenticated,
})
const primaryNavItems: NavItem[] = [
{ path: '/scan', label: 'Scan', icon: '🗂️', featureKey: 'scan' },
{ path: '/process', label: 'Process', icon: '⚙️', featureKey: 'process' },
{ path: '/search', label: 'Search Photos', icon: '🔍', featureKey: 'search_photos' },
{ path: '/identify', label: 'Identify People', icon: '👤', featureKey: 'identify_people' },
{ path: '/auto-match', label: 'Auto-Match', icon: '🤖', featureKey: 'auto_match' },
{ path: '/modify', label: 'Modify People', icon: '✏️', featureKey: 'modify_people' },
{ path: '/tags', label: 'Tag Photos', icon: '🏷️', featureKey: 'tag_photos' },
]
const maintenanceNavItems: NavItem[] = [
{ path: '/faces-maintenance', label: 'Faces', icon: '🔧', featureKey: 'faces_maintenance' },
{ path: '/approve-identified', label: 'User Identified Faces', icon: '✅', featureKey: 'user_identified' },
{ path: '/reported-photos', label: 'User Reported Photos', icon: '🚩', featureKey: 'user_reported' },
{ path: '/pending-linkages', label: 'User Tagged Photos', icon: '🔖', featureKey: 'user_tagged' },
{ path: '/pending-photos', label: 'User Uploaded Photos', icon: '📤', featureKey: 'user_uploaded' },
{ path: '/manage-users', label: 'Users', icon: '👥', featureKey: 'manage_users' },
]
const footerNavItems: NavItem[] = [{ path: '/help', label: 'Help', icon: '📚' }]
const filterNavItems = (items: NavItem[]) =>
items.filter((item) => !item.featureKey || hasPermission(item.featureKey))
const renderNavLink = (
item: { path: string; label: string; icon: string },
extraClasses = ''
) => {
const isActive = location.pathname === item.path
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
} ${extraClasses}`}
>
<span>{item.icon}</span>
<span>{item.label}</span>
</Link>
)
}
const visiblePrimary = filterNavItems(primaryNavItems)
const visibleMaintenance = filterNavItems(maintenanceNavItems)
const visibleFooter = filterNavItems(footerNavItems)
// Get page title based on route
const getPageTitle = () => {
const route = location.pathname
if (route === '/') return '🏠 Home Page'
if (route === '/scan') return '🗂️ Scan Photos'
if (route === '/process') return '⚙️ Process Faces'
if (route === '/search') return '🔍 Search Photos'
if (route === '/identify') return '👤 Identify'
if (route === '/auto-match') return '🤖 Auto-Match Faces'
if (route === '/modify') return '✏️ Modify Identified'
if (route === '/tags') return '🏷️ Photos tagging interface'
if (route === '/manage-photos') return 'Manage Photos'
if (route === '/faces-maintenance') return '🔧 Faces Maintenance'
if (route === '/approve-identified') return '✅ Approve Identified'
if (route === '/manage-users') return '👥 Manage Users'
if (route === '/reported-photos') return '🚩 Reported Photos'
if (route === '/pending-linkages') return '🔖 User Tagged Photos'
if (route === '/pending-photos') return '📤 Manage User Uploaded Photos'
if (route === '/settings') return 'Settings'
if (route === '/help') return '📚 Help'
return 'PunimTag'
}
return (
<div className="min-h-screen bg-gray-50">
{/* Top bar */}
<div className="bg-white border-b border-gray-200 shadow-sm">
<div className="flex">
{/* Left sidebar - fixed position with logo */}
<div className="fixed left-0 top-0 w-64 bg-white border-r border-gray-200 h-20 flex items-center justify-center px-4 z-10">
<Link to="/" className="flex items-center justify-center hover:opacity-80 transition-opacity">
<img
src="/logo.png"
alt="PunimTag"
className="h-12 w-auto"
onError={(e) => {
// Fallback if logo.png doesn't exist, try logo.svg
const target = e.target as HTMLImageElement
if (target.src.endsWith('logo.png')) {
target.src = '/logo.svg'
}
}}
/>
</Link>
</div>
{/* Header content - aligned with main content */}
<div className="ml-64 flex-1 px-4">
<div className="flex justify-between items-center h-20">
<div className="flex items-center">
<h1 className="text-lg font-bold text-gray-900">{getPageTitle()}</h1>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{username}</span>
<button
onClick={logout}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900"
>
Logout
</button>
</div>
</div>
</div>
</div>
</div>
<div className="flex relative">
{/* Left sidebar - fixed position */}
<div className="fixed left-0 top-20 w-64 bg-white border-r border-gray-200 h-[calc(100vh-5rem)] overflow-y-auto">
<nav className="p-4 space-y-1">
{visiblePrimary.map((item) => renderNavLink(item))}
{visibleMaintenance.length > 0 && (
<div className="mt-4">
<button
type="button"
onClick={() => setMaintenanceExpanded((prev) => !prev)}
className="w-full px-3 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 flex items-center justify-between hover:text-gray-700"
>
<span>Maintenance</span>
<span>{maintenanceExpanded ? '▼' : '▶'}</span>
</button>
{maintenanceExpanded && (
<div className="mt-1 space-y-1">
{visibleMaintenance.map((item) => renderNavLink(item, 'ml-4'))}
</div>
)}
</div>
)}
{visibleFooter.length > 0 && (
<div className="mt-4 space-y-1">
{visibleFooter.map((item) => renderNavLink(item))}
</div>
)}
</nav>
</div>
{/* Main content - with left margin to account for fixed sidebar */}
<div className="flex-1 ml-64 p-4">
<Outlet />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,129 @@
import { useState } from 'react'
import { authApi } from '../api/auth'
import { useAuth } from '../context/AuthContext'
interface PasswordChangeModalProps {
onSuccess: () => void
}
export default function PasswordChangeModal({ onSuccess }: PasswordChangeModalProps) {
const { clearPasswordChangeRequired } = useAuth()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
// Validation
if (!currentPassword || !newPassword || !confirmPassword) {
setError('All fields are required')
return
}
if (newPassword.length < 6) {
setError('New password must be at least 6 characters')
return
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match')
return
}
if (currentPassword === newPassword) {
setError('New password must be different from current password')
return
}
try {
setLoading(true)
await authApi.changePassword({
current_password: currentPassword,
new_password: newPassword,
})
clearPasswordChangeRequired()
onSuccess()
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to change password')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Change Password Required</h2>
<p className="text-sm text-gray-600 mb-4">
You must change your password before continuing. Please enter your current password
(provided by your administrator) and choose a new password.
</p>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Current Password *
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
New Password * (min 6 characters)
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
minLength={6}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm New Password *
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
minLength={6}
/>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,430 @@
import { useEffect, useState, useRef } from 'react'
import { PhotoSearchResult, photosApi } from '../api/photos'
import { apiClient } from '../api/client'
interface PhotoViewerProps {
photos: PhotoSearchResult[]
initialIndex: number
onClose: () => void
}
const ZOOM_MIN = 0.5
const ZOOM_MAX = 5
const ZOOM_STEP = 0.25
const SLIDESHOW_INTERVALS = [
{ value: 1, label: '1s' },
{ value: 2, label: '2s' },
{ value: 3, label: '3s' },
{ value: 5, label: '5s' },
{ value: 10, label: '10s' },
]
export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoViewerProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex)
const [imageLoading, setImageLoading] = useState(true)
const [imageError, setImageError] = useState(false)
const preloadedImages = useRef<Set<number>>(new Set())
// Zoom state
const [zoom, setZoom] = useState(1)
const [panX, setPanX] = useState(0)
const [panY, setPanY] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
const imageContainerRef = useRef<HTMLDivElement>(null)
// Slideshow state
const [isPlaying, setIsPlaying] = useState(false)
const [slideshowInterval, setSlideshowInterval] = useState(3) // seconds
const slideshowTimerRef = useRef<NodeJS.Timeout | null>(null)
// Favorite state
const [isFavorite, setIsFavorite] = useState(false)
const [loadingFavorite, setLoadingFavorite] = useState(false)
const currentPhoto = photos[currentIndex]
const canGoPrev = currentIndex > 0
const canGoNext = currentIndex < photos.length - 1
// Get photo URL
const getPhotoUrl = (photoId: number) => {
return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
}
// Preload adjacent images
const preloadAdjacent = (index: number) => {
// Preload next photo
if (index + 1 < photos.length) {
const nextPhotoId = photos[index + 1].id
if (!preloadedImages.current.has(nextPhotoId)) {
const img = new Image()
img.src = getPhotoUrl(nextPhotoId)
preloadedImages.current.add(nextPhotoId)
}
}
// Preload previous photo
if (index - 1 >= 0) {
const prevPhotoId = photos[index - 1].id
if (!preloadedImages.current.has(prevPhotoId)) {
const img = new Image()
img.src = getPhotoUrl(prevPhotoId)
preloadedImages.current.add(prevPhotoId)
}
}
}
// Handle navigation
const goPrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
// Reset zoom when navigating
setZoom(1)
setPanX(0)
setPanY(0)
}
}
const goNext = () => {
if (currentIndex < photos.length - 1) {
setCurrentIndex(currentIndex + 1)
// Reset zoom when navigating
setZoom(1)
setPanX(0)
setPanY(0)
}
}
// Zoom functions
const zoomIn = () => {
setZoom(prev => Math.min(prev + ZOOM_STEP, ZOOM_MAX))
}
const zoomOut = () => {
setZoom(prev => Math.max(prev - ZOOM_STEP, ZOOM_MIN))
}
const resetZoom = () => {
setZoom(1)
setPanX(0)
setPanY(0)
}
const handleWheel = (e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
// Zoom with Ctrl/Cmd + wheel
e.preventDefault()
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP
setZoom(prev => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, prev + delta)))
}
}
// Pan (drag) functionality
const handleMouseDown = (e: React.MouseEvent) => {
if (zoom > 1) {
setIsDragging(true)
setDragStart({ x: e.clientX - panX, y: e.clientY - panY })
}
}
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging && zoom > 1) {
setPanX(e.clientX - dragStart.x)
setPanY(e.clientY - dragStart.y)
}
}
const handleMouseUp = () => {
setIsDragging(false)
}
// Slideshow functions
const toggleSlideshow = () => {
setIsPlaying(prev => !prev)
}
useEffect(() => {
if (isPlaying) {
slideshowTimerRef.current = setInterval(() => {
setCurrentIndex(prev => {
if (prev < photos.length - 1) {
return prev + 1
} else {
// Loop back to start or stop
setIsPlaying(false)
return prev
}
})
// Reset zoom when slideshow advances
setZoom(1)
setPanX(0)
setPanY(0)
}, slideshowInterval * 1000)
} else {
if (slideshowTimerRef.current) {
clearInterval(slideshowTimerRef.current)
slideshowTimerRef.current = null
}
}
return () => {
if (slideshowTimerRef.current) {
clearInterval(slideshowTimerRef.current)
}
}
}, [isPlaying, slideshowInterval, photos.length])
// Handle image load
useEffect(() => {
if (!currentPhoto) return
setImageLoading(true)
setImageError(false)
// Reset zoom when photo changes
setZoom(1)
setPanX(0)
setPanY(0)
// Load favorite status when photo changes
photosApi.checkFavorite(currentPhoto.id)
.then(result => setIsFavorite(result.is_favorite))
.catch(err => {
console.error('Error checking favorite:', err)
setIsFavorite(false)
})
// Preload adjacent images when current photo changes
preloadAdjacent(currentIndex)
}, [currentIndex, currentPhoto, photos.length])
// Toggle favorite
const toggleFavorite = async () => {
if (loadingFavorite || !currentPhoto) return
setLoadingFavorite(true)
try {
const result = await photosApi.toggleFavorite(currentPhoto.id)
setIsFavorite(result.is_favorite)
} catch (error) {
console.error('Error toggling favorite:', error)
alert('Error updating favorite status')
} finally {
setLoadingFavorite(false)
}
}
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'ArrowLeft' && !isPlaying) {
e.preventDefault()
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
setZoom(1)
setPanX(0)
setPanY(0)
}
} else if (e.key === 'ArrowRight' && !isPlaying) {
e.preventDefault()
if (currentIndex < photos.length - 1) {
setCurrentIndex(currentIndex + 1)
setZoom(1)
setPanX(0)
setPanY(0)
}
} else if (e.key === '+' || e.key === '=') {
e.preventDefault()
setZoom(prev => Math.min(prev + ZOOM_STEP, ZOOM_MAX))
} else if (e.key === '-' || e.key === '_') {
e.preventDefault()
setZoom(prev => Math.max(prev - ZOOM_STEP, ZOOM_MIN))
} else if (e.key === '0') {
e.preventDefault()
setZoom(1)
setPanX(0)
setPanY(0)
} else if (e.key === ' ') {
e.preventDefault()
setIsPlaying(prev => !prev)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentIndex, photos.length, onClose, isPlaying])
if (!currentPhoto) {
return null
}
const photoUrl = getPhotoUrl(currentPhoto.id)
return (
<div className="fixed inset-0 bg-black z-[100] flex flex-col">
{/* Top Left Info Corner */}
<div className="absolute top-0 left-0 z-10 bg-black bg-opacity-70 text-white p-2 rounded-br-lg">
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs"
title="Close (Esc)"
>
</button>
<div className="text-xs">
{currentIndex + 1} / {photos.length}
</div>
{currentPhoto.filename && (
<div className="text-xs text-gray-300 truncate max-w-xs">
{currentPhoto.filename}
</div>
)}
</div>
</div>
{/* Top Right Controls */}
<div className="absolute top-0 right-0 z-10 bg-black bg-opacity-70 text-white p-2 rounded-bl-lg">
<div className="flex items-center gap-2">
{/* Favorite button */}
<button
onClick={toggleFavorite}
disabled={loadingFavorite}
className={`px-3 py-1 rounded text-xs ${
isFavorite
? 'bg-yellow-600 hover:bg-yellow-700'
: 'bg-gray-700 hover:bg-gray-600'
} disabled:opacity-50`}
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
{isFavorite ? '⭐' : '☆'}
</button>
{/* Slideshow controls */}
{isPlaying && (
<select
value={slideshowInterval}
onChange={(e) => setSlideshowInterval(Number(e.target.value))}
onClick={(e) => e.stopPropagation()}
className="px-2 py-1 bg-gray-700 rounded text-xs"
title="Slideshow speed"
>
{SLIDESHOW_INTERVALS.map(interval => (
<option key={interval.value} value={interval.value}>
{interval.label}
</option>
))}
</select>
)}
<button
onClick={toggleSlideshow}
className={`px-3 py-1 rounded text-xs ${
isPlaying
? 'bg-red-600 hover:bg-red-700'
: 'bg-green-600 hover:bg-green-700'
}`}
title={isPlaying ? 'Pause slideshow (Space)' : 'Start slideshow (Space)'}
>
{isPlaying ? '⏸' : '▶'}
</button>
</div>
</div>
{/* Main Image Area */}
<div
ref={imageContainerRef}
className="flex-1 flex items-center justify-center relative overflow-hidden"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
>
{imageLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black z-20">
<div className="text-white text-lg">Loading...</div>
</div>
)}
{imageError ? (
<div className="text-white text-center">
<div className="text-lg mb-2">Failed to load image</div>
<div className="text-sm text-gray-400">{currentPhoto.path}</div>
</div>
) : (
<div
style={{
transform: `translate(${panX}px, ${panY}px) scale(${zoom})`,
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
}}
>
<img
src={photoUrl}
alt={currentPhoto.filename || `Photo ${currentIndex + 1}`}
className="max-w-full max-h-full object-contain"
style={{ userSelect: 'none', pointerEvents: 'none' }}
onLoad={() => setImageLoading(false)}
onError={() => {
setImageLoading(false)
setImageError(true)
}}
draggable={false}
/>
</div>
)}
{/* Zoom Controls */}
<div className="absolute top-12 right-4 z-30 flex flex-col gap-2">
<button
onClick={zoomIn}
disabled={zoom >= ZOOM_MAX}
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
title="Zoom in (Ctrl/Cmd + Wheel)"
>
+
</button>
<div className="px-3 py-1 bg-black bg-opacity-70 text-white rounded text-center text-xs">
{Math.round(zoom * 100)}%
</div>
<button
onClick={zoomOut}
disabled={zoom <= ZOOM_MIN}
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
title="Zoom out (Ctrl/Cmd + Wheel)"
>
</button>
{zoom !== 1 && (
<button
onClick={resetZoom}
className="px-3 py-1 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded text-xs"
title="Reset zoom"
>
Reset
</button>
)}
</div>
{/* Navigation Buttons */}
<button
onClick={goPrev}
disabled={!canGoPrev || isPlaying}
className="absolute left-4 top-1/2 -translate-y-1/2 px-4 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed z-30"
title="Previous (←)"
>
Prev
</button>
<button
onClick={goNext}
disabled={!canGoNext || isPlaying}
className="absolute right-4 top-1/2 -translate-y-1/2 px-4 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed z-30"
title="Next (→)"
>
Next
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,152 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'
import { authApi, TokenResponse } from '../api/auth'
import { UserRoleValue } from '../api/users'
interface AuthState {
isAuthenticated: boolean
username: string | null
isLoading: boolean
passwordChangeRequired: boolean
isAdmin: boolean
role: UserRoleValue | null
permissions: Record<string, boolean>
}
interface AuthContextType extends AuthState {
login: (username: string, password: string) => Promise<{ success: boolean; error?: string; passwordChangeRequired?: boolean }>
logout: () => void
clearPasswordChangeRequired: () => void
hasPermission: (featureKey: string) => boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [authState, setAuthState] = useState<AuthState>({
isAuthenticated: false,
username: null,
isLoading: true,
passwordChangeRequired: false,
isAdmin: false,
role: null,
permissions: {},
})
useEffect(() => {
const token = localStorage.getItem('access_token')
if (token) {
authApi
.me()
.then((user) => {
setAuthState({
isAuthenticated: true,
username: user.username,
isLoading: false,
passwordChangeRequired: false,
isAdmin: user.is_admin || false,
role: (user.role as UserRoleValue) || null,
permissions: user.permissions || {},
})
})
.catch(() => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
setAuthState({
isAuthenticated: false,
username: null,
isLoading: false,
passwordChangeRequired: false,
isAdmin: false,
role: null,
permissions: {},
})
})
} else {
setAuthState({
isAuthenticated: false,
username: null,
isLoading: false,
passwordChangeRequired: false,
isAdmin: false,
role: null,
permissions: {},
})
}
}, [])
const login = async (username: string, password: string) => {
try {
const tokens: TokenResponse = await authApi.login({ username, password })
localStorage.setItem('access_token', tokens.access_token)
localStorage.setItem('refresh_token', tokens.refresh_token)
const user = await authApi.me()
const passwordChangeRequired = tokens.password_change_required || false
setAuthState({
isAuthenticated: true,
username: user.username,
isLoading: false,
passwordChangeRequired,
isAdmin: user.is_admin || false,
role: (user.role as UserRoleValue) || null,
permissions: user.permissions || {},
})
return { success: true, passwordChangeRequired }
} catch (error: any) {
return {
success: false,
error: error.response?.data?.detail || 'Login failed',
}
}
}
const clearPasswordChangeRequired = () => {
setAuthState((prev) => ({
...prev,
passwordChangeRequired: false,
}))
}
const logout = () => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
// Clear sessionStorage settings on logout
sessionStorage.removeItem('identify_settings')
setAuthState({
isAuthenticated: false,
username: null,
isLoading: false,
passwordChangeRequired: false,
isAdmin: false,
role: null,
permissions: {},
})
}
const hasPermission = useCallback(
(featureKey: string): boolean => {
if (!featureKey) {
return authState.isAdmin
}
if (authState.isAdmin) {
return true
}
return Boolean(authState.permissions[featureKey])
},
[authState.isAdmin, authState.permissions]
)
return (
<AuthContext.Provider value={{ ...authState, login, logout, clearPasswordChangeRequired, hasPermission }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

View File

@ -0,0 +1,42 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
interface DeveloperModeContextType {
isDeveloperMode: boolean
setDeveloperMode: (enabled: boolean) => void
}
const DeveloperModeContext = createContext<DeveloperModeContextType | undefined>(undefined)
const STORAGE_KEY = 'punimtag_developer_mode'
export function DeveloperModeProvider({ children }: { children: ReactNode }) {
const [isDeveloperMode, setIsDeveloperMode] = useState<boolean>(false)
// Load from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored !== null) {
setIsDeveloperMode(stored === 'true')
}
}, [])
const setDeveloperMode = (enabled: boolean) => {
setIsDeveloperMode(enabled)
localStorage.setItem(STORAGE_KEY, enabled.toString())
}
return (
<DeveloperModeContext.Provider value={{ isDeveloperMode, setDeveloperMode }}>
{children}
</DeveloperModeContext.Provider>
)
}
export function useDeveloperMode() {
const context = useContext(DeveloperModeContext)
if (context === undefined) {
throw new Error('useDeveloperMode must be used within a DeveloperModeProvider')
}
return context
}

View File

@ -0,0 +1,84 @@
import { useState, useEffect } from 'react'
import { authApi, TokenResponse } from '../api/auth'
interface AuthState {
isAuthenticated: boolean
username: string | null
isLoading: boolean
}
export function useAuth() {
const [authState, setAuthState] = useState<AuthState>({
isAuthenticated: false,
username: null,
isLoading: true,
})
useEffect(() => {
const token = localStorage.getItem('access_token')
if (token) {
// Verify token by fetching user info
authApi
.me()
.then((user) => {
setAuthState({
isAuthenticated: true,
username: user.username,
isLoading: false,
})
})
.catch(() => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
setAuthState({
isAuthenticated: false,
username: null,
isLoading: false,
})
})
} else {
setAuthState({
isAuthenticated: false,
username: null,
isLoading: false,
})
}
}, [])
const login = async (username: string, password: string) => {
try {
const tokens: TokenResponse = await authApi.login({ username, password })
localStorage.setItem('access_token', tokens.access_token)
localStorage.setItem('refresh_token', tokens.refresh_token)
const user = await authApi.me()
setAuthState({
isAuthenticated: true,
username: user.username,
isLoading: false,
})
return { success: true }
} catch (error: any) {
return {
success: false,
error: error.response?.data?.detail || 'Login failed',
}
}
}
const logout = () => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
setAuthState({
isAuthenticated: false,
username: null,
isLoading: false,
})
}
return {
...authState,
login,
logout,
}
}

View File

@ -0,0 +1,68 @@
import { useEffect, useRef } from 'react'
interface UseInactivityTimeoutOptions {
timeoutMs: number
onTimeout: () => void
isEnabled?: boolean
}
const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
'mousemove',
'mousedown',
'keydown',
'scroll',
'touchstart',
'focus',
]
export function useInactivityTimeout({
timeoutMs,
onTimeout,
isEnabled = true,
}: UseInactivityTimeoutOptions) {
const timeoutRef = useRef<number | null>(null)
const callbackRef = useRef(onTimeout)
useEffect(() => {
callbackRef.current = onTimeout
}, [onTimeout])
useEffect(() => {
if (!isEnabled) {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
return
}
const resetTimer = () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current)
}
timeoutRef.current = window.setTimeout(() => {
callbackRef.current()
}, timeoutMs)
}
const handleVisibilityChange = () => {
if (!document.hidden) {
resetTimer()
}
}
resetTimer()
ACTIVITY_EVENTS.forEach((event) => window.addEventListener(event, resetTimer))
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current)
}
ACTIVITY_EVENTS.forEach((event) => window.removeEventListener(event, resetTimer))
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [timeoutMs, isEnabled])
}

View File

@ -0,0 +1,65 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
}
body {
margin: 0;
min-height: 100vh;
}
/* Custom scrollbar styling for similar faces container */
.similar-faces-scrollable {
/* Firefox */
scrollbar-width: auto;
scrollbar-color: #4B5563 #F3F4F6;
}
.similar-faces-scrollable::-webkit-scrollbar {
/* Chrome, Safari, Edge */
width: 12px;
}
.similar-faces-scrollable::-webkit-scrollbar-track {
background: #F3F4F6;
border-radius: 6px;
}
.similar-faces-scrollable::-webkit-scrollbar-thumb {
background: #4B5563;
border-radius: 6px;
border: 2px solid #F3F4F6;
}
.similar-faces-scrollable::-webkit-scrollbar-thumb:hover {
background: #374151;
}
.role-permissions-scroll {
scrollbar-width: auto;
scrollbar-color: #1d4ed8 #e5e7eb;
}
.role-permissions-scroll::-webkit-scrollbar {
width: 16px;
height: 16px;
background-color: #bfdbfe;
}
.role-permissions-scroll::-webkit-scrollbar-track {
background: #bfdbfe;
border-radius: 8px;
}
.role-permissions-scroll::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%);
border-radius: 8px;
border: 3px solid #bfdbfe;
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2);
}

View File

@ -0,0 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App.tsx'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)

View File

@ -0,0 +1,606 @@
import { useEffect, useState, useCallback } from 'react'
import pendingIdentificationsApi, {
PendingIdentification,
IdentificationReportResponse,
UserIdentificationStats
} from '../api/pendingIdentifications'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
export default function ApproveIdentified() {
const { isAdmin } = useAuth()
const [pendingIdentifications, setPendingIdentifications] = useState<PendingIdentification[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'deny' | null>>({})
const [submitting, setSubmitting] = useState(false)
const [includeDenied, setIncludeDenied] = useState(false)
const [showReport, setShowReport] = useState(false)
const [reportData, setReportData] = useState<IdentificationReportResponse | null>(null)
const [reportLoading, setReportLoading] = useState(false)
const [reportError, setReportError] = useState<string | null>(null)
const [dateFrom, setDateFrom] = useState<string>('')
const [dateTo, setDateTo] = useState<string>('')
const [clearing, setClearing] = useState(false)
const loadPendingIdentifications = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await pendingIdentificationsApi.list(includeDenied)
setPendingIdentifications(response.items)
} catch (err: any) {
let errorMessage = 'Failed to load pending identifications'
if (err.response?.data?.detail) {
errorMessage = err.response.data.detail
} else if (err.message) {
errorMessage = err.message
// Provide more context for network errors
if (err.message === 'Network Error' || err.code === 'ERR_NETWORK') {
errorMessage = `Network Error: Cannot connect to backend API (${apiClient.defaults.baseURL}). Please check:\n1. Backend is running\n2. You are logged in\n3. CORS is configured correctly`
}
}
setError(errorMessage)
console.error('Error loading pending identifications:', err)
} finally {
setLoading(false)
}
}, [includeDenied])
useEffect(() => {
loadPendingIdentifications()
}, [loadPendingIdentifications])
const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return date.toLocaleDateString()
} catch {
return dateString
}
}
const formatName = (pending: PendingIdentification): string => {
const parts = [
pending.first_name,
pending.middle_name,
pending.last_name,
].filter(Boolean)
if (pending.maiden_name) {
parts.push(`(${pending.maiden_name})`)
}
return parts.join(' ')
}
const handleDecisionChange = (id: number, decision: 'approve' | 'deny') => {
setDecisions(prev => {
const currentDecision = prev[id]
// If clicking the same checkbox, deselect it
if (currentDecision === decision) {
const updated = { ...prev }
delete updated[id]
return updated
}
// Otherwise, set the new decision (this will automatically deselect the other)
return {
...prev,
[id]: decision
}
})
}
const handleSubmit = async () => {
// Get all decisions that have been made, for pending or denied items (not approved)
const decisionsList = Object.entries(decisions)
.filter(([id, decision]) => {
const pending = pendingIdentifications.find(p => p.id === parseInt(id))
return decision !== null && pending && pending.status !== 'approved'
})
.map(([id, decision]) => ({
id: parseInt(id),
decision: decision!
}))
if (decisionsList.length === 0) {
alert('Please select Approve or Deny for at least one identification.')
return
}
if (!confirm(`Submit ${decisionsList.length} decision(s)?`)) {
return
}
setSubmitting(true)
try {
const response = await pendingIdentificationsApi.approveDeny({
decisions: decisionsList
})
const message = [
`✅ Approved: ${response.approved}`,
`❌ Denied: ${response.denied}`,
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : ''
].filter(Boolean).join('\n')
alert(message)
if (response.errors.length > 0) {
console.error('Errors:', response.errors)
}
// Reload the list to show updated status
await loadPendingIdentifications()
// Clear decisions
setDecisions({})
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to submit decisions'
alert(`Error: ${errorMessage}`)
console.error('Error submitting decisions:', err)
} finally {
setSubmitting(false)
}
}
const loadReport = useCallback(async () => {
setReportLoading(true)
setReportError(null)
try {
const response = await pendingIdentificationsApi.getReport(
dateFrom || undefined,
dateTo || undefined
)
setReportData(response)
} catch (err: any) {
setReportError(err.response?.data?.detail || err.message || 'Failed to load report')
console.error('Error loading report:', err)
} finally {
setReportLoading(false)
}
}, [dateFrom, dateTo])
const handleOpenReport = () => {
setShowReport(true)
loadReport()
}
const handleCloseReport = () => {
setShowReport(false)
setReportData(null)
setReportError(null)
setDateFrom('')
setDateTo('')
}
const formatDateTime = (dateString: string | null | undefined): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return date.toLocaleString()
} catch {
return dateString
}
}
const handleClearDenied = async () => {
if (!confirm('Are you sure you want to delete all denied records? This action cannot be undone.')) {
return
}
setClearing(true)
try {
const response = await pendingIdentificationsApi.clearDenied()
const message = [
`✅ Deleted ${response.deleted_records} denied record(s)`,
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : ''
].filter(Boolean).join('\n')
alert(message)
if (response.errors.length > 0) {
console.error('Errors:', response.errors)
alert('Errors:\n' + response.errors.join('\n'))
}
// Reload the list to reflect changes
await loadPendingIdentifications()
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to clear denied records'
alert(`Error: ${errorMessage}`)
console.error('Error clearing denied records:', err)
} finally {
setClearing(false)
}
}
return (
<div>
<div className="bg-white rounded-lg shadow p-6">
{loading && (
<div className="text-center py-8">
<p className="text-gray-600">Loading identified people...</p>
</div>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
<p className="font-semibold">Error loading data</p>
<p className="text-sm mt-1">{error}</p>
<button
onClick={loadPendingIdentifications}
className="mt-3 px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Retry
</button>
</div>
)}
{!loading && !error && (
<>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-600">
Total pending identifications: <span className="font-semibold">{pendingIdentifications.length}</span>
</div>
<div className="flex items-center gap-4">
<button
onClick={() => {
if (!isAdmin) {
return
}
handleClearDenied()
}}
disabled={clearing || !isAdmin}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
title={
isAdmin
? 'Delete all denied records from the database'
: 'Only admins can clear denied records'
}
>
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
</button>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeDenied}
onChange={(e) => setIncludeDenied(e.target.checked)}
className="w-4 h-4 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Include denied</span>
</label>
<button
onClick={handleSubmit}
disabled={submitting || Object.values(decisions).filter(d => d !== null).length === 0}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
>
{submitting ? 'Submitting...' : 'Submit Decisions'}
</button>
</div>
</div>
{pendingIdentifications.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No pending identifications found.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date of Birth
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Face
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Approve
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{pendingIdentifications.map((pending) => {
const isDenied = pending.status === 'denied'
const isApproved = pending.status === 'approved'
return (
<tr key={pending.id} className={`hover:bg-gray-50 ${isDenied ? 'opacity-60 bg-gray-50' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{formatName(pending)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDate(pending.date_of_birth)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{pending.photo_id ? (
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${pending.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${pending.face_id}/crop`}
alt={`Face ${pending.face_id}`}
className="w-16 h-16 object-cover rounded border border-gray-300"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className = 'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${pending.face_id}`
parent.appendChild(fallback)
}
}}
/>
</div>
) : (
<img
src={`/api/v1/faces/${pending.face_id}/crop`}
alt={`Face ${pending.face_id}`}
className="w-16 h-16 object-cover rounded border border-gray-300"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className = 'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${pending.face_id}`
parent.appendChild(fallback)
}
}}
/>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{pending.user_name || 'Unknown'}
</div>
<div className="text-sm text-gray-500">
{pending.user_email || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDate(pending.created_at)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{isApproved ? (
<div className="text-sm text-green-600 font-medium">Approved</div>
) : (
<div className="flex flex-col gap-2">
{isDenied && (
<span className="text-xs text-red-600 font-medium">(Denied)</span>
)}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decisions[pending.id] === 'approve'}
onChange={() => {
const currentDecision = decisions[pending.id]
if (currentDecision === 'approve') {
// Deselect if already selected
handleDecisionChange(pending.id, 'approve')
} else {
// Select approve (this will deselect deny if selected)
handleDecisionChange(pending.id, 'approve')
}
}}
className="w-4 h-4 text-green-600 focus:ring-green-500 rounded"
/>
<span className="text-sm text-gray-700">Approve</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decisions[pending.id] === 'deny'}
onChange={() => {
const currentDecision = decisions[pending.id]
if (currentDecision === 'deny') {
// Deselect if already selected
handleDecisionChange(pending.id, 'deny')
} else {
// Select deny (this will deselect approve if selected)
handleDecisionChange(pending.id, 'deny')
}
}}
className="w-4 h-4 text-red-600 focus:ring-red-500 rounded"
/>
<span className="text-sm text-gray-700">Deny</span>
</label>
</div>
</div>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
{/* Report Modal */}
{showReport && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">Identification Report</h2>
<button
onClick={handleCloseReport}
className="text-gray-400 hover:text-gray-600 text-2xl font-bold"
>
×
</button>
</div>
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Date From
</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Date To
</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-end">
<button
onClick={loadReport}
disabled={reportLoading}
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
>
{reportLoading ? 'Loading...' : 'Apply Filter'}
</button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{reportLoading && (
<div className="text-center py-8">
<p className="text-gray-600">Loading report...</p>
</div>
)}
{reportError && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
<p className="font-semibold">Error loading report</p>
<p className="text-sm mt-1">{reportError}</p>
</div>
)}
{!reportLoading && !reportError && reportData && (
<>
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded">
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="font-semibold text-gray-700">Total Users:</span>{' '}
<span className="text-gray-900">{reportData.total_users}</span>
</div>
<div>
<span className="font-semibold text-gray-700">Total Faces:</span>{' '}
<span className="text-gray-900">{reportData.total_faces}</span>
</div>
<div>
<span className="font-semibold text-gray-700">Average per User:</span>{' '}
<span className="text-gray-900">
{reportData.total_users > 0
? Math.round((reportData.total_faces / reportData.total_users) * 10) / 10
: 0}
</span>
</div>
</div>
</div>
{reportData.items.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No identifications found for the selected date range.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Faces Identified
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
First Identification
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Identification
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reportData.items.map((stat: UserIdentificationStats) => (
<tr key={stat.user_id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{stat.full_name}
</div>
<div className="text-sm text-gray-500">{stat.username}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{stat.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">
{stat.face_count}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDateTime(stat.first_identification_date)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDateTime(stat.last_identification_date)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,936 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import facesApi, {
AutoMatchPersonSummary,
AutoMatchFaceItem
} from '../api/faces'
import peopleApi, { Person } from '../api/people'
import { apiClient } from '../api/client'
import { useDeveloperMode } from '../context/DeveloperModeContext'
const DEFAULT_TOLERANCE = 0.6
export default function AutoMatch() {
const { isDeveloperMode } = useDeveloperMode()
const [tolerance, setTolerance] = useState(DEFAULT_TOLERANCE)
const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(70)
const [isActive, setIsActive] = useState(false)
const [people, setPeople] = useState<AutoMatchPersonSummary[]>([])
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonSummary[]>([])
// Store matches separately, keyed by person_id
const [matchesCache, setMatchesCache] = useState<Record<number, AutoMatchFaceItem[]>>({})
const [currentIndex, setCurrentIndex] = useState(0)
const [searchQuery, setSearchQuery] = useState('')
const [allPeople, setAllPeople] = useState<Person[]>([])
const [loadingPeople, setLoadingPeople] = useState(false)
const [showPeopleDropdown, setShowPeopleDropdown] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const [selectedFaces, setSelectedFaces] = useState<Record<number, boolean>>({})
const [originalSelectedFaces, setOriginalSelectedFaces] = useState<Record<number, boolean>>({})
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [hasNoResults, setHasNoResults] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
// SessionStorage keys for persisting state and settings
const STATE_KEY = 'automatch_state'
const SETTINGS_KEY = 'automatch_settings'
// Track if initial load has happened
const initialLoadRef = useRef(false)
// Track if settings have been loaded from sessionStorage
const [settingsLoaded, setSettingsLoaded] = useState(false)
// Track if state has been restored from sessionStorage
const [stateRestored, setStateRestored] = useState(false)
// Track if initial restoration is complete (prevents reload effects from firing during restoration)
const restorationCompleteRef = useRef(false)
const currentPerson = useMemo(() => {
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
return activePeople[currentIndex]
}, [filteredPeople, people, currentIndex])
const currentMatches = useMemo(() => {
if (!currentPerson) return []
return matchesCache[currentPerson.person_id] || []
}, [currentPerson, matchesCache])
// Check if any matches are selected
const hasSelectedMatches = useMemo(() => {
return currentMatches.some(match => selectedFaces[match.id] === true)
}, [currentMatches, selectedFaces])
// Load matches for a specific person (lazy loading)
const loadPersonMatches = async (personId: number) => {
// Skip if already cached
if (matchesCache[personId]) {
return
}
try {
const response = await facesApi.getAutoMatchPersonMatches(personId, {
tolerance,
filter_frontal_only: false
})
setMatchesCache(prev => ({
...prev,
[personId]: response.matches
}))
// Update total_matches in people list
setPeople(prev => prev.map(p =>
p.person_id === personId
? { ...p, total_matches: response.total_matches }
: p
))
// If no matches found, remove person from list (matching original behavior)
// Original endpoint only returns people who have matches
if (response.total_matches === 0) {
setPeople(prev => {
const removedIndex = prev.findIndex(p => p.person_id === personId)
// Adjust current index if needed
if (removedIndex !== -1) {
setCurrentIndex(currentIdx => {
if (currentIdx >= removedIndex) {
return Math.max(0, currentIdx - 1)
}
return currentIdx
})
}
return prev.filter(p => p.person_id !== personId)
})
setFilteredPeople(prev => prev.filter(p => p.person_id !== personId))
}
} catch (error) {
console.error('Failed to load matches for person:', error)
// Set empty matches on error, and remove person from list
setMatchesCache(prev => ({
...prev,
[personId]: []
}))
// Remove person if matches failed to load (assume no matches)
setPeople(prev => prev.filter(p => p.person_id !== personId))
setFilteredPeople(prev => prev.filter(p => p.person_id !== personId))
}
}
// Shared function for auto-load and refresh (loads people list only - fast)
const loadAutoMatch = async (clearState: boolean = false) => {
if (tolerance < 0 || tolerance > 1) {
return
}
setBusy(true)
setIsRefreshing(true)
try {
// Clear saved state if explicitly requested (Refresh button)
if (clearState) {
sessionStorage.removeItem(STATE_KEY)
setMatchesCache({}) // Clear matches cache
}
// Load people list only (fast - no match calculations)
const response = await facesApi.getAutoMatchPeople({
filter_frontal_only: false
})
if (response.people.length === 0) {
setHasNoResults(true)
setPeople([])
setFilteredPeople([])
setIsActive(false)
setBusy(false)
setIsRefreshing(false)
return
}
setHasNoResults(false)
setPeople(response.people)
setFilteredPeople([])
setCurrentIndex(0)
setSelectedFaces({})
setOriginalSelectedFaces({})
setIsActive(true)
// Load matches for first person immediately
if (response.people.length > 0) {
await loadPersonMatches(response.people[0].person_id)
}
} catch (error) {
console.error('Auto-match failed:', error)
} finally {
setBusy(false)
setIsRefreshing(false)
}
}
// Load settings from sessionStorage on mount
useEffect(() => {
try {
const saved = sessionStorage.getItem(SETTINGS_KEY)
if (saved) {
const settings = JSON.parse(saved)
if (settings.tolerance !== undefined) setTolerance(settings.tolerance)
if (settings.autoAcceptThreshold !== undefined) setAutoAcceptThreshold(settings.autoAcceptThreshold)
}
} catch (error) {
console.error('Error loading settings from sessionStorage:', error)
} finally {
setSettingsLoaded(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Load state from sessionStorage on mount (people, current index, selected faces)
// Note: This effect runs after settings are loaded, so tolerance is already set
useEffect(() => {
if (!settingsLoaded) return // Wait for settings to load first
try {
const saved = sessionStorage.getItem(STATE_KEY)
if (saved) {
const state = JSON.parse(saved)
// Only restore state if tolerance matches (cached state is for current tolerance)
if (state.people && Array.isArray(state.people) && state.people.length > 0 &&
state.tolerance === tolerance) {
setPeople(state.people)
if (state.currentIndex !== undefined) {
setCurrentIndex(Math.min(state.currentIndex, state.people.length - 1))
}
if (state.selectedFaces && typeof state.selectedFaces === 'object') {
setSelectedFaces(state.selectedFaces)
}
if (state.originalSelectedFaces && typeof state.originalSelectedFaces === 'object') {
setOriginalSelectedFaces(state.originalSelectedFaces)
}
if (state.matchesCache && typeof state.matchesCache === 'object') {
setMatchesCache(state.matchesCache)
}
if (state.isActive !== undefined) {
setIsActive(state.isActive)
}
if (state.hasNoResults !== undefined) {
setHasNoResults(state.hasNoResults)
}
// Mark that we restored state, so we don't reload
initialLoadRef.current = true
// Mark restoration as complete after state is restored
setTimeout(() => {
restorationCompleteRef.current = true
}, 50)
} else if (state.tolerance !== undefined && state.tolerance !== tolerance) {
// Tolerance changed, clear old cache
sessionStorage.removeItem(STATE_KEY)
}
}
} catch (error) {
console.error('Error loading state from sessionStorage:', error)
} finally {
setStateRestored(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsLoaded])
// Save state to sessionStorage whenever it changes (but only after initial restore)
useEffect(() => {
if (!stateRestored) return // Don't save during initial restore
try {
const state = {
people,
currentIndex,
selectedFaces,
originalSelectedFaces,
matchesCache,
isActive,
hasNoResults,
tolerance, // Include tolerance to validate cache on restore
}
sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
} catch (error) {
console.error('Error saving state to sessionStorage:', error)
}
}, [people, currentIndex, selectedFaces, originalSelectedFaces, matchesCache, isActive, hasNoResults, tolerance, stateRestored])
// Save state on unmount (when navigating away) - use refs to capture latest values
const peopleRef = useRef(people)
const currentIndexRef = useRef(currentIndex)
const selectedFacesRef = useRef(selectedFaces)
const originalSelectedFacesRef = useRef(originalSelectedFaces)
const matchesCacheRef = useRef(matchesCache)
const isActiveRef = useRef(isActive)
const hasNoResultsRef = useRef(hasNoResults)
const toleranceRef = useRef(tolerance)
// Update refs whenever state changes
useEffect(() => {
peopleRef.current = people
currentIndexRef.current = currentIndex
selectedFacesRef.current = selectedFaces
originalSelectedFacesRef.current = originalSelectedFaces
matchesCacheRef.current = matchesCache
isActiveRef.current = isActive
hasNoResultsRef.current = hasNoResults
toleranceRef.current = tolerance
}, [people, currentIndex, selectedFaces, originalSelectedFaces, matchesCache, isActive, hasNoResults, tolerance])
// Save state on unmount (when navigating away)
useEffect(() => {
return () => {
try {
const state = {
people: peopleRef.current,
currentIndex: currentIndexRef.current,
selectedFaces: selectedFacesRef.current,
originalSelectedFaces: originalSelectedFacesRef.current,
matchesCache: matchesCacheRef.current,
isActive: isActiveRef.current,
hasNoResults: hasNoResultsRef.current,
tolerance: toleranceRef.current, // Include tolerance to validate cache on restore
}
sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
} catch (error) {
console.error('Error saving state on unmount:', error)
}
}
}, [])
// Save settings to sessionStorage whenever they change (but only after initial load)
useEffect(() => {
if (!settingsLoaded) return // Don't save during initial load
try {
const settings = {
tolerance,
autoAcceptThreshold,
}
sessionStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
} catch (error) {
console.error('Error saving settings to sessionStorage:', error)
}
}, [tolerance, autoAcceptThreshold, settingsLoaded])
// Load all people for dropdown
useEffect(() => {
const loadAllPeople = async () => {
try {
setLoadingPeople(true)
const response = await peopleApi.list()
setAllPeople(response.items || [])
} catch (error) {
console.error('Failed to load people:', error)
setAllPeople([])
} finally {
setLoadingPeople(false)
}
}
loadAllPeople()
}, [])
// Initial load on mount (after settings and state are loaded)
useEffect(() => {
if (!initialLoadRef.current && settingsLoaded && stateRestored) {
initialLoadRef.current = true
// Only load if we didn't restore state (no people means we need to load)
if (people.length === 0) {
loadAutoMatch()
// If we're loading fresh, mark restoration as complete immediately
restorationCompleteRef.current = true
} else {
// If state was restored, restorationCompleteRef is already set in the state restoration effect
// But ensure it's set in case state restoration didn't happen
if (!restorationCompleteRef.current) {
setTimeout(() => {
restorationCompleteRef.current = true
}, 50)
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsLoaded, stateRestored])
// Reload when tolerance changes (immediate reload)
// But only if restoration is complete (prevents reload during initial restoration)
useEffect(() => {
if (initialLoadRef.current && restorationCompleteRef.current) {
// Clear matches cache when tolerance changes (matches depend on tolerance)
setMatchesCache({})
loadAutoMatch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tolerance])
// Apply search filter
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredPeople([])
return
}
const query = searchQuery.trim().toLowerCase()
const filtered = people.filter(person => {
// Extract last name from person name (matching desktop logic)
let lastName = ''
if (person.person_name.includes(',')) {
lastName = person.person_name.split(',')[0].trim().toLowerCase()
} else {
const nameParts = person.person_name.trim().split(' ')
if (nameParts.length > 0) {
lastName = nameParts[nameParts.length - 1].toLowerCase()
}
}
return lastName.includes(query)
})
setFilteredPeople(filtered)
setCurrentIndex(0)
}, [searchQuery, people])
const startAutoMatch = async () => {
if (tolerance < 0 || tolerance > 1) {
alert('Please enter a valid tolerance value between 0.0 and 1.0.')
return
}
if (autoAcceptThreshold < 0 || autoAcceptThreshold > 100) {
alert('Please enter a valid auto-accept threshold between 0 and 100.')
return
}
setBusy(true)
try {
const response = await facesApi.autoMatch({
tolerance,
auto_accept: true,
auto_accept_threshold: autoAcceptThreshold
})
// Show summary if auto-accept was performed
if (response.auto_accepted) {
const summary = [
`✅ Auto-matched ${response.auto_accepted_faces || 0} faces`,
response.skipped_persons ? `⚠️ Skipped ${response.skipped_persons} persons (non-frontal reference)` : '',
response.skipped_matches ? ` Skipped ${response.skipped_matches} matches (didn't meet criteria)` : ''
].filter(Boolean).join('\n')
if (summary) {
alert(summary)
}
// Reload faces after auto-accept to remove auto-accepted faces from the list
// Clear cache to get fresh data after auto-accept
await loadAutoMatch(true)
return
}
if (response.people.length === 0) {
alert('🔍 No similar faces found for auto-identification')
setHasNoResults(true)
setPeople([])
setFilteredPeople([])
setIsActive(false)
setBusy(false)
return
}
setHasNoResults(false)
setPeople(response.people)
setFilteredPeople([])
setCurrentIndex(0)
setSelectedFaces({})
setOriginalSelectedFaces({})
setIsActive(true)
} catch (error) {
console.error('Auto-match failed:', error)
alert('Failed to start auto-match. Please try again.')
} finally {
setBusy(false)
}
}
const handleFaceToggle = (faceId: number) => {
setSelectedFaces(prev => ({
...prev,
[faceId]: !prev[faceId],
}))
}
const selectAll = () => {
const newSelected: Record<number, boolean> = {}
currentMatches.forEach(match => {
newSelected[match.id] = true
})
setSelectedFaces(newSelected)
}
const clearAll = () => {
const newSelected: Record<number, boolean> = {}
currentMatches.forEach(match => {
newSelected[match.id] = false
})
setSelectedFaces(newSelected)
}
const saveChanges = async () => {
if (!currentPerson) return
setSaving(true)
try {
const faceIds = currentMatches
.filter(match => selectedFaces[match.id] === true)
.map(match => match.id)
await peopleApi.acceptMatches(currentPerson.person_id, faceIds)
// Update original selected faces to current state
const newOriginal: Record<number, boolean> = {}
currentMatches.forEach(match => {
newOriginal[match.id] = selectedFaces[match.id] || false
})
setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal }))
alert(`✅ Saved ${faceIds.length} match(es)`)
} catch (error) {
console.error('Save failed:', error)
alert('Failed to save matches. Please try again.')
} finally {
setSaving(false)
}
}
// Load matches when current person changes (lazy loading)
useEffect(() => {
if (currentPerson && restorationCompleteRef.current) {
loadPersonMatches(currentPerson.person_id)
// Preload matches for next person in background
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
if (currentIndex + 1 < activePeople.length) {
const nextPerson = activePeople[currentIndex + 1]
loadPersonMatches(nextPerson.person_id)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPerson?.person_id, currentIndex])
// Restore selected faces when navigating to a different person
useEffect(() => {
if (currentPerson) {
const matches = matchesCache[currentPerson.person_id] || []
const restored: Record<number, boolean> = {}
matches.forEach(match => {
restored[match.id] = originalSelectedFaces[match.id] || false
})
setSelectedFaces(restored)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache])
const goBack = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
}
}
const goNext = () => {
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
if (currentIndex < activePeople.length - 1) {
setCurrentIndex(currentIndex + 1)
}
}
const clearSearch = () => {
setSearchQuery('')
setFilteredPeople([])
setCurrentIndex(0)
setShowPeopleDropdown(false)
}
const formatPersonName = (person: Person): string => {
const parts: string[] = []
// Last name with comma
if (person.last_name) {
parts.push(`${person.last_name},`)
}
// Middle name between last and first
if (person.middle_name) {
parts.push(person.middle_name)
}
// First name
if (person.first_name) {
parts.push(person.first_name)
}
// Maiden name in parentheses
if (person.maiden_name) {
parts.push(`(${person.maiden_name})`)
}
if (parts.length === 0) {
return person.first_name || person.last_name || 'Unknown'
}
// Format as "Last, Middle First (Maiden)"
return parts.join(' ')
}
const formatFullPersonName = (personId: number): string => {
const person = allPeople.find(p => p.id === personId)
if (!person) {
// Fallback to person_name if person not found in allPeople
const currentPersonData = people.find(p => p.person_id === personId) ||
filteredPeople.find(p => p.person_id === personId)
return currentPersonData?.person_name || 'Unknown'
}
return formatPersonName(person)
}
const handlePersonSelect = (personId: number) => {
const person = allPeople.find(p => p.id === personId)
if (person) {
// Extract last name and set as search query
const lastName = person.last_name || ''
setSearchQuery(lastName)
setShowPeopleDropdown(false)
}
}
// Filter people based on search query for dropdown
const filteredPeopleForDropdown = useMemo(() => {
if (!searchQuery.trim()) {
return allPeople
}
const query = searchQuery.trim().toLowerCase()
return allPeople.filter(person => {
const lastName = (person.last_name || '').toLowerCase()
const firstName = (person.first_name || '').toLowerCase()
const middleName = (person.middle_name || '').toLowerCase()
const fullName = `${lastName}, ${firstName}${middleName ? ` ${middleName}` : ''}`.toLowerCase()
return fullName.includes(query) || lastName.includes(query) || firstName.includes(query)
})
}, [searchQuery, allPeople])
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
searchInputRef.current &&
!searchInputRef.current.contains(event.target as Node)
) {
setShowPeopleDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
const canGoBack = currentIndex > 0
const canGoNext = currentIndex < activePeople.length - 1
return (
<div className="flex flex-col h-full">
{/* Configuration */}
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="flex items-center gap-4">
<button
onClick={() => loadAutoMatch(true)}
disabled={busy}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
title="Refresh and start from beginning"
>
{isRefreshing ? 'Refreshing...' : '🔄 Refresh'}
</button>
{isDeveloperMode && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Tolerance:</label>
<input
type="number"
min="0"
max="1"
step="0.1"
value={tolerance}
onChange={(e) => setTolerance(parseFloat(e.target.value) || 0)}
disabled={busy}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<span className="text-xs text-gray-500">(lower = stricter matching)</span>
</div>
)}
<div className="flex items-center gap-2">
<button
onClick={startAutoMatch}
disabled={busy || hasNoResults}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
title={hasNoResults ? 'No matches found. Adjust tolerance or process more photos.' : ''}
>
{busy ? 'Processing...' : hasNoResults ? 'No Matches Available' : '🚀 Run Auto-Match'}
</button>
</div>
{isDeveloperMode && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Auto-Accept Threshold:</label>
<input
type="number"
min="0"
max="100"
step="5"
value={autoAcceptThreshold}
onChange={(e) => setAutoAcceptThreshold(parseInt(e.target.value) || 70)}
disabled={busy || hasNoResults}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<span className="text-xs text-gray-500">% (min similarity)</span>
</div>
)}
</div>
<div className="mt-2 text-xs text-gray-600 bg-blue-50 border border-blue-200 rounded p-2">
<span className="font-medium"> Auto-Match Criteria:</span> Only faces with similarity higher than 70% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
</div>
</div>
{isActive && (
<>
{/* Main panels */}
<div className="flex-1 grid grid-cols-2 gap-4 mb-4">
{/* Left panel - Identified Person */}
<div className="bg-white rounded-lg shadow p-4 flex flex-col">
<h2 className="text-lg font-semibold mb-4">Identified Person</h2>
{/* Search controls */}
<div className="mb-4">
<div className="flex gap-2 mb-2 relative">
<div className="flex-1 relative">
<input
ref={searchInputRef}
type="text"
placeholder="Type Last Name or Select Person"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setShowPeopleDropdown(true)
}}
onFocus={() => setShowPeopleDropdown(true)}
disabled={people.length === 1 || loadingPeople}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
/>
{showPeopleDropdown && filteredPeopleForDropdown.length > 0 && !loadingPeople && (
<div
ref={dropdownRef}
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded shadow-lg max-h-60 overflow-auto"
>
{filteredPeopleForDropdown.map((person) => (
<div
key={person.id}
onClick={() => handlePersonSelect(person.id)}
className="px-3 py-2 hover:bg-blue-50 cursor-pointer text-sm"
>
{formatPersonName(person)}
</div>
))}
</div>
)}
</div>
<button
onClick={clearSearch}
disabled={people.length === 1}
className="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
>
Clear
</button>
</div>
{people.length === 1 && (
<p className="text-xs text-gray-500">(Search disabled - only one person found)</p>
)}
</div>
{/* Person info */}
{currentPerson && (
<>
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
{isDeveloperMode && (
<div className="text-sm text-gray-600">
Person {currentIndex + 1}
</div>
)}
<div className="space-x-2">
<button
onClick={goBack}
disabled={!canGoBack}
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-400"
>
Prev
</button>
<button
onClick={goNext}
disabled={!canGoNext}
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-400"
>
Next
</button>
</div>
</div>
<p className="font-semibold">👤 Person: {formatFullPersonName(currentPerson.person_id)}</p>
<p className="text-sm text-gray-600">
📁 Photo: {currentPerson.reference_photo_filename}
</p>
{isDeveloperMode && (
<p className="text-sm text-gray-600">
📍 Face location: {currentPerson.reference_location}
</p>
)}
<p className="text-sm text-gray-600">
📊 {currentPerson.face_count} faces already identified
</p>
</div>
{/* Person face image */}
<div className="mb-4 flex justify-center">
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${currentPerson.reference_photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${currentPerson.reference_face_id}/crop`}
alt="Reference face"
className="max-w-[300px] max-h-[300px] rounded border border-gray-300"
/>
</div>
</div>
{/* Save button */}
<div className="flex justify-center">
<button
onClick={saveChanges}
disabled={saving || !hasSelectedMatches}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{saving ? '💾 Saving...' : `💾 Save matches for ${currentPerson.person_name}`}
</button>
</div>
</>
)}
</div>
{/* Right panel - Unidentified Faces */}
<div className="bg-white rounded-lg shadow p-4 flex flex-col">
<h2 className="text-lg font-semibold mb-4">Unidentified Faces to Match</h2>
{/* Select All / Clear All buttons */}
<div className="flex gap-2 mb-4">
<button
onClick={selectAll}
disabled={currentMatches.length === 0}
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
>
Select All
</button>
<button
onClick={clearAll}
disabled={currentMatches.length === 0}
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
>
Clear All
</button>
</div>
{/* Matches grid */}
<div className="flex-1 overflow-y-auto">
{currentMatches.length === 0 ? (
<p className="text-gray-500 text-center py-8">No matches found</p>
) : (
<div className="space-y-2">
{currentMatches.map((match) => (
<div
key={match.id}
className="flex items-center gap-3 p-2 border border-gray-200 rounded hover:bg-gray-50"
>
<input
type="checkbox"
checked={selectedFaces[match.id] || false}
onChange={() => handleFaceToggle(match.id)}
className="w-4 h-4"
/>
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${match.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${match.id}/crop`}
alt="Match face"
className="w-20 h-20 object-cover rounded border border-gray-300"
/>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span
className={`px-2 py-1 rounded text-xs font-semibold ${
match.similarity >= 70
? 'bg-green-100 text-green-800'
: match.similarity >= 60
? 'bg-yellow-100 text-yellow-800'
: 'bg-orange-100 text-orange-800'
}`}
>
{Math.round(match.similarity)}% Match
</span>
</div>
<p className="text-xs text-gray-600">📁 {match.photo_filename}</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Navigation controls */}
<div className="flex items-center justify-between bg-white rounded-lg shadow p-4">
<div className="flex gap-2">
<button
onClick={goBack}
disabled={!canGoBack}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
Back
</button>
<button
onClick={goNext}
disabled={!canGoNext}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="text-sm text-gray-600">
{isDeveloperMode && `Person ${currentIndex + 1} `}
{currentPerson && `${currentPerson.total_matches} matches`}
</div>
</div>
</>
)}
</div>
)
}

View File

@ -0,0 +1,296 @@
import { useEffect, useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { photosApi, PhotoSearchResult } from '../api/photos'
import apiClient from '../api/client'
export default function Dashboard() {
const { username } = useAuth()
const [samplePhotos, setSamplePhotos] = useState<PhotoSearchResult[]>([])
const [loadingPhotos, setLoadingPhotos] = useState(true)
useEffect(() => {
loadSamplePhotos()
}, [])
const loadSamplePhotos = async () => {
try {
setLoadingPhotos(true)
// Try to get some recent photos to display
const result = await photosApi.searchPhotos({
search_type: 'processed',
page: 1,
page_size: 6,
})
setSamplePhotos(result.items || [])
} catch (error) {
console.error('Failed to load sample photos:', error)
setSamplePhotos([])
} finally {
setLoadingPhotos(false)
}
}
const getPhotoImageUrl = (photoId: number): string => {
return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
}
return (
<div className="min-h-screen">
{/* Hero Section */}
<section className="relative py-12 px-4 overflow-hidden" style={{ background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%)' }}>
<div className="max-w-6xl mx-auto relative z-10">
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold mb-4 leading-tight" style={{ color: '#F97316' }}>
Welcome to PunimTag
</h1>
<p className="text-xl md:text-2xl mb-3" style={{ color: '#2563EB' }}>
Your Intelligent Photo Management System
</p>
<p className="text-lg max-w-2xl mx-auto text-gray-600">
Organize, identify, and search through your photo collection like never before.
</p>
</div>
</div>
</section>
{/* Feature Showcase 1 - AI Recognition */}
<section className="py-16 px-4 bg-white">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<div className="text-5xl mb-4">🤖</div>
<h2 className="text-4xl font-bold text-gray-900 mb-4">
Recognize Faces Automatically
</h2>
<p className="text-lg text-gray-600 mb-6">
Never lose track of who's in your photos again. Our smart system
automatically finds and recognizes faces in all your pictures. Just
tell it who someone is once, and it will find them in thousands of
photoseven from years ago.
</p>
<ul className="space-y-3 text-gray-700">
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}></span>
<span>Automatically finds faces in all your photos</span>
</li>
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}></span>
<span>Recognizes the same person across different photos</span>
</li>
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}></span>
<span>Works even with photos taken years apart</span>
</li>
</ul>
</div>
<div className="relative">
<div className="bg-gray-50 rounded-2xl p-8 shadow-xl">
<div className="aspect-video bg-white rounded-lg shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">👥</div>
<p className="text-gray-500 text-sm">
Face recognition in action
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Feature Showcase 2 - Smart Search */}
<section className="py-16 px-4 bg-gray-50">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div className="order-2 md:order-1 relative">
<div className="bg-gray-50 rounded-2xl p-8 shadow-xl">
<div className="aspect-video bg-white rounded-lg shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">🔍</div>
<p className="text-gray-500 text-sm">
Powerful search interface
</p>
</div>
</div>
</div>
</div>
<div className="order-1 md:order-2">
<div className="text-5xl mb-4">🔍</div>
<h2 className="text-4xl font-bold text-gray-900 mb-4">
Find Anything, Instantly
</h2>
<p className="text-lg text-gray-600 mb-6">
Search your entire photo collection by people, dates, tags, or
folders. Our advanced filtering system makes it easy to find
exactly what you're looking for, no matter how large your
collection grows.
</p>
<ul className="space-y-3 text-gray-700">
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}></span>
<span>Search by person name across all photos</span>
</li>
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}></span>
<span>Filter by date ranges and folders</span>
</li>
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}></span>
<span>Tag-based organization and filtering</span>
</li>
</ul>
</div>
</div>
</div>
</section>
{/* Feature Showcase 3 - Batch Processing */}
<section className="py-16 px-4 bg-white">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<div className="text-5xl mb-4"></div>
<h2 className="text-4xl font-bold text-gray-900 mb-4">
Process Thousands at Once
</h2>
<p className="text-lg text-gray-600 mb-6">
Don't let a large photo collection overwhelm you. Our batch
processing system efficiently handles thousands of photos with
real-time progress tracking. Watch as your photos are organized
automatically.
</p>
<ul className="space-y-3 text-gray-700">
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}></span>
<span>Batch face detection and recognition</span>
</li>
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}></span>
<span>Real-time progress updates</span>
</li>
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}></span>
<span>Background job processing</span>
</li>
</ul>
</div>
<div className="relative">
<div className="bg-gray-50 rounded-2xl p-8 shadow-xl">
<div className="aspect-video bg-white rounded-lg shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">📊</div>
<p className="text-gray-500 text-sm">
Batch processing dashboard
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Visual Gallery Section */}
<section className="py-16 px-4 bg-white">
<div className="max-w-6xl mx-auto">
<h2 className="text-4xl font-bold text-center text-gray-900 mb-4">
Organize Your Memories
</h2>
<p className="text-center text-lg text-gray-600 mb-12 max-w-2xl mx-auto">
Transform your photo collection into an organized, searchable library
of memories. Find any moment, any person, any time.
</p>
{loadingPhotos ? (
<div className="grid md:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-gray-50 rounded-xl p-6 shadow-md aspect-square flex items-center justify-center animate-pulse"
>
<div className="text-center">
<div className="text-6xl mb-4">📸</div>
<p className="text-gray-500 text-sm">Loading...</p>
</div>
</div>
))}
</div>
) : samplePhotos.length > 0 ? (
<div className="grid md:grid-cols-3 gap-6">
{samplePhotos.slice(0, 6).map((photo) => (
<div
key={photo.id}
className="bg-gray-50 rounded-xl p-2 shadow-md aspect-square overflow-hidden group hover:shadow-lg transition-shadow"
>
<img
src={getPhotoImageUrl(photo.id)}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className =
'w-full h-full flex items-center justify-center error-fallback'
fallback.innerHTML =
'<div class="text-center"><div class="text-6xl mb-4">📸</div><p class="text-gray-500 text-sm">Photo</p></div>'
parent.appendChild(fallback)
}
}}
/>
</div>
))}
</div>
) : (
<div className="grid md:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-gray-50 rounded-xl p-6 shadow-md aspect-square flex items-center justify-center"
>
<div className="text-center">
<div className="text-6xl mb-4">📸</div>
<p className="text-gray-500 text-sm">Your photos</p>
</div>
</div>
))}
</div>
)}
</div>
</section>
{/* CTA Section */}
<section className="py-20 px-4 bg-white">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-4xl md:text-5xl font-bold mb-6" style={{ color: '#F97316' }}>
Ready to Get Started?
</h2>
<p className="text-xl mb-8 max-w-2xl mx-auto text-gray-600">
Begin organizing your photo collection today. Use the navigation menu
to explore all the powerful features PunimTag has to offer.
</p>
<div className="flex flex-wrap justify-center gap-4">
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">🗂</span> Scan Photos
</div>
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold"></span> Process Faces
</div>
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">👤</span> Identify People
</div>
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">🤖</span> Auto-Match
</div>
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">🔍</span> Search Photos
</div>
</div>
</div>
</section>
</div>
)
}

View File

@ -0,0 +1,435 @@
import { useEffect, useState, useMemo } from 'react'
import facesApi, { MaintenanceFaceItem } from '../api/faces'
import { apiClient } from '../api/client'
type SortColumn = 'person_name' | 'quality' | 'photo_path' | 'excluded'
type SortDir = 'asc' | 'desc'
type ExcludedFilter = 'all' | 'excluded' | 'included'
type IdentifiedFilter = 'all' | 'identified' | 'unidentified'
export default function FacesMaintenance() {
const [faces, setFaces] = useState<MaintenanceFaceItem[]>([])
const [total, setTotal] = useState(0)
const [pageSize, setPageSize] = useState(50)
const [minQuality, setMinQuality] = useState(0.0)
const [maxQuality, setMaxQuality] = useState(1.0)
const [excludedFilter, setExcludedFilter] = useState<ExcludedFilter>('all')
const [identifiedFilter, setIdentifiedFilter] = useState<IdentifiedFilter>('all')
const [selectedFaces, setSelectedFaces] = useState<Set<number>>(new Set())
const [loading, setLoading] = useState(false)
const [deleting, setDeleting] = useState(false)
const [excluding, setExcluding] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
const [sortDir, setSortDir] = useState<SortDir>('asc')
const loadFaces = async () => {
setLoading(true)
try {
const res = await facesApi.getMaintenanceFaces({
page: 1,
page_size: pageSize,
min_quality: minQuality,
max_quality: maxQuality,
excluded_filter: excludedFilter,
identified_filter: identifiedFilter,
})
setFaces(res.items)
setTotal(res.total)
setSelectedFaces(new Set()) // Clear selection when reloading
} catch (error) {
console.error('Error loading faces:', error)
alert('Error loading faces. Please try again.')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadFaces()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, minQuality, maxQuality, excludedFilter, identifiedFilter])
const toggleSelection = (faceId: number) => {
setSelectedFaces(prev => {
const newSet = new Set(prev)
if (newSet.has(faceId)) {
newSet.delete(faceId)
} else {
newSet.add(faceId)
}
return newSet
})
}
const selectAll = () => {
setSelectedFaces(new Set(sortedFaces.map(f => f.id)))
}
const unselectAll = () => {
setSelectedFaces(new Set())
}
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDir('asc')
}
}
const sortedFaces = useMemo(() => {
if (!sortColumn) return faces
return [...faces].sort((a, b) => {
let aVal: any
let bVal: any
switch (sortColumn) {
case 'person_name':
aVal = a.person_name || 'Unidentified'
bVal = b.person_name || 'Unidentified'
break
case 'quality':
aVal = a.quality_score
bVal = b.quality_score
break
case 'photo_path':
aVal = a.photo_path
bVal = b.photo_path
break
case 'excluded':
aVal = a.excluded ? 1 : 0
bVal = b.excluded ? 1 : 0
break
}
if (typeof aVal === 'string') {
aVal = aVal.toLowerCase()
bVal = bVal.toLowerCase()
}
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1
return 0
})
}, [faces, sortColumn, sortDir])
const handleDelete = async () => {
if (selectedFaces.size === 0) {
alert('Please select at least one face to delete.')
return
}
setShowDeleteConfirm(true)
}
const confirmDelete = async () => {
setShowDeleteConfirm(false)
setDeleting(true)
try {
await facesApi.deleteFaces({
face_ids: Array.from(selectedFaces),
})
// Reload faces after deletion
await loadFaces()
alert(`Successfully deleted ${selectedFaces.size} face(s)`)
} catch (error) {
console.error('Error deleting faces:', error)
alert('Error deleting faces. Please try again.')
} finally {
setDeleting(false)
}
}
const handleExclude = async () => {
if (selectedFaces.size === 0) {
alert('Please select at least one face to exclude.')
return
}
setExcluding(true)
try {
const faceIds = Array.from(selectedFaces)
// Exclude each selected face
await Promise.all(faceIds.map(faceId => facesApi.setExcluded(faceId, true)))
// Reload faces after exclusion
await loadFaces()
alert(`Successfully excluded ${selectedFaces.size} face(s)`)
} catch (error) {
console.error('Error excluding faces:', error)
alert('Error excluding faces. Please try again.')
} finally {
setExcluding(false)
}
}
return (
<div>
{/* Controls */}
<div className="bg-white rounded-lg shadow mb-4 p-4">
<div className="grid grid-cols-5 gap-4">
{/* Quality Range Selector */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Quality Range
</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={1}
step={0.01}
value={minQuality}
onChange={(e) => setMinQuality(parseFloat(e.target.value))}
className="flex-1"
/>
<input
type="range"
min={0}
max={1}
step={0.01}
value={maxQuality}
onChange={(e) => setMaxQuality(parseFloat(e.target.value))}
className="flex-1"
/>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Min: {(minQuality * 100).toFixed(0)}%</span>
<span>Max: {(maxQuality * 100).toFixed(0)}%</span>
</div>
</div>
{/* Excluded Faces Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Excluded Faces
</label>
<select
value={excludedFilter}
onChange={(e) => setExcludedFilter(e.target.value as ExcludedFilter)}
className="block w-auto border rounded px-2 py-1 text-sm"
>
<option value="all">All</option>
<option value="excluded">Excluded only</option>
<option value="included">Included only</option>
</select>
</div>
{/* Identified Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Identified
</label>
<select
value={identifiedFilter}
onChange={(e) => setIdentifiedFilter(e.target.value as IdentifiedFilter)}
className="block w-auto border rounded px-2 py-1 text-sm"
>
<option value="all">All</option>
<option value="identified">Identified only</option>
<option value="unidentified">Unidentified only</option>
</select>
</div>
{/* Batch Size */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Batch Size
</label>
<select
value={pageSize}
onChange={(e) => setPageSize(parseInt(e.target.value))}
className="block w-auto border rounded px-2 py-1 text-sm"
>
{[25, 50, 100, 200, 500, 1000, 1500, 2000].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
{/* Action Buttons */}
<div className="flex items-end gap-2">
<button
onClick={selectAll}
disabled={faces.length === 0}
className="px-3 py-2 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
>
Select All
</button>
<button
onClick={unselectAll}
disabled={selectedFaces.size === 0}
className="px-3 py-2 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
>
Unselect All
</button>
<button
onClick={handleExclude}
disabled={selectedFaces.size === 0 || excluding}
className="px-3 py-2 text-sm bg-orange-600 text-white rounded hover:bg-orange-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{excluding ? 'Excluding...' : 'Exclude Selected'}
</button>
<button
onClick={handleDelete}
disabled={selectedFaces.size === 0 || deleting}
className="px-3 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{deleting ? 'Deleting...' : 'Delete Selected'}
</button>
</div>
</div>
</div>
{/* Results */}
<div className="bg-white rounded-lg shadow p-4">
<div className="mb-4">
<span className="text-sm font-medium text-gray-700">
Total: {total} face(s)
</span>
{selectedFaces.size > 0 && (
<span className="ml-4 text-sm text-gray-600">
Selected: {selectedFaces.size} face(s)
</span>
)}
</div>
{loading ? (
<div className="text-center py-8 text-gray-500">Loading faces...</div>
) : sortedFaces.length === 0 ? (
<div className="text-center py-8 text-gray-500">No faces found</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left p-2 w-12"></th>
<th className="text-left p-2 w-24">Thumbnail</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('person_name')}
>
Person Name {sortColumn === 'person_name' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('photo_path')}
>
File Path {sortColumn === 'photo_path' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('quality')}
>
Quality {sortColumn === 'quality' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('excluded')}
>
Excluded {sortColumn === 'excluded' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
</tr>
</thead>
<tbody>
{sortedFaces.map((face) => (
<tr key={face.id} className="border-b hover:bg-gray-50">
<td className="p-2">
<input
type="checkbox"
checked={selectedFaces.has(face.id)}
onChange={() => toggleSelection(face.id)}
className="cursor-pointer"
/>
</td>
<td className="p-2">
<div
className="w-20 h-20 bg-gray-100 rounded overflow-hidden flex items-center justify-center relative group cursor-pointer"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${face.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`${apiClient.defaults.baseURL}/api/v1/faces/${face.id}/crop`}
alt={`Face ${face.id}`}
className="max-w-full max-h-full object-contain pointer-events-none"
crossOrigin="anonymous"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className = 'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${face.id}`
parent.appendChild(fallback)
}
}}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-opacity pointer-events-none" />
</div>
</td>
<td className="p-2">
{face.person_name || (
<span className="text-gray-400 italic">Unidentified</span>
)}
</td>
<td className="p-2">
<span className="text-blue-600" title={face.photo_path}>
{face.photo_path}
</span>
</td>
<td className="p-2">
{(face.quality_score * 100).toFixed(1)}%
</td>
<td className="p-2">
{face.excluded ? (
<span className="text-red-600 font-medium">Yes</span>
) : (
<span className="text-gray-500">No</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Delete Confirmation Dialog */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h3 className="text-lg font-bold mb-4">Confirm Delete</h3>
<p className="text-gray-700 mb-6">
Are you sure you want to delete {selectedFaces.size} face(s) from
the database? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={confirmDelete}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,133 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login, isAuthenticated, isLoading } = useAuth()
const navigate = useNavigate()
useEffect(() => {
// Only redirect if user is already authenticated (e.g., visiting /login while logged in)
// Don't redirect on isLoading changes during login attempts
if (isAuthenticated && !isLoading) {
navigate('/', { replace: true })
}
}, [isAuthenticated, isLoading, navigate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
e.stopPropagation()
setError('')
setLoading(true)
try {
const result = await login(username, password)
if (result.success) {
navigate('/', { replace: true })
} else {
setError(result.error || 'Login failed')
setLoading(false)
}
} catch (err) {
setError('Login failed')
setLoading(false)
}
}
// Only show loading screen on initial auth check, not during login attempts
if (isLoading && !loading) {
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="max-w-md w-full">
<div className="bg-white rounded-lg shadow-md p-8">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<img
src="/logo.png"
alt="PunimTag"
className="h-16 w-auto"
onError={(e) => {
// Fallback if logo.png doesn't exist, try logo.svg
const target = e.target as HTMLImageElement
if (target.src.endsWith('logo.png')) {
target.src = '/logo.svg'
}
}}
/>
</div>
<p className="text-gray-600">Photo Management System</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1"
>
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700 focus:outline-none"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? '🙈' : '👁️'}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,11 @@
export default function ManagePhotos() {
return (
<div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-gray-600">Photo management functionality coming soon...</p>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,808 @@
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { pendingPhotosApi, PendingPhotoResponse, ReviewDecision, CleanupResponse } from '../api/pendingPhotos'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { videosApi } from '../api/videos'
type SortKey = 'photo' | 'uploaded_by' | 'file_info' | 'submitted_at' | 'status'
export default function PendingPhotos() {
const { hasPermission, isAdmin } = useAuth()
const canManageUploads = hasPermission('user_uploaded')
const canRunCleanup = isAdmin
const [pendingPhotos, setPendingPhotos] = useState<PendingPhotoResponse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'reject' | null>>({})
const [rejectionReasons, setRejectionReasons] = useState<Record<number, string>>({})
const [bulkRejectionReason, setBulkRejectionReason] = useState<string>('')
const [submitting, setSubmitting] = useState(false)
const [statusFilter, setStatusFilter] = useState<string>('pending')
const [imageUrls, setImageUrls] = useState<Record<number, string>>({})
const [notification, setNotification] = useState<{
approved: number
rejected: number
warnings: string[]
errors: string[]
} | null>(null)
const imageUrlsRef = useRef<Record<number, string>>({})
const [sortBy, setSortBy] = useState<SortKey>('submitted_at')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
const loadPendingPhotos = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await pendingPhotosApi.listPendingPhotos(
statusFilter || undefined
)
setPendingPhotos(response.items)
// Clear decisions when loading different status
setDecisions({})
setRejectionReasons({})
// Load images as blobs with authentication
const newImageUrls: Record<number, string> = {}
for (const photo of response.items) {
try {
const blobUrl = await pendingPhotosApi.getPendingPhotoImageBlob(photo.id)
newImageUrls[photo.id] = blobUrl
} catch (err) {
console.error(`Failed to load image for photo ${photo.id}:`, err)
}
}
setImageUrls(newImageUrls)
imageUrlsRef.current = newImageUrls
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load pending photos')
console.error('Error loading pending photos:', err)
} finally {
setLoading(false)
}
}, [statusFilter])
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
Object.values(imageUrlsRef.current).forEach((url) => {
URL.revokeObjectURL(url)
})
}
}, [])
useEffect(() => {
loadPendingPhotos()
}, [loadPendingPhotos])
const sortedPendingPhotos = useMemo(() => {
const items = [...pendingPhotos]
const direction = sortDirection === 'asc' ? 1 : -1
const compareStrings = (a: string | null | undefined, b: string | null | undefined) =>
(a || '').localeCompare(b || '', undefined, { sensitivity: 'base' })
items.sort((a, b) => {
if (sortBy === 'photo') {
return (a.id - b.id) * direction
}
if (sortBy === 'uploaded_by') {
const aName = a.user_name || a.user_email || ''
const bName = b.user_name || b.user_email || ''
return compareStrings(aName, bName) * direction
}
if (sortBy === 'file_info') {
return compareStrings(a.original_filename, b.original_filename) * direction
}
if (sortBy === 'submitted_at') {
const aTime = a.submitted_at || ''
const bTime = b.submitted_at || ''
return (aTime < bTime ? -1 : aTime > bTime ? 1 : 0) * direction
}
if (sortBy === 'status') {
return compareStrings(a.status, b.status) * direction
}
return 0
})
return items
}, [pendingPhotos, sortBy, sortDirection])
const toggleSort = (key: SortKey) => {
setSortBy((currentKey) => {
if (currentKey === key) {
setSortDirection((currentDirection) => (currentDirection === 'asc' ? 'desc' : 'asc'))
return currentKey
}
setSortDirection('asc')
return key
})
}
const renderSortLabel = (label: string, key: SortKey) => {
const isActive = sortBy === key
const directionSymbol = !isActive ? '↕' : sortDirection === 'asc' ? '▲' : '▼'
return (
<button
type="button"
onClick={() => toggleSort(key)}
className="inline-flex items-center gap-1 text-xs font-medium text-gray-500 uppercase tracking-wider hover:text-gray-700"
>
<span>{label}</span>
<span className="text-[10px]">{directionSymbol}</span>
</button>
)
}
const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return date.toLocaleString()
} catch {
return dateString
}
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
const handleDecisionChange = (id: number, decision: 'approve' | 'reject') => {
const currentDecision = decisions[id]
const isUnselecting = currentDecision === decision
setDecisions((prev) => {
// If clicking the same option, unselect it
if (prev[id] === decision) {
const updated = { ...prev }
delete updated[id]
return updated
}
// Otherwise, set the new decision (this automatically unchecks the other checkbox)
return {
...prev,
[id]: decision,
}
})
// Handle rejection reasons
if (isUnselecting) {
// Unselecting - clear rejection reason
setRejectionReasons((prev) => {
const updated = { ...prev }
delete updated[id]
return updated
})
} else if (decision === 'approve') {
// Switching to approve - clear rejection reason
setRejectionReasons((prev) => {
const updated = { ...prev }
delete updated[id]
return updated
})
} else if (decision === 'reject' && bulkRejectionReason.trim()) {
// Switching to reject - apply bulk rejection reason if set
setRejectionReasons((prev) => ({
...prev,
[id]: bulkRejectionReason,
}))
}
}
const handleRejectionReasonChange = (id: number, reason: string) => {
setRejectionReasons((prev) => ({
...prev,
[id]: reason,
}))
}
const handleSelectAllApprove = () => {
const pendingPhotoIds = pendingPhotos
.filter((photo) => photo.status === 'pending')
.map((photo) => photo.id)
const newDecisions: Record<number, 'approve'> = {}
pendingPhotoIds.forEach((id) => {
newDecisions[id] = 'approve'
})
setDecisions((prev) => ({
...prev,
...newDecisions,
}))
// Clear all rejection reasons and bulk rejection reason since we're approving
setRejectionReasons({})
setBulkRejectionReason('')
}
const handleSelectAllReject = () => {
const pendingPhotoIds = pendingPhotos
.filter((photo) => photo.status === 'pending')
.map((photo) => photo.id)
const newDecisions: Record<number, 'reject'> = {}
pendingPhotoIds.forEach((id) => {
newDecisions[id] = 'reject'
})
setDecisions((prev) => ({
...prev,
...newDecisions,
}))
// Apply bulk rejection reason if set
if (bulkRejectionReason.trim()) {
const newRejectionReasons: Record<number, string> = {}
pendingPhotoIds.forEach((id) => {
newRejectionReasons[id] = bulkRejectionReason
})
setRejectionReasons((prev) => ({
...prev,
...newRejectionReasons,
}))
}
}
const handleBulkRejectionReasonChange = (reason: string) => {
setBulkRejectionReason(reason)
// Apply to all currently rejected photos
const rejectedPhotoIds = Object.entries(decisions)
.filter(([id, decision]) => decision === 'reject')
.map(([id]) => parseInt(id))
if (rejectedPhotoIds.length > 0) {
const newRejectionReasons: Record<number, string> = {}
rejectedPhotoIds.forEach((id) => {
newRejectionReasons[id] = reason
})
setRejectionReasons((prev) => ({
...prev,
...newRejectionReasons,
}))
}
}
const handleSubmit = async () => {
// Get all decisions that have been made for pending items
const decisionsList: ReviewDecision[] = Object.entries(decisions)
.filter(([id, decision]) => {
const photo = pendingPhotos.find((p) => p.id === parseInt(id))
return decision !== null && photo && photo.status === 'pending'
})
.map(([id, decision]) => ({
id: parseInt(id),
decision: decision!,
rejection_reason: decision === 'reject' ? (rejectionReasons[parseInt(id)] || null) : null,
}))
if (decisionsList.length === 0) {
alert('Please select Approve or Reject for at least one pending photo.')
return
}
// Show confirmation
const approveCount = decisionsList.filter((d) => d.decision === 'approve').length
const rejectCount = decisionsList.filter((d) => d.decision === 'reject').length
const confirmMessage = `Submit ${decisionsList.length} decision(s)?\n\nThis will approve ${approveCount} photo(s) and reject ${rejectCount} photo(s).`
if (!confirm(confirmMessage)) {
return
}
setSubmitting(true)
try {
const response = await pendingPhotosApi.reviewPendingPhotos({
decisions: decisionsList,
})
// Show custom notification instead of alert
setNotification({
approved: response.approved,
rejected: response.rejected,
warnings: response.warnings || [],
errors: response.errors,
})
if (response.errors.length > 0) {
console.error('Errors:', response.errors)
}
if (response.warnings && response.warnings.length > 0) {
console.info('Warnings:', response.warnings)
}
// Reload the list to show updated status
await loadPendingPhotos()
// Clear decisions and reasons
setDecisions({})
setRejectionReasons({})
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to submit decisions'
alert(`Error: ${errorMessage}`)
console.error('Error submitting decisions:', err)
} finally {
setSubmitting(false)
}
}
const handleCleanupFiles = async (statusFilter?: string) => {
const confirmMessage = statusFilter
? `Delete files from shared space for ${statusFilter} photos? This cannot be undone.`
: 'Delete files from shared space for all approved/rejected photos? This cannot be undone.'
if (!confirm(confirmMessage)) {
return
}
try {
const response: CleanupResponse = await pendingPhotosApi.cleanupFiles(statusFilter)
const message = [
`✅ Deleted ${response.deleted_files} file(s) from shared space`,
response.warnings && response.warnings.length > 0
? ` ${response.warnings.length} file(s) were already deleted`
: '',
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : '',
]
.filter(Boolean)
.join('\n')
alert(message)
if (response.warnings && response.warnings.length > 0) {
console.info('Cleanup warnings:', response.warnings)
}
if (response.errors.length > 0) {
console.error('Cleanup errors:', response.errors)
}
// Reload the list
await loadPendingPhotos()
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to cleanup files'
alert(`Error: ${errorMessage}`)
console.error('Error cleaning up files:', err)
}
}
const handleCleanupDatabase = async (statusFilter?: string) => {
const confirmMessage = statusFilter
? `Delete all ${statusFilter} records from pending_photos table? This cannot be undone.`
: 'Delete all approved and rejected records from pending_photos table? Pending records will be kept. This cannot be undone.'
if (!confirm(confirmMessage)) {
return
}
try {
const response: CleanupResponse = await pendingPhotosApi.cleanupDatabase(statusFilter)
const message = [
`✅ Deleted ${response.deleted_records} record(s) from database`,
response.warnings && response.warnings.length > 0
? ` ${response.warnings.join(', ')}`
: '',
response.errors.length > 0
? `⚠️ Errors: ${response.errors.join('; ')}`
: '',
]
.filter(Boolean)
.join('\n')
alert(message)
if (response.warnings && response.warnings.length > 0) {
console.info('Cleanup warnings:', response.warnings)
}
if (response.errors.length > 0) {
console.error('Cleanup errors:', response.errors)
}
// Reload the list
await loadPendingPhotos()
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to cleanup database'
alert(`Error: ${errorMessage}`)
console.error('Error cleaning up database:', err)
}
}
return (
<div>
{/* Notification */}
{notification && (
<div className="mb-4 bg-white border border-gray-200 rounded-lg shadow-lg p-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-green-600 text-lg"></span>
<span className="font-medium">Approved: {notification.approved}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-red-600 text-lg"></span>
<span className="font-medium">Rejected: {notification.rejected}</span>
</div>
{notification.warnings.length > 0 && (
<div className="text-xs text-gray-600 ml-7">
{notification.warnings.join(', ')}
</div>
)}
{notification.errors.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-yellow-600 text-lg"></span>
<span className="font-medium">Errors: {notification.errors.length}</span>
</div>
)}
</div>
<button
onClick={() => setNotification(null)}
className="mt-3 px-3 py-1.5 text-sm text-gray-600 bg-gray-50 rounded hover:bg-gray-100 hover:text-gray-700 transition-colors"
>
Dismiss
</button>
</div>
)}
<div className="bg-white rounded-lg shadow p-6">
{loading && (
<div className="text-center py-8">
<p className="text-gray-600">Loading pending photos...</p>
</div>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
<p className="font-semibold">Error loading data</p>
<p className="text-sm mt-1">{error}</p>
<button
onClick={loadPendingPhotos}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Retry
</button>
</div>
)}
{!loading && !error && (
<>
<div className="mb-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-600">
Total photos: <span className="font-semibold">{pendingPhotos.length}</span>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div>
<div className="flex items-center gap-2">
{canManageUploads && (
<>
<button
onClick={() => {
if (!canRunCleanup) {
return
}
handleCleanupFiles()
}}
disabled={!canRunCleanup}
className="px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
title={
canRunCleanup
? 'Delete files from shared space for approved/rejected photos'
: 'Cleanup files is restricted to admins'
}
>
🗑 Cleanup Files
</button>
<button
onClick={() => {
if (!canRunCleanup) {
return
}
handleCleanupDatabase()
}}
disabled={!canRunCleanup}
className="px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
title={
canRunCleanup
? 'Delete approved and rejected records from pending_photos table (pending records will be kept)'
: 'Clear database is restricted to admins'
}
>
🗑 Clear Database
</button>
</>
)}
{pendingPhotos.filter((p) => p.status === 'pending').length > 0 && (
<>
<button
onClick={handleSelectAllApprove}
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Approve
</button>
<button
onClick={handleSelectAllReject}
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Reject
</button>
</>
)}
<button
onClick={handleSubmit}
disabled={
submitting ||
Object.values(decisions).filter((d) => d !== null).length === 0
}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
>
{submitting ? 'Submitting...' : 'Submit Decisions'}
</button>
</div>
</div>
{Object.values(decisions).some((d) => d === 'reject') && (
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700 font-medium whitespace-nowrap">
Bulk Rejection Reason:
</label>
<textarea
value={bulkRejectionReason}
onChange={(e) => handleBulkRejectionReasonChange(e.target.value)}
placeholder="Enter rejection reason to apply to all rejected photos..."
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md resize-none focus:ring-blue-500 focus:border-blue-500"
rows={2}
/>
</div>
)}
</div>
{pendingPhotos.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No pending photos found.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left">
{renderSortLabel('Photo', 'photo')}
</th>
<th className="px-6 py-3 text-left">
{renderSortLabel('Uploaded By', 'uploaded_by')}
</th>
<th className="px-6 py-3 text-left">
{renderSortLabel('File Info', 'file_info')}
</th>
<th className="px-6 py-3 text-left">
{renderSortLabel('Submitted At', 'submitted_at')}
</th>
<th className="px-6 py-3 text-left">
{renderSortLabel('Status', 'status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Decision
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rejection Reason
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedPendingPhotos.map((photo) => {
const isPending = photo.status === 'pending'
const isApproved = photo.status === 'approved'
const isRejected = photo.status === 'rejected'
const canMakeDecision = isPending
return (
<tr
key={photo.id}
className={`hover:bg-gray-50 ${
isApproved || isRejected ? 'opacity-60 bg-gray-50' : ''
}`}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={async () => {
const isVideo = photo.mime_type?.startsWith('video/')
if (isVideo) {
// For videos, open the video file directly
const videoUrl = `${apiClient.defaults.baseURL}/api/v1/pending-photos/${photo.id}/image`
window.open(videoUrl, '_blank')
} else {
// For images, fetch as blob and open in new tab
try {
const blobUrl = imageUrls[photo.id] || await pendingPhotosApi.getPendingPhotoImageBlob(photo.id)
// Create a new window with the blob URL
const newWindow = window.open()
if (newWindow) {
newWindow.location.href = blobUrl
}
} catch (err) {
console.error('Failed to open full-size image:', err)
alert('Failed to load full-size image')
}
}
}}
title={photo.mime_type?.startsWith('video/') ? 'Click to open video' : 'Click to open full photo'}
>
{photo.mime_type?.startsWith('video/') ? (
<div className="w-24 h-24 bg-gray-800 rounded border border-gray-300 flex items-center justify-center relative">
<svg
className="w-12 h-12 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
</svg>
<div className="absolute bottom-1 right-1 bg-black bg-opacity-70 text-white text-[8px] px-1 rounded">
VIDEO
</div>
</div>
) : imageUrls[photo.id] ? (
<img
src={imageUrls[photo.id]}
alt={photo.original_filename}
className="w-24 h-24 object-cover rounded border border-gray-300"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className =
'text-gray-400 text-xs error-fallback'
fallback.textContent = 'Image not found'
parent.appendChild(fallback)
}
}}
/>
) : (
<div className="w-24 h-24 bg-gray-200 rounded border border-gray-300 flex items-center justify-center text-xs text-gray-400">
Loading...
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{photo.user_name || 'Unknown'}
</div>
<div className="text-sm text-gray-500">
{photo.user_email || '-'}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{photo.original_filename}
</div>
<div className="text-sm text-gray-500">
{formatFileSize(photo.file_size)} {photo.mime_type}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDate(photo.submitted_at)}
</div>
{photo.reviewed_at && (
<div className="text-xs text-gray-400 mt-1">
Reviewed: {formatDate(photo.reviewed_at)}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
photo.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: photo.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{photo.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{canMakeDecision ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decisions[photo.id] === 'approve'}
onChange={(e) => {
if (e.target.checked) {
handleDecisionChange(photo.id, 'approve')
} else {
// Unchecking - remove decision
handleDecisionChange(photo.id, 'approve')
}
}}
className="w-4 h-4 text-green-600 focus:ring-green-500 rounded"
/>
<span className="text-sm text-gray-700">Approve</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decisions[photo.id] === 'reject'}
onChange={(e) => {
if (e.target.checked) {
handleDecisionChange(photo.id, 'reject')
} else {
// Unchecking - remove decision
handleDecisionChange(photo.id, 'reject')
}
}}
className="w-4 h-4 text-red-600 focus:ring-red-500 rounded"
/>
<span className="text-sm text-gray-700">Reject</span>
</label>
</div>
</div>
) : (
<span className="text-sm text-gray-500 italic">
{isApproved ? 'Approved' : isRejected ? 'Rejected' : '-'}
</span>
)}
</td>
<td className="px-6 py-4">
{canMakeDecision && decisions[photo.id] === 'reject' ? (
<textarea
value={rejectionReasons[photo.id] || ''}
onChange={(e) =>
handleRejectionReasonChange(photo.id, e.target.value)
}
placeholder="Optional: Enter rejection reason..."
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md resize-none focus:ring-blue-500 focus:border-blue-500"
rows={2}
/>
) : isRejected && photo.rejection_reason ? (
<div className="text-sm text-gray-700 whitespace-pre-wrap">
<div className="bg-red-50 p-2 rounded border border-red-200">
{photo.rejection_reason}
</div>
</div>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,459 @@
import { useState, useRef, useEffect } from 'react'
import { facesApi, ProcessFacesRequest } from '../api/faces'
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
import { useDeveloperMode } from '../context/DeveloperModeContext'
interface JobProgress {
id: string
status: string
progress: number
message: string
processed?: number
total?: number
faces_detected?: number
faces_stored?: number
}
const DETECTOR_OPTIONS = ['retinaface', 'mtcnn', 'opencv', 'ssd']
const MODEL_OPTIONS = ['ArcFace', 'Facenet', 'Facenet512', 'VGG-Face']
export default function Process() {
const { isDeveloperMode } = useDeveloperMode()
const [batchSize, setBatchSize] = useState<number | undefined>(undefined)
const [detectorBackend, setDetectorBackend] = useState('retinaface')
const [modelName, setModelName] = useState('ArcFace')
const [isProcessing, setIsProcessing] = useState(false)
const [currentJob, setCurrentJob] = useState<JobResponse | null>(null)
const [jobProgress, setJobProgress] = useState<JobProgress | null>(null)
const [error, setError] = useState<string | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
// Cleanup event source on unmount
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
}
}, [])
const handleStartProcessing = async () => {
setIsProcessing(true)
setError(null)
setCurrentJob(null)
setJobProgress(null)
try {
const request: ProcessFacesRequest = {
batch_size: batchSize || undefined,
detector_backend: detectorBackend,
model_name: modelName,
}
const response = await facesApi.processFaces(request)
// Set processing state immediately
setIsProcessing(true)
setCurrentJob({
id: response.job_id,
status: JobStatus.PENDING,
progress: 0,
message: response.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
// Start SSE stream for job progress
startJobProgressStream(response.job_id)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Processing failed')
setIsProcessing(false)
}
}
const handleStopProcessing = async () => {
// Use jobProgress if currentJob is not available (might happen if status is still Pending)
const jobId = currentJob?.id || jobProgress?.id
if (!jobId) {
console.error('Cannot stop: No job ID available')
setError('Cannot stop: No active job found')
return
}
console.log(`[Process] STOP button clicked for job ${jobId}`)
try {
// Call API to cancel the job
console.log(`[Process] Calling cancelJob API for job ${jobId}`)
const result = await jobsApi.cancelJob(jobId)
console.log('[Process] Job cancellation requested:', result)
// Update job status to show cancellation is in progress
if (currentJob) {
setCurrentJob({
...currentJob,
status: JobStatus.PROGRESS,
message: 'Cancellation requested - finishing current photo...',
})
} else if (jobProgress) {
// If currentJob is not set, update jobProgress
setJobProgress({
...jobProgress,
status: 'progress',
message: 'Cancellation requested - finishing current photo...',
})
}
// Don't close SSE stream yet - keep it open to wait for job to actually stop
// The job will finish the current photo, then stop and send a final status update
// The SSE stream handler will close the stream when job status becomes SUCCESS or FAILURE
// Set a flag to indicate cancellation was requested
// This will be checked in the SSE handler
setError(null) // Clear any previous errors
} catch (err: any) {
console.error('[Process] Error cancelling job:', err)
const errorMessage = err.response?.data?.detail || err.message || 'Failed to cancel job'
setError(errorMessage)
console.error('[Process] Full error details:', {
message: err.message,
response: err.response?.data,
status: err.response?.status,
})
}
}
const startJobProgressStream = (jobId: string) => {
// Close existing stream if any
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
const eventSource = jobsApi.streamJobProgress(jobId)
eventSourceRef.current = eventSource
eventSource.onmessage = (event) => {
try {
const data: JobProgress = JSON.parse(event.data)
setJobProgress(data)
// Update job status
const statusMap: Record<string, JobStatus> = {
pending: JobStatus.PENDING,
started: JobStatus.STARTED,
progress: JobStatus.PROGRESS,
success: JobStatus.SUCCESS,
failure: JobStatus.FAILURE,
cancelled: JobStatus.CANCELLED,
}
const jobStatus = statusMap[data.status] || JobStatus.PENDING
setCurrentJob({
id: data.id,
status: jobStatus,
progress: data.progress,
message: data.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
// Keep processing state true while job is running
if (jobStatus === JobStatus.STARTED || jobStatus === JobStatus.PROGRESS) {
setIsProcessing(true)
}
// Check if job is complete
if (jobStatus === JobStatus.SUCCESS || jobStatus === JobStatus.FAILURE || jobStatus === JobStatus.CANCELLED) {
setIsProcessing(false)
eventSource.close()
eventSourceRef.current = null
// Handle cancelled jobs
if (jobStatus === JobStatus.CANCELLED) {
const progressInfo = data.processed !== undefined && data.total !== undefined
? ` (processed ${data.processed} of ${data.total} photos)`
: ''
setError(`Processing stopped: ${data.message || 'Cancelled by user'}${progressInfo}`)
}
// Show error message for failures
else if (jobStatus === JobStatus.FAILURE) {
// Show failure message with progress info if available
const progressInfo = data.processed !== undefined && data.total !== undefined
? ` (processed ${data.processed} of ${data.total} photos)`
: ''
setError(`Processing failed: ${data.message || 'Unknown error'}${progressInfo}`)
}
// Fetch final job result to get processing stats for successful or cancelled jobs
if (jobStatus === JobStatus.SUCCESS || jobStatus === JobStatus.CANCELLED) {
fetchJobResult(jobId)
}
}
} catch (err) {
console.error('Error parsing SSE event:', err)
}
}
eventSource.onerror = (err) => {
console.error('SSE error:', err)
// Don't automatically set isProcessing to false on error
// Job might still be running even if SSE connection failed
// Check job status directly instead
if (currentJob) {
// Try to fetch job status directly
jobsApi.getJob(currentJob.id).then((job) => {
const stillRunning = job.status === JobStatus.STARTED || job.status === JobStatus.PROGRESS
setIsProcessing(stillRunning)
setCurrentJob(job)
}).catch(() => {
// If we can't get status, assume job might still be running
console.warn('Could not fetch job status after SSE error')
})
}
}
}
const fetchJobResult = async (jobId: string) => {
try {
const job = await jobsApi.getJob(jobId)
setCurrentJob(job)
} catch (err) {
console.error('Error fetching job result:', err)
}
}
const getStatusColor = (status: JobStatus) => {
switch (status) {
case JobStatus.SUCCESS:
return 'text-green-600'
case JobStatus.FAILURE:
return 'text-red-600'
case JobStatus.STARTED:
case JobStatus.PROGRESS:
return 'text-blue-600'
default:
return 'text-gray-600'
}
}
return (
<div className="p-6">
<div className="space-y-6">
{/* Configuration Section */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Processing Configuration
</h2>
<div className="space-y-4">
{/* Batch Size */}
<div>
<label
htmlFor="batch-size"
className="block text-sm font-medium text-gray-700 mb-1"
>
Batch Size
</label>
<input
id="batch-size"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={batchSize || ''}
onChange={(e) => {
const value = e.target.value
// Only allow numeric input
if (value === '' || /^\d+$/.test(value)) {
setBatchSize(value ? parseInt(value, 10) : undefined)
}
}}
onKeyDown={(e) => {
// Allow: backspace, delete, tab, escape, enter, and decimal point
if (
[8, 9, 27, 13, 46, 110, 190].indexOf(e.keyCode) !== -1 ||
// Allow: Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X
(e.keyCode === 65 && e.ctrlKey === true) ||
(e.keyCode === 67 && e.ctrlKey === true) ||
(e.keyCode === 86 && e.ctrlKey === true) ||
(e.keyCode === 88 && e.ctrlKey === true) ||
// Allow: home, end, left, right
(e.keyCode >= 35 && e.keyCode <= 39)
) {
return
}
// Ensure that it is a number and stop the keypress
if ((e.shiftKey || (e.keyCode < 48 || e.keyCode > 57)) && (e.keyCode < 96 || e.keyCode > 105)) {
e.preventDefault()
}
}}
className="w-32 px-2 py-1 text-sm border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
disabled={isProcessing}
/>
<p className="mt-1 text-xs text-gray-500">
Leave empty to process all unprocessed photos
</p>
</div>
{/* Detector Backend - Only visible in developer mode */}
{isDeveloperMode && (
<div>
<label
htmlFor="detector-backend"
className="block text-sm font-medium text-gray-700 mb-2"
>
Face Detector
</label>
<select
id="detector-backend"
value={detectorBackend}
onChange={(e) => setDetectorBackend(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
disabled={isProcessing}
>
{DETECTOR_OPTIONS.map((option) => (
<option key={option} value={option}>
{option.charAt(0).toUpperCase() + option.slice(1)}
</option>
))}
</select>
<p className="mt-1 text-sm text-gray-500">
RetinaFace recommended for best accuracy
</p>
</div>
)}
{/* Model Name - Only visible in developer mode */}
{isDeveloperMode && (
<div>
<label
htmlFor="model-name"
className="block text-sm font-medium text-gray-700 mb-2"
>
Recognition Model
</label>
<select
id="model-name"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
disabled={isProcessing}
>
{MODEL_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<p className="mt-1 text-gray-500">
ArcFace recommended for best accuracy
</p>
</div>
)}
{/* Control Buttons */}
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={handleStartProcessing}
disabled={isProcessing}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? 'Processing...' : 'Start Processing'}
</button>
{isProcessing && (
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
console.log('[Process] STOP button clicked, isProcessing:', isProcessing)
console.log('[Process] currentJob:', currentJob)
console.log('[Process] jobProgress:', jobProgress)
handleStopProcessing()
}}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!currentJob && !jobProgress}
>
Stop
</button>
)}
</div>
</div>
</div>
{/* Progress Section */}
{(currentJob || jobProgress) && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Processing Progress
</h2>
{currentJob && (
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span
className={`text-sm font-medium ${getStatusColor(
currentJob.status
)}`}
>
{currentJob.status === JobStatus.SUCCESS && '✓ '}
{currentJob.status === JobStatus.FAILURE && '✗ '}
{currentJob.status === JobStatus.CANCELLED && '⏹ '}
{currentJob.status === JobStatus.CANCELLED
? 'Stopped'
: currentJob.status.charAt(0).toUpperCase() +
currentJob.status.slice(1)}
</span>
<span className="text-sm text-gray-600">
{currentJob.progress}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${currentJob.progress}%` }}
/>
</div>
</div>
{jobProgress && (
<div className="space-y-2 text-sm text-gray-600">
{jobProgress.processed !== undefined &&
jobProgress.total !== undefined && (
<p>
Photos processed: {jobProgress.processed} /{' '}
{jobProgress.total}
</p>
)}
{jobProgress.faces_detected !== undefined && (
<p>Faces detected: {jobProgress.faces_detected}</p>
)}
{jobProgress.faces_stored !== undefined && (
<p>Faces stored: {jobProgress.faces_stored}</p>
)}
{jobProgress.message && (
<p className="mt-1 font-medium">{jobProgress.message}</p>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Error Section */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,509 @@
import { useEffect, useState, useCallback } from 'react'
import {
reportedPhotosApi,
ReportedPhotoResponse,
ReviewDecision,
} from '../api/reportedPhotos'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { videosApi } from '../api/videos'
export default function ReportedPhotos() {
const { isAdmin } = useAuth()
const [reportedPhotos, setReportedPhotos] = useState<ReportedPhotoResponse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [decisions, setDecisions] = useState<Record<number, 'keep' | 'remove' | null>>({})
const [reviewNotes, setReviewNotes] = useState<Record<number, string>>({})
const [submitting, setSubmitting] = useState(false)
const [clearing, setClearing] = useState(false)
const [statusFilter, setStatusFilter] = useState<string>('pending')
const loadReportedPhotos = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await reportedPhotosApi.listReportedPhotos(
statusFilter || undefined
)
setReportedPhotos(response.items)
// Initialize review notes from existing data
const existingNotes: Record<number, string> = {}
response.items.forEach((item) => {
if (item.review_notes) {
existingNotes[item.id] = item.review_notes
}
})
setReviewNotes(existingNotes)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load reported photos')
console.error('Error loading reported photos:', err)
} finally {
setLoading(false)
}
}, [statusFilter])
useEffect(() => {
loadReportedPhotos()
}, [loadReportedPhotos])
const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return date.toLocaleString()
} catch {
return dateString
}
}
const handleDecisionChange = (id: number, decision: 'keep' | 'remove') => {
setDecisions((prev) => {
const currentDecision = prev[id] ?? null
const nextDecision = currentDecision === decision ? null : decision
return {
...prev,
[id]: nextDecision,
}
})
}
const handleReviewNotesChange = (id: number, notes: string) => {
setReviewNotes((prev) => ({
...prev,
[id]: notes,
}))
}
const handleSubmit = async () => {
// Get all decisions that have been made for pending or reviewed items
const decisionsList: ReviewDecision[] = Object.entries(decisions)
.filter(([id, decision]) => {
const reported = reportedPhotos.find((p) => p.id === parseInt(id))
return decision !== null && reported && (reported.status === 'pending' || reported.status === 'reviewed')
})
.map(([id, decision]) => ({
id: parseInt(id),
decision: decision!,
review_notes: reviewNotes[parseInt(id)] || null,
}))
if (decisionsList.length === 0) {
alert('Please select Keep or Remove for at least one reported photo.')
return
}
// Check if there are any 'remove' decisions
const removeDecisions = decisionsList.filter((d) => d.decision === 'remove')
const keepDecisions = decisionsList.filter((d) => d.decision === 'keep')
// Show specific confirmation for removal
if (removeDecisions.length > 0) {
const removeCount = removeDecisions.length
const photoDetails = removeDecisions
.map((d) => {
const reported = reportedPhotos.find((p) => p.id === d.id)
return reported?.photo_filename || `Photo #${reported?.photo_id || d.id}`
})
.join('\n - ')
const confirmMessage = `⚠️ WARNING: You are about to PERMANENTLY REMOVE ${removeCount} photo(s):\n\n - ${photoDetails}\n\nThis will:\n • Delete the photo(s) from the database\n • Delete all faces detected in the photo(s)\n • Delete all encodings related to those faces\n\nThis action CANNOT be undone!\n\nAre you sure you want to proceed?`
if (!confirm(confirmMessage)) {
return
}
}
// Show general confirmation if there are also 'keep' decisions
if (keepDecisions.length > 0) {
const confirmMessage = `Submit ${decisionsList.length} decision(s)?\n\nThis will ${
removeDecisions.length
} remove photo(s) and ${keepDecisions.length} keep photo(s).`
if (!confirm(confirmMessage)) {
return
}
}
setSubmitting(true)
try {
const response = await reportedPhotosApi.reviewReportedPhotos({
decisions: decisionsList,
})
const messageParts = [
`✅ Kept: ${response.kept}`,
`❌ Removed: ${response.removed}`,
]
if (import.meta.env.DEV && response.errors.length > 0) {
messageParts.push(`⚠️ Errors: ${response.errors.length}`)
}
const message = messageParts.join('\n')
alert(message)
if (response.errors.length > 0) {
console.error('Reported photo review errors:', response.errors)
}
// Reload the list to show updated status
await loadReportedPhotos()
// Clear decisions and notes
setDecisions({})
setReviewNotes({})
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to submit decisions'
alert(`Error: ${errorMessage}`)
console.error('Error submitting decisions:', err)
} finally {
setSubmitting(false)
}
}
const handleClearDatabase = async () => {
const confirmMessage = [
'Delete all kept and removed reported photo records from the auth database?',
'',
'Only photos with Pending status will remain.',
'This action cannot be undone.',
].join('\n')
if (!confirm(confirmMessage)) {
return
}
setClearing(true)
try {
const response = await reportedPhotosApi.cleanupReportedPhotos()
const summary = [
`✅ Deleted ${response.deleted_records} record(s)`,
response.warnings && response.warnings.length > 0
? ` ${response.warnings.join('; ')}`
: '',
response.errors.length > 0 ? `⚠️ ${response.errors.join('; ')}` : '',
]
.filter(Boolean)
.join('\n')
alert(summary || 'Cleanup complete.')
if (response.errors.length > 0) {
console.error('Cleanup errors:', response.errors)
}
if (response.warnings && response.warnings.length > 0) {
console.info('Cleanup warnings:', response.warnings)
}
await loadReportedPhotos()
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to cleanup reported photos'
alert(`Error: ${errorMessage}`)
console.error('Error clearing reported photos:', err)
} finally {
setClearing(false)
}
}
return (
<div>
<div className="bg-white rounded-lg shadow p-6">
{loading && (
<div className="text-center py-8">
<p className="text-gray-600">Loading reported photos...</p>
</div>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
<p className="font-semibold">Error loading data</p>
<p className="text-sm mt-1">{error}</p>
<button
onClick={loadReportedPhotos}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Retry
</button>
</div>
)}
{!loading && !error && (
<>
<div className="mb-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-600">
Total reported photos:{' '}
<span className="font-semibold">{reportedPhotos.length}</span>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="reviewed">Reviewed</option>
<option value="dismissed">Dismissed</option>
</select>
</div>
<button
onClick={handleSubmit}
disabled={
submitting ||
Object.values(decisions).filter((d) => d !== null).length === 0
}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
>
{submitting ? 'Submitting...' : 'Submit Decisions'}
</button>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => {
if (!isAdmin) {
return
}
handleClearDatabase()
}}
disabled={clearing || !isAdmin}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-200 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
title={
isAdmin
? 'Delete kept/removed records'
: 'Only admins can clear reported photos'
}
>
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
</button>
<span className="text-sm text-gray-700">
Clear kept/removed records
</span>
</div>
</div>
{reportedPhotos.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No reported photos found.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Photo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reported By
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reported At
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Report Comment
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Decision
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Review Notes
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reportedPhotos.map((reported) => {
const isReviewed = reported.status === 'reviewed'
const isDismissed = reported.status === 'dismissed'
const canMakeDecision = !isDismissed && (reported.status === 'pending' || reported.status === 'reviewed')
return (
<tr
key={reported.id}
className={`hover:bg-gray-50 ${
isReviewed || isDismissed ? 'opacity-60 bg-gray-50' : ''
}`}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{reported.photo_id ? (
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const isVideo = reported.photo_media_type === 'video'
const url = isVideo
? videosApi.getVideoUrl(reported.photo_id)
: `${apiClient.defaults.baseURL}/api/v1/photos/${reported.photo_id}/image`
window.open(url, '_blank')
}}
title={reported.photo_media_type === 'video' ? 'Click to open video' : 'Click to open full photo'}
>
{reported.photo_media_type === 'video' ? (
<img
src={videosApi.getThumbnailUrl(reported.photo_id)}
alt={`Video ${reported.photo_id}`}
className="w-24 h-24 object-cover rounded border border-gray-300"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className =
'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${reported.photo_id}`
parent.appendChild(fallback)
}
}}
/>
) : (
<img
src={`/api/v1/photos/${reported.photo_id}/image`}
alt={`Photo ${reported.photo_id}`}
className="w-24 h-24 object-cover rounded border border-gray-300"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className =
'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${reported.photo_id}`
parent.appendChild(fallback)
}
}}
/>
)}
</div>
) : (
<div className="text-gray-400 text-xs">Photo not found</div>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{reported.photo_filename || `Photo #${reported.photo_id}`}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{reported.user_name || 'Unknown'}
</div>
<div className="text-sm text-gray-500">
{reported.user_email || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDate(reported.reported_at)}
</div>
</td>
<td className="px-6 py-4">
{reported.report_comment ? (
<div className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-2 rounded border border-gray-200">
{reported.report_comment}
</div>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
reported.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: reported.status === 'reviewed'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{reported.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{canMakeDecision ? (
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name={`decision-${reported.id}-keep`}
value="keep"
checked={decisions[reported.id] === 'keep'}
onChange={() => handleDecisionChange(reported.id, 'keep')}
className="w-4 h-4 text-green-600 focus:ring-green-500"
/>
<span className="text-sm text-gray-700">Keep</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name={`decision-${reported.id}-remove`}
value="remove"
checked={decisions[reported.id] === 'remove'}
onChange={() => handleDecisionChange(reported.id, 'remove')}
className="w-4 h-4 text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">Remove</span>
</label>
</div>
) : (
<span className="text-sm text-gray-500 italic">-</span>
)}
</td>
<td className="px-6 py-4">
{isReviewed || isDismissed ? (
<div className="text-sm text-gray-700 whitespace-pre-wrap">
{reported.review_notes ? (
<div className="bg-gray-50 p-2 rounded border border-gray-200">
{reported.review_notes}
</div>
) : (
<span className="text-gray-400 italic">-</span>
)}
</div>
) : (
<div className="flex flex-col gap-2">
{reported.review_notes && (
<div className="bg-blue-50 p-2 rounded border border-blue-200 text-sm text-gray-700">
<div className="text-xs text-blue-600 font-medium mb-1">
Existing notes:
</div>
<div className="whitespace-pre-wrap">
{reported.review_notes}
</div>
</div>
)}
<textarea
value={reviewNotes[reported.id] || ''}
onChange={(e) =>
handleReviewNotesChange(reported.id, e.target.value)
}
placeholder="Optional review notes..."
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md resize-none"
rows={2}
/>
</div>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,460 @@
import { useState, useRef, useEffect } from 'react'
import { photosApi, PhotoImportRequest } from '../api/photos'
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
interface JobProgress {
id: string
status: string
progress: number
message: string
processed?: number
total?: number
}
export default function Scan() {
const [folderPath, setFolderPath] = useState('')
const [recursive, setRecursive] = useState(true)
const [isImporting, setIsImporting] = useState(false)
const [isBrowsing, setIsBrowsing] = useState(false)
const [currentJob, setCurrentJob] = useState<JobResponse | null>(null)
const [jobProgress, setJobProgress] = useState<JobProgress | null>(null)
const [importResult, setImportResult] = useState<{
added?: number
existing?: number
total?: number
} | null>(null)
const [error, setError] = useState<string | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
// Cleanup event source on unmount
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
}
}, [])
const handleFolderBrowse = async () => {
setIsBrowsing(true)
setError(null)
// Try backend API first (uses tkinter for native folder picker with full path)
try {
console.log('Attempting to open native folder picker...')
const result = await photosApi.browseFolder()
console.log('Backend folder picker result:', result)
if (result.success && result.path) {
// Ensure we have a valid absolute path (not just folder name)
const path = result.path.trim()
if (path && path.length > 0) {
// Verify it looks like an absolute path:
// - Unix/Linux: starts with / (includes mounted network shares like /mnt/...)
// - Windows local: starts with drive letter like C:\
// - Windows UNC: starts with \\ (network paths like \\server\share\folder)
const isUnixPath = path.startsWith('/')
const isWindowsLocalPath = /^[A-Za-z]:[\\/]/.test(path)
const isWindowsUncPath = path.startsWith('\\\\') || path.startsWith('//')
if (isUnixPath || isWindowsLocalPath || isWindowsUncPath) {
setFolderPath(path)
setIsBrowsing(false)
return
} else {
// Backend validated it, so trust it even if it doesn't match our patterns
// (might be a valid path format we didn't account for)
console.warn('Backend returned path with unexpected format:', path)
setFolderPath(path)
setIsBrowsing(false)
return
}
}
}
// If we get here, result.success was false or path was empty
console.warn('Backend folder picker returned no path:', result)
if (result.success === false && result.message) {
setError(result.message || 'No folder was selected. Please try again.')
} else {
setError('No folder was selected. Please try again.')
}
setIsBrowsing(false)
} catch (err: any) {
// Backend API failed, fall back to browser picker
console.warn('Backend folder picker unavailable, using browser fallback:', err)
// Extract error message from various possible locations
const errorMsg = err?.response?.data?.detail ||
err?.response?.data?.message ||
err?.message ||
String(err) ||
''
console.log('Error details:', {
status: err?.response?.status,
detail: err?.response?.data?.detail,
message: err?.message,
fullError: err
})
// Check if it's a display/availability issue
if (errorMsg.includes('display') || errorMsg.includes('DISPLAY') || errorMsg.includes('tkinter')) {
// Show user-friendly message about display issue
setError('Native folder picker unavailable. Using browser fallback.')
} else if (err?.response?.status === 503) {
// 503 Service Unavailable - likely tkinter or display issue
setError('Native folder picker unavailable (tkinter/display issue). Using browser fallback.')
} else {
// Other error - log it but continue to browser fallback
console.error('Error calling backend folder picker:', err)
setError('Native folder picker unavailable. Using browser fallback.')
}
}
// Fallback: Use browser-based folder picker
// This code runs if backend API failed or returned no path
console.log('Attempting browser fallback folder picker...')
// Use File System Access API if available (modern browsers)
if (typeof window !== 'undefined' && 'showDirectoryPicker' in window) {
try {
console.log('Using File System Access API...')
const directoryHandle = await (window as any).showDirectoryPicker()
// Get the folder name from the handle
const folderName = directoryHandle.name
// Note: Browsers don't expose full absolute paths for security reasons
console.log('Selected folder name:', folderName)
// Browser picker only gives folder name, not full path
// Set the folder name and show helpful message
setFolderPath(folderName)
setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.')
} catch (err: any) {
// User cancelled the picker
if (err.name !== 'AbortError') {
console.error('Error selecting folder:', err)
setError('Error opening folder picker: ' + err.message)
} else {
// User cancelled - clear any previous error
setError(null)
}
} finally {
setIsBrowsing(false)
}
} else {
// Fallback: use a hidden directory input
// Note: This will show a browser confirmation dialog that cannot be removed
console.log('Using file input fallback...')
const input = document.createElement('input')
input.type = 'file'
input.setAttribute('webkitdirectory', '')
input.setAttribute('directory', '')
input.setAttribute('multiple', '')
input.style.display = 'none'
input.onchange = (e: any) => {
const files = e.target.files
if (files && files.length > 0) {
const firstFile = files[0]
const relativePath = firstFile.webkitRelativePath
const pathParts = relativePath.split('/')
const rootFolder = pathParts[0]
// Note: Browsers don't expose full absolute paths for security reasons
console.log('Selected folder name:', rootFolder)
// Browser picker only gives folder name, not full path
// Set the folder name and show helpful message
setFolderPath(rootFolder)
setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.')
}
if (document.body.contains(input)) {
document.body.removeChild(input)
}
setIsBrowsing(false)
}
input.oncancel = () => {
if (document.body.contains(input)) {
document.body.removeChild(input)
}
setIsBrowsing(false)
}
document.body.appendChild(input)
input.click()
}
}
const handleScanFolder = async () => {
if (!folderPath.trim()) {
setError('Please enter a folder path')
return
}
setIsImporting(true)
setError(null)
setImportResult(null)
setCurrentJob(null)
setJobProgress(null)
try {
const request: PhotoImportRequest = {
folder_path: folderPath.trim(),
recursive,
}
const response = await photosApi.importPhotos(request)
setCurrentJob({
id: response.job_id,
status: JobStatus.PENDING,
progress: 0,
message: response.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
// Start SSE stream for job progress
startJobProgressStream(response.job_id)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Import failed')
setIsImporting(false)
}
}
const startJobProgressStream = (jobId: string) => {
// Close existing stream if any
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
const eventSource = photosApi.streamJobProgress(jobId)
eventSourceRef.current = eventSource
eventSource.onmessage = (event) => {
try {
const data: JobProgress = JSON.parse(event.data)
setJobProgress(data)
// Update job status
const statusMap: Record<string, JobStatus> = {
pending: JobStatus.PENDING,
started: JobStatus.STARTED,
progress: JobStatus.PROGRESS,
success: JobStatus.SUCCESS,
failure: JobStatus.FAILURE,
}
setCurrentJob({
id: data.id,
status: statusMap[data.status] || JobStatus.PENDING,
progress: data.progress,
message: data.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
// Check if job is complete
if (data.status === 'success' || data.status === 'failure') {
setIsImporting(false)
eventSource.close()
eventSourceRef.current = null
// Fetch final job result to get added/existing counts
if (data.status === 'success') {
fetchJobResult(jobId)
}
}
} catch (err) {
console.error('Error parsing SSE event:', err)
}
}
eventSource.onerror = (err) => {
console.error('SSE error:', err)
eventSource.close()
eventSourceRef.current = null
}
}
const fetchJobResult = async (jobId: string) => {
try {
const job = await jobsApi.getJob(jobId)
// Job result may contain added/existing counts in metadata
// For now, we'll just update the job status
setCurrentJob(job)
} catch (err) {
console.error('Error fetching job result:', err)
}
}
const getStatusColor = (status: JobStatus) => {
switch (status) {
case JobStatus.SUCCESS:
return 'text-green-600'
case JobStatus.FAILURE:
return 'text-red-600'
case JobStatus.STARTED:
case JobStatus.PROGRESS:
return 'text-blue-600'
default:
return 'text-gray-600'
}
}
return (
<div className="p-6">
<div className="space-y-6">
{/* Folder Scan Section */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Scan Folder
</h2>
<div className="space-y-4">
<div>
<label
htmlFor="folder-path"
className="block text-sm font-medium text-gray-700 mb-2"
>
Folder Path
</label>
<div className="flex gap-2">
<input
id="folder-path"
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="/path/to/photos"
className="w-1/2 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
disabled={isImporting}
/>
<button
type="button"
onClick={handleFolderBrowse}
disabled={isImporting || isBrowsing}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isBrowsing ? 'Opening...' : 'Browse'}
</button>
</div>
<p className="mt-1 text-sm text-gray-500">
Enter the full absolute path to the folder containing photos / videos.
</p>
</div>
<div className="flex items-center">
<input
id="recursive"
type="checkbox"
checked={recursive}
onChange={(e) => setRecursive(e.target.checked)}
disabled={isImporting}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label
htmlFor="recursive"
className="ml-2 block text-sm text-gray-700"
>
Scan subdirectories recursively
</label>
</div>
<button
type="button"
onClick={handleScanFolder}
disabled={isImporting || !folderPath.trim()}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? 'Scanning...' : 'Start Scanning'}
</button>
</div>
</div>
{/* Progress Section */}
{(currentJob || jobProgress) && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Import Progress
</h2>
{currentJob && (
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span
className={`text-sm font-medium ${getStatusColor(currentJob.status)}`}
>
{currentJob.status === JobStatus.SUCCESS && '✓ '}
{currentJob.status === JobStatus.FAILURE && '✗ '}
{currentJob.status.charAt(0).toUpperCase() +
currentJob.status.slice(1)}
</span>
<span className="text-sm text-gray-600">
{currentJob.progress}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${currentJob.progress}%` }}
/>
</div>
</div>
{jobProgress && (
<div className="text-sm text-gray-600">
{jobProgress.processed !== undefined &&
jobProgress.total !== undefined && (
<p>
Processed: {jobProgress.processed} /{' '}
{jobProgress.total}
</p>
)}
{jobProgress.message && (
<p className="mt-1">{jobProgress.message}</p>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Results Section */}
{importResult && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Import Results
</h2>
<div className="space-y-2 text-sm">
{importResult.added !== undefined && (
<p className="text-green-600">
{importResult.added} new photos added
</p>
)}
{importResult.existing !== undefined && (
<p className="text-gray-600">
{importResult.existing} photos already in database
</p>
)}
{importResult.total !== undefined && (
<p className="text-gray-700 font-medium">
Total: {importResult.total} photos
</p>
)}
</div>
</div>
)}
{/* Error Section */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
import { useDeveloperMode } from '../context/DeveloperModeContext'
export default function Settings() {
const { isDeveloperMode, setDeveloperMode } = useDeveloperMode()
return (
<div>
<div className="bg-white rounded-lg shadow p-6 mb-4">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Developer Options</h2>
<div className="flex items-center justify-between py-3 border-b border-gray-200">
<div className="flex-1">
<label htmlFor="developer-mode" className="text-sm font-medium text-gray-700">
Developer Mode
</label>
<p className="text-xs text-gray-500 mt-1">
Enable developer features. Additional features will be available when enabled.
</p>
</div>
<div className="ml-4">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
id="developer-mode"
checked={isDeveloperMode}
onChange={(e) => setDeveloperMode(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,598 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
pendingLinkagesApi,
PendingLinkageResponse,
ReviewDecision,
} from '../api/pendingLinkages'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { videosApi } from '../api/videos'
type DecisionValue = 'approve' | 'deny'
type SortKey = 'photo' | 'tag' | 'submitted_by' | 'submitted_at' | 'status'
function formatDate(value: string | null | undefined): string {
if (!value) {
return '-'
}
try {
return new Date(value).toLocaleString()
} catch (error) {
console.error('Failed to format date', error)
return value
}
}
export default function UserTaggedPhotos() {
const { isAdmin } = useAuth()
const [linkages, setLinkages] = useState<PendingLinkageResponse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [statusFilter, setStatusFilter] = useState<string>('pending')
const [decisions, setDecisions] = useState<Record<number, DecisionValue | null>>({})
const [submitting, setSubmitting] = useState(false)
const [clearing, setClearing] = useState(false)
const [sortBy, setSortBy] = useState<SortKey>('submitted_at')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
const loadLinkages = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await pendingLinkagesApi.listPendingLinkages(
statusFilter || undefined
)
setLinkages(response.items)
setDecisions({})
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load user tagged photos')
console.error('Error loading pending linkages:', err)
} finally {
setLoading(false)
}
}, [statusFilter])
useEffect(() => {
loadLinkages()
}, [loadLinkages])
const pendingCount = useMemo(
() => linkages.filter((item) => item.status === 'pending').length,
[linkages]
)
const sortedLinkages = useMemo(() => {
const items = [...linkages]
items.sort((a, b) => {
const direction = sortDirection === 'asc' ? 1 : -1
const compareStrings = (x: string | null | undefined, y: string | null | undefined) =>
(x || '').localeCompare(y || '', undefined, { sensitivity: 'base' })
if (sortBy === 'photo') {
return (a.photo_id - b.photo_id) * direction
}
if (sortBy === 'tag') {
const aTag = a.resolved_tag_name || a.proposed_tag_name || ''
const bTag = b.resolved_tag_name || b.proposed_tag_name || ''
return compareStrings(aTag, bTag) * direction
}
if (sortBy === 'submitted_by') {
const aName = a.user_name || a.user_email || ''
const bName = b.user_name || b.user_email || ''
return compareStrings(aName, bName) * direction
}
if (sortBy === 'submitted_at') {
const aTime = a.created_at || ''
const bTime = b.created_at || ''
return (aTime < bTime ? -1 : aTime > bTime ? 1 : 0) * direction
}
if (sortBy === 'status') {
return compareStrings(a.status, b.status) * direction
}
return 0
})
return items
}, [linkages, sortBy, sortDirection])
const toggleSort = (key: SortKey) => {
setSortBy((currentKey) => {
if (currentKey === key) {
setSortDirection((currentDirection) => (currentDirection === 'asc' ? 'desc' : 'asc'))
return currentKey
}
setSortDirection('asc')
return key
})
}
const renderSortLabel = (label: string, key: SortKey) => {
const isActive = sortBy === key
const directionSymbol = !isActive ? '↕' : sortDirection === 'asc' ? '▲' : '▼'
return (
<button
type="button"
onClick={() => toggleSort(key)}
className="inline-flex items-center gap-1 text-xs font-medium text-gray-500 uppercase tracking-wider hover:text-gray-700"
>
<span>{label}</span>
<span className="text-[10px]">{directionSymbol}</span>
</button>
)
}
const hasPendingDecision = useMemo(
() =>
Object.entries(decisions).some(([id, value]) => {
const linkage = linkages.find((item) => item.id === Number(id))
return value !== null && linkage?.status === 'pending'
}),
[decisions, linkages]
)
const handleDecisionChange = (id: number, nextDecision: DecisionValue) => {
setDecisions((prev) => {
const current = prev[id] ?? null
const toggled = current === nextDecision ? null : nextDecision
return {
...prev,
[id]: toggled,
}
})
}
const handleSelectAllApprove = () => {
const pendingIds = linkages
.filter((item) => item.status === 'pending')
.map((item) => item.id)
if (pendingIds.length === 0) {
return
}
const newDecisions: Record<number, DecisionValue> = {}
pendingIds.forEach((id) => {
newDecisions[id] = 'approve'
})
setDecisions((prev) => ({
...prev,
...newDecisions,
}))
}
const handleSelectAllDeny = () => {
const pendingIds = linkages
.filter((item) => item.status === 'pending')
.map((item) => item.id)
if (pendingIds.length === 0) {
return
}
const newDecisions: Record<number, DecisionValue> = {}
pendingIds.forEach((id) => {
newDecisions[id] = 'deny'
})
setDecisions((prev) => ({
...prev,
...newDecisions,
}))
}
const handleSubmit = async () => {
const decisionsList: ReviewDecision[] = Object.entries(decisions)
.filter(([id, decision]) => {
const linkage = linkages.find((item) => item.id === Number(id))
return decision !== null && linkage?.status === 'pending'
})
.map(([id, decision]) => ({
id: Number(id),
decision: decision as DecisionValue,
}))
if (decisionsList.length === 0) {
alert('Select Approve or Deny for at least one pending tag.')
return
}
const approveCount = decisionsList.filter((item) => item.decision === 'approve').length
const denyCount = decisionsList.length - approveCount
const confirmMessage = [
`Submit ${decisionsList.length} decision(s)?`,
approveCount ? `✅ Approve: ${approveCount}` : null,
denyCount ? `❌ Deny: ${denyCount}` : null,
]
.filter(Boolean)
.join('\n')
if (!confirm(confirmMessage)) {
return
}
setSubmitting(true)
try {
const response = await pendingLinkagesApi.reviewPendingLinkages({
decisions: decisionsList,
})
const summary = [
`Approved: ${response.approved}`,
`Denied: ${response.denied}`,
response.tags_created ? `New tags: ${response.tags_created}` : null,
response.linkages_created ? `New linkages: ${response.linkages_created}` : null,
response.errors.length ? `Errors: ${response.errors.join('; ')}` : null,
]
.filter(Boolean)
.join('\n')
alert(summary || 'Review complete.')
await loadLinkages()
setDecisions({})
} catch (err: any) {
const message = err.response?.data?.detail || err.message || 'Failed to submit decisions'
alert(message)
console.error('Error submitting pending linkage decisions:', err)
} finally {
setSubmitting(false)
}
}
const handleClearDatabase = async () => {
const confirmMessage = [
'Delete all approved and denied records?',
'',
'Only records with Pending status will remain.',
'This action cannot be undone.',
].join('\n')
if (!confirm(confirmMessage)) {
return
}
setClearing(true)
try {
const response = await pendingLinkagesApi.cleanupPendingLinkages()
const summary = [
`✅ Deleted ${response.deleted_records} record(s)`,
response.warnings && response.warnings.length > 0
? ` ${response.warnings.join('; ')}`
: '',
response.errors.length > 0 ? `⚠️ ${response.errors.join('; ')}` : '',
]
.filter(Boolean)
.join('\n')
alert(summary || 'Cleanup complete.')
if (response.errors.length > 0) {
console.error('Cleanup errors:', response.errors)
}
if (response.warnings && response.warnings.length > 0) {
console.info('Cleanup warnings:', response.warnings)
}
await loadLinkages()
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to cleanup pending linkages'
alert(`Error: ${errorMessage}`)
console.error('Error clearing pending linkages:', err)
} finally {
setClearing(false)
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4">
<div>
<p className="text-gray-600">
Review tags suggested by users. Approving creates/links the tag to the selected photo.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
Status
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="border border-gray-300 rounded-md px-3 py-1 text-sm"
>
<option value="pending">Pending</option>
<option value="">All Statuses</option>
<option value="approved">Approved</option>
<option value="denied">Denied</option>
</select>
</label>
<button
type="button"
onClick={loadLinkages}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Refresh
</button>
<div className="text-sm text-gray-500">
Pending items: <span className="font-semibold text-gray-800">{pendingCount}</span>
</div>
<div className="flex-1" />
{linkages.filter((item) => item.status === 'pending').length > 0 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSelectAllApprove}
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Approve
</button>
<button
type="button"
onClick={handleSelectAllDeny}
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Deny
</button>
</div>
)}
<button
type="button"
onClick={handleSubmit}
disabled={submitting || loading || !hasPendingDecision}
className={`inline-flex items-center px-4 py-2 rounded-md text-sm font-semibold text-white ${
submitting || loading || !hasPendingDecision
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{submitting ? 'Submitting...' : 'Submit Decisions'}
</button>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => {
if (!isAdmin) {
return
}
handleClearDatabase()
}}
disabled={clearing || !isAdmin}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-200 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
title={
isAdmin
? 'Delete approved/denied records'
: 'Only admins can clear pending linkages'
}
>
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
</button>
<span className="text-sm text-gray-700">
Clear approved/denied records
</span>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
{loading ? (
<div className="flex items-center justify-center h-64 text-gray-500">Loading...</div>
) : linkages.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-lg p-6 text-center text-gray-500">
No user tagged photos found for this filter.
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left">
{renderSortLabel('Photo', 'photo')}
</th>
<th scope="col" className="px-6 py-3 text-left">
{renderSortLabel('Proposed Tag', 'tag')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Current Tags
</th>
<th scope="col" className="px-6 py-3 text-left">
{renderSortLabel('Submitted By', 'submitted_by')}
</th>
<th scope="col" className="px-6 py-3 text-left">
{renderSortLabel('Submitted At', 'submitted_at')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Notes
</th>
<th scope="col" className="px-6 py-3 text-left">
{renderSortLabel('Status', 'status')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Decision
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedLinkages.map((linkage) => {
const canReview = linkage.status === 'pending'
const decision = decisions[linkage.id] ?? null
return (
<tr key={linkage.id} className={canReview ? '' : 'opacity-70 bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap">
{linkage.photo_id ? (
<div
className="cursor-pointer hover:opacity-90 transition-opacity w-24"
onClick={() => {
const isVideo = linkage.photo_media_type === 'video'
const url = isVideo
? videosApi.getVideoUrl(linkage.photo_id)
: `${apiClient.defaults.baseURL}/api/v1/photos/${linkage.photo_id}/image`
window.open(url, '_blank')
}}
title={linkage.photo_media_type === 'video' ? 'Open video in new tab' : 'Open photo in new tab'}
>
{linkage.photo_media_type === 'video' ? (
<img
src={videosApi.getThumbnailUrl(linkage.photo_id)}
alt={`Video ${linkage.photo_id}`}
className="w-24 h-24 object-cover rounded border border-gray-300"
loading="lazy"
onError={(event) => {
const target = event.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.fallback-text')) {
const fallback = document.createElement('div')
fallback.className = 'fallback-text text-gray-400 text-xs text-center'
fallback.textContent = `#${linkage.photo_id}`
parent.appendChild(fallback)
}
}}
/>
) : (
<img
src={`/api/v1/photos/${linkage.photo_id}/image`}
alt={`Photo ${linkage.photo_id}`}
className="w-24 h-24 object-cover rounded border border-gray-300"
loading="lazy"
onError={(event) => {
const target = event.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.fallback-text')) {
const fallback = document.createElement('div')
fallback.className = 'fallback-text text-gray-400 text-xs text-center'
fallback.textContent = `#${linkage.photo_id}`
parent.appendChild(fallback)
}
}}
/>
)}
</div>
) : (
<div className="text-xs text-gray-400">Photo not found</div>
)}
<div className="text-xs text-gray-500 mt-1">
{linkage.photo_filename || `Photo #${linkage.photo_id}`}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{linkage.resolved_tag_name || linkage.proposed_tag_name || '-'}
</span>
{linkage.tag_id === null && linkage.proposed_tag_name && (
<span className="text-xs text-yellow-700 bg-yellow-50 px-2 py-0.5 rounded mt-1 inline-flex items-center gap-1">
<span>New tag</span>
</span>
)}
{linkage.tag_id && (
<span className="text-xs text-green-700 bg-green-50 px-2 py-0.5 rounded mt-1 inline-flex items-center gap-1">
<span>Existing tag</span>
<span>#{linkage.tag_id}</span>
</span>
)}
</div>
</td>
<td className="px-6 py-4">
{linkage.photo_tags.length === 0 ? (
<span className="text-sm text-gray-400 italic">No tags</span>
) : (
<div className="flex flex-wrap gap-2">
{linkage.photo_tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-xs bg-gray-100 text-gray-700 rounded-full"
>
{tag}
</span>
))}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{linkage.user_name || 'Unknown'}</div>
<div className="text-xs text-gray-500">{linkage.user_email || '-'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(linkage.created_at)}
</td>
<td className="px-6 py-4">
{linkage.notes ? (
<div className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 border border-gray-200 rounded p-2">
{linkage.notes}
</div>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
linkage.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: linkage.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{linkage.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{canReview ? (
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decision === 'approve'}
onChange={() => handleDecisionChange(linkage.id, 'approve')}
className="w-4 h-4 text-green-600 focus:ring-green-500"
/>
<span className="text-sm text-gray-700">Approve</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decision === 'deny'}
onChange={() => handleDecisionChange(linkage.id, 'deny')}
className="w-4 h-4 text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">Deny</span>
</label>
</div>
) : (
<span className="text-sm text-gray-500 italic">-</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}

10
admin-frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})

0
backend/__init__.py Normal file
View File

3
backend/api/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""API routers package for PunimTag Web."""

357
backend/api/auth.py Normal file
View File

@ -0,0 +1,357 @@
"""Authentication endpoints."""
from __future__ import annotations
import os
from datetime import datetime, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from backend.constants.roles import (
DEFAULT_ADMIN_ROLE,
DEFAULT_USER_ROLE,
ROLE_VALUES,
)
from backend.db.session import get_db
from backend.db.models import User
from backend.utils.password import verify_password, hash_password
from backend.schemas.auth import (
LoginRequest,
RefreshRequest,
TokenResponse,
UserResponse,
PasswordChangeRequest,
PasswordChangeResponse,
)
from backend.services.role_permissions import fetch_role_permissions_map
router = APIRouter(prefix="/auth", tags=["auth"])
security = HTTPBearer()
# Placeholder secrets - replace with env vars in production
SECRET_KEY = "dev-secret-key-change-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 360
REFRESH_TOKEN_EXPIRE_DAYS = 7
# Single user mode placeholder - read from environment or use defaults
SINGLE_USER_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
SINGLE_USER_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin") # Change in production
def create_access_token(data: dict, expires_delta: timedelta) -> str:
"""Create JWT access token."""
to_encode = data.copy()
expire = datetime.utcnow() + expires_delta
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(data: dict) -> str:
"""Create JWT refresh token."""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]
) -> dict:
"""Get current user from JWT token."""
try:
payload = jwt.decode(
credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]
)
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
return {"username": username}
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
def get_current_user_with_id(
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> dict:
"""Get current user with ID from main database.
Looks up the user in the main database and returns username and user_id.
If user doesn't exist, creates them (for bootstrap scenarios).
"""
username = current_user["username"]
# Check if user exists in main database
user = db.query(User).filter(User.username == username).first()
# If user doesn't exist, create them (for bootstrap scenarios)
if not user:
from backend.utils.password import hash_password
# Generate unique email to avoid conflicts
base_email = f"{username}@example.com"
email = base_email
counter = 1
# Ensure email is unique
while db.query(User).filter(User.email == email).first():
email = f"{username}+{counter}@example.com"
counter += 1
# Create user (they should change password)
default_password_hash = hash_password("changeme")
user = User(
username=username,
password_hash=default_password_hash,
email=email,
full_name=username,
is_active=True,
is_admin=False,
role=DEFAULT_USER_ROLE,
)
db.add(user)
db.commit()
db.refresh(user)
return {"username": username, "user_id": user.id}
def _resolve_user_role(user: User | None, is_admin_flag: bool) -> str:
"""Determine the role value for a user, ensuring it is valid."""
if user and user.role in ROLE_VALUES:
return user.role
return DEFAULT_ADMIN_ROLE if is_admin_flag else DEFAULT_USER_ROLE
@router.post("/login", response_model=TokenResponse)
def login(credentials: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
"""Authenticate user and return tokens.
First checks main database for users, falls back to hardcoded admin/admin
for backward compatibility.
"""
# First, try to find user in main database
user = db.query(User).filter(User.username == credentials.username).first()
if user:
# User exists in main database - verify password
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is inactive",
)
# Check if password_hash exists (migration might not have run)
if not user.password_hash:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Password not set. Please contact administrator to set your password.",
)
if not verify_password(credentials.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
# Update last login
user.last_login = datetime.utcnow()
db.add(user)
db.commit()
# Generate tokens
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": credentials.username},
expires_delta=access_token_expires,
)
refresh_token = create_refresh_token(data={"sub": credentials.username})
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
password_change_required=user.password_change_required,
)
# Fallback to hardcoded admin/admin for backward compatibility
if (
credentials.username == SINGLE_USER_USERNAME
and credentials.password == SINGLE_USER_PASSWORD
):
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": credentials.username},
expires_delta=access_token_expires,
)
refresh_token = create_refresh_token(data={"sub": credentials.username})
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
password_change_required=False, # Hardcoded admin doesn't require password change
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(request: RefreshRequest) -> TokenResponse:
"""Refresh access token using refresh token."""
try:
payload = jwt.decode(
request.refresh_token, SECRET_KEY, algorithms=[ALGORITHM]
)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
)
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": username}, expires_delta=access_token_expires
)
new_refresh_token = create_refresh_token(data={"sub": username})
return TokenResponse(
access_token=access_token, refresh_token=new_refresh_token
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
@router.get("/me", response_model=UserResponse)
def get_current_user_info(
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> UserResponse:
"""Get current user information including admin status."""
username = current_user["username"]
# Check if user exists in main database to get admin status
user = db.query(User).filter(User.username == username).first()
# If user doesn't exist in main database, check if we should bootstrap them
if not user:
# Check if any admin users exist
admin_count = db.query(User).filter(User.is_admin == True).count()
# If no admins exist, bootstrap current user as admin
if admin_count == 0:
from backend.utils.password import hash_password
# Generate unique email to avoid conflicts
base_email = f"{username}@example.com"
email = base_email
counter = 1
# Ensure email is unique
while db.query(User).filter(User.email == email).first():
email = f"{username}+{counter}@example.com"
counter += 1
# Create user as admin for bootstrap (they should change password)
default_password_hash = hash_password("changeme")
try:
user = User(
username=username,
password_hash=default_password_hash,
email=email,
full_name=username,
is_active=True,
is_admin=True,
role=DEFAULT_ADMIN_ROLE,
)
db.add(user)
db.commit()
db.refresh(user)
is_admin = True
except Exception:
# If creation fails (e.g., race condition), try to get existing user
db.rollback()
user = db.query(User).filter(User.username == username).first()
if user:
# Update existing user to be admin if no admins exist
if not user.is_admin:
user.is_admin = True
user.role = DEFAULT_ADMIN_ROLE
db.commit()
db.refresh(user)
is_admin = user.is_admin
else:
is_admin = False
else:
is_admin = False
else:
is_admin = user.is_admin if user else False
role_value = _resolve_user_role(user, is_admin)
permissions_map = fetch_role_permissions_map(db)
permissions = permissions_map.get(role_value, {})
return UserResponse(
username=username,
is_admin=is_admin,
role=role_value,
permissions=permissions,
)
@router.post("/change-password", response_model=PasswordChangeResponse)
def change_password(
request: PasswordChangeRequest,
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> PasswordChangeResponse:
"""Change user password.
Requires current password verification.
After successful change, clears password_change_required flag.
"""
username = current_user["username"]
# Find user in main database
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Verify current password
if not verify_password(request.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect",
)
# Update password
user.password_hash = hash_password(request.new_password)
user.password_change_required = False # Clear the flag after password change
db.add(user)
db.commit()
return PasswordChangeResponse(
success=True,
message="Password changed successfully",
)

699
backend/api/auth_users.py Normal file
View File

@ -0,0 +1,699 @@
"""Auth database user management endpoints - admin only."""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlalchemy.orm import Session
from backend.api.auth import get_current_user
from backend.api.users import get_current_admin_user
from backend.db.session import get_auth_db, get_db
from backend.schemas.auth_users import (
AuthUserCreateRequest,
AuthUserResponse,
AuthUserUpdateRequest,
AuthUsersListResponse,
)
from backend.utils.password import hash_password
router = APIRouter(prefix="/auth-users", tags=["auth-users"])
logger = logging.getLogger(__name__)
def _check_column_exists(auth_db: Session, table_name: str, column_name: str) -> bool:
"""Check if a column exists in a table."""
try:
from sqlalchemy import inspect as sqlalchemy_inspect
inspector = sqlalchemy_inspect(auth_db.bind)
columns = {col["name"] for col in inspector.get_columns(table_name)}
return column_name in columns
except Exception:
return False
def _get_role_from_is_admin(auth_db: Session, is_admin: bool) -> str:
"""Get role value from is_admin boolean. Returns 'Admin' if is_admin is True, 'User' otherwise."""
return "Admin" if is_admin else "User"
def _get_is_admin_from_role(role: str | None) -> bool:
"""Get is_admin boolean from role string. Returns True if role is 'Admin', False otherwise."""
return role == "Admin" if role else False
@router.get("", response_model=AuthUsersListResponse)
def list_auth_users(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
auth_db: Session = Depends(get_auth_db),
) -> AuthUsersListResponse:
"""List all users from auth database - admin only."""
try:
# Check if optional columns exist
has_role_column = _check_column_exists(auth_db, "users", "role")
has_is_active_column = _check_column_exists(auth_db, "users", "is_active")
# Query users from auth database with all columns from schema
# Try to include is_active and role if columns exist
result = None
try:
# Build SELECT query based on which columns exist
select_fields = "id, email, name, is_admin, has_write_access"
if has_is_active_column:
select_fields += ", is_active"
if has_role_column:
select_fields += ", role"
select_fields += ", created_at, updated_at"
result = auth_db.execute(text(f"""
SELECT {select_fields}
FROM users
ORDER BY COALESCE(name, email) ASC
"""))
except Exception:
# Rollback the failed transaction before trying again
auth_db.rollback()
try:
# Try with is_active only (no role)
select_fields = "id, email, name, is_admin, has_write_access"
if has_is_active_column:
select_fields += ", is_active"
select_fields += ", created_at, updated_at"
result = auth_db.execute(text(f"""
SELECT {select_fields}
FROM users
ORDER BY COALESCE(name, email) ASC
"""))
except Exception:
# Rollback again before final attempt
auth_db.rollback()
# Base columns only
result = auth_db.execute(text("""
SELECT
id,
email,
name,
is_admin,
has_write_access,
created_at,
updated_at
FROM users
ORDER BY COALESCE(name, email) ASC
"""))
if result is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to query auth users",
)
rows = result.fetchall()
users = []
for row in rows:
try:
# Access row attributes directly - SQLAlchemy Row objects support attribute access
user_id = int(row.id)
email = str(row.email)
name = row.name if row.name is not None else None
# Get boolean fields - convert to proper boolean
# These columns have defaults so they should always have values
is_admin = bool(row.is_admin)
has_write_access = bool(row.has_write_access)
# Optional columns - try to get from row, default to None if not selected
is_active = None
try:
is_active = row.is_active
if is_active is not None:
is_active = bool(is_active)
else:
# NULL values should be treated as True (active)
is_active = True
except (AttributeError, KeyError):
# Column not selected or doesn't exist - default to True (active)
is_active = True
# Get role - if column doesn't exist, derive from is_admin
if has_role_column:
role = getattr(row, 'role', None)
else:
role = _get_role_from_is_admin(auth_db, is_admin)
created_at = getattr(row, 'created_at', None)
updated_at = getattr(row, 'updated_at', None)
users.append(AuthUserResponse(
id=user_id,
name=name,
email=email,
is_admin=is_admin,
has_write_access=has_write_access,
is_active=is_active,
role=role,
created_at=created_at,
updated_at=updated_at,
))
except Exception as row_error:
logger.warning(f"Error processing auth user row: {row_error}")
# Skip this row and continue
continue
return AuthUsersListResponse(items=users, total=len(users))
except HTTPException:
raise
except Exception as e:
auth_db.rollback()
import traceback
error_detail = f"Failed to list auth users: {str(e)}\n{traceback.format_exc()}"
logger.error(f"Error listing auth users: {error_detail}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list auth users: {str(e)}",
)
@router.post("", response_model=AuthUserResponse, status_code=status.HTTP_201_CREATED)
def create_auth_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
request: AuthUserCreateRequest,
auth_db: Session = Depends(get_auth_db),
) -> AuthUserResponse:
"""Create a new user in auth database - admin only."""
try:
# Check if user with same email already exists (email is unique)
check_result = auth_db.execute(text("""
SELECT id FROM users
WHERE email = :email
"""), {"email": request.email})
existing = check_result.first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User with email '{request.email}' already exists",
)
# Insert new user
# Check database dialect for RETURNING support
dialect = auth_db.bind.dialect.name if auth_db.bind else 'postgresql'
supports_returning = dialect == 'postgresql'
# Hash the password
password_hash = hash_password(request.password)
if supports_returning:
result = auth_db.execute(text("""
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
RETURNING id, email, name, is_admin, has_write_access, created_at, updated_at
"""), {
"email": request.email,
"name": request.name,
"password_hash": password_hash,
"is_admin": request.is_admin,
"has_write_access": request.has_write_access,
})
auth_db.commit()
row = result.first()
else:
# SQLite - insert then select
auth_db.execute(text("""
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
"""), {
"email": request.email,
"name": request.name,
"password_hash": password_hash,
"is_admin": request.is_admin,
"has_write_access": request.has_write_access,
})
auth_db.commit()
# Get the last inserted row
result = auth_db.execute(text("""
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
FROM users
WHERE id = last_insert_rowid()
"""))
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create user",
)
is_admin = bool(row.is_admin)
has_write_access = bool(row.has_write_access)
return AuthUserResponse(
id=row.id,
name=getattr(row, 'name', None),
email=row.email,
is_admin=is_admin,
has_write_access=has_write_access,
created_at=getattr(row, 'created_at', None),
updated_at=getattr(row, 'updated_at', None),
)
except HTTPException:
raise
except Exception as e:
auth_db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create auth user: {str(e)}",
)
@router.get("/{user_id}", response_model=AuthUserResponse)
def get_auth_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
auth_db: Session = Depends(get_auth_db),
) -> AuthUserResponse:
"""Get a specific auth user by ID - admin only."""
try:
# Check if optional columns exist
has_role_column = _check_column_exists(auth_db, "users", "role")
has_is_active_column = _check_column_exists(auth_db, "users", "is_active")
# Try to include is_active and role if columns exist
try:
# Build SELECT query based on which columns exist
select_fields = "id, email, name, is_admin, has_write_access"
if has_is_active_column:
select_fields += ", is_active"
if has_role_column:
select_fields += ", role"
select_fields += ", created_at, updated_at"
result = auth_db.execute(text(f"""
SELECT {select_fields}
FROM users
WHERE id = :user_id
"""), {"user_id": user_id})
except Exception:
# Rollback the failed transaction before trying again
auth_db.rollback()
try:
# Try with is_active only (no role)
select_fields = "id, email, name, is_admin, has_write_access"
if has_is_active_column:
select_fields += ", is_active"
select_fields += ", created_at, updated_at"
result = auth_db.execute(text(f"""
SELECT {select_fields}
FROM users
WHERE id = :user_id
"""), {"user_id": user_id})
except Exception:
# Rollback again before final attempt
auth_db.rollback()
# Columns don't exist, select without them
result = auth_db.execute(text("""
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
FROM users
WHERE id = :user_id
"""), {"user_id": user_id})
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Auth user with ID {user_id} not found",
)
is_admin = bool(row.is_admin)
has_write_access = bool(row.has_write_access)
is_active = getattr(row, 'is_active', None)
if is_active is not None:
is_active = bool(is_active)
else:
# NULL values should be treated as True (active)
is_active = True
# Get role - if column doesn't exist, derive from is_admin
if has_role_column:
role = getattr(row, 'role', None)
else:
role = _get_role_from_is_admin(auth_db, is_admin)
return AuthUserResponse(
id=row.id,
name=getattr(row, 'name', None),
email=row.email,
is_admin=is_admin,
has_write_access=has_write_access,
is_active=is_active,
role=role,
created_at=getattr(row, 'created_at', None),
updated_at=getattr(row, 'updated_at', None),
)
except HTTPException:
raise
except Exception as e:
auth_db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get auth user: {str(e)}",
)
@router.put("/{user_id}", response_model=AuthUserResponse)
def update_auth_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
request: AuthUserUpdateRequest,
auth_db: Session = Depends(get_auth_db),
) -> AuthUserResponse:
"""Update an auth user - admin only."""
try:
# Check if user exists
check_result = auth_db.execute(text("""
SELECT id FROM users WHERE id = :user_id
"""), {"user_id": user_id})
if not check_result.first():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Auth user with ID {user_id} not found",
)
# Check if email conflicts with another user (email is unique)
check_conflict = auth_db.execute(text("""
SELECT id FROM users
WHERE id != :user_id AND email = :email
"""), {
"user_id": user_id,
"email": request.email,
})
if check_conflict.first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User with email '{request.email}' already exists",
)
# Check if role column exists
has_role_column = _check_column_exists(auth_db, "users", "role")
# Update all fields (all are required, is_active and role are optional)
dialect = auth_db.bind.dialect.name if auth_db.bind else 'postgresql'
supports_returning = dialect == 'postgresql'
# Determine is_admin value - if role is provided and role column doesn't exist, use role to set is_admin
is_admin_value = request.is_admin
if request.role is not None and not has_role_column:
# Role column doesn't exist, derive is_admin from role
is_admin_value = _get_is_admin_from_role(request.role)
# Build UPDATE query - include is_active and role if provided and column exists
update_fields = ["email = :email", "name = :name", "is_admin = :is_admin", "has_write_access = :has_write_access"]
update_params = {
"user_id": user_id,
"email": request.email,
"name": request.name,
"is_admin": is_admin_value,
"has_write_access": request.has_write_access,
}
# Update password if provided
if request.password and request.password.strip():
password_hash = hash_password(request.password)
update_fields.append("password_hash = :password_hash")
update_params["password_hash"] = password_hash
if request.is_active is not None:
update_fields.append("is_active = :is_active")
update_params["is_active"] = request.is_active
if request.role is not None and has_role_column:
# Only update role if the column exists
update_fields.append("role = :role")
update_params["role"] = request.role
update_sql = f"""
UPDATE users
SET {', '.join(update_fields)}
WHERE id = :user_id
"""
if supports_returning:
# Build select fields - try to include optional columns
select_fields = "id, email, name, is_admin, has_write_access"
if request.is_active is not None or _check_column_exists(auth_db, "users", "is_active"):
select_fields += ", is_active"
if has_role_column:
select_fields += ", role"
select_fields += ", created_at, updated_at"
result = auth_db.execute(text(f"""
{update_sql}
RETURNING {select_fields}
"""), update_params)
auth_db.commit()
row = result.first()
else:
# SQLite - update then select
auth_db.execute(text(update_sql), update_params)
auth_db.commit()
# Get the updated row - try to include optional columns
try:
if has_role_column:
result = auth_db.execute(text("""
SELECT id, email, name, is_admin, has_write_access, is_active, role, created_at, updated_at
FROM users
WHERE id = :user_id
"""), {"user_id": user_id})
else:
result = auth_db.execute(text("""
SELECT id, email, name, is_admin, has_write_access, is_active, created_at, updated_at
FROM users
WHERE id = :user_id
"""), {"user_id": user_id})
row = result.first()
except Exception:
auth_db.rollback()
try:
result = auth_db.execute(text("""
SELECT id, email, name, is_admin, has_write_access, is_active, created_at, updated_at
FROM users
WHERE id = :user_id
"""), {"user_id": user_id})
row = result.first()
except Exception:
auth_db.rollback()
result = auth_db.execute(text("""
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
FROM users
WHERE id = :user_id
"""), {"user_id": user_id})
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user",
)
is_admin = bool(row.is_admin)
has_write_access = bool(row.has_write_access)
is_active = getattr(row, 'is_active', None)
if is_active is not None:
is_active = bool(is_active)
else:
# NULL values should be treated as True (active)
is_active = True
# Get role - if column doesn't exist, derive from is_admin
if has_role_column:
role = getattr(row, 'role', None)
else:
role = _get_role_from_is_admin(auth_db, is_admin)
return AuthUserResponse(
id=row.id,
name=getattr(row, 'name', None),
email=row.email,
is_admin=is_admin,
has_write_access=has_write_access,
is_active=is_active,
role=role,
created_at=getattr(row, 'created_at', None),
updated_at=getattr(row, 'updated_at', None),
)
except HTTPException:
raise
except Exception as e:
auth_db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update auth user: {str(e)}",
)
@router.delete("/{user_id}")
def delete_auth_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
auth_db: Session = Depends(get_auth_db),
) -> Response:
"""Delete an auth user - admin only.
If the user has linked data (pending_photos, pending_identifications,
inappropriate_photo_reports), the user will be set to inactive instead
of deleted. Admins will be notified via logging.
"""
try:
# Check if user exists and get user info
user_result = auth_db.execute(text("""
SELECT id, email, name FROM users WHERE id = :user_id
"""), {"user_id": user_id})
user_row = user_result.first()
if not user_row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Auth user with ID {user_id} not found",
)
user_email = user_row.email
user_name = user_row.name or user_email
# Check for linked data in auth database
pending_photos_count = auth_db.execute(text("""
SELECT COUNT(*) FROM pending_photos WHERE user_id = :user_id
"""), {"user_id": user_id}).scalar() or 0
pending_identifications_count = auth_db.execute(text("""
SELECT COUNT(*) FROM pending_identifications WHERE user_id = :user_id
"""), {"user_id": user_id}).scalar() or 0
inappropriate_reports_count = auth_db.execute(text("""
SELECT COUNT(*) FROM inappropriate_photo_reports WHERE user_id = :user_id
"""), {"user_id": user_id}).scalar() or 0
has_linked_data = (
pending_photos_count > 0 or
pending_identifications_count > 0 or
inappropriate_reports_count > 0
)
if has_linked_data:
# Check if is_active column exists by trying to query it
dialect = auth_db.bind.dialect.name if auth_db.bind else "postgresql"
has_is_active_column = False
try:
# Try to select is_active column to check if it exists
test_result = auth_db.execute(text("""
SELECT is_active FROM users WHERE id = :user_id LIMIT 1
"""), {"user_id": user_id})
test_result.first()
has_is_active_column = True
except Exception:
# Column doesn't exist - this should have been added at startup
# but if it wasn't, we can't proceed
error_msg = "is_active column does not exist in auth database users table"
logger.error(
f"Cannot deactivate auth user '{user_name}' (ID: {user_id}): {error_msg}"
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=(
f"Cannot delete user '{user_name}' because they have linked data "
f"({pending_photos_count} pending photo(s), "
f"{pending_identifications_count} pending identification(s), "
f"{inappropriate_reports_count} inappropriate photo report(s)) "
f"and the is_active column does not exist in the auth database users table. "
f"Please restart the server to add the column automatically, or contact "
f"your database administrator to add it manually: "
f"ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT TRUE"
),
)
# Set user inactive instead of deleting
if dialect == "postgresql":
auth_db.execute(text("""
UPDATE users SET is_active = FALSE WHERE id = :user_id
"""), {"user_id": user_id})
else:
# SQLite uses 0 for FALSE
auth_db.execute(text("""
UPDATE users SET is_active = 0 WHERE id = :user_id
"""), {"user_id": user_id})
auth_db.commit()
# Notify admins via logging
logger.warning(
f"Auth user '{user_name}' (ID: {user_id}, email: {user_email}) was set to inactive "
f"instead of deleted because they have linked data: {pending_photos_count} pending "
f"photo(s), {pending_identifications_count} pending identification(s), "
f"{inappropriate_reports_count} inappropriate photo report(s). "
f"Action performed by admin: {current_admin['username']}",
extra={
"user_id": user_id,
"user_email": user_email,
"user_name": user_name,
"pending_photos_count": pending_photos_count,
"pending_identifications_count": pending_identifications_count,
"inappropriate_reports_count": inappropriate_reports_count,
"admin_username": current_admin["username"],
}
)
# Return success but indicate user was deactivated
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
"message": (
f"User '{user_name}' has been set to inactive because they have "
f"linked data ({pending_photos_count} pending photo(s), "
f"{pending_identifications_count} pending identification(s), "
f"{inappropriate_reports_count} inappropriate photo report(s))."
),
"deactivated": True,
"pending_photos_count": pending_photos_count,
"pending_identifications_count": pending_identifications_count,
"inappropriate_reports_count": inappropriate_reports_count,
}
)
else:
# No linked data - safe to delete
auth_db.execute(text("""
DELETE FROM users WHERE id = :user_id
"""), {"user_id": user_id})
auth_db.commit()
logger.info(
f"Auth user '{user_name}' (ID: {user_id}, email: {user_email}) was deleted. "
f"Action performed by admin: {current_admin['username']}",
extra={
"user_id": user_id,
"user_email": user_email,
"user_name": user_name,
"admin_username": current_admin["username"],
}
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
except HTTPException:
raise
except Exception as e:
auth_db.rollback()
error_str = str(e)
# Check for permission errors
if "permission denied" in error_str.lower() or "insufficient privilege" in error_str.lower():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Permission denied: The database user does not have DELETE permission on the users table. Please contact your database administrator.",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete auth user: {error_str}",
)

1026
backend/api/faces.py Normal file

File diff suppressed because it is too large Load Diff

14
backend/api/health.py Normal file
View File

@ -0,0 +1,14 @@
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health_check() -> dict[str, str]:
"""Basic health endpoint."""
return {"status": "ok"}

263
backend/api/jobs.py Normal file
View File

@ -0,0 +1,263 @@
"""Job management endpoints."""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import StreamingResponse
from rq import Queue
from rq.job import Job
from redis import Redis
import json
import time
from backend.schemas.jobs import JobResponse, JobStatus
router = APIRouter(prefix="/jobs", tags=["jobs"])
# Redis connection for RQ
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
queue = Queue(connection=redis_conn)
@router.get("/{job_id}", response_model=JobResponse)
def get_job(job_id: str) -> JobResponse:
"""Get job status by ID."""
try:
job = Job.fetch(job_id, connection=redis_conn)
rq_status = job.get_status()
status_map = {
"queued": JobStatus.PENDING,
"started": JobStatus.STARTED, # Job is actively running
"finished": JobStatus.SUCCESS,
"failed": JobStatus.FAILURE,
}
job_status = status_map.get(rq_status, JobStatus.PENDING)
# If job is started, check if it has progress
if rq_status == "started":
# Job is running - show progress if available
progress = job.meta.get("progress", 0) if job.meta else 0
message = job.meta.get("message", "Processing...") if job.meta else "Processing..."
# Map to PROGRESS status if we have actual progress
if progress > 0:
job_status = JobStatus.PROGRESS
elif job_status == JobStatus.STARTED or job_status == JobStatus.PROGRESS:
progress = job.meta.get("progress", 0) if job.meta else 0
elif job_status == JobStatus.SUCCESS:
progress = 100
else:
progress = 0
message = job.meta.get("message", "") if job.meta else ""
# Check if job was cancelled
if job.meta and job.meta.get("cancelled", False):
# If job finished gracefully after cancellation, mark as CANCELLED
if rq_status == "finished":
job_status = JobStatus.CANCELLED
# If still running, show current status but with cancellation message
elif rq_status == "started":
job_status = JobStatus.PROGRESS if progress > 0 else JobStatus.STARTED
else:
job_status = JobStatus.CANCELLED
message = job.meta.get("message", "Cancelled by user")
# If job failed, include error message
if rq_status == "failed" and job.exc_info:
# Extract error message from exception info
error_lines = job.exc_info.split("\n")
if error_lines:
message = f"Failed: {error_lines[0]}"
return JobResponse(
id=job.id,
status=job_status,
progress=progress,
message=message,
created_at=datetime.fromisoformat(str(job.created_at)),
updated_at=datetime.fromisoformat(
str(job.ended_at or job.started_at or job.created_at)
),
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job {job_id} not found: {str(e)}",
)
@router.get("/stream/{job_id}")
def stream_job_progress(job_id: str):
"""Stream job progress via Server-Sent Events (SSE)."""
def event_generator():
"""Generate SSE events for job progress."""
last_progress = -1
last_message = ""
while True:
try:
job = Job.fetch(job_id, connection=redis_conn)
rq_status = job.get_status()
status_map = {
"queued": JobStatus.PENDING,
"started": JobStatus.STARTED,
"finished": JobStatus.SUCCESS,
"failed": JobStatus.FAILURE,
}
job_status = status_map.get(rq_status, JobStatus.PENDING)
# Check if job was cancelled - this takes priority
if job.meta and job.meta.get("cancelled", False):
# If job is finished and was cancelled, it completed gracefully
if rq_status == "finished":
job_status = JobStatus.CANCELLED
progress = job.meta.get("progress", 0) if job.meta else 0
# If job is still running but cancellation was requested, keep it as PROGRESS/STARTED
# until it actually stops
elif rq_status == "started":
# Job is still running - let it finish current photo
progress = job.meta.get("progress", 0) if job.meta else 0
job_status = JobStatus.PROGRESS if progress > 0 else JobStatus.STARTED
else:
job_status = JobStatus.CANCELLED
progress = job.meta.get("progress", 0) if job.meta else 0
message = job.meta.get("message", "Cancelled by user")
else:
progress = 0
if job_status == JobStatus.STARTED:
# Job is running - show progress if available
progress = job.meta.get("progress", 0) if job.meta else 0
# Map to PROGRESS status if we have actual progress
if progress > 0:
job_status = JobStatus.PROGRESS
elif job_status == JobStatus.PROGRESS:
progress = job.meta.get("progress", 0) if job.meta else 0
elif job_status == JobStatus.SUCCESS:
progress = 100
elif job_status == JobStatus.FAILURE:
progress = 0
message = job.meta.get("message", "") if job.meta else ""
# Only send event if progress or message changed
if progress != last_progress or message != last_message:
event_data = {
"id": job.id,
"status": job_status.value,
"progress": progress,
"message": message,
"processed": job.meta.get("processed", 0) if job.meta else 0,
"total": job.meta.get("total", 0) if job.meta else 0,
"faces_detected": job.meta.get("faces_detected", 0) if job.meta else 0,
"faces_stored": job.meta.get("faces_stored", 0) if job.meta else 0,
}
yield f"data: {json.dumps(event_data)}\n\n"
last_progress = progress
last_message = message
# Stop streaming if job is complete, failed, or cancelled
if job_status in (JobStatus.SUCCESS, JobStatus.FAILURE, JobStatus.CANCELLED):
break
time.sleep(0.5) # Poll every 500ms
except Exception as e:
error_data = {"error": str(e)}
yield f"data: {json.dumps(error_data)}\n\n"
break
return StreamingResponse(
event_generator(), media_type="text/event-stream"
)
@router.delete("/{job_id}")
def cancel_job(job_id: str) -> dict:
"""Cancel a job (if queued) or stop a running job.
Note: For running jobs, this sets a cancellation flag.
The job will check this flag and exit gracefully.
"""
try:
print(f"[Jobs API] Cancel request for job_id={job_id}")
job = Job.fetch(job_id, connection=redis_conn)
rq_status = job.get_status()
print(f"[Jobs API] Job {job_id} current status: {rq_status}")
if rq_status == "finished":
return {
"message": f"Job {job_id} is already finished",
"status": "finished",
}
if rq_status == "failed":
return {
"message": f"Job {job_id} already failed",
"status": "failed",
}
if rq_status == "queued":
# Cancel queued job - remove from queue
job.cancel()
print(f"[Jobs API] ✓ Cancelled queued job {job_id}")
return {
"message": f"Job {job_id} cancelled (was queued)",
"status": "cancelled",
}
if rq_status == "started":
# For running jobs, set cancellation flag in metadata
# The task will check this and exit gracefully
print(f"[Jobs API] Setting cancellation flag for running job {job_id}")
if job.meta is None:
job.meta = {}
job.meta["cancelled"] = True
job.meta["message"] = "Cancellation requested..."
# CRITICAL: Save metadata immediately and verify it was saved
job.save_meta()
print(f"[Jobs API] Saved metadata for job {job_id}")
# Verify the flag was saved by fetching fresh
try:
fresh_job = Job.fetch(job_id, connection=redis_conn)
if not fresh_job.meta or not fresh_job.meta.get("cancelled", False):
print(f"[Jobs API] ❌ WARNING: Cancellation flag NOT found after save for job {job_id}")
print(f"[Jobs API] Fresh job meta: {fresh_job.meta}")
else:
print(f"[Jobs API] ✓ Verified: Cancellation flag is set for job {job_id}")
except Exception as verify_error:
print(f"[Jobs API] ⚠️ Could not verify cancellation flag: {verify_error}")
# Also try to cancel the job (which will interrupt it if possible)
# This sends a signal to the worker process
try:
job.cancel()
print(f"[Jobs API] ✓ RQ cancel() called for job {job_id}")
except Exception as cancel_error:
# Job might already be running, that's OK - metadata flag will be checked
print(f"[Jobs API] Note: RQ cancel() raised exception (may be expected): {cancel_error}")
return {
"message": f"Job {job_id} cancellation requested",
"status": "cancelling",
}
print(f"[Jobs API] Job {job_id} in unexpected status: {rq_status}")
return {
"message": f"Job {job_id} status: {rq_status}",
"status": rq_status,
}
except Exception as e:
print(f"[Jobs API] ❌ Error cancelling job {job_id}: {e}")
import traceback
traceback.print_exc()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job {job_id} not found: {str(e)}",
)

17
backend/api/metrics.py Normal file
View File

@ -0,0 +1,17 @@
"""Metrics endpoint."""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter()
@router.get("/metrics")
def get_metrics() -> dict:
"""Basic metrics endpoint - placeholder for Phase 1."""
return {
"status": "ok",
"message": "Metrics endpoint - to be enhanced in future phases",
}

View File

@ -0,0 +1,542 @@
"""Pending identifications endpoints for approval workflow."""
from __future__ import annotations
from datetime import date, datetime
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import text, func
from sqlalchemy.orm import Session
from backend.constants.roles import DEFAULT_USER_ROLE
from backend.db.session import get_auth_db, get_db
from backend.db.models import Face, Person, PersonEncoding, User
from backend.api.users import get_current_admin_user, require_feature_permission
from backend.utils.password import hash_password
router = APIRouter(prefix="/pending-identifications", tags=["pending-identifications"])
def get_or_create_frontend_user(db: Session) -> User:
"""Get or create the special 'FrontEndUser' system user.
This user represents identifications made through the frontend approval UI,
distinguishing them from direct user identifications.
"""
FRONTEND_USERNAME = "FrontEndUser"
# Try to get existing user
user = db.query(User).filter(User.username == FRONTEND_USERNAME).first()
if user:
return user
# Create the system user if it doesn't exist
# Use a non-loginable password hash (random, won't be used for login)
default_password_hash = hash_password("system_user_not_for_login")
user = User(
username=FRONTEND_USERNAME,
password_hash=default_password_hash,
email="frontend@punimtag.system",
full_name="Frontend System User",
is_active=False, # Not an active user, just a system marker
is_admin=False,
role=DEFAULT_USER_ROLE,
password_change_required=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
class PendingIdentificationResponse(BaseModel):
"""Pending identification DTO returned from API."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
face_id: int
photo_id: Optional[int] = None
user_id: int
user_name: Optional[str] = None
user_email: str
first_name: str
last_name: str
middle_name: Optional[str] = None
maiden_name: Optional[str] = None
date_of_birth: Optional[date] = None
status: str
created_at: str
updated_at: str
class PendingIdentificationsListResponse(BaseModel):
"""List of pending identifications."""
model_config = ConfigDict(protected_namespaces=())
items: list[PendingIdentificationResponse]
total: int
class ApproveDenyDecision(BaseModel):
"""Decision for a single pending identification."""
model_config = ConfigDict(protected_namespaces=())
id: int
decision: str # 'approve' or 'deny'
class ApproveDenyRequest(BaseModel):
"""Request to approve/deny multiple pending identifications."""
model_config = ConfigDict(protected_namespaces=())
decisions: list[ApproveDenyDecision]
class ApproveDenyResponse(BaseModel):
"""Response from approve/deny operation."""
model_config = ConfigDict(protected_namespaces=())
approved: int
denied: int
errors: list[str]
class UserIdentificationStats(BaseModel):
"""Statistics for a single user's identifications."""
model_config = ConfigDict(protected_namespaces=())
user_id: int
username: str
full_name: str
email: str
face_count: int
first_identification_date: Optional[datetime] = None
last_identification_date: Optional[datetime] = None
class IdentificationReportResponse(BaseModel):
"""Response containing identification statistics grouped by user."""
model_config = ConfigDict(protected_namespaces=())
items: list[UserIdentificationStats]
total_faces: int
total_users: int
class ClearDatabaseResponse(BaseModel):
"""Response from clearing denied records."""
model_config = ConfigDict(protected_namespaces=())
deleted_records: int
errors: list[str]
@router.get("", response_model=PendingIdentificationsListResponse)
def list_pending_identifications(
current_user: Annotated[
dict, Depends(require_feature_permission("user_identified"))
],
include_denied: bool = False,
db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
) -> PendingIdentificationsListResponse:
"""List all pending identifications from the auth database.
This endpoint reads from the separate auth database (DATABASE_URL_AUTH)
and returns all pending identifications from the pending_identifications table.
By default, only shows records with status='pending' for approval.
Set include_denied=True to also show denied records.
"""
try:
# Query pending_identifications from auth database using raw SQL
# Join with users table to get user name/email
# Filter by status='pending' to show only records awaiting approval
# Optionally include denied records if include_denied is True
if include_denied:
result = db.execute(text("""
SELECT
pi.id,
pi.face_id,
pi.user_id,
u.name as user_name,
u.email as user_email,
pi.first_name,
pi.last_name,
pi.middle_name,
pi.maiden_name,
pi.date_of_birth,
pi.status,
pi.created_at,
pi.updated_at
FROM pending_identifications pi
LEFT JOIN users u ON pi.user_id = u.id
WHERE pi.status IN ('pending', 'denied')
ORDER BY pi.status ASC, pi.last_name ASC, pi.first_name ASC, pi.created_at DESC
"""))
else:
result = db.execute(text("""
SELECT
pi.id,
pi.face_id,
pi.user_id,
u.name as user_name,
u.email as user_email,
pi.first_name,
pi.last_name,
pi.middle_name,
pi.maiden_name,
pi.date_of_birth,
pi.status,
pi.created_at,
pi.updated_at
FROM pending_identifications pi
LEFT JOIN users u ON pi.user_id = u.id
WHERE pi.status = 'pending'
ORDER BY pi.last_name ASC, pi.first_name ASC, pi.created_at DESC
"""))
rows = result.fetchall()
items = []
for row in rows:
# Get photo_id from main database
photo_id = None
face = main_db.query(Face).filter(Face.id == row.face_id).first()
if face:
photo_id = face.photo_id
items.append(PendingIdentificationResponse(
id=row.id,
face_id=row.face_id,
photo_id=photo_id,
user_id=row.user_id,
user_name=row.user_name,
user_email=row.user_email,
first_name=row.first_name,
last_name=row.last_name,
middle_name=row.middle_name,
maiden_name=row.maiden_name,
date_of_birth=row.date_of_birth,
status=row.status,
created_at=str(row.created_at) if row.created_at else '',
updated_at=str(row.updated_at) if row.updated_at else '',
))
return PendingIdentificationsListResponse(items=items, total=len(items))
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error reading from auth database: {str(e)}"
)
@router.post("/approve-deny", response_model=ApproveDenyResponse)
def approve_deny_pending_identifications(
current_user: Annotated[
dict, Depends(require_feature_permission("user_identified"))
],
request: ApproveDenyRequest,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
) -> ApproveDenyResponse:
"""Approve or deny pending identifications.
For approved identifications:
- Updates status in auth database to 'approved'
- Identifies the face in main database
- Creates person if needed
For denied identifications:
- Updates status in auth database to 'denied'
"""
approved_count = 0
denied_count = 0
errors = []
for decision in request.decisions:
try:
# Get pending identification from auth database
# Allow processing of both 'pending' and 'denied' status (to allow re-approval)
result = auth_db.execute(text("""
SELECT
pi.id,
pi.face_id,
pi.first_name,
pi.last_name,
pi.middle_name,
pi.maiden_name,
pi.date_of_birth
FROM pending_identifications pi
WHERE pi.id = :id AND pi.status IN ('pending', 'denied')
"""), {"id": decision.id})
row = result.fetchone()
if not row:
errors.append(f"Pending identification {decision.id} not found or already processed")
continue
if decision.decision == 'approve':
# Identify the face in main database
face = main_db.query(Face).filter(Face.id == row.face_id).first()
if not face:
errors.append(f"Face {row.face_id} not found in main database")
# Still update status to denied since we can't process it
auth_db.execute(text("""
UPDATE pending_identifications
SET status = 'denied', updated_at = :updated_at
WHERE id = :id
"""), {"id": decision.id, "updated_at": datetime.utcnow()})
auth_db.commit()
denied_count += 1
continue
# Check if person already exists (by name and DOB)
# Match the unique constraint: first_name, last_name, middle_name, maiden_name, date_of_birth
# Build query with proper None handling
query = main_db.query(Person).filter(
Person.first_name == row.first_name,
Person.last_name == row.last_name,
)
# Handle optional fields - use IS NULL for None values
if row.middle_name:
query = query.filter(Person.middle_name == row.middle_name)
else:
query = query.filter(Person.middle_name.is_(None))
if row.maiden_name:
query = query.filter(Person.maiden_name == row.maiden_name)
else:
query = query.filter(Person.maiden_name.is_(None))
if row.date_of_birth:
query = query.filter(Person.date_of_birth == row.date_of_birth)
else:
query = query.filter(Person.date_of_birth.is_(None))
person = query.first()
# Create person if doesn't exist
created_person = False
if not person:
# Explicitly set created_date to ensure it's a valid datetime object
person = Person(
first_name=row.first_name,
last_name=row.last_name,
middle_name=row.middle_name,
maiden_name=row.maiden_name,
date_of_birth=row.date_of_birth,
created_date=datetime.utcnow(),
)
main_db.add(person)
main_db.flush() # get person.id
created_person = True
# Link face to person
# Use FrontEndUser to indicate this was approved through the frontend UI
frontend_user = get_or_create_frontend_user(main_db)
face.person_id = person.id
face.identified_by_user_id = frontend_user.id
main_db.add(face)
# Insert person_encoding
pe = PersonEncoding(
person_id=person.id,
face_id=face.id,
encoding=face.encoding,
quality_score=face.quality_score,
detector_backend=face.detector_backend,
model_name=face.model_name,
)
main_db.add(pe)
main_db.commit()
# Update status in auth database
auth_db.execute(text("""
UPDATE pending_identifications
SET status = 'approved', updated_at = :updated_at
WHERE id = :id
"""), {"id": decision.id, "updated_at": datetime.utcnow()})
auth_db.commit()
approved_count += 1
elif decision.decision == 'deny':
# Update status to denied
auth_db.execute(text("""
UPDATE pending_identifications
SET status = 'denied', updated_at = :updated_at
WHERE id = :id
"""), {"id": decision.id, "updated_at": datetime.utcnow()})
auth_db.commit()
denied_count += 1
else:
errors.append(f"Invalid decision '{decision.decision}' for pending identification {decision.id}")
except Exception as e:
errors.append(f"Error processing pending identification {decision.id}: {str(e)}")
# Rollback any partial changes
main_db.rollback()
auth_db.rollback()
return ApproveDenyResponse(
approved=approved_count,
denied=denied_count,
errors=errors
)
@router.get("/report", response_model=IdentificationReportResponse)
def get_identification_report(
current_user: Annotated[
dict, Depends(require_feature_permission("user_identified"))
],
date_from: Optional[str] = Query(None, description="Filter by identification date (from) - YYYY-MM-DD"),
date_to: Optional[str] = Query(None, description="Filter by identification date (to) - YYYY-MM-DD"),
main_db: Session = Depends(get_db),
) -> IdentificationReportResponse:
"""Get identification statistics grouped by user.
Shows how many faces each user identified and when.
Can be filtered by date range using PersonEncoding.created_date.
"""
# Query faces that have been identified (have person_id and identified_by_user_id)
# Join with PersonEncoding to get created_date (when face was identified)
# Join with User to get user information
# Use distinct count to avoid counting the same face multiple times
# (in case person encodings were updated, creating multiple PersonEncoding records)
query = (
main_db.query(
User.id.label('user_id'),
User.username,
User.full_name,
User.email,
func.count(func.distinct(Face.id)).label('face_count'),
func.min(PersonEncoding.created_date).label('first_date'),
func.max(PersonEncoding.created_date).label('last_date')
)
.join(Face, User.id == Face.identified_by_user_id)
.join(PersonEncoding, Face.id == PersonEncoding.face_id)
.filter(Face.person_id.isnot(None))
.filter(Face.identified_by_user_id.isnot(None))
.group_by(User.id, User.username, User.full_name, User.email)
)
# Apply date filtering if provided (filter before grouping)
if date_from:
try:
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d").date()
query = query.filter(func.date(PersonEncoding.created_date) >= date_from_obj)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid date_from format. Use YYYY-MM-DD"
)
if date_to:
try:
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d").date()
query = query.filter(func.date(PersonEncoding.created_date) <= date_to_obj)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid date_to format. Use YYYY-MM-DD"
)
# Execute query and get results
results = query.order_by(func.count(Face.id).desc(), User.username.asc()).all()
# Convert to response model
items = []
total_faces = 0
for row in results:
total_faces += row.face_count
items.append(
UserIdentificationStats(
user_id=row.user_id,
username=row.username,
full_name=row.full_name or row.username,
email=row.email,
face_count=row.face_count,
first_identification_date=row.first_date,
last_identification_date=row.last_date,
)
)
return IdentificationReportResponse(
items=items,
total_faces=total_faces,
total_users=len(items)
)
@router.post("/clear-denied", response_model=ClearDatabaseResponse)
def clear_denied_identifications(
current_admin: dict = Depends(get_current_admin_user),
auth_db: Session = Depends(get_auth_db),
) -> ClearDatabaseResponse:
"""Delete all denied pending identifications from the database.
This permanently removes all records with status='denied' from the
pending_identifications table in the auth database.
"""
deleted_records = 0
errors = []
try:
# First check if there are any denied records
check_result = auth_db.execute(text("""
SELECT COUNT(*) as count FROM pending_identifications
WHERE status = 'denied'
"""))
denied_count = check_result.fetchone().count if check_result else 0
if denied_count == 0:
# No denied records to delete
return ClearDatabaseResponse(
deleted_records=0,
errors=[]
)
# Delete all denied records
result = auth_db.execute(text("""
DELETE FROM pending_identifications
WHERE status = 'denied'
"""))
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
auth_db.commit()
if deleted_records == 0 and denied_count > 0:
errors.append("No records were deleted despite finding denied records")
except Exception as e:
auth_db.rollback()
error_msg = str(e)
errors.append(f"Error deleting denied records: {error_msg}")
# Check if it's a permission error
if "permission denied" in error_msg.lower() or "insufficient privilege" in error_msg.lower():
errors.append(
"Database permission error. Please run this command manually:\n"
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_identifications TO punimtag;\""
)
return ClearDatabaseResponse(
deleted_records=deleted_records,
errors=errors
)

View File

@ -0,0 +1,450 @@
"""Pending linkage review endpoints - admin only."""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Optional, Union
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
from backend.api.users import require_feature_permission
from backend.db.models import Photo, PhotoTagLinkage, Tag
from backend.db.session import get_auth_db, get_db
router = APIRouter(prefix="/pending-linkages", tags=["pending-linkages"])
def _get_or_create_tag_by_name(db: Session, tag_name: str) -> tuple[Tag, bool]:
"""Return a tag for the provided name, creating it if necessary."""
normalized = (tag_name or "").strip()
if not normalized:
raise ValueError("Tag name cannot be empty")
existing = (
db.query(Tag)
.filter(Tag.tag_name.ilike(normalized))
.first()
)
if existing:
return existing, False
tag = Tag(tag_name=normalized)
db.add(tag)
db.flush()
return tag, True
def _format_datetime(value: Union[str, datetime, None]) -> Optional[str]:
"""Safely serialize datetime values returned from different drivers."""
if value is None:
return None
if isinstance(value, str):
return value
if isinstance(value, datetime):
return value.isoformat()
return str(value)
class PendingLinkageResponse(BaseModel):
"""Pending linkage DTO returned from API."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
photo_id: int
tag_id: Optional[int] = None
proposed_tag_name: Optional[str] = None
resolved_tag_name: Optional[str] = None
user_id: int
user_name: Optional[str] = None
user_email: Optional[str] = None
status: str
notes: Optional[str] = None
created_at: str
updated_at: Optional[str] = None
photo_filename: Optional[str] = None
photo_path: Optional[str] = None
photo_media_type: Optional[str] = None
photo_tags: list[str] = Field(default_factory=list)
class PendingLinkagesListResponse(BaseModel):
"""List of pending linkage rows."""
model_config = ConfigDict(protected_namespaces=())
items: list[PendingLinkageResponse]
total: int
class ReviewDecision(BaseModel):
"""Decision payload for a pending linkage row."""
model_config = ConfigDict(protected_namespaces=())
id: int
decision: str # 'approve' or 'deny'
class ReviewRequest(BaseModel):
"""Request payload for reviewing pending linkages."""
model_config = ConfigDict(protected_namespaces=())
decisions: list[ReviewDecision]
class ReviewResponse(BaseModel):
"""Review summary returned after processing decisions."""
model_config = ConfigDict(protected_namespaces=())
approved: int
denied: int
tags_created: int
linkages_created: int
errors: list[str]
@router.get("", response_model=PendingLinkagesListResponse)
def list_pending_linkages(
current_user: Annotated[
dict, Depends(require_feature_permission("user_tagged"))
],
status_filter: Annotated[
Optional[str],
Query(
description="Optional status filter: pending, approved, or denied."
),
] = None,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
) -> PendingLinkagesListResponse:
"""List all pending linkages stored in the auth database."""
valid_statuses = {"pending", "approved", "denied"}
if status_filter and status_filter not in valid_statuses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid status_filter. Use pending, approved, or denied.",
)
try:
params = {}
status_clause = ""
if status_filter:
status_clause = "WHERE pl.status = :status_filter"
params["status_filter"] = status_filter
result = auth_db.execute(
text(
f"""
SELECT
pl.id,
pl.photo_id,
pl.tag_id,
pl.tag_name,
pl.user_id,
pl.status,
pl.notes,
pl.created_at,
pl.updated_at,
u.name AS user_name,
u.email AS user_email
FROM pending_linkages pl
LEFT JOIN users u ON pl.user_id = u.id
{status_clause}
ORDER BY pl.created_at DESC
"""
),
params,
)
rows = result.fetchall()
photo_ids = {row.photo_id for row in rows if row.photo_id}
tag_ids = {row.tag_id for row in rows if row.tag_id}
photo_map: dict[int, Photo] = {}
if photo_ids:
photos = (
main_db.query(Photo)
.filter(Photo.id.in_(photo_ids))
.all()
)
photo_map = {photo.id: photo for photo in photos}
tag_map: dict[int, str] = {}
if tag_ids:
tags = (
main_db.query(Tag)
.filter(Tag.id.in_(tag_ids))
.all()
)
tag_map = {tag.id: tag.tag_name for tag in tags}
photo_tags_map: dict[int, list[str]] = {
photo_id: [] for photo_id in photo_ids
}
if photo_ids:
tag_rows = (
main_db.query(PhotoTagLinkage.photo_id, Tag.tag_name)
.join(Tag, Tag.id == PhotoTagLinkage.tag_id)
.filter(PhotoTagLinkage.photo_id.in_(photo_ids))
.all()
)
for photo_id, tag_name in tag_rows:
photo_tags_map.setdefault(photo_id, []).append(tag_name)
items: list[PendingLinkageResponse] = []
for row in rows:
created_at = _format_datetime(getattr(row, "created_at", None)) or ""
updated_at = _format_datetime(getattr(row, "updated_at", None))
photo = photo_map.get(row.photo_id)
resolved_tag_name = None
if row.tag_id:
resolved_tag_name = tag_map.get(row.tag_id)
proposal_name = row.tag_name
items.append(
PendingLinkageResponse(
id=row.id,
photo_id=row.photo_id,
tag_id=row.tag_id,
proposed_tag_name=proposal_name,
resolved_tag_name=resolved_tag_name or proposal_name,
user_id=row.user_id,
user_name=row.user_name,
user_email=row.user_email,
status=row.status,
notes=row.notes,
created_at=created_at,
updated_at=updated_at,
photo_filename=photo.filename if photo else None,
photo_path=photo.path if photo else None,
photo_media_type=photo.media_type if photo else None,
photo_tags=photo_tags_map.get(row.photo_id, []),
)
)
return PendingLinkagesListResponse(items=items, total=len(items))
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error reading pending linkages: {exc}",
)
@router.post("/review", response_model=ReviewResponse)
def review_pending_linkages(
current_user: Annotated[
dict, Depends(require_feature_permission("user_tagged"))
],
request: ReviewRequest,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
) -> ReviewResponse:
"""Approve or deny pending user-proposed tag linkages."""
approved = 0
denied = 0
tags_created = 0
linkages_created = 0
errors: list[str] = []
now = datetime.utcnow()
for decision in request.decisions:
try:
row = auth_db.execute(
text(
"""
SELECT id, photo_id, tag_id, tag_name, status
FROM pending_linkages
WHERE id = :id
"""
),
{"id": decision.id},
).fetchone()
if not row:
errors.append(
f"Pending linkage {decision.id} not found or already deleted"
)
continue
if row.status != "pending":
errors.append(
f"Pending linkage {decision.id} cannot be reviewed (status={row.status})"
)
continue
if decision.decision == "deny":
auth_db.execute(
text(
"""
UPDATE pending_linkages
SET status = 'denied',
updated_at = :updated_at
WHERE id = :id
"""
),
{"id": decision.id, "updated_at": now},
)
auth_db.commit()
denied += 1
continue
if decision.decision != "approve":
errors.append(
f"Invalid decision '{decision.decision}' for linkage {decision.id}"
)
continue
photo = (
main_db.query(Photo)
.filter(Photo.id == row.photo_id)
.first()
)
if not photo:
errors.append(
f"Photo {row.photo_id} not found for linkage {decision.id}"
)
continue
tag_obj: Optional[Tag] = None
created_tag = False
if row.tag_id:
tag_obj = (
main_db.query(Tag)
.filter(Tag.id == row.tag_id)
.first()
)
if not tag_obj and row.tag_name:
tag_obj, created_tag = _get_or_create_tag_by_name(
main_db, row.tag_name
)
elif not tag_obj:
errors.append(
f"Tag {row.tag_id} missing for linkage {decision.id}"
)
continue
else:
if not row.tag_name:
errors.append(
f"No tag information provided for linkage {decision.id}"
)
continue
tag_obj, created_tag = _get_or_create_tag_by_name(
main_db, row.tag_name
)
if created_tag:
tags_created += 1
resolved_tag_id = tag_obj.id # type: ignore[union-attr]
existing_linkage = (
main_db.query(PhotoTagLinkage)
.filter(
PhotoTagLinkage.photo_id == row.photo_id,
PhotoTagLinkage.tag_id == resolved_tag_id,
)
.first()
)
if not existing_linkage:
linkage = PhotoTagLinkage(
photo_id=row.photo_id,
tag_id=resolved_tag_id,
)
main_db.add(linkage)
linkages_created += 1
main_db.commit()
auth_db.execute(
text(
"""
UPDATE pending_linkages
SET status = 'approved',
tag_id = :tag_id,
updated_at = :updated_at
WHERE id = :id
"""
),
{
"id": decision.id,
"tag_id": resolved_tag_id,
"updated_at": now,
},
)
auth_db.commit()
approved += 1
except ValueError as exc:
main_db.rollback()
auth_db.rollback()
errors.append(f"Validation error for linkage {decision.id}: {exc}")
except Exception as exc:
main_db.rollback()
auth_db.rollback()
errors.append(f"Error processing linkage {decision.id}: {exc}")
return ReviewResponse(
approved=approved,
denied=denied,
tags_created=tags_created,
linkages_created=linkages_created,
errors=errors,
)
class CleanupResponse(BaseModel):
"""Response payload for cleanup operations."""
model_config = ConfigDict(protected_namespaces=())
deleted_records: int
errors: list[str]
warnings: list[str] = []
@router.post("/cleanup", response_model=CleanupResponse)
def cleanup_pending_linkages(
current_user: Annotated[
dict, Depends(require_feature_permission("user_tagged"))
],
auth_db: Session = Depends(get_auth_db),
) -> CleanupResponse:
"""Delete all approved or denied records from pending_linkages table."""
warnings: list[str] = []
try:
result = auth_db.execute(
text(
"""
DELETE FROM pending_linkages
WHERE status IN ('approved', 'denied')
"""
)
)
deleted_records = result.rowcount if hasattr(result, "rowcount") else 0
auth_db.commit()
if deleted_records == 0:
warnings.append("No approved or denied pending linkages to delete.")
return CleanupResponse(
deleted_records=deleted_records,
errors=[],
warnings=warnings,
)
except Exception as exc:
auth_db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to cleanup pending linkages: {exc}",
)

View File

@ -0,0 +1,719 @@
"""Pending photos endpoints - admin only."""
from __future__ import annotations
import os
from datetime import datetime
from typing import Annotated, Optional
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import text
from sqlalchemy.orm import Session
from backend.db.session import get_auth_db, get_db
from backend.api.users import get_current_admin_user, require_feature_permission
from backend.api.auth import get_current_user
from backend.services.photo_service import import_photo_from_path, calculate_file_hash
from backend.settings import PHOTO_STORAGE_DIR
router = APIRouter(prefix="/pending-photos", tags=["pending-photos"])
class PendingPhotoResponse(BaseModel):
"""Pending photo DTO returned from API."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
user_id: int
user_name: Optional[str] = None
user_email: Optional[str] = None
filename: str
original_filename: str
file_path: str
file_size: int
mime_type: str
status: str
submitted_at: str
reviewed_at: Optional[str] = None
reviewed_by: Optional[int] = None
rejection_reason: Optional[str] = None
class PendingPhotosListResponse(BaseModel):
"""List of pending photos."""
model_config = ConfigDict(protected_namespaces=())
items: list[PendingPhotoResponse]
total: int
class ReviewDecision(BaseModel):
"""Decision for a single pending photo."""
model_config = ConfigDict(protected_namespaces=())
id: int
decision: str # 'approve' or 'reject'
rejection_reason: Optional[str] = None
class ReviewRequest(BaseModel):
"""Request to review multiple pending photos."""
model_config = ConfigDict(protected_namespaces=())
decisions: list[ReviewDecision]
class ReviewResponse(BaseModel):
"""Response from review operation."""
model_config = ConfigDict(protected_namespaces=())
approved: int
rejected: int
errors: list[str]
warnings: list[str] = [] # Informational messages (e.g., duplicates)
@router.get("", response_model=PendingPhotosListResponse)
def list_pending_photos(
current_user: Annotated[
dict, Depends(require_feature_permission("user_uploaded"))
],
status_filter: Optional[str] = None,
auth_db: Session = Depends(get_auth_db),
) -> PendingPhotosListResponse:
"""List all pending photos from the auth database.
This endpoint reads from the separate auth database (DATABASE_URL_AUTH)
and returns all pending photos from the pending_photos table.
Optionally filter by status: 'pending', 'approved', or 'rejected'.
"""
try:
# Query pending_photos from auth database using raw SQL
# Join with users table to get user name/email
if status_filter:
result = auth_db.execute(text("""
SELECT
pp.id,
pp.user_id,
u.name as user_name,
u.email as user_email,
pp.filename,
pp.original_filename,
pp.file_path,
pp.file_size,
pp.mime_type,
pp.status,
pp.submitted_at,
pp.reviewed_at,
pp.reviewed_by,
pp.rejection_reason
FROM pending_photos pp
LEFT JOIN users u ON pp.user_id = u.id
WHERE pp.status = :status_filter
ORDER BY pp.submitted_at DESC
"""), {"status_filter": status_filter})
else:
result = auth_db.execute(text("""
SELECT
pp.id,
pp.user_id,
u.name as user_name,
u.email as user_email,
pp.filename,
pp.original_filename,
pp.file_path,
pp.file_size,
pp.mime_type,
pp.status,
pp.submitted_at,
pp.reviewed_at,
pp.reviewed_by,
pp.rejection_reason
FROM pending_photos pp
LEFT JOIN users u ON pp.user_id = u.id
ORDER BY pp.submitted_at DESC
"""))
rows = result.fetchall()
items = []
for row in rows:
items.append(PendingPhotoResponse(
id=row.id,
user_id=row.user_id,
user_name=row.user_name,
user_email=row.user_email,
filename=row.filename,
original_filename=row.original_filename,
file_path=row.file_path,
file_size=row.file_size,
mime_type=row.mime_type,
status=row.status,
submitted_at=str(row.submitted_at) if row.submitted_at else '',
reviewed_at=str(row.reviewed_at) if row.reviewed_at else None,
reviewed_by=row.reviewed_by,
rejection_reason=row.rejection_reason,
))
return PendingPhotosListResponse(items=items, total=len(items))
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error reading from auth database: {str(e)}"
)
@router.get("/{photo_id}/image")
def get_pending_photo_image(
photo_id: int,
current_user: Annotated[dict, Depends(get_current_user)],
auth_db: Session = Depends(get_auth_db),
) -> FileResponse:
"""Get the image file for a pending photo.
Photos are stored in /mnt/db-server-uploads. The file_path in the database
may be relative (just filename) or absolute. This function handles both cases.
"""
import os
try:
result = auth_db.execute(text("""
SELECT file_path, mime_type, filename
FROM pending_photos
WHERE id = :id
"""), {"id": photo_id})
row = result.fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Pending photo {photo_id} not found"
)
# Base directory for uploaded photos
base_dir = Path("/mnt/db-server-uploads")
# Handle both absolute and relative paths
db_file_path = row.file_path
if os.path.isabs(db_file_path):
# Absolute path - use as is
file_path = Path(db_file_path)
else:
# Relative path - prepend base directory
file_path = base_dir / db_file_path
# If file doesn't exist at constructed path, try just the filename
if not file_path.exists():
# Try with just the filename from database
file_path = base_dir / row.filename
if not file_path.exists():
# Try with original_filename if available
result2 = auth_db.execute(text("""
SELECT original_filename
FROM pending_photos
WHERE id = :id
"""), {"id": photo_id})
row2 = result2.fetchone()
if row2 and row2.original_filename:
file_path = base_dir / row2.original_filename
if not file_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Photo file not found at {file_path}"
)
return FileResponse(
path=str(file_path),
media_type=row.mime_type or "image/jpeg",
filename=file_path.name
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving photo: {str(e)}"
)
@router.post("/review", response_model=ReviewResponse)
def review_pending_photos(
current_user: Annotated[
dict, Depends(require_feature_permission("user_uploaded"))
],
request: ReviewRequest,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
) -> ReviewResponse:
"""Review pending photos - approve or reject them.
For 'approve' decision:
- Moves photo file from /mnt/db-server-uploads to main photo storage
- Imports photo into main database (Scan process)
- Updates status in auth database to 'approved'
For 'reject' decision:
- Updates status in auth database to 'rejected'
- Photo file remains in place (can be deleted later if needed)
"""
import shutil
import uuid
approved_count = 0
rejected_count = 0
duplicate_count = 0
errors = []
admin_user_id = current_user.get("user_id")
now = datetime.utcnow()
# Base directories
# Try to get upload directory from environment, fallback to hardcoded path
upload_base_dir = Path(os.getenv("UPLOAD_DIR") or os.getenv("PENDING_PHOTOS_DIR") or "/mnt/db-server-uploads")
main_storage_dir = Path(PHOTO_STORAGE_DIR)
main_storage_dir.mkdir(parents=True, exist_ok=True)
for decision in request.decisions:
try:
# Get pending photo from auth database with file info
# Only allow processing 'pending' status photos
result = auth_db.execute(text("""
SELECT
pp.id,
pp.status,
pp.file_path,
pp.filename,
pp.original_filename
FROM pending_photos pp
WHERE pp.id = :id AND pp.status = 'pending'
"""), {"id": decision.id})
row = result.fetchone()
if not row:
errors.append(f"Pending photo {decision.id} not found or already reviewed")
continue
if decision.decision == 'approve':
# Find the source file
db_file_path = row.file_path
source_path = None
# Try to find the file - handle both absolute and relative paths
if os.path.isabs(db_file_path):
# Use absolute path directly
source_path = Path(db_file_path)
else:
# Try relative to upload base directory
source_path = upload_base_dir / db_file_path
# If file doesn't exist, try alternative locations
if not source_path.exists():
# Try with just the filename in upload_base_dir
source_path = upload_base_dir / row.filename
if not source_path.exists() and row.original_filename:
# Try with original filename
source_path = upload_base_dir / row.original_filename
# If still not found, try looking in user subdirectories
if not source_path.exists() and upload_base_dir.exists():
# Check if file_path contains user ID subdirectory
# file_path format might be: {userId}/{filename} or full path
try:
for user_id_dir in upload_base_dir.iterdir():
if user_id_dir.is_dir():
potential_path = user_id_dir / row.filename
if potential_path.exists():
source_path = potential_path
break
if row.original_filename:
potential_path = user_id_dir / row.original_filename
if potential_path.exists():
source_path = potential_path
break
except (PermissionError, OSError) as e:
# Can't read directory, skip this search
pass
if not source_path.exists():
errors.append(f"Photo file not found for pending photo {decision.id}. Tried: {db_file_path}, {upload_base_dir / row.filename}, {upload_base_dir / row.original_filename if row.original_filename else 'N/A'}")
continue
# Calculate file hash and check for duplicates BEFORE moving file
try:
file_hash = calculate_file_hash(str(source_path))
except Exception as e:
errors.append(f"Failed to calculate hash for pending photo {decision.id}: {str(e)}")
continue
# Check if photo with same hash already exists in main database
# Handle case where file_hash column might not exist or be NULL for old photos
try:
existing_photo = main_db.execute(text("""
SELECT id, path FROM photos WHERE file_hash = :file_hash AND file_hash IS NOT NULL
"""), {"file_hash": file_hash}).fetchone()
except Exception as e:
# If file_hash column doesn't exist, skip duplicate check
# This can happen if database schema is outdated
if "no such column" in str(e).lower() or "file_hash" in str(e).lower():
existing_photo = None
else:
raise
if existing_photo:
# Photo already exists - mark as duplicate and skip import
# Don't add to errors - we'll show a summary message instead
# Update status to rejected with duplicate reason
auth_db.execute(text("""
UPDATE pending_photos
SET status = 'rejected',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
rejection_reason = 'Duplicate photo already exists in database'
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
})
auth_db.commit()
rejected_count += 1
duplicate_count += 1
continue
# Generate unique filename for main storage to avoid conflicts
file_ext = source_path.suffix
unique_filename = f"{uuid.uuid4()}{file_ext}"
dest_path = main_storage_dir / unique_filename
# Copy file to main storage (keep original in shared location)
try:
shutil.copy2(str(source_path), str(dest_path))
except Exception as e:
errors.append(f"Failed to copy photo file for {decision.id}: {str(e)}")
continue
# Import photo into main database (Scan process)
# This will also check for duplicates by hash, but we've already checked above
try:
photo, is_new = import_photo_from_path(main_db, str(dest_path))
if not is_new:
# Photo already exists (shouldn't happen due to hash check above, but handle gracefully)
if dest_path.exists():
dest_path.unlink()
errors.append(f"Photo already exists in main database: {photo.path}")
continue
except Exception as e:
# If import fails, delete the copied file (original remains in shared location)
if dest_path.exists():
try:
dest_path.unlink()
except:
pass
errors.append(f"Failed to import photo {decision.id} into main database: {str(e)}")
continue
# Update status to approved in auth database
auth_db.execute(text("""
UPDATE pending_photos
SET status = 'approved',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
})
auth_db.commit()
approved_count += 1
elif decision.decision == 'reject':
# Update status to rejected
auth_db.execute(text("""
UPDATE pending_photos
SET status = 'rejected',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
rejection_reason = :rejection_reason
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
"rejection_reason": decision.rejection_reason or None,
})
auth_db.commit()
rejected_count += 1
else:
errors.append(f"Invalid decision '{decision.decision}' for pending photo {decision.id}")
except Exception as e:
errors.append(f"Error processing pending photo {decision.id}: {str(e)}")
# Rollback any partial changes
auth_db.rollback()
main_db.rollback()
# Add friendly message about duplicates if any were found
warnings = []
if duplicate_count > 0:
if duplicate_count == 1:
warnings.append(f"{duplicate_count} photo was not added as it already exists in the database")
else:
warnings.append(f"{duplicate_count} photos were not added as they already exist in the database")
return ReviewResponse(
approved=approved_count,
rejected=rejected_count,
errors=errors,
warnings=warnings
)
class CleanupResponse(BaseModel):
"""Response from cleanup operation."""
model_config = ConfigDict(protected_namespaces=())
deleted_files: int
deleted_records: int
errors: list[str]
warnings: list[str] = [] # Informational messages (e.g., files already deleted)
@router.post("/cleanup-files", response_model=CleanupResponse)
def cleanup_shared_files(
current_admin: dict = Depends(get_current_admin_user),
status_filter: Optional[str] = Query(None, description="Filter by status: 'approved', 'rejected', or None for both"),
auth_db: Session = Depends(get_auth_db),
) -> CleanupResponse:
"""Delete photo files from shared space for approved or rejected photos.
Args:
status_filter: Optional filter - 'approved', 'rejected', or None for both
"""
deleted_files = 0
errors = []
warnings = []
upload_base_dir = Path("/mnt/db-server-uploads")
# Build query based on status filter
if status_filter:
query = text("""
SELECT id, file_path, filename, original_filename, status
FROM pending_photos
WHERE status = :status_filter
""")
result = auth_db.execute(query, {"status_filter": status_filter})
else:
query = text("""
SELECT id, file_path, filename, original_filename, status
FROM pending_photos
WHERE status IN ('approved', 'rejected')
""")
result = auth_db.execute(query)
rows = result.fetchall()
for row in rows:
try:
# Find the file - handle both absolute and relative paths
db_file_path = row.file_path
file_path = None
if os.path.isabs(db_file_path):
file_path = Path(db_file_path)
else:
file_path = upload_base_dir / db_file_path
# If file doesn't exist, try with filename
if not file_path.exists():
file_path = upload_base_dir / row.filename
if not file_path.exists() and row.original_filename:
file_path = upload_base_dir / row.original_filename
if file_path.exists():
try:
file_path.unlink()
deleted_files += 1
except PermissionError:
errors.append(f"Permission denied deleting file for pending photo {row.id}: {file_path}")
except Exception as e:
errors.append(f"Failed to delete file for pending photo {row.id}: {str(e)}")
else:
# File not found is expected if already deleted - show as warning, not error
warnings.append(f"File already deleted for pending photo {row.id}")
except Exception as e:
errors.append(f"Error processing pending photo {row.id}: {str(e)}")
return CleanupResponse(
deleted_files=deleted_files,
deleted_records=0, # Files only, not records
errors=errors,
warnings=warnings
)
@router.post("/cleanup-database", response_model=CleanupResponse)
def cleanup_pending_photos_database(
current_admin: dict = Depends(get_current_admin_user),
status_filter: Optional[str] = Query(None, description="Filter by status: 'approved', 'rejected', or None for approved+rejected (excludes pending)"),
auth_db: Session = Depends(get_auth_db),
) -> CleanupResponse:
"""Delete records from pending_photos table.
Args:
status_filter: Optional filter - 'approved', 'rejected', or None for approved+rejected (excludes pending)
"""
deleted_records = 0
errors = []
warnings = []
try:
# First check if table exists and has records
if status_filter:
# Check count for specific status
check_result = auth_db.execute(text("""
SELECT COUNT(*) as count FROM pending_photos
WHERE status = :status_filter
"""), {"status_filter": status_filter})
else:
# Check count for approved and rejected (exclude pending)
check_result = auth_db.execute(text("""
SELECT COUNT(*) as count FROM pending_photos
WHERE status IN ('approved', 'rejected')
"""))
total_count = check_result.fetchone().count if check_result else 0
if total_count == 0:
# No records to delete - not an error, just return success
return CleanupResponse(
deleted_files=0,
deleted_records=0,
errors=[],
warnings=[]
)
# Perform deletion
if status_filter:
result = auth_db.execute(text("""
DELETE FROM pending_photos
WHERE status = :status_filter
"""), {"status_filter": status_filter})
else:
# Default behavior: delete only approved and rejected, exclude pending
result = auth_db.execute(text("""
DELETE FROM pending_photos
WHERE status IN ('approved', 'rejected')
"""))
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
auth_db.commit()
if deleted_records == 0 and total_count > 0:
# No records matched the filter - this shouldn't be an error if status_filter was provided
# But if no filter and total_count > 0, something went wrong
if not status_filter:
errors.append(f"Expected to delete {total_count} record(s) but deleted 0. Check database permissions.")
else:
warnings.append(f"No records found matching status filter: {status_filter}")
except Exception as e:
auth_db.rollback()
import traceback
error_details = traceback.format_exc()
# Check if this is a permission error
error_str = str(e)
if "InsufficientPrivilege" in error_str or "permission denied" in error_str.lower():
# Try to automatically grant the permission using sudo (non-interactive)
import subprocess
import os
from urllib.parse import urlparse
try:
# Get database name from connection
auth_db_url = os.getenv("DATABASE_URL_AUTH", "")
if auth_db_url:
# Parse database URL to get database name
if auth_db_url.startswith("postgresql+psycopg2://"):
auth_db_url = auth_db_url.replace("postgresql+psycopg2://", "postgresql://")
parsed = urlparse(auth_db_url)
db_name = parsed.path.lstrip("/")
# Try to grant permission using sudo -n (non-interactive, requires passwordless sudo)
result = subprocess.run(
[
"sudo", "-n", "-u", "postgres", "psql", "-d", db_name,
"-c", "GRANT DELETE ON TABLE pending_photos TO punimtag;"
],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
# Permission granted, try deletion again
try:
if status_filter:
result = auth_db.execute(text("""
DELETE FROM pending_photos
WHERE status = :status_filter
"""), {"status_filter": status_filter})
else:
result = auth_db.execute(text("""
DELETE FROM pending_photos
"""))
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
auth_db.commit()
# Success - return early
return CleanupResponse(
deleted_files=0,
deleted_records=deleted_records,
errors=[],
warnings=[]
)
except Exception as retry_e:
errors.append(f"Permission granted but deletion still failed: {str(retry_e)}")
else:
# Sudo failed (needs password) - provide instructions
errors.append(
"Database permission error. Please run this command manually:\n"
f"sudo -u postgres psql -d {db_name} -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
)
else:
errors.append(
"Database permission error. Please run this command manually:\n"
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
)
except subprocess.TimeoutExpired:
errors.append(
"Database permission error. Please run this command manually:\n"
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
)
except Exception as grant_e:
errors.append(
"Database permission error. Please run this command manually:\n"
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
)
else:
errors.append(f"Failed to delete records from database: {str(e)}")
# Log full traceback for debugging
import logging
logger = logging.getLogger(__name__)
logger.error(f"Cleanup database error: {error_details}")
return CleanupResponse(
deleted_files=0, # Database only, not files
deleted_records=deleted_records,
errors=errors,
warnings=warnings
)

320
backend/api/people.py Normal file
View File

@ -0,0 +1,320 @@
"""People management endpoints."""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from backend.db.session import get_db
from backend.db.models import Person, Face, PersonEncoding, PhotoPersonLinkage, Photo
from backend.api.auth import get_current_user_with_id
from backend.schemas.people import (
PeopleListResponse,
PersonCreateRequest,
PersonResponse,
PersonUpdateRequest,
PersonWithFacesResponse,
PeopleWithFacesListResponse,
)
from backend.schemas.faces import PersonFacesResponse, PersonFaceItem, AcceptMatchesRequest, IdentifyFaceResponse
from backend.services.face_service import accept_auto_match_matches
router = APIRouter(prefix="/people", tags=["people"])
@router.get("", response_model=PeopleListResponse)
def list_people(
last_name: str | None = Query(None, description="Filter by last name (case-insensitive)"),
db: Session = Depends(get_db),
) -> PeopleListResponse:
"""List all people sorted by last_name, first_name.
Optionally filter by last_name if provided (case-insensitive search).
"""
query = db.query(Person)
if last_name:
# Case-insensitive search on last_name
query = query.filter(func.lower(Person.last_name).contains(func.lower(last_name)))
people = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all()
items = [PersonResponse.model_validate(p) for p in people]
return PeopleListResponse(items=items, total=len(items))
@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)"),
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).
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
query = (
db.query(
Person,
func.count(Face.id.distinct()).label('face_count')
)
.outerjoin(Face, Person.id == Face.person_id)
.group_by(Person.id)
)
if last_name:
# Case-insensitive search on both 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)))
)
results = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all()
# Get video counts separately for each person
person_ids = [person.id for person, _ in results]
video_counts = {}
if person_ids:
video_count_query = (
db.query(
PhotoPersonLinkage.person_id,
func.count(PhotoPersonLinkage.id).label('video_count')
)
.join(Photo, PhotoPersonLinkage.photo_id == Photo.id)
.filter(
PhotoPersonLinkage.person_id.in_(person_ids),
Photo.media_type == "video"
)
.group_by(PhotoPersonLinkage.person_id)
)
for person_id, video_count in video_count_query.all():
video_counts[person_id] = video_count
items = [
PersonWithFacesResponse(
id=person.id,
first_name=person.first_name,
last_name=person.last_name,
middle_name=person.middle_name,
maiden_name=person.maiden_name,
date_of_birth=person.date_of_birth,
face_count=face_count or 0, # Convert None to 0 for people with no faces
video_count=video_counts.get(person.id, 0), # Get video count or default to 0
)
for person, face_count in results
]
return PeopleWithFacesListResponse(items=items, total=len(items))
@router.post("", response_model=PersonResponse, status_code=status.HTTP_201_CREATED)
def create_person(request: PersonCreateRequest, db: Session = Depends(get_db)) -> PersonResponse:
"""Create a new person."""
first_name = request.first_name.strip()
last_name = request.last_name.strip()
middle_name = request.middle_name.strip() if request.middle_name else None
maiden_name = request.maiden_name.strip() if request.maiden_name else None
# Explicitly set created_date to ensure it's a valid datetime object
person = Person(
first_name=first_name,
last_name=last_name,
middle_name=middle_name,
maiden_name=maiden_name,
date_of_birth=request.date_of_birth,
created_date=datetime.utcnow(),
)
db.add(person)
try:
db.commit()
except Exception as e:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
db.refresh(person)
return PersonResponse.model_validate(person)
@router.get("/{person_id}", response_model=PersonResponse)
def get_person(person_id: int, db: Session = Depends(get_db)) -> PersonResponse:
"""Get person by ID."""
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
return PersonResponse.model_validate(person)
@router.put("/{person_id}", response_model=PersonResponse)
def update_person(
person_id: int,
request: PersonUpdateRequest,
db: Session = Depends(get_db),
) -> PersonResponse:
"""Update person information."""
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
# Update fields
person.first_name = request.first_name.strip()
person.last_name = request.last_name.strip()
person.middle_name = request.middle_name.strip() if request.middle_name else None
person.maiden_name = request.maiden_name.strip() if request.maiden_name else None
person.date_of_birth = request.date_of_birth
try:
db.commit()
db.refresh(person)
except Exception as e:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
return PersonResponse.model_validate(person)
@router.get("/{person_id}/faces", response_model=PersonFacesResponse)
def get_person_faces(person_id: int, db: Session = Depends(get_db)) -> PersonFacesResponse:
"""Get all faces for a specific person."""
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
from backend.db.models import Photo
faces = (
db.query(Face)
.join(Photo, Face.photo_id == Photo.id)
.filter(Face.person_id == person_id)
.order_by(Photo.filename)
.all()
)
items = [
PersonFaceItem(
id=face.id,
photo_id=face.photo_id,
photo_path=face.photo.path,
photo_filename=face.photo.filename,
location=face.location,
face_confidence=float(face.face_confidence),
quality_score=float(face.quality_score),
detector_backend=face.detector_backend,
model_name=face.model_name,
)
for face in faces
]
return PersonFacesResponse(person_id=person_id, items=items, total=len(items))
@router.get("/{person_id}/videos")
def get_person_videos(person_id: int, db: Session = Depends(get_db)) -> dict:
"""Get all videos linked to a specific person."""
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
# Get all video linkages for this person
linkages = (
db.query(PhotoPersonLinkage, Photo)
.join(Photo, PhotoPersonLinkage.photo_id == Photo.id)
.filter(
PhotoPersonLinkage.person_id == person_id,
Photo.media_type == "video"
)
.order_by(Photo.filename)
.all()
)
items = [
{
"id": photo.id,
"filename": photo.filename,
"path": photo.path,
"date_taken": photo.date_taken.isoformat() if photo.date_taken else None,
"date_added": photo.date_added.isoformat() if photo.date_added else None,
"linkage_id": linkage.id,
}
for linkage, photo in linkages
]
return {
"person_id": person_id,
"items": items,
"total": len(items),
}
@router.post("/{person_id}/accept-matches", response_model=IdentifyFaceResponse)
def accept_matches(
person_id: int,
request: AcceptMatchesRequest,
current_user: Annotated[dict, Depends(get_current_user_with_id)],
db: Session = Depends(get_db),
) -> IdentifyFaceResponse:
"""Accept auto-match matches for a person.
Matches desktop auto-match save workflow exactly:
1. Identifies selected faces with this person
2. Inserts person_encodings for each identified face
3. Updates person encodings (removes old, adds current)
Tracks which user identified the faces.
"""
from backend.api.auth import get_current_user_with_id
user_id = current_user["user_id"]
identified_count, updated_count = accept_auto_match_matches(
db, person_id, request.face_ids, user_id=user_id
)
return IdentifyFaceResponse(
identified_face_ids=request.face_ids,
person_id=person_id,
created_person=False,
)
@router.delete("/{person_id}")
def delete_person(person_id: int, db: Session = Depends(get_db)) -> Response:
"""Delete a person and all their linkages.
This will:
1. Delete all person_encodings for this person
2. Unlink all faces (set person_id to NULL)
3. Delete all video linkages (PhotoPersonLinkage records)
4. Delete the person record
"""
person = db.query(Person).filter(Person.id == person_id).first()
if not person:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Person {person_id} not found",
)
try:
# Delete all person_encodings for this person
db.query(PersonEncoding).filter(PersonEncoding.person_id == person_id).delete(synchronize_session=False)
# Unlink all faces (set person_id to NULL)
db.query(Face).filter(Face.person_id == person_id).update(
{"person_id": None}, synchronize_session=False
)
# Delete all video linkages (PhotoPersonLinkage records)
db.query(PhotoPersonLinkage).filter(PhotoPersonLinkage.person_id == person_id).delete(synchronize_session=False)
# Delete the person record
db.delete(person)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete person: {str(e)}",
)

1014
backend/api/photos.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,375 @@
"""Reported photos endpoints - admin only."""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import text
from sqlalchemy.orm import Session
from backend.db.session import get_auth_db, get_db
from backend.db.models import Photo, PhotoTagLinkage
from backend.api.users import get_current_admin_user, require_feature_permission
router = APIRouter(prefix="/reported-photos", tags=["reported-photos"])
class ReportedPhotoResponse(BaseModel):
"""Reported photo DTO returned from API."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
photo_id: int
user_id: int
user_name: Optional[str] = None
user_email: Optional[str] = None
status: str
reported_at: str
reviewed_at: Optional[str] = None
reviewed_by: Optional[int] = None
review_notes: Optional[str] = None
report_comment: Optional[str] = None
# Photo details from main database
photo_path: Optional[str] = None
photo_filename: Optional[str] = None
photo_media_type: Optional[str] = None
class ReportedPhotosListResponse(BaseModel):
"""List of reported photos."""
model_config = ConfigDict(protected_namespaces=())
items: list[ReportedPhotoResponse]
total: int
class ReviewDecision(BaseModel):
"""Decision for a single reported photo."""
model_config = ConfigDict(protected_namespaces=())
id: int
decision: str # 'keep' or 'remove'
review_notes: Optional[str] = None
class ReviewRequest(BaseModel):
"""Request to review multiple reported photos."""
model_config = ConfigDict(protected_namespaces=())
decisions: list[ReviewDecision]
class ReviewResponse(BaseModel):
"""Response from review operation."""
model_config = ConfigDict(protected_namespaces=())
kept: int
removed: int
errors: list[str]
class CleanupResponse(BaseModel):
"""Response payload for cleanup operations."""
model_config = ConfigDict(protected_namespaces=())
deleted_records: int
errors: list[str]
warnings: list[str] = []
@router.get("", response_model=ReportedPhotosListResponse)
def list_reported_photos(
current_user: Annotated[
dict, Depends(require_feature_permission("user_reported"))
],
status_filter: Optional[str] = None,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
) -> ReportedPhotosListResponse:
"""List all reported photos from the auth database.
This endpoint reads from the separate auth database (DATABASE_URL_AUTH)
and returns all reported photos from the inappropriate_photo_reports table.
Optionally filter by status: 'pending', 'reviewed', or 'dismissed'.
"""
try:
# Query inappropriate_photo_reports from auth database using raw SQL
# Join with users table to get user name/email
if status_filter:
result = auth_db.execute(text("""
SELECT
ipr.id,
ipr.photo_id,
ipr.user_id,
u.name as user_name,
u.email as user_email,
ipr.status,
ipr.reported_at,
ipr.reviewed_at,
ipr.reviewed_by,
ipr.review_notes,
ipr.report_comment
FROM inappropriate_photo_reports ipr
LEFT JOIN users u ON ipr.user_id = u.id
WHERE ipr.status = :status_filter
ORDER BY ipr.reported_at DESC
"""), {"status_filter": status_filter})
else:
result = auth_db.execute(text("""
SELECT
ipr.id,
ipr.photo_id,
ipr.user_id,
u.name as user_name,
u.email as user_email,
ipr.status,
ipr.reported_at,
ipr.reviewed_at,
ipr.reviewed_by,
ipr.review_notes,
ipr.report_comment
FROM inappropriate_photo_reports ipr
LEFT JOIN users u ON ipr.user_id = u.id
ORDER BY ipr.reported_at DESC
"""))
rows = result.fetchall()
items = []
for row in rows:
# Get photo details from main database
photo_path = None
photo_filename = None
photo_media_type = None
photo = main_db.query(Photo).filter(Photo.id == row.photo_id).first()
if photo:
photo_path = photo.path
photo_filename = photo.filename
photo_media_type = photo.media_type
items.append(ReportedPhotoResponse(
id=row.id,
photo_id=row.photo_id,
user_id=row.user_id,
user_name=row.user_name,
user_email=row.user_email,
status=row.status,
reported_at=str(row.reported_at) if row.reported_at else '',
reviewed_at=str(row.reviewed_at) if row.reviewed_at else None,
reviewed_by=row.reviewed_by,
review_notes=row.review_notes,
report_comment=row.report_comment,
photo_path=photo_path,
photo_filename=photo_filename,
photo_media_type=photo_media_type,
))
return ReportedPhotosListResponse(items=items, total=len(items))
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error reading from auth database: {str(e)}"
)
@router.post("/review", response_model=ReviewResponse)
def review_reported_photos(
current_user: Annotated[
dict, Depends(require_feature_permission("user_reported"))
],
request: ReviewRequest,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
) -> ReviewResponse:
"""Review reported photos - keep or remove them.
For 'keep' decision:
- Updates status in auth database to 'reviewed'
- Photo remains in main database
For 'remove' decision:
- Updates status in auth database to 'reviewed'
- Deletes photo from main database (cascade deletes faces, tags, etc.)
"""
kept_count = 0
removed_count = 0
errors = []
admin_user_id = current_user.get("user_id")
now = datetime.utcnow()
for decision in request.decisions:
try:
# Get reported photo from auth database
# Allow processing 'pending' and 'reviewed' status reports (to allow changing decisions)
result = auth_db.execute(text("""
SELECT
ipr.id,
ipr.photo_id,
ipr.status
FROM inappropriate_photo_reports ipr
WHERE ipr.id = :id AND ipr.status IN ('pending', 'reviewed')
"""), {"id": decision.id})
row = result.fetchone()
if not row:
errors.append(f"Reported photo {decision.id} not found or cannot be reviewed (status: dismissed)")
continue
if decision.decision == 'remove':
# Delete photo from main database (cascade will handle related records)
photo = main_db.query(Photo).filter(Photo.id == row.photo_id).first()
if not photo:
auth_db.execute(text("""
UPDATE inappropriate_photo_reports
SET status = 'dismissed',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
review_notes = :review_notes
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
"review_notes": decision.review_notes or "Photo not found in database; auto-dismissed"
})
auth_db.commit()
removed_count += 1
continue
# Delete tag linkages for this photo
main_db.query(PhotoTagLinkage).filter(
PhotoTagLinkage.photo_id == photo.id
).delete(synchronize_session=False)
# Delete the photo (cascade will delete faces, etc.)
main_db.delete(photo)
main_db.commit()
# Update status in auth database to dismissed
auth_db.execute(text("""
UPDATE inappropriate_photo_reports
SET status = 'dismissed',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
review_notes = :review_notes
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
"review_notes": decision.review_notes or "Photo removed"
})
auth_db.commit()
removed_count += 1
elif decision.decision == 'keep':
# Update status to reviewed (photo stays in database)
auth_db.execute(text("""
UPDATE inappropriate_photo_reports
SET status = 'reviewed',
reviewed_at = :reviewed_at,
reviewed_by = :reviewed_by,
review_notes = :review_notes
WHERE id = :id
"""), {
"id": decision.id,
"reviewed_at": now,
"reviewed_by": admin_user_id,
"review_notes": decision.review_notes or "Photo kept"
})
auth_db.commit()
kept_count += 1
else:
errors.append(f"Invalid decision '{decision.decision}' for reported photo {decision.id}")
except Exception as e:
errors.append(f"Error processing reported photo {decision.id}: {str(e)}")
# Rollback any partial changes
main_db.rollback()
auth_db.rollback()
return ReviewResponse(
kept=kept_count,
removed=removed_count,
errors=errors
)
@router.post("/cleanup", response_model=CleanupResponse)
def cleanup_reported_photos(
current_admin: dict = Depends(get_current_admin_user),
status_filter: Annotated[
Optional[str],
Query(description="Use 'keep' to clear reviewed or 'remove' to clear dismissed records.")
] = None,
auth_db: Session = Depends(get_auth_db),
) -> CleanupResponse:
"""Delete rows from inappropriate_photo_reports based on review status."""
status_mapping = {
"keep": "reviewed",
"remove": "dismissed",
}
if status_filter and status_filter not in status_mapping:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid status_filter. Use 'keep', 'remove', or omit the parameter.",
)
db_status_filter = status_mapping.get(status_filter)
warnings: list[str] = []
try:
if db_status_filter:
result = auth_db.execute(
text(
"""
DELETE FROM inappropriate_photo_reports
WHERE status = :status_filter
"""
),
{"status_filter": db_status_filter},
)
else:
result = auth_db.execute(
text(
"""
DELETE FROM inappropriate_photo_reports
WHERE status IN ('reviewed', 'dismissed')
"""
)
)
deleted_records = result.rowcount if hasattr(result, "rowcount") else 0
auth_db.commit()
if deleted_records == 0:
if db_status_filter:
warnings.append(
f"No reported photos matched the '{status_filter}' decision filter."
)
else:
warnings.append("No reviewed or dismissed reported photos to delete.")
return CleanupResponse(
deleted_records=deleted_records,
errors=[],
warnings=warnings,
)
except Exception as exc:
auth_db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to cleanup reported photos: {exc}",
)

View File

@ -0,0 +1,74 @@
"""Manage role-to-feature permissions."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from backend.api.users import get_current_admin_user
from backend.constants.role_features import ROLE_FEATURES, ROLE_FEATURE_KEYS
from backend.constants.roles import ROLE_VALUES
from backend.db.session import get_db
from backend.schemas.role_permissions import (
RoleFeatureSchema,
RolePermissionsResponse,
RolePermissionsUpdateRequest,
)
from backend.services.role_permissions import (
ensure_role_permissions_initialized,
fetch_role_permissions_map,
set_role_permissions,
)
router = APIRouter(prefix="/role-permissions", tags=["role-permissions"])
@router.get("", response_model=RolePermissionsResponse)
def list_role_permissions(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
db: Session = Depends(get_db),
) -> RolePermissionsResponse:
"""Return the current role/feature permission matrix."""
ensure_role_permissions_initialized(db)
permissions = fetch_role_permissions_map(db)
features = [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]
return RolePermissionsResponse(features=features, permissions=permissions)
@router.put("", response_model=RolePermissionsResponse)
def update_role_permissions(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
request: RolePermissionsUpdateRequest,
db: Session = Depends(get_db),
) -> RolePermissionsResponse:
"""Update permissions for the provided matrix."""
invalid_roles = set(request.permissions.keys()) - set(ROLE_VALUES)
if invalid_roles:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid role(s): {', '.join(sorted(invalid_roles))}",
)
for feature_map in request.permissions.values():
invalid_features = set(feature_map.keys()) - set(ROLE_FEATURE_KEYS)
if invalid_features:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid feature(s): {', '.join(sorted(invalid_features))}",
)
set_role_permissions(db, request.permissions)
permissions = fetch_role_permissions_map(db)
features = [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]
return RolePermissionsResponse(features=features, permissions=permissions)

191
backend/api/tags.py Normal file
View File

@ -0,0 +1,191 @@
"""Tag management endpoints."""
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from backend.db.session import get_db
from backend.schemas.tags import (
PhotoTagsRequest,
PhotoTagsResponse,
TagCreateRequest,
TagResponse,
TagsResponse,
TagUpdateRequest,
TagDeleteRequest,
PhotoTagsListResponse,
PhotoTagItem,
PhotosWithTagsResponse,
PhotoWithTagsItem,
)
from backend.services.tag_service import (
add_tags_to_photos,
get_or_create_tag,
list_tags,
remove_tags_from_photos,
get_photo_tags,
update_tag,
delete_tags,
get_photos_with_tags,
)
from backend.db.models import Photo
router = APIRouter(prefix="/tags", tags=["tags"])
@router.get("", response_model=TagsResponse)
def get_tags(db: Session = Depends(get_db)) -> TagsResponse:
"""List all tags."""
tags = list_tags(db)
items = [TagResponse.model_validate(t) for t in tags]
return TagsResponse(items=items, total=len(items))
@router.post("", response_model=TagResponse)
def create_tag(
request: TagCreateRequest, db: Session = Depends(get_db)
) -> TagResponse:
"""Create a new tag (or return existing if already exists)."""
tag = get_or_create_tag(db, request.tag_name)
db.commit()
db.refresh(tag)
return TagResponse.model_validate(tag)
@router.post("/photos/add", response_model=PhotoTagsResponse)
def add_tags_to_photos_endpoint(
request: PhotoTagsRequest, db: Session = Depends(get_db)
) -> PhotoTagsResponse:
"""Add tags to multiple photos."""
if not request.photo_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="photo_ids is required"
)
if not request.tag_names:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="tag_names is required"
)
photos_updated, tags_added = add_tags_to_photos(
db, request.photo_ids, request.tag_names
)
return PhotoTagsResponse(
message=f"Added tags to {photos_updated} photos",
photos_updated=photos_updated,
tags_added=tags_added,
tags_removed=0,
)
@router.post("/photos/remove", response_model=PhotoTagsResponse)
def remove_tags_from_photos_endpoint(
request: PhotoTagsRequest, db: Session = Depends(get_db)
) -> PhotoTagsResponse:
"""Remove tags from multiple photos."""
if not request.photo_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="photo_ids is required"
)
if not request.tag_names:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="tag_names is required"
)
photos_updated, tags_removed = remove_tags_from_photos(
db, request.photo_ids, request.tag_names
)
return PhotoTagsResponse(
message=f"Removed tags from {photos_updated} photos",
photos_updated=photos_updated,
tags_added=0,
tags_removed=tags_removed,
)
@router.get("/photos/{photo_id}", response_model=PhotoTagsListResponse)
def get_photo_tags_endpoint(
photo_id: int, db: Session = Depends(get_db)
) -> PhotoTagsListResponse:
"""Get all tags for a specific photo."""
# Validate photo exists
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if not photo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"Photo {photo_id} not found"
)
tags_data = get_photo_tags(db, photo_id)
items = [
PhotoTagItem(tag_id=tag_id, tag_name=tag_name)
for tag_id, tag_name in tags_data
]
return PhotoTagsListResponse(photo_id=photo_id, tags=items, total=len(items))
@router.put("/{tag_id}", response_model=TagResponse)
def update_tag_endpoint(
tag_id: int, request: TagUpdateRequest, db: Session = Depends(get_db)
) -> TagResponse:
"""Update a tag name."""
try:
tag = update_tag(db, tag_id, request.tag_name)
return TagResponse.model_validate(tag)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
)
@router.post("/delete", response_model=dict)
def delete_tags_endpoint(
request: TagDeleteRequest, db: Session = Depends(get_db)
) -> dict:
"""Delete tags and all their linkages."""
if not request.tag_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="tag_ids list cannot be empty",
)
deleted_count = delete_tags(db, request.tag_ids)
return {
"message": f"Deleted {deleted_count} tag(s)",
"deleted_count": deleted_count,
}
@router.get("/photos", response_model=PhotosWithTagsResponse)
def get_photos_with_tags_endpoint(db: Session = Depends(get_db)) -> PhotosWithTagsResponse:
"""Get all photos with tags and face counts, matching desktop tag manager query exactly.
Returns all photos with their tags (comma-separated) and face counts,
ordered by date_taken DESC, filename.
"""
photos_data = get_photos_with_tags(db)
items = [
PhotoWithTagsItem(
id=p['id'],
filename=p['filename'],
path=p['path'],
processed=p['processed'],
date_taken=p['date_taken'],
date_added=p['date_added'],
face_count=p['face_count'],
unidentified_face_count=p['unidentified_face_count'],
tags=p['tags'],
people_names=p.get('people_names', ''),
)
for p in photos_data
]
return PhotosWithTagsResponse(items=items, total=len(items))

534
backend/api/users.py Normal file
View File

@ -0,0 +1,534 @@
"""User management endpoints - admin only."""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from backend.api.auth import get_current_user
from backend.constants.roles import (
DEFAULT_ADMIN_ROLE,
DEFAULT_USER_ROLE,
ROLE_VALUES,
UserRole,
is_admin_role,
)
from backend.db.session import get_auth_db, get_db
from backend.db.models import Face, PhotoFavorite, PhotoPersonLinkage, User
from backend.schemas.users import (
UserCreateRequest,
UserResponse,
UserUpdateRequest,
UsersListResponse,
)
from backend.utils.password import hash_password
from backend.services.role_permissions import fetch_role_permissions_map
router = APIRouter(prefix="/users", tags=["users"])
logger = logging.getLogger(__name__)
def _normalize_role_and_admin(
role: str | None,
is_admin_flag: bool | None,
) -> tuple[str, bool]:
"""Normalize requested role/is_admin values into a consistent pair."""
selected_role = role or (DEFAULT_ADMIN_ROLE if is_admin_flag else DEFAULT_USER_ROLE)
if selected_role not in ROLE_VALUES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid role '{selected_role}'",
)
derived_is_admin = is_admin_role(selected_role)
if is_admin_flag is not None and is_admin_flag != derived_is_admin:
logger.warning(
"Role/is_admin mismatch detected. Using role-derived admin flag.",
extra={"role": selected_role, "is_admin_flag": is_admin_flag},
)
return selected_role, derived_is_admin
def _ensure_role_set(user: User) -> None:
"""Guarantee that a User instance has a valid role value."""
if user.role in ROLE_VALUES:
return
fallback_role = DEFAULT_ADMIN_ROLE if user.is_admin else DEFAULT_USER_ROLE
user.role = fallback_role
def get_auth_db_optional() -> Session | None:
"""Get auth database session if available, otherwise return None."""
try:
return next(get_auth_db())
except ValueError:
# Auth database not configured
return None
def create_auth_user_if_missing(
email: str,
full_name: str,
password_hash: str,
is_admin: bool,
) -> None:
"""Create matching auth user if one does not already exist."""
if not email:
return
auth_db = get_auth_db_optional()
if auth_db is None:
return
try:
check_result = auth_db.execute(
text(
"""
SELECT id FROM users
WHERE email = :email
"""
),
{"email": email},
)
existing_auth = check_result.first()
if existing_auth:
return
dialect = auth_db.bind.dialect.name if auth_db.bind else "postgresql"
supports_returning = dialect == "postgresql"
has_write_access = is_admin
if supports_returning:
auth_db.execute(
text(
"""
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
"""
),
{
"email": email,
"name": full_name,
"password_hash": password_hash,
"is_admin": is_admin,
"has_write_access": has_write_access,
},
)
auth_db.commit()
else:
auth_db.execute(
text(
"""
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
"""
),
{
"email": email,
"name": full_name,
"password_hash": password_hash,
"is_admin": is_admin,
"has_write_access": has_write_access,
},
)
auth_db.commit()
except Exception as e: # pragma: no cover - logging helper
auth_db.rollback()
import traceback
print(
f"Warning: Failed to create auth user: {str(e)}\n{traceback.format_exc()}"
)
finally:
auth_db.close()
def get_current_admin_user(
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> dict:
"""Get current user and verify admin status from main database.
Raises HTTPException if user is not an admin.
If no admin users exist, allows the current user to bootstrap as admin.
"""
username = current_user["username"]
# Check if any admin users exist
admin_count = db.query(User).filter(User.is_admin == True).count()
# If no admins exist, allow current user to bootstrap as admin
if admin_count == 0:
# Check if user already exists in main database
main_user = db.query(User).filter(User.username == username).first()
if not main_user:
# Create the user as admin for bootstrap
# Use a default password hash (user should change password after first login)
# In production, this should be handled differently
default_password_hash = hash_password("changeme")
main_user = User(
username=username,
password_hash=default_password_hash,
is_active=True,
is_admin=True,
role=DEFAULT_ADMIN_ROLE,
)
db.add(main_user)
db.commit()
db.refresh(main_user)
elif not main_user.is_admin:
# User exists but is not admin - make them admin for bootstrap
main_user.is_admin = True
main_user.role = DEFAULT_ADMIN_ROLE
db.add(main_user)
db.commit()
db.refresh(main_user)
return {"username": username, "user_id": main_user.id}
# Normal admin check - user must exist and be admin
main_user = db.query(User).filter(User.username == username).first()
if not main_user or not main_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return {"username": username, "user_id": main_user.id}
def require_feature_permission(feature_key: str):
"""Return a dependency that enforces feature-level access via role permissions."""
def dependency(
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> dict:
username = current_user["username"]
user = db.query(User).filter(User.username == username).first()
if not user:
default_password_hash = hash_password("changeme")
user = User(
username=username,
password_hash=default_password_hash,
is_active=True,
is_admin=False,
role=DEFAULT_USER_ROLE,
)
db.add(user)
db.commit()
db.refresh(user)
_ensure_role_set(user)
has_access = user.is_admin or is_admin_role(user.role)
if not has_access:
permissions_map = fetch_role_permissions_map(db)
role_permissions = permissions_map.get(user.role, {})
has_access = bool(role_permissions.get(feature_key))
if not has_access:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied for this feature",
)
return {
"username": username,
"user_id": user.id,
"role": user.role,
"is_admin": user.is_admin,
}
return dependency
@router.get("", response_model=UsersListResponse)
def list_users(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
is_active: bool | None = Query(None, description="Filter by active status"),
is_admin: bool | None = Query(None, description="Filter by admin status"),
db: Session = Depends(get_db),
) -> UsersListResponse:
"""List all users - admin only.
Optionally filter by is_active and/or is_admin status.
"""
query = db.query(User)
if is_active is not None:
query = query.filter(User.is_active == is_active)
if is_admin is not None:
query = query.filter(User.is_admin == is_admin)
users = query.order_by(User.username.asc()).all()
for user in users:
_ensure_role_set(user)
items = [UserResponse.model_validate(u) for u in users]
return UsersListResponse(items=items, total=len(items))
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
request: UserCreateRequest,
db: Session = Depends(get_db),
) -> UserResponse:
"""Create a new user - admin only.
If give_frontend_permission is True, also creates the user in the auth database
for frontend access.
"""
# Check if username already exists
existing_user = db.query(User).filter(User.username == request.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Username '{request.username}' already exists",
)
# Check if email already exists
existing_email = db.query(User).filter(User.email == request.email).first()
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email address '{request.email}' is already in use",
)
# Hash the password before storing
password_hash = hash_password(request.password)
if request.role is None:
requested_role = None
elif isinstance(request.role, UserRole):
requested_role = request.role.value
else:
requested_role = str(request.role)
normalized_role, normalized_is_admin = _normalize_role_and_admin(
requested_role,
request.is_admin,
)
user = User(
username=request.username,
password_hash=password_hash,
email=request.email,
full_name=request.full_name,
is_active=request.is_active,
is_admin=normalized_is_admin,
role=normalized_role,
password_change_required=True, # Force password change on first login
)
db.add(user)
db.commit()
db.refresh(user)
if request.give_frontend_permission:
create_auth_user_if_missing(
email=request.email,
full_name=request.full_name,
password_hash=password_hash,
is_admin=normalized_is_admin,
)
return UserResponse.model_validate(user)
@router.get("/{user_id}", response_model=UserResponse)
def get_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
db: Session = Depends(get_db),
) -> UserResponse:
"""Get a specific user by ID - admin only."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {user_id} not found",
)
_ensure_role_set(user)
return UserResponse.model_validate(user)
@router.put("/{user_id}", response_model=UserResponse)
def update_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
request: UserUpdateRequest,
db: Session = Depends(get_db),
) -> UserResponse:
"""Update a user - admin only."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {user_id} not found",
)
if request.role is None:
desired_role = None
elif isinstance(request.role, UserRole):
desired_role = request.role.value
else:
desired_role = str(request.role)
if desired_role is None:
if request.is_admin is not None:
desired_role = DEFAULT_ADMIN_ROLE if request.is_admin else DEFAULT_USER_ROLE
elif user.role:
desired_role = user.role
else:
desired_role = DEFAULT_ADMIN_ROLE if user.is_admin else DEFAULT_USER_ROLE
normalized_role, normalized_is_admin = _normalize_role_and_admin(
desired_role,
request.is_admin,
)
# Prevent admin from removing their own admin status
if current_admin["username"] == user.username and not normalized_is_admin:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove your own admin status",
)
# Check if email is being changed and if the new email already exists
if request.email is not None and request.email != user.email:
existing_email = db.query(User).filter(User.email == request.email).first()
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email address '{request.email}' is already in use",
)
# Update fields if provided
if request.password is not None:
user.password_hash = hash_password(request.password)
if request.email is not None:
user.email = request.email
if request.full_name is not None:
user.full_name = request.full_name
if request.is_active is not None:
user.is_active = request.is_active
user.is_admin = normalized_is_admin
user.role = normalized_role
db.add(user)
db.commit()
db.refresh(user)
if request.give_frontend_permission:
create_auth_user_if_missing(
email=user.email,
full_name=user.full_name or user.username,
password_hash=user.password_hash,
is_admin=user.is_admin,
)
return UserResponse.model_validate(user)
@router.delete("/{user_id}")
def delete_user(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
user_id: int,
db: Session = Depends(get_db),
) -> Response:
"""Delete a user - admin only.
If the user has linked data (faces identified, video person linkages),
the user will be set to inactive instead of deleted, and favorites will
be removed. Admins will be notified via logging.
Prevents admin from deleting themselves.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {user_id} not found",
)
# Prevent admin from deleting themselves
if current_admin["username"] == user.username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account",
)
# Check for linked data (faces or photo_person_linkages identified by this user)
faces_count = db.query(Face).filter(Face.identified_by_user_id == user_id).count()
linkages_count = db.query(PhotoPersonLinkage).filter(
PhotoPersonLinkage.identified_by_user_id == user_id
).count()
has_linked_data = faces_count > 0 or linkages_count > 0
# Always delete favorites (they use username, not user_id)
favorites_deleted = db.query(PhotoFavorite).filter(
PhotoFavorite.username == user.username
).delete()
if has_linked_data:
# Set user inactive instead of deleting
user.is_active = False
db.add(user)
db.commit()
# Notify admins via logging
logger.warning(
f"User '{user.username}' (ID: {user_id}) was set to inactive instead of deleted "
f"because they have linked data: {faces_count} face(s) and {linkages_count} "
f"video person linkage(s). {favorites_deleted} favorite(s) were deleted. "
f"Action performed by admin: {current_admin['username']}",
extra={
"user_id": user_id,
"username": user.username,
"faces_count": faces_count,
"linkages_count": linkages_count,
"favorites_deleted": favorites_deleted,
"admin_username": current_admin["username"],
}
)
# Return success but indicate user was deactivated
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
"message": (
f"User '{user.username}' has been set to inactive because they have "
f"linked data ({faces_count} face(s), {linkages_count} linkage(s)). "
f"{favorites_deleted} favorite(s) were deleted."
),
"deactivated": True,
"faces_count": faces_count,
"linkages_count": linkages_count,
"favorites_deleted": favorites_deleted,
}
)
else:
# No linked data - safe to delete
db.delete(user)
db.commit()
logger.info(
f"User '{user.username}' (ID: {user_id}) was deleted. "
f"{favorites_deleted} favorite(s) were deleted. "
f"Action performed by admin: {current_admin['username']}",
extra={
"user_id": user_id,
"username": user.username,
"favorites_deleted": favorites_deleted,
"admin_username": current_admin["username"],
}
)
return Response(status_code=status.HTTP_204_NO_CONTENT)

16
backend/api/version.py Normal file
View File

@ -0,0 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter
from backend.settings import APP_VERSION
router = APIRouter()
@router.get("/version")
def version() -> dict[str, str]:
"""Return API version information."""
return {"version": APP_VERSION}

343
backend/api/videos.py Normal file
View File

@ -0,0 +1,343 @@
"""Video person identification endpoints."""
from __future__ import annotations
from datetime import date
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from backend.db.session import get_db
from backend.db.models import Photo, User
from backend.api.auth import get_current_user_with_id
from backend.schemas.videos import (
ListVideosResponse,
VideoListItem,
PersonInfo,
VideoPeopleResponse,
VideoPersonInfo,
IdentifyVideoRequest,
IdentifyVideoResponse,
RemoveVideoPersonResponse,
)
from backend.services.video_service import (
list_videos_for_identification,
get_video_people,
identify_person_in_video,
remove_person_from_video,
get_video_people_count,
)
from backend.services.thumbnail_service import get_video_thumbnail_path
router = APIRouter(prefix="/videos", tags=["videos"])
@router.get("", response_model=ListVideosResponse)
def list_videos(
current_user: Annotated[dict, Depends(get_current_user_with_id)],
folder_path: Optional[str] = Query(None, description="Filter by folder path"),
date_from: Optional[str] = Query(None, description="Filter by date taken (from, YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="Filter by date taken (to, YYYY-MM-DD)"),
has_people: Optional[bool] = Query(None, description="Filter videos with/without identified people"),
person_name: Optional[str] = Query(None, description="Filter videos containing person with this name"),
sort_by: str = Query("filename", description="Sort field: filename, date_taken, date_added"),
sort_dir: str = Query("asc", description="Sort direction: asc or desc"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
) -> ListVideosResponse:
"""List videos for person identification."""
# Parse date filters
date_from_parsed = None
date_to_parsed = None
if date_from:
try:
date_from_parsed = date.fromisoformat(date_from)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid date_from format: {date_from}. Use YYYY-MM-DD",
)
if date_to:
try:
date_to_parsed = date.fromisoformat(date_to)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid date_to format: {date_to}. Use YYYY-MM-DD",
)
# Validate sort parameters
if sort_by not in ["filename", "date_taken", "date_added"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid sort_by: {sort_by}. Must be filename, date_taken, or date_added",
)
if sort_dir not in ["asc", "desc"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid sort_dir: {sort_dir}. Must be asc or desc",
)
# Get videos
videos, total = list_videos_for_identification(
db=db,
folder_path=folder_path,
date_from=date_from_parsed,
date_to=date_to_parsed,
has_people=has_people,
person_name=person_name,
sort_by=sort_by,
sort_dir=sort_dir,
page=page,
page_size=page_size,
)
# Build response items
items = []
for video in videos:
# Get people for this video
people_data = get_video_people(db, video.id)
identified_people = []
for person, linkage in people_data:
identified_people.append(
PersonInfo(
id=person.id,
first_name=person.first_name,
last_name=person.last_name,
middle_name=person.middle_name,
maiden_name=person.maiden_name,
date_of_birth=person.date_of_birth,
)
)
# Convert date_added to date if it's datetime
date_added = video.date_added
if hasattr(date_added, "date"):
date_added = date_added.date()
items.append(
VideoListItem(
id=video.id,
filename=video.filename,
path=video.path,
date_taken=video.date_taken,
date_added=date_added,
identified_people=identified_people,
identified_people_count=len(identified_people),
)
)
return ListVideosResponse(items=items, page=page, page_size=page_size, total=total)
@router.get("/{video_id}/people", response_model=VideoPeopleResponse)
def get_video_people_endpoint(
video_id: int,
db: Session = Depends(get_db),
) -> VideoPeopleResponse:
"""Get all people identified in a video."""
# Verify video exists
video = db.query(Photo).filter(
Photo.id == video_id,
Photo.media_type == "video"
).first()
if not video:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Video {video_id} not found",
)
# Get people
people_data = get_video_people(db, video_id)
people = []
for person, linkage in people_data:
# Get username if identified_by_user_id exists
username = None
if linkage.identified_by_user_id:
user = db.query(User).filter(User.id == linkage.identified_by_user_id).first()
if user:
username = user.username
people.append(
VideoPersonInfo(
person_id=person.id,
first_name=person.first_name,
last_name=person.last_name,
middle_name=person.middle_name,
maiden_name=person.maiden_name,
date_of_birth=person.date_of_birth,
identified_by=username,
identified_date=linkage.created_date,
)
)
return VideoPeopleResponse(video_id=video_id, people=people)
@router.post("/{video_id}/identify", response_model=IdentifyVideoResponse)
def identify_person_in_video_endpoint(
video_id: int,
request: IdentifyVideoRequest,
current_user: Annotated[dict, Depends(get_current_user_with_id)],
db: Session = Depends(get_db),
) -> IdentifyVideoResponse:
"""Identify a person in a video."""
user_id = current_user.get("id")
try:
person, created_person = identify_person_in_video(
db=db,
video_id=video_id,
person_id=request.person_id,
first_name=request.first_name,
last_name=request.last_name,
middle_name=request.middle_name,
maiden_name=request.maiden_name,
date_of_birth=request.date_of_birth,
user_id=user_id,
)
message = (
f"Person '{person.first_name} {person.last_name}' identified in video"
if not created_person
else f"Created new person '{person.first_name} {person.last_name}' and identified in video"
)
return IdentifyVideoResponse(
video_id=video_id,
person_id=person.id,
created_person=created_person,
message=message,
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.delete("/{video_id}/people/{person_id}", response_model=RemoveVideoPersonResponse)
def remove_person_from_video_endpoint(
video_id: int,
person_id: int,
current_user: Annotated[dict, Depends(get_current_user_with_id)],
db: Session = Depends(get_db),
) -> RemoveVideoPersonResponse:
"""Remove person identification from video."""
try:
removed = remove_person_from_video(
db=db,
video_id=video_id,
person_id=person_id,
)
if removed:
return RemoveVideoPersonResponse(
video_id=video_id,
person_id=person_id,
removed=True,
message=f"Person {person_id} removed from video {video_id}",
)
else:
return RemoveVideoPersonResponse(
video_id=video_id,
person_id=person_id,
removed=False,
message=f"Person {person_id} not found in video {video_id}",
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.get("/{video_id}/thumbnail")
def get_video_thumbnail(
video_id: int,
db: Session = Depends(get_db),
) -> FileResponse:
"""Get video thumbnail (generated on-demand and cached)."""
# Verify video exists
video = db.query(Photo).filter(
Photo.id == video_id,
Photo.media_type == "video"
).first()
if not video:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Video {video_id} not found",
)
# Generate or get cached thumbnail
thumbnail_path = get_video_thumbnail_path(video.path)
if not thumbnail_path or not thumbnail_path.exists():
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate video thumbnail",
)
# Return thumbnail with caching headers
response = FileResponse(
str(thumbnail_path),
media_type="image/jpeg",
)
response.headers["Cache-Control"] = "public, max-age=86400" # Cache for 1 day
return response
@router.get("/{video_id}/video")
def get_video_file(
video_id: int,
db: Session = Depends(get_db),
) -> FileResponse:
"""Serve video file for playback."""
import os
import mimetypes
# Verify video exists
video = db.query(Photo).filter(
Photo.id == video_id,
Photo.media_type == "video"
).first()
if not video:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Video {video_id} not found",
)
if not os.path.exists(video.path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Video file not found: {video.path}",
)
# Determine media type from file extension
media_type, _ = mimetypes.guess_type(video.path)
if not media_type or not media_type.startswith('video/'):
media_type = "video/mp4"
# Use FileResponse with range request support for video streaming
response = FileResponse(
video.path,
media_type=media_type,
)
response.headers["Content-Disposition"] = "inline"
response.headers["Accept-Ranges"] = "bytes"
response.headers["Cache-Control"] = "public, max-age=3600"
return response

730
backend/app.py Normal file
View File

@ -0,0 +1,730 @@
from __future__ import annotations
import os
import subprocess
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import inspect, text
from backend.api.auth import router as auth_router
from backend.api.faces import router as faces_router
from backend.api.health import router as health_router
from backend.api.jobs import router as jobs_router
from backend.api.metrics import router as metrics_router
from backend.api.people import router as people_router
from backend.api.pending_identifications import router as pending_identifications_router
from backend.api.pending_linkages import router as pending_linkages_router
from backend.api.photos import router as photos_router
from backend.api.reported_photos import router as reported_photos_router
from backend.api.pending_photos import router as pending_photos_router
from backend.api.tags import router as tags_router
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.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
from backend.db.base import Base, engine
from backend.db.session import auth_engine, database_url, get_auth_database_url
# Import models to ensure they're registered with Base.metadata
from backend.db import models # noqa: F401
from backend.db.models import RolePermission
from backend.utils.password import hash_password
# Global worker process (will be set in lifespan)
_worker_process: subprocess.Popen | None = None
def start_worker() -> None:
"""Start RQ worker in background subprocess."""
global _worker_process
try:
from redis import Redis
# Check Redis connection first
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
redis_conn.ping()
# Start worker as a subprocess (avoids signal handler issues)
# __file__ is backend/app.py, so parent.parent is the project root (punimtag/)
project_root = Path(__file__).parent.parent
# Use explicit Python path to avoid Cursor interception
# Check if sys.executable is Cursor, if so use /usr/bin/python3
python_executable = sys.executable
if "cursor" in python_executable.lower() or not python_executable.startswith("/usr"):
python_executable = "/usr/bin/python3"
# Ensure PYTHONPATH is set correctly and pass DATABASE_URL_AUTH explicitly
# Load .env file to get DATABASE_URL_AUTH if not already in environment
from dotenv import load_dotenv
env_file = project_root / ".env"
if env_file.exists():
load_dotenv(dotenv_path=env_file)
worker_env = {
**{k: v for k, v in os.environ.items()},
"PYTHONPATH": str(project_root),
}
# Explicitly ensure DATABASE_URL_AUTH is passed to worker subprocess
if "DATABASE_URL_AUTH" in os.environ:
worker_env["DATABASE_URL_AUTH"] = os.environ["DATABASE_URL_AUTH"]
_worker_process = subprocess.Popen(
[
python_executable,
"-m",
"backend.worker",
],
cwd=str(project_root),
stdout=None, # Don't capture - let output go to console
stderr=None, # Don't capture - let errors go to console
env=worker_env
)
# Give it a moment to start, then check if it's still running
import time
time.sleep(0.5)
if _worker_process.poll() is not None:
# Process already exited - there was an error
print(f"❌ Worker process exited immediately with code {_worker_process.returncode}")
print(" Check worker errors above")
else:
print(f"✅ RQ worker started in background subprocess (PID: {_worker_process.pid})")
except Exception as e:
print(f"⚠️ Failed to start RQ worker: {e}")
print(" Background jobs will not be processed. Ensure Redis is running.")
def stop_worker() -> None:
"""Stop RQ worker gracefully."""
global _worker_process
if _worker_process:
try:
_worker_process.terminate()
try:
_worker_process.wait(timeout=5)
except subprocess.TimeoutExpired:
_worker_process.kill()
print("✅ RQ worker stopped")
except Exception:
pass
def ensure_user_password_hash_column(inspector) -> None:
"""Ensure users table contains password_hash column."""
if "users" not in inspector.get_table_names():
print(" Users table does not exist yet - will be created with password_hash column")
return
columns = {column["name"] for column in inspector.get_columns("users")}
if "password_hash" in columns:
print(" password_hash column already exists in users table")
return
print("🔄 Adding password_hash column to users table...")
default_hash = hash_password("changeme")
with engine.connect() as connection:
with connection.begin():
# PostgreSQL: Add column as nullable first, then update, then set NOT NULL
connection.execute(
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT")
)
connection.execute(
text(
"UPDATE users SET password_hash = :default_hash "
"WHERE password_hash IS NULL OR password_hash = ''"
),
{"default_hash": default_hash},
)
# Set NOT NULL constraint
connection.execute(
text("ALTER TABLE users ALTER COLUMN password_hash SET NOT NULL")
)
print("✅ Added password_hash column to users table (default password: changeme)")
def ensure_user_password_change_required_column(inspector) -> None:
"""Ensure users table contains password_change_required column."""
if "users" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("users")}
if "password_change_required" in columns:
print(" password_change_required column already exists in users table")
return
print("🔄 Adding password_change_required column to users table...")
with engine.connect() as connection:
with connection.begin():
connection.execute(
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS password_change_required BOOLEAN NOT NULL DEFAULT true")
)
print("✅ Added password_change_required column to users table")
def ensure_user_email_unique_constraint(inspector) -> None:
"""Ensure users table email column has a unique constraint."""
if "users" not in inspector.get_table_names():
return
# Check if email column exists
columns = {col["name"] for col in inspector.get_columns("users")}
if "email" not in columns:
print(" email column does not exist in users table yet")
return
# Check if unique constraint already exists on email
with engine.connect() as connection:
# Check if unique constraint exists
result = connection.execute(text("""
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_name = 'users'
AND constraint_type = 'UNIQUE'
AND constraint_name LIKE '%email%'
"""))
if result.first():
print(" Unique constraint on email column already exists")
return
# Try to add unique constraint (will fail if duplicates exist)
try:
print("🔄 Adding unique constraint to email column...")
connection.execute(text("ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email)"))
connection.commit()
print("✅ Added unique constraint to email column")
except Exception as e:
# If constraint already exists or duplicates exist, that's okay
# API validation will prevent new duplicates
if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
print(f" Could not add unique constraint (may have duplicates): {e}")
else:
print(f"⚠️ Could not add unique constraint: {e}")
def ensure_face_identified_by_user_id_column(inspector) -> None:
"""Ensure faces table contains identified_by_user_id column."""
if "faces" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("faces")}
if "identified_by_user_id" in columns:
print(" identified_by_user_id column already exists in faces table")
return
print("🔄 Adding identified_by_user_id column to faces table...")
dialect = engine.dialect.name
with engine.connect() as connection:
with connection.begin():
if dialect == "postgresql":
connection.execute(
text("ALTER TABLE faces ADD COLUMN IF NOT EXISTS identified_by_user_id INTEGER REFERENCES users(id)")
)
# Add index
try:
connection.execute(
text("CREATE INDEX IF NOT EXISTS idx_faces_identified_by ON faces(identified_by_user_id)")
)
except Exception:
pass # Index might already exist
print("✅ Added identified_by_user_id column to faces table")
def ensure_user_role_column(inspector) -> None:
"""Ensure users table has a role column with valid values."""
if "users" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("users")}
dialect = engine.dialect.name
role_values = sorted(ROLE_VALUES)
placeholder_parts = ", ".join(
f":role_value_{index}" for index, _ in enumerate(role_values)
)
where_clause = (
"role IS NULL OR role = ''"
if not placeholder_parts
else f"role IS NULL OR role = '' OR role NOT IN ({placeholder_parts})"
)
params = {
f"role_value_{index}": value for index, value in enumerate(role_values)
}
params["admin_role"] = DEFAULT_ADMIN_ROLE
params["default_role"] = DEFAULT_USER_ROLE
with engine.connect() as connection:
with connection.begin():
if "role" not in columns:
if dialect == "postgresql":
connection.execute(
text(
f"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT "
f"NOT NULL DEFAULT '{DEFAULT_USER_ROLE}'"
)
)
else:
connection.execute(
text(
f"ALTER TABLE users ADD COLUMN role TEXT "
f"DEFAULT '{DEFAULT_USER_ROLE}'"
)
)
connection.execute(
text(
f"""
UPDATE users
SET role = CASE
WHEN is_admin THEN :admin_role
ELSE :default_role
END
WHERE {where_clause}
"""
),
params,
)
connection.execute(
text("CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)")
)
print("✅ Ensured users.role column exists and is populated")
def ensure_photo_media_type_column(inspector) -> None:
"""Ensure photos table contains media_type column."""
if "photos" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("photos")}
if "media_type" in columns:
print(" media_type column already exists in photos table")
return
print("🔄 Adding media_type column to photos table...")
dialect = engine.dialect.name
with engine.connect() as connection:
with connection.begin():
if dialect == "postgresql":
connection.execute(
text("ALTER TABLE photos ADD COLUMN IF NOT EXISTS media_type TEXT NOT NULL DEFAULT 'image'")
)
# Add index
try:
connection.execute(
text("CREATE INDEX IF NOT EXISTS idx_photos_media_type ON photos(media_type)")
)
except Exception:
pass # Index might already exist
print("✅ Added media_type column to photos table")
def ensure_face_excluded_column(inspector) -> None:
"""Ensure faces table contains excluded column."""
if "faces" not in inspector.get_table_names():
print(" Faces table does not exist yet - will be created with excluded column")
return
columns = {column["name"] for column in inspector.get_columns("faces")}
if "excluded" in columns:
# Column already exists, no need to print or do anything
return
print("🔄 Adding excluded column to faces table...")
dialect = engine.dialect.name
with engine.connect() as connection:
with connection.begin():
if dialect == "postgresql":
# PostgreSQL: Add column with default value
connection.execute(
text("ALTER TABLE faces ADD COLUMN IF NOT EXISTS excluded BOOLEAN DEFAULT FALSE NOT NULL")
)
# Create index
try:
connection.execute(
text("CREATE INDEX IF NOT EXISTS idx_faces_excluded ON faces(excluded)")
)
except Exception:
pass # Index might already exist
print("✅ Added excluded column to faces table")
def ensure_photo_person_linkage_table(inspector) -> None:
"""Ensure photo_person_linkage table exists for direct video-person associations."""
if "photo_person_linkage" in inspector.get_table_names():
print(" photo_person_linkage table already exists")
return
print("🔄 Creating photo_person_linkage table...")
with engine.connect() as connection:
with connection.begin():
connection.execute(text("""
CREATE TABLE IF NOT EXISTS photo_person_linkage (
id SERIAL PRIMARY KEY,
photo_id INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
identified_by_user_id INTEGER REFERENCES users(id),
created_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(photo_id, person_id)
)
"""))
# Create indexes
for idx_name, idx_col in [
("idx_photo_person_photo", "photo_id"),
("idx_photo_person_person", "person_id"),
("idx_photo_person_user", "identified_by_user_id"),
]:
try:
connection.execute(
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON photo_person_linkage({idx_col})")
)
except Exception:
pass # Index might already exist
print("✅ Created photo_person_linkage table")
def ensure_auth_user_is_active_column() -> None:
"""Ensure auth database users table contains is_active column.
NOTE: Auth database is managed by the frontend. This function only checks/updates
if the database and table already exist. It will not fail if they don't exist.
"""
if auth_engine is None:
# Auth database not configured
return
try:
from sqlalchemy import inspect as sqlalchemy_inspect
# Try to get inspector - gracefully handle if database doesn't exist
try:
auth_inspector = sqlalchemy_inspect(auth_engine)
except Exception as inspect_exc:
error_str = str(inspect_exc).lower()
if "does not exist" in error_str or "database" in error_str:
# Database doesn't exist - that's okay, frontend will create it
return
# Some other error - log but don't fail
print(f" Could not inspect auth database: {inspect_exc}")
return
if "users" not in auth_inspector.get_table_names():
# Table doesn't exist - that's okay, frontend will create it
return
columns = {column["name"] for column in auth_inspector.get_columns("users")}
if "is_active" in columns:
print(" is_active column already exists in auth database users table")
return
# Column doesn't exist - try to add it
print("🔄 Adding is_active column to auth database users table...")
dialect = auth_engine.dialect.name
try:
with auth_engine.connect() as connection:
with connection.begin():
connection.execute(
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE")
)
print("✅ Added is_active column to auth database users table")
except Exception as alter_exc:
# Check if it's a permission error
error_str = str(alter_exc)
if "permission" in error_str.lower() or "insufficient" in error_str.lower() or "owner" in error_str.lower():
print("⚠️ Cannot add is_active column: insufficient database privileges")
print(" The column will need to be added manually by a database administrator:")
print(" ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT TRUE;")
print(" Until then, users with linked data cannot be deleted.")
else:
# Some other error
print(f"⚠️ Failed to add is_active column to auth database users table: {alter_exc}")
except Exception as exc:
print(f"⚠️ Failed to check/add is_active column to auth database users table: {exc}")
# Don't raise - auth database might not be available or have permission issues
# The delete endpoint will handle this case gracefully
def ensure_role_permissions_table(inspector) -> None:
"""Ensure the role_permissions table exists for permission matrix."""
if "role_permissions" in inspector.get_table_names():
return
try:
print("🔄 Creating role_permissions table...")
RolePermission.__table__.create(bind=engine, checkfirst=True)
print("✅ Created role_permissions table")
except Exception as exc:
print(f"⚠️ Failed to create role_permissions table: {exc}")
def ensure_postgresql_database(db_url: str) -> None:
"""Ensure PostgreSQL database exists, create it if it doesn't."""
if not db_url.startswith("postgresql"):
return # Not PostgreSQL, skip
try:
from urllib.parse import urlparse, parse_qs
import os
import psycopg2
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
# Parse the database URL
parsed = urlparse(db_url.replace("postgresql+psycopg2://", "postgresql://"))
db_name = parsed.path.lstrip("/")
user = parsed.username
password = parsed.password
host = parsed.hostname or "localhost"
port = parsed.port or 5432
if not db_name:
return # No database name specified
# Try to connect to the database
try:
test_conn = psycopg2.connect(
host=host,
port=port,
user=user,
password=password,
database=db_name
)
test_conn.close()
return # Database exists
except psycopg2.OperationalError as e:
if "does not exist" not in str(e):
# Some other error (permissions, connection, etc.)
print(f"⚠️ Cannot check if database '{db_name}' exists: {e}")
return
# Database doesn't exist - try to create it
print(f"🔄 Creating PostgreSQL database '{db_name}'...")
# Connect to postgres database to create the new database
# Try with the configured user first (they might have CREATEDB privilege)
admin_conn = None
try:
admin_conn = psycopg2.connect(
host=host,
port=port,
user=user,
password=password,
database="postgres"
)
except psycopg2.OperationalError:
# Try postgres superuser (might need password from environment or .pgpass)
try:
import os
postgres_password = os.getenv("POSTGRES_PASSWORD", "")
admin_conn = psycopg2.connect(
host=host,
port=port,
user="postgres",
password=postgres_password if postgres_password else None,
database="postgres"
)
except psycopg2.OperationalError as e:
print(f"⚠️ Cannot create database '{db_name}': insufficient privileges")
print(f" Error: {e}")
print(f" Please create it manually:")
print(f" sudo -u postgres psql -c \"CREATE DATABASE {db_name};\"")
print(f" sudo -u postgres psql -c \"GRANT ALL PRIVILEGES ON DATABASE {db_name} TO {user};\"")
return
if admin_conn is None:
return
admin_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cursor = admin_conn.cursor()
# Check if database exists
cursor.execute(
"SELECT 1 FROM pg_database WHERE datname = %s",
(db_name,)
)
exists = cursor.fetchone()
if not exists:
# Create the database
try:
cursor.execute(f'CREATE DATABASE "{db_name}"')
if user != "postgres" and admin_conn.info.user == "postgres":
# Grant privileges to the user if we're connected as postgres
try:
cursor.execute(f'GRANT ALL PRIVILEGES ON DATABASE "{db_name}" TO "{user}"')
except Exception as grant_exc:
print(f"⚠️ Created database '{db_name}' but could not grant privileges: {grant_exc}")
# Grant schema permissions (needed for creating tables)
if admin_conn.info.user == "postgres":
try:
# Connect to the new database to grant schema permissions
cursor.close()
admin_conn.close()
schema_conn = psycopg2.connect(
host=host,
port=port,
user="postgres",
password=os.getenv("POSTGRES_PASSWORD", "") if os.getenv("POSTGRES_PASSWORD") else None,
database=db_name
)
schema_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
schema_cursor = schema_conn.cursor()
schema_cursor.execute(f'GRANT ALL ON SCHEMA public TO "{user}"')
schema_cursor.execute(f'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "{user}"')
schema_cursor.execute(f'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO "{user}"')
schema_cursor.close()
schema_conn.close()
print(f"✅ Granted schema permissions to user '{user}'")
except Exception as schema_exc:
print(f"⚠️ Created database '{db_name}' but could not grant schema permissions: {schema_exc}")
print(f" Please run manually:")
print(f" sudo -u postgres psql -d {db_name} -c \"GRANT ALL ON SCHEMA public TO {user};\"")
print(f" sudo -u postgres psql -d {db_name} -c \"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO {user};\"")
print(f"✅ Created database '{db_name}'")
except Exception as create_exc:
print(f"⚠️ Failed to create database '{db_name}': {create_exc}")
print(f" Please create it manually:")
print(f" sudo -u postgres psql -c \"CREATE DATABASE {db_name};\"")
if user != "postgres":
print(f" sudo -u postgres psql -c \"GRANT ALL PRIVILEGES ON DATABASE {db_name} TO {user};\"")
cursor.close()
admin_conn.close()
return
else:
print(f" Database '{db_name}' already exists")
cursor.close()
admin_conn.close()
except Exception as exc:
print(f"⚠️ Failed to ensure database exists: {exc}")
import traceback
print(f" Traceback: {traceback.format_exc()}")
# Don't raise - let the connection attempt fail naturally with a clearer error
def ensure_auth_database_tables() -> None:
"""Ensure auth database tables exist, create them if they don't.
NOTE: This function is deprecated. Auth database is now managed by the frontend.
This function is kept for backward compatibility but will not create tables.
"""
# Auth database is managed by the frontend - do not create tables here
return
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown events."""
# Ensure database exists and tables are created on first run
try:
# Ensure main PostgreSQL database exists
# This must happen BEFORE we try to use the engine
ensure_postgresql_database(database_url)
# Note: Auth database is managed by the frontend, not created here
# Only create tables if they don't already exist (safety check)
inspector = inspect(engine)
existing_tables = set(inspector.get_table_names())
# Check if required application tables exist (not just alembic_version)
required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings", "photo_favorites", "users", "photo_person_linkage"}
missing_tables = required_tables - existing_tables
if missing_tables:
# Some required tables are missing - create all tables
# create_all() only creates missing tables, won't drop existing ones
Base.metadata.create_all(bind=engine)
if len(missing_tables) == len(required_tables):
print("✅ Database initialized (first run - tables created)")
else:
print(f"✅ Database tables created (missing tables: {', '.join(missing_tables)})")
else:
# All required tables exist - don't recreate (prevents data loss)
print(f"✅ Database already initialized ({len(existing_tables)} tables exist)")
# Ensure new columns exist (backward compatibility without migrations)
ensure_user_password_hash_column(inspector)
ensure_user_password_change_required_column(inspector)
ensure_user_email_unique_constraint(inspector)
ensure_face_identified_by_user_id_column(inspector)
ensure_user_role_column(inspector)
ensure_photo_media_type_column(inspector)
ensure_photo_person_linkage_table(inspector)
ensure_face_excluded_column(inspector)
ensure_role_permissions_table(inspector)
# Setup auth database tables for both frontends (viewer and admin)
if auth_engine is not None:
try:
ensure_auth_user_is_active_column()
# Import and call worker's setup function to create all auth tables
from backend.worker import setup_auth_database_tables
setup_auth_database_tables()
except Exception as auth_exc:
# Auth database might not exist yet - that's okay, frontend will handle it
print(f" Auth database not available: {auth_exc}")
print(" Frontend will manage auth database setup")
except Exception as exc:
print(f"❌ Database initialization failed: {exc}")
raise
# Startup
start_worker()
yield
# Shutdown
stop_worker()
def create_app() -> FastAPI:
"""Create and configure the FastAPI application instance."""
app = FastAPI(
title=APP_TITLE,
version=APP_VERSION,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health_router, tags=["health"])
app.include_router(version_router, tags=["meta"])
app.include_router(metrics_router, tags=["metrics"])
app.include_router(auth_router, prefix="/api/v1")
app.include_router(jobs_router, prefix="/api/v1")
app.include_router(photos_router, prefix="/api/v1")
app.include_router(faces_router, prefix="/api/v1")
app.include_router(people_router, prefix="/api/v1")
app.include_router(videos_router, prefix="/api/v1")
app.include_router(pending_identifications_router, prefix="/api/v1")
app.include_router(pending_linkages_router, prefix="/api/v1")
app.include_router(reported_photos_router, prefix="/api/v1")
app.include_router(pending_photos_router, prefix="/api/v1")
app.include_router(tags_router, prefix="/api/v1")
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")
return app
app = create_app()

29
backend/config.py Normal file
View File

@ -0,0 +1,29 @@
"""Configuration values used by the PunimTag web services.
This module replaces the legacy desktop configuration to keep the web
application self-contained.
"""
from __future__ import annotations
# Supported image formats for uploads/imports
SUPPORTED_IMAGE_FORMATS = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif"}
# Supported video formats for scanning (not processed for faces)
SUPPORTED_VIDEO_FORMATS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".flv", ".wmv", ".mpg", ".mpeg"}
# DeepFace behavior
DEEPFACE_ENFORCE_DETECTION = False
DEEPFACE_ALIGN_FACES = True
# Face filtering thresholds
MIN_FACE_CONFIDENCE = 0.4
MIN_FACE_SIZE = 40
MAX_FACE_SIZE = 1500
# Matching tolerance and calibration options
DEFAULT_FACE_TOLERANCE = 0.6
USE_CALIBRATED_CONFIDENCE = True
CONFIDENCE_CALIBRATION_METHOD = "empirical" # "empirical", "linear", or "sigmoid"

View File

@ -0,0 +1,43 @@
"""Feature definitions and default role permissions."""
from __future__ import annotations
from typing import Dict, Final, List, Set
from backend.constants.roles import UserRole
ROLE_FEATURES: Final[List[dict[str, str]]] = [
{"key": "scan", "label": "Scan"},
{"key": "process", "label": "Process"},
{"key": "search_photos", "label": "Search Photos"},
{"key": "identify_people", "label": "Identify People"},
{"key": "auto_match", "label": "Auto-Match"},
{"key": "modify_people", "label": "Modify People"},
{"key": "tag_photos", "label": "Tag Photos"},
{"key": "faces_maintenance", "label": "Faces Maintenance"},
{"key": "user_identified", "label": "User Identified"},
{"key": "user_reported", "label": "User Reported"},
{"key": "user_tagged", "label": "User Tagged Photos"},
{"key": "user_uploaded", "label": "User Uploaded"},
{"key": "manage_users", "label": "Manage Users"},
{"key": "manage_roles", "label": "Manage Roles"},
]
ROLE_FEATURE_KEYS: Final[List[str]] = [feature["key"] for feature in ROLE_FEATURES]
DEFAULT_ROLE_FEATURE_MATRIX: Final[Dict[str, Set[str]]] = {
UserRole.ADMIN.value: set(ROLE_FEATURE_KEYS),
UserRole.MANAGER.value: set(ROLE_FEATURE_KEYS),
UserRole.MODERATOR.value: {"scan", "process", "manage_users"},
UserRole.REVIEWER.value: {"user_identified", "user_reported", "user_uploaded", "user_tagged"},
UserRole.EDITOR.value: {"user_identified", "user_uploaded", "manage_users", "user_tagged"},
UserRole.IMPORTER.value: {"user_uploaded"},
UserRole.VIEWER.value: {"user_identified", "user_reported", "user_tagged"},
}
def get_default_permission(role: str, feature_key: str) -> bool:
"""Return the default allowed value for a role/feature pair."""
allowed_features = DEFAULT_ROLE_FEATURE_MATRIX.get(role, set())
return feature_key in allowed_features

View File

@ -0,0 +1,32 @@
"""Shared role definitions for backend user management."""
from __future__ import annotations
from enum import Enum
from typing import Final, Set
class UserRole(str, Enum):
"""Enumerated set of supported user roles."""
ADMIN = "admin"
MANAGER = "manager"
MODERATOR = "moderator"
REVIEWER = "reviewer"
EDITOR = "editor"
IMPORTER = "importer"
VIEWER = "viewer"
ROLE_VALUES: Final[Set[str]] = {role.value for role in UserRole}
ADMIN_ROLE_VALUES: Final[Set[str]] = {
UserRole.ADMIN.value,
}
DEFAULT_ADMIN_ROLE: Final[str] = UserRole.ADMIN.value
DEFAULT_USER_ROLE: Final[str] = UserRole.VIEWER.value
def is_admin_role(role: str) -> bool:
"""Return True when the provided role is considered an admin role."""
return role in ADMIN_ROLE_VALUES

3
backend/db/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""Database package for PunimTag Web."""

9
backend/db/base.py Normal file
View File

@ -0,0 +1,9 @@
"""Database base configuration."""
from __future__ import annotations
from backend.db.models import Base
from backend.db.session import engine
__all__ = ["Base", "engine"]

286
backend/db/models.py Normal file
View File

@ -0,0 +1,286 @@
"""SQLAlchemy models for PunimTag Web - matching desktop schema exactly."""
from __future__ import annotations
from datetime import datetime, date
from typing import TYPE_CHECKING
from sqlalchemy import (
Boolean,
Column,
Date,
DateTime,
ForeignKey,
Index,
Integer,
LargeBinary,
Numeric,
Text,
UniqueConstraint,
CheckConstraint,
)
from sqlalchemy.orm import declarative_base, relationship
from backend.constants.roles import DEFAULT_USER_ROLE
if TYPE_CHECKING:
pass
Base = declarative_base()
class Photo(Base):
"""Photo model - matches desktop schema exactly."""
__tablename__ = "photos"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
path = Column(Text, unique=True, nullable=False, index=True)
filename = Column(Text, nullable=False)
date_added = Column(DateTime, default=datetime.utcnow, nullable=False)
date_taken = Column(Date, nullable=True, index=True)
processed = Column(Boolean, default=False, nullable=False, index=True)
file_hash = Column(Text, nullable=True, index=True) # Nullable to support existing photos without hashes
media_type = Column(Text, default="image", nullable=False, index=True) # "image" or "video"
faces = relationship("Face", back_populates="photo", cascade="all, delete-orphan")
photo_tags = relationship(
"PhotoTagLinkage", back_populates="photo", cascade="all, delete-orphan"
)
favorites = relationship("PhotoFavorite", back_populates="photo", cascade="all, delete-orphan")
video_people = relationship(
"PhotoPersonLinkage", back_populates="photo", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_photos_processed", "processed"),
Index("idx_photos_date_taken", "date_taken"),
Index("idx_photos_date_added", "date_added"),
Index("idx_photos_file_hash", "file_hash"),
)
class Person(Base):
"""Person model - matches desktop schema exactly."""
__tablename__ = "people"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
first_name = Column(Text, nullable=False)
last_name = Column(Text, nullable=False)
middle_name = Column(Text, nullable=True)
maiden_name = Column(Text, nullable=True)
date_of_birth = Column(Date, nullable=True)
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
faces = relationship("Face", back_populates="person")
person_encodings = relationship(
"PersonEncoding", back_populates="person", cascade="all, delete-orphan"
)
video_photos = relationship(
"PhotoPersonLinkage", back_populates="person", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint(
"first_name", "last_name", "middle_name", "maiden_name", "date_of_birth",
name="uq_people_names_dob"
),
)
class Face(Base):
"""Face detection model - matches desktop schema exactly."""
__tablename__ = "faces"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
person_id = Column(Integer, ForeignKey("people.id"), nullable=True, index=True)
encoding = Column(LargeBinary, nullable=False)
location = Column(Text, nullable=False)
confidence = Column(Numeric, default=0.0, nullable=False)
quality_score = Column(Numeric, default=0.0, nullable=False, index=True)
is_primary_encoding = Column(Boolean, default=False, nullable=False)
detector_backend = Column(Text, default="retinaface", nullable=False)
model_name = Column(Text, default="ArcFace", nullable=False)
face_confidence = Column(Numeric, default=0.0, nullable=False)
exif_orientation = Column(Integer, nullable=True)
pose_mode = Column(Text, default="frontal", nullable=False, index=True)
yaw_angle = Column(Numeric, nullable=True)
pitch_angle = Column(Numeric, nullable=True)
roll_angle = Column(Numeric, nullable=True)
landmarks = Column(Text, nullable=True) # JSON string of facial landmarks
identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
excluded = Column(Boolean, default=False, nullable=False, index=True) # Exclude from identification
photo = relationship("Photo", back_populates="faces")
person = relationship("Person", back_populates="faces")
person_encodings = relationship(
"PersonEncoding", back_populates="face", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_faces_person_id", "person_id"),
Index("idx_faces_photo_id", "photo_id"),
Index("idx_faces_quality", "quality_score"),
Index("idx_faces_pose_mode", "pose_mode"),
Index("idx_faces_identified_by", "identified_by_user_id"),
Index("idx_faces_excluded", "excluded"),
)
class PersonEncoding(Base):
"""Person encoding model - matches desktop schema exactly (was person_encodings)."""
__tablename__ = "person_encodings"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
person_id = Column(Integer, ForeignKey("people.id"), nullable=False, index=True)
face_id = Column(Integer, ForeignKey("faces.id"), nullable=False, index=True)
encoding = Column(LargeBinary, nullable=False)
quality_score = Column(Numeric, default=0.0, nullable=False, index=True)
detector_backend = Column(Text, default="retinaface", nullable=False)
model_name = Column(Text, default="ArcFace", nullable=False)
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
person = relationship("Person", back_populates="person_encodings")
face = relationship("Face", back_populates="person_encodings")
__table_args__ = (
Index("idx_person_encodings_person_id", "person_id"),
Index("idx_person_encodings_quality", "quality_score"),
)
class Tag(Base):
"""Tag model - matches desktop schema exactly."""
__tablename__ = "tags"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
tag_name = Column(Text, unique=True, nullable=False, index=True)
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
photo_tags = relationship(
"PhotoTagLinkage", back_populates="tag", cascade="all, delete-orphan"
)
class PhotoTagLinkage(Base):
"""Photo-Tag linkage model - matches desktop schema exactly (was phototaglinkage)."""
__tablename__ = "phototaglinkage"
linkage_id = Column(Integer, primary_key=True, autoincrement=True)
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
tag_id = Column(Integer, ForeignKey("tags.id"), nullable=False, index=True)
linkage_type = Column(
Integer, default=0, nullable=False,
server_default="0"
)
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
photo = relationship("Photo", back_populates="photo_tags")
tag = relationship("Tag", back_populates="photo_tags")
__table_args__ = (
UniqueConstraint("photo_id", "tag_id", name="uq_photo_tag"),
CheckConstraint("linkage_type IN (0, 1)", name="ck_linkage_type"),
Index("idx_photo_tags_tag", "tag_id"),
Index("idx_photo_tags_photo", "photo_id"),
)
class PhotoFavorite(Base):
"""Photo favorites model - user-specific favorites."""
__tablename__ = "photo_favorites"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(Text, nullable=False, index=True)
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
photo = relationship("Photo", back_populates="favorites")
__table_args__ = (
UniqueConstraint("username", "photo_id", name="uq_user_photo_favorite"),
Index("idx_favorites_username", "username"),
Index("idx_favorites_photo", "photo_id"),
)
class User(Base):
"""User model for main database - separate from auth database users."""
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
username = Column(Text, unique=True, nullable=False, index=True)
password_hash = Column(Text, nullable=False) # Hashed password
email = Column(Text, unique=True, nullable=False, index=True)
full_name = Column(Text, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False, index=True)
role = Column(
Text,
nullable=False,
default=DEFAULT_USER_ROLE,
server_default=DEFAULT_USER_ROLE,
index=True,
)
password_change_required = Column(Boolean, default=True, nullable=False, index=True)
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
last_login = Column(DateTime, nullable=True)
__table_args__ = (
Index("idx_users_username", "username"),
Index("idx_users_email", "email"),
Index("idx_users_is_admin", "is_admin"),
Index("idx_users_password_change_required", "password_change_required"),
Index("idx_users_role", "role"),
)
class PhotoPersonLinkage(Base):
"""Direct linkage between Video (Photo with media_type='video') and Person.
This allows identifying people in videos without requiring face detection.
Only used for videos, not photos (photos use Face model for identification).
"""
__tablename__ = "photo_person_linkage"
id = Column(Integer, primary_key=True, autoincrement=True)
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
person_id = Column(Integer, ForeignKey("people.id"), nullable=False, index=True)
identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
photo = relationship("Photo", back_populates="video_people")
person = relationship("Person", back_populates="video_photos")
__table_args__ = (
UniqueConstraint("photo_id", "person_id", name="uq_photo_person"),
Index("idx_photo_person_photo", "photo_id"),
Index("idx_photo_person_person", "person_id"),
Index("idx_photo_person_user", "identified_by_user_id"),
)
class RolePermission(Base):
"""Role-to-feature permission matrix."""
__tablename__ = "role_permissions"
id = Column(Integer, primary_key=True, autoincrement=True)
role = Column(Text, nullable=False, index=True)
feature_key = Column(Text, nullable=False, index=True)
allowed = Column(Boolean, nullable=False, default=False, server_default="0")
__table_args__ = (
UniqueConstraint("role", "feature_key", name="uq_role_feature"),
Index("idx_role_permissions_role_feature", "role", "feature_key"),
)

106
backend/db/session.py Normal file
View File

@ -0,0 +1,106 @@
from __future__ import annotations
from pathlib import Path
from typing import Generator
from dotenv import load_dotenv
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Load environment variables from .env file if it exists
# Path: backend/db/session.py -> backend/db -> backend -> punimtag/ -> .env
env_path = Path(__file__).parent.parent.parent / ".env"
load_dotenv(dotenv_path=env_path)
def get_database_url() -> str:
"""Fetch database URL from environment or defaults."""
import os
# Check for environment variable first
db_url = os.getenv("DATABASE_URL")
if db_url:
return db_url
# Default to PostgreSQL for development
return "postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag"
def get_auth_database_url() -> str:
"""Fetch auth database URL from environment."""
import os
db_url = os.getenv("DATABASE_URL_AUTH")
if not db_url:
raise ValueError("DATABASE_URL_AUTH environment variable not set")
return db_url
database_url = get_database_url()
# PostgreSQL connection pool settings
pool_kwargs = {
"pool_pre_ping": True,
"pool_size": 10,
"max_overflow": 20,
"pool_recycle": 3600,
}
engine = create_engine(
database_url,
future=True,
**pool_kwargs
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
def get_db() -> Generator:
"""Yield a DB session for request lifecycle."""
db = SessionLocal()
try:
yield db
finally:
db.close()
# Auth database setup
try:
auth_database_url = get_auth_database_url()
auth_pool_kwargs = {
"pool_pre_ping": True,
"pool_size": 10,
"max_overflow": 20,
"pool_recycle": 3600,
}
auth_engine = create_engine(
auth_database_url,
future=True,
**auth_pool_kwargs
)
AuthSessionLocal = sessionmaker(bind=auth_engine, autoflush=False, autocommit=False, future=True)
except ValueError as e:
# DATABASE_URL_AUTH not set - auth database not available
print(f"[DB Session] ⚠️ Auth database not configured: {e}")
auth_engine = None
AuthSessionLocal = None
except Exception as e:
# Other errors (connection failures, etc.) - log but don't crash
import os
print(f"[DB Session] ⚠️ Failed to initialize auth database: {e}")
print(f"[DB Session] URL was: {os.getenv('DATABASE_URL_AUTH', 'not set')}")
auth_engine = None
AuthSessionLocal = None
def get_auth_db() -> Generator:
"""Yield a DB session for auth database request lifecycle."""
if AuthSessionLocal is None:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Auth database not configured. Please set DATABASE_URL_AUTH environment variable in the backend configuration."
)
db = AuthSessionLocal()
try:
yield db
finally:
db.close()

View File

@ -0,0 +1,2 @@
"""Pydantic schemas for PunimTag Web."""

65
backend/schemas/auth.py Normal file
View File

@ -0,0 +1,65 @@
"""Authentication schemas for web API."""
from __future__ import annotations
from typing import Dict
from pydantic import BaseModel, ConfigDict
from backend.constants.roles import DEFAULT_USER_ROLE, UserRole
class LoginRequest(BaseModel):
"""Login request payload."""
model_config = ConfigDict(protected_namespaces=())
username: str
password: str
class RefreshRequest(BaseModel):
"""Refresh token request payload."""
model_config = ConfigDict(protected_namespaces=())
refresh_token: str
class TokenResponse(BaseModel):
"""Token response payload."""
model_config = ConfigDict(protected_namespaces=())
access_token: str
refresh_token: str
password_change_required: bool = False
class UserResponse(BaseModel):
"""User response payload."""
model_config = ConfigDict(protected_namespaces=())
username: str
is_admin: bool = False
role: UserRole = DEFAULT_USER_ROLE
permissions: Dict[str, bool] = {}
class PasswordChangeRequest(BaseModel):
"""Password change request payload."""
model_config = ConfigDict(protected_namespaces=())
current_password: str
new_password: str
class PasswordChangeResponse(BaseModel):
"""Password change response payload."""
model_config = ConfigDict(protected_namespaces=())
success: bool
message: str

View File

@ -0,0 +1,61 @@
"""Auth database user management schemas for web API."""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class AuthUserResponse(BaseModel):
"""Auth user DTO returned from API."""
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
id: int
name: Optional[str] = None
email: str
is_admin: Optional[bool] = None
has_write_access: Optional[bool] = None
is_active: Optional[bool] = None
role: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class AuthUserCreateRequest(BaseModel):
"""Request payload to create a new auth user."""
model_config = ConfigDict(protected_namespaces=())
email: EmailStr = Field(..., description="Email address (unique, required)")
name: str = Field(..., min_length=1, max_length=200, description="Name (required)")
password: str = Field(..., min_length=6, description="Password (minimum 6 characters, required)")
is_admin: bool = Field(..., description="Admin role (required)")
has_write_access: bool = Field(..., description="Write access (required)")
class AuthUserUpdateRequest(BaseModel):
"""Request payload to update an auth user."""
model_config = ConfigDict(protected_namespaces=())
email: EmailStr = Field(..., description="Email address (required)")
name: str = Field(..., min_length=1, max_length=200, description="Name (required)")
is_admin: bool = Field(..., description="Admin role (required)")
has_write_access: bool = Field(..., description="Write access (required)")
is_active: Optional[bool] = Field(None, description="Active status (optional)")
role: Optional[str] = Field(None, description="Role: 'Admin' or 'User' (optional)")
password: Optional[str] = Field(None, min_length=6, description="New password (optional, minimum 6 characters, leave empty to keep current)")
class AuthUsersListResponse(BaseModel):
"""List of auth users."""
model_config = ConfigDict(protected_namespaces=())
items: list[AuthUserResponse]
total: int

Some files were not shown because too many files have changed in this diff Show More