feat: complete LOW priority code quality improvements
Backend: - LOW-002: Add Query validation with max page size limits (100) - LOW-003: Replace magic strings with TaskStatus.is_done flag - LOW-004: Add 'creation' trigger type validation - Add action_executor.py with UpdateFieldAction and AutoAssignAction Frontend: - LOW-005: Replace TypeScript 'any' with 'unknown' + type guards - LOW-006: Add ConfirmModal component with A11Y support - LOW-007: Add ToastContext for user feedback notifications - LOW-009: Add Skeleton components (17 loading states replaced) - LOW-010: Setup Vitest with 21 tests for ConfirmModal and Skeleton Components updated: - App.tsx, ProtectedRoute.tsx, Spaces.tsx, Projects.tsx, Tasks.tsx - ProjectSettings.tsx, AuditPage.tsx, WorkloadPage.tsx, ProjectHealthPage.tsx - Comments.tsx, AttachmentList.tsx, TriggerList.tsx, TaskDetailModal.tsx - NotificationBell.tsx, BlockerDialog.tsx, CalendarView.tsx, WorkloadUserDetail.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,9 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E
|
||||
|
||||
Returns:
|
||||
Tuple of (should_encrypt, encryption_key)
|
||||
|
||||
Raises:
|
||||
HTTPException: If project is confidential but encryption is not available
|
||||
"""
|
||||
# Only encrypt for confidential projects
|
||||
if project.security_level != "confidential":
|
||||
@@ -125,11 +128,14 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E
|
||||
|
||||
# Check if encryption is available
|
||||
if not encryption_service.is_encryption_available():
|
||||
logger.warning(
|
||||
logger.error(
|
||||
f"Project {project.id} is confidential but encryption is not configured. "
|
||||
"Files will be stored unencrypted."
|
||||
"Rejecting file upload to maintain security."
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Confidential project requires encryption. Please configure ENCRYPTION_MASTER_KEY environment variable."
|
||||
)
|
||||
return False, None
|
||||
|
||||
# Get active encryption key
|
||||
active_key = db.query(EncryptionKey).filter(
|
||||
@@ -137,11 +143,14 @@ def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[E
|
||||
).first()
|
||||
|
||||
if not active_key:
|
||||
logger.warning(
|
||||
logger.error(
|
||||
f"Project {project.id} is confidential but no active encryption key exists. "
|
||||
"Files will be stored unencrypted. Create a key using /api/admin/encryption-keys/rotate"
|
||||
"Rejecting file upload to maintain security."
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Confidential project requires encryption. Please create an active encryption key first."
|
||||
)
|
||||
return False, None
|
||||
|
||||
return True, active_key
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
@@ -13,8 +13,8 @@ router = APIRouter()
|
||||
|
||||
@router.get("", response_model=List[DepartmentResponse])
|
||||
async def list_departments(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
skip: int = Query(0, ge=0, description="Number of departments to skip"),
|
||||
limit: int = Query(100, ge=1, le=200, description="Max departments to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("users.read")),
|
||||
):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
@@ -71,8 +71,8 @@ async def generate_weekly_report(
|
||||
|
||||
@router.get("/api/reports/history", response_model=ReportHistoryListResponse)
|
||||
async def list_report_history(
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
limit: int = Query(10, ge=1, le=100, description="Number of reports to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of reports to skip"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
@@ -11,6 +11,8 @@ from app.schemas.trigger import (
|
||||
)
|
||||
from app.middleware.auth import get_current_user, check_project_access, check_project_edit_access
|
||||
from app.services.trigger_scheduler import TriggerSchedulerService
|
||||
from app.services.trigger_service import TriggerService
|
||||
from app.services.action_executor import ActionValidationError
|
||||
|
||||
router = APIRouter(tags=["triggers"])
|
||||
|
||||
@@ -60,10 +62,11 @@ async def create_trigger(
|
||||
)
|
||||
|
||||
# Validate trigger type
|
||||
if trigger_data.trigger_type not in ["field_change", "schedule"]:
|
||||
valid_trigger_types = ["field_change", "schedule", "creation"]
|
||||
if trigger_data.trigger_type not in valid_trigger_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid trigger type. Must be 'field_change' or 'schedule'",
|
||||
detail=f"Invalid trigger type. Must be one of: {', '.join(valid_trigger_types)}",
|
||||
)
|
||||
|
||||
# Validate conditions based on trigger type
|
||||
@@ -111,6 +114,16 @@ async def create_trigger(
|
||||
detail=error_msg or "Invalid cron expression",
|
||||
)
|
||||
|
||||
# Validate actions configuration (FEAT-014, FEAT-015)
|
||||
try:
|
||||
actions_dicts = [a.model_dump(exclude_none=True) for a in trigger_data.actions]
|
||||
TriggerService.validate_actions(actions_dicts, db)
|
||||
except ActionValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
# Create trigger
|
||||
trigger = Trigger(
|
||||
id=str(uuid.uuid4()),
|
||||
@@ -119,7 +132,7 @@ async def create_trigger(
|
||||
description=trigger_data.description,
|
||||
trigger_type=trigger_data.trigger_type,
|
||||
conditions=trigger_data.conditions.model_dump(),
|
||||
actions=[a.model_dump() for a in trigger_data.actions],
|
||||
actions=[a.model_dump(exclude_none=True) for a in trigger_data.actions],
|
||||
is_active=trigger_data.is_active,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
@@ -239,7 +252,16 @@ async def update_trigger(
|
||||
)
|
||||
trigger.conditions = trigger_data.conditions.model_dump(exclude_none=True)
|
||||
if trigger_data.actions is not None:
|
||||
trigger.actions = [a.model_dump() for a in trigger_data.actions]
|
||||
# Validate actions configuration (FEAT-014, FEAT-015)
|
||||
try:
|
||||
actions_dicts = [a.model_dump(exclude_none=True) for a in trigger_data.actions]
|
||||
TriggerService.validate_actions(actions_dicts, db)
|
||||
except ActionValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
trigger.actions = [a.model_dump(exclude_none=True) for a in trigger_data.actions]
|
||||
if trigger_data.is_active is not None:
|
||||
trigger.is_active = trigger_data.is_active
|
||||
|
||||
@@ -278,8 +300,8 @@ async def delete_trigger(
|
||||
@router.get("/api/triggers/{trigger_id}/logs", response_model=TriggerLogListResponse)
|
||||
async def list_trigger_logs(
|
||||
trigger_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
limit: int = Query(50, ge=1, le=200, description="Number of logs to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of logs to skip"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
|
||||
@@ -50,8 +50,8 @@ async def search_users(
|
||||
|
||||
@router.get("", response_model=List[UserResponse])
|
||||
async def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
skip: int = Query(0, ge=0, description="Number of users to skip"),
|
||||
limit: int = Query(100, ge=1, le=500, description="Max users to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("users.read")),
|
||||
):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, computed_field
|
||||
from typing import Optional, List, Any, Dict
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
@@ -79,6 +79,12 @@ class TaskResponse(TaskBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Alias for original_estimate for frontend compatibility
|
||||
@computed_field
|
||||
@property
|
||||
def time_estimate(self) -> Optional[Decimal]:
|
||||
return self.original_estimate
|
||||
|
||||
|
||||
class TaskWithDetails(TaskResponse):
|
||||
assignee_name: Optional[str] = None
|
||||
|
||||
@@ -28,9 +28,23 @@ class TriggerCondition(BaseModel):
|
||||
|
||||
|
||||
class TriggerAction(BaseModel):
|
||||
type: str = Field(default="notify", description="Action type: notify")
|
||||
target: str = Field(default="assignee", description="Target: assignee, creator, project_owner, user:<id>")
|
||||
"""Action configuration for triggers.
|
||||
|
||||
Supported action types:
|
||||
- notify: Send notification (requires target, optional template)
|
||||
- update_field: Update task field (requires field, value)
|
||||
- auto_assign: Auto-assign task (requires strategy, optional user_id for specific_user)
|
||||
"""
|
||||
type: str = Field(..., description="Action type: notify, update_field, auto_assign")
|
||||
# Notify action fields
|
||||
target: Optional[str] = Field(None, description="Target: assignee, creator, project_owner, user:<id>")
|
||||
template: Optional[str] = Field(None, description="Message template with variables")
|
||||
# update_field action fields (FEAT-014)
|
||||
field: Optional[str] = Field(None, description="Field to update: priority, status_id, due_date")
|
||||
value: Optional[Any] = Field(None, description="New value for the field")
|
||||
# auto_assign action fields (FEAT-015)
|
||||
strategy: Optional[str] = Field(None, description="Strategy: round_robin, least_loaded, specific_user")
|
||||
user_id: Optional[str] = Field(None, description="User ID for specific_user strategy")
|
||||
|
||||
|
||||
class TriggerCreate(BaseModel):
|
||||
|
||||
484
backend/app/services/action_executor.py
Normal file
484
backend/app/services/action_executor.py
Normal file
@@ -0,0 +1,484 @@
|
||||
"""Action executor service for automation triggers.
|
||||
|
||||
This module provides the action execution framework for the automation system.
|
||||
It supports extensible action types through a registry pattern.
|
||||
|
||||
FEAT-014: update_field - Update task field values
|
||||
FEAT-015: auto_assign - Automatic task assignment with multiple strategies
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Task, User, TaskStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ActionExecutionError(Exception):
|
||||
"""Exception raised when action execution fails."""
|
||||
pass
|
||||
|
||||
|
||||
class ActionValidationError(Exception):
|
||||
"""Exception raised when action config validation fails."""
|
||||
pass
|
||||
|
||||
|
||||
class BaseAction(ABC):
|
||||
"""Base class for all action types."""
|
||||
|
||||
action_type: str = ""
|
||||
|
||||
@abstractmethod
|
||||
def validate_config(self, config: Dict[str, Any], db: Session) -> None:
|
||||
"""Validate action configuration.
|
||||
|
||||
Args:
|
||||
config: Action configuration dict
|
||||
db: Database session for validation queries
|
||||
|
||||
Raises:
|
||||
ActionValidationError: If config is invalid
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def execute(
|
||||
self,
|
||||
db: Session,
|
||||
task: Task,
|
||||
config: Dict[str, Any],
|
||||
context: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute the action.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
task: Target task
|
||||
config: Action configuration
|
||||
context: Execution context (old_values, new_values, current_user, etc.)
|
||||
|
||||
Returns:
|
||||
Dict with execution result details
|
||||
|
||||
Raises:
|
||||
ActionExecutionError: If execution fails
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class UpdateFieldAction(BaseAction):
|
||||
"""Action to update task field values (FEAT-014).
|
||||
|
||||
Supported fields:
|
||||
- priority: low, medium, high, urgent
|
||||
- status_id: Valid status ID for the task's project
|
||||
- due_date: ISO format date string
|
||||
|
||||
Config format:
|
||||
{
|
||||
"field": "priority",
|
||||
"value": "high"
|
||||
}
|
||||
"""
|
||||
|
||||
action_type = "update_field"
|
||||
|
||||
# Standard fields that can be updated
|
||||
UPDATABLE_FIELDS = {
|
||||
"priority": ["low", "medium", "high", "urgent"],
|
||||
"status_id": None, # Validated dynamically
|
||||
"due_date": None, # Date string validation
|
||||
}
|
||||
|
||||
def validate_config(self, config: Dict[str, Any], db: Session) -> None:
|
||||
"""Validate update_field configuration."""
|
||||
field = config.get("field")
|
||||
value = config.get("value")
|
||||
|
||||
if not field:
|
||||
raise ActionValidationError("Missing required 'field' in update_field config")
|
||||
|
||||
if value is None:
|
||||
raise ActionValidationError("Missing required 'value' in update_field config")
|
||||
|
||||
if field not in self.UPDATABLE_FIELDS:
|
||||
raise ActionValidationError(
|
||||
f"Invalid field '{field}'. Supported fields: {list(self.UPDATABLE_FIELDS.keys())}"
|
||||
)
|
||||
|
||||
# Validate priority values
|
||||
if field == "priority":
|
||||
valid_values = self.UPDATABLE_FIELDS["priority"]
|
||||
if value not in valid_values:
|
||||
raise ActionValidationError(
|
||||
f"Invalid priority value '{value}'. Valid values: {valid_values}"
|
||||
)
|
||||
|
||||
# Validate due_date format
|
||||
if field == "due_date" and value:
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise ActionValidationError(
|
||||
f"Invalid due_date format '{value}'. Use ISO format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)"
|
||||
)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
db: Session,
|
||||
task: Task,
|
||||
config: Dict[str, Any],
|
||||
context: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute field update action."""
|
||||
field = config["field"]
|
||||
value = config["value"]
|
||||
old_value = getattr(task, field, None)
|
||||
|
||||
logger.info(
|
||||
f"Executing update_field action: task={task.id}, field={field}, "
|
||||
f"old_value={old_value}, new_value={value}"
|
||||
)
|
||||
|
||||
# Validate status_id exists for this project
|
||||
if field == "status_id" and value:
|
||||
status = db.query(TaskStatus).filter(
|
||||
TaskStatus.id == value,
|
||||
TaskStatus.project_id == task.project_id,
|
||||
).first()
|
||||
if not status:
|
||||
raise ActionExecutionError(
|
||||
f"Status ID '{value}' not found in project {task.project_id}"
|
||||
)
|
||||
|
||||
# Convert due_date string to datetime
|
||||
if field == "due_date" and value:
|
||||
if isinstance(value, str):
|
||||
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
# Update the field
|
||||
setattr(task, field, value)
|
||||
task.updated_at = datetime.utcnow()
|
||||
|
||||
return {
|
||||
"action_type": self.action_type,
|
||||
"status": "success",
|
||||
"field": field,
|
||||
"old_value": str(old_value) if old_value else None,
|
||||
"new_value": str(value) if value else None,
|
||||
}
|
||||
|
||||
|
||||
class AutoAssignAction(BaseAction):
|
||||
"""Action for automatic task assignment (FEAT-015).
|
||||
|
||||
Strategies:
|
||||
- round_robin: Assign to project members in rotation
|
||||
- least_loaded: Assign to member with lowest workload
|
||||
- specific_user: Assign to a specific user
|
||||
|
||||
Config format:
|
||||
{
|
||||
"strategy": "round_robin" | "least_loaded" | "specific_user",
|
||||
"user_id": "xxx" // Required only for specific_user strategy
|
||||
}
|
||||
"""
|
||||
|
||||
action_type = "auto_assign"
|
||||
|
||||
VALID_STRATEGIES = ["round_robin", "least_loaded", "specific_user"]
|
||||
|
||||
# Class-level state for round-robin tracking per project
|
||||
_round_robin_index: Dict[str, int] = {}
|
||||
|
||||
def validate_config(self, config: Dict[str, Any], db: Session) -> None:
|
||||
"""Validate auto_assign configuration."""
|
||||
strategy = config.get("strategy")
|
||||
|
||||
if not strategy:
|
||||
raise ActionValidationError("Missing required 'strategy' in auto_assign config")
|
||||
|
||||
if strategy not in self.VALID_STRATEGIES:
|
||||
raise ActionValidationError(
|
||||
f"Invalid strategy '{strategy}'. Valid strategies: {self.VALID_STRATEGIES}"
|
||||
)
|
||||
|
||||
if strategy == "specific_user":
|
||||
user_id = config.get("user_id")
|
||||
if not user_id:
|
||||
raise ActionValidationError(
|
||||
"Missing required 'user_id' for specific_user strategy"
|
||||
)
|
||||
# Validate user exists
|
||||
user = db.query(User).filter(
|
||||
User.id == user_id,
|
||||
User.is_active == True,
|
||||
).first()
|
||||
if not user:
|
||||
raise ActionValidationError(f"User '{user_id}' not found or inactive")
|
||||
|
||||
def execute(
|
||||
self,
|
||||
db: Session,
|
||||
task: Task,
|
||||
config: Dict[str, Any],
|
||||
context: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute auto-assign action."""
|
||||
strategy = config["strategy"]
|
||||
old_assignee_id = task.assignee_id
|
||||
|
||||
logger.info(
|
||||
f"Executing auto_assign action: task={task.id}, strategy={strategy}, "
|
||||
f"old_assignee={old_assignee_id}"
|
||||
)
|
||||
|
||||
if strategy == "specific_user":
|
||||
new_assignee_id = self._assign_specific_user(db, config)
|
||||
elif strategy == "round_robin":
|
||||
new_assignee_id = self._assign_round_robin(db, task)
|
||||
elif strategy == "least_loaded":
|
||||
new_assignee_id = self._assign_least_loaded(db, task)
|
||||
else:
|
||||
raise ActionExecutionError(f"Unknown strategy: {strategy}")
|
||||
|
||||
if new_assignee_id:
|
||||
task.assignee_id = new_assignee_id
|
||||
task.updated_at = datetime.utcnow()
|
||||
|
||||
# Get assignee name for logging
|
||||
assignee = db.query(User).filter(User.id == new_assignee_id).first()
|
||||
assignee_name = assignee.name if assignee else "Unknown"
|
||||
|
||||
logger.info(
|
||||
f"Task {task.id} assigned to user {new_assignee_id} ({assignee_name}) "
|
||||
f"using {strategy} strategy"
|
||||
)
|
||||
|
||||
return {
|
||||
"action_type": self.action_type,
|
||||
"status": "success",
|
||||
"strategy": strategy,
|
||||
"old_assignee_id": old_assignee_id,
|
||||
"new_assignee_id": new_assignee_id,
|
||||
"assignee_name": assignee_name,
|
||||
}
|
||||
else:
|
||||
logger.warning(
|
||||
f"No eligible assignee found for task {task.id} using {strategy} strategy"
|
||||
)
|
||||
return {
|
||||
"action_type": self.action_type,
|
||||
"status": "skipped",
|
||||
"strategy": strategy,
|
||||
"reason": "No eligible assignee found",
|
||||
}
|
||||
|
||||
def _assign_specific_user(self, db: Session, config: Dict[str, Any]) -> Optional[str]:
|
||||
"""Assign to a specific user."""
|
||||
user_id = config["user_id"]
|
||||
user = db.query(User).filter(
|
||||
User.id == user_id,
|
||||
User.is_active == True,
|
||||
).first()
|
||||
return user.id if user else None
|
||||
|
||||
def _assign_round_robin(self, db: Session, task: Task) -> Optional[str]:
|
||||
"""Assign using round-robin strategy among project members.
|
||||
|
||||
Gets all active users in the same department as the project owner,
|
||||
then rotates through them.
|
||||
"""
|
||||
project = task.project
|
||||
if not project:
|
||||
return None
|
||||
|
||||
# Get project members: owner + users in same department
|
||||
members = self._get_project_members(db, project)
|
||||
|
||||
if not members:
|
||||
return None
|
||||
|
||||
# Get or initialize round-robin index for this project
|
||||
project_id = project.id
|
||||
if project_id not in self._round_robin_index:
|
||||
self._round_robin_index[project_id] = 0
|
||||
|
||||
# Get next member in rotation
|
||||
index = self._round_robin_index[project_id] % len(members)
|
||||
selected_user = members[index]
|
||||
|
||||
# Update index for next assignment
|
||||
self._round_robin_index[project_id] = (index + 1) % len(members)
|
||||
|
||||
return selected_user.id
|
||||
|
||||
def _assign_least_loaded(self, db: Session, task: Task) -> Optional[str]:
|
||||
"""Assign to the project member with lowest workload.
|
||||
|
||||
Workload is calculated based on number of incomplete tasks assigned.
|
||||
"""
|
||||
project = task.project
|
||||
if not project:
|
||||
return None
|
||||
|
||||
members = self._get_project_members(db, project)
|
||||
|
||||
if not members:
|
||||
return None
|
||||
|
||||
# Calculate workload for each member (count of incomplete tasks)
|
||||
member_workloads = []
|
||||
for member in members:
|
||||
# Count incomplete tasks (status.is_done = False or no status)
|
||||
incomplete_count = (
|
||||
db.query(func.count(Task.id))
|
||||
.outerjoin(TaskStatus, Task.status_id == TaskStatus.id)
|
||||
.filter(
|
||||
Task.assignee_id == member.id,
|
||||
Task.is_deleted == False,
|
||||
(TaskStatus.is_done == False) | (Task.status_id == None),
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
member_workloads.append((member, incomplete_count))
|
||||
|
||||
# Sort by workload (ascending) and return the least loaded member
|
||||
member_workloads.sort(key=lambda x: x[1])
|
||||
|
||||
if member_workloads:
|
||||
selected_user, workload = member_workloads[0]
|
||||
logger.debug(
|
||||
f"Least loaded assignment: user={selected_user.id}, "
|
||||
f"current_workload={workload}"
|
||||
)
|
||||
return selected_user.id
|
||||
|
||||
return None
|
||||
|
||||
def _get_project_members(self, db: Session, project) -> List[User]:
|
||||
"""Get all potential assignees for a project.
|
||||
|
||||
Returns active users who are either:
|
||||
- The project owner
|
||||
- In the same department as the project
|
||||
"""
|
||||
owner_id = project.owner_id
|
||||
department_id = project.department_id
|
||||
|
||||
query = db.query(User).filter(User.is_active == True)
|
||||
|
||||
if department_id:
|
||||
# Get users in same department OR project owner
|
||||
query = query.filter(
|
||||
(User.department_id == department_id) | (User.id == owner_id)
|
||||
)
|
||||
else:
|
||||
# No department, just return owner
|
||||
query = query.filter(User.id == owner_id)
|
||||
|
||||
return query.all()
|
||||
|
||||
|
||||
class ActionExecutor:
|
||||
"""Registry and executor for action types.
|
||||
|
||||
Usage:
|
||||
executor = ActionExecutor()
|
||||
result = executor.execute_action(db, task, action_config, context)
|
||||
"""
|
||||
|
||||
_actions: Dict[str, Type[BaseAction]] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, action_class: Type[BaseAction]) -> None:
|
||||
"""Register an action type."""
|
||||
cls._actions[action_class.action_type] = action_class
|
||||
logger.debug(f"Registered action type: {action_class.action_type}")
|
||||
|
||||
@classmethod
|
||||
def get_supported_actions(cls) -> List[str]:
|
||||
"""Get list of supported action types."""
|
||||
return list(cls._actions.keys())
|
||||
|
||||
@classmethod
|
||||
def validate_action(cls, action: Dict[str, Any], db: Session) -> None:
|
||||
"""Validate an action configuration.
|
||||
|
||||
Args:
|
||||
action: Action dict with 'type' and other config
|
||||
db: Database session
|
||||
|
||||
Raises:
|
||||
ActionValidationError: If action is invalid
|
||||
"""
|
||||
action_type = action.get("type")
|
||||
|
||||
if not action_type:
|
||||
raise ActionValidationError("Missing action 'type'")
|
||||
|
||||
if action_type not in cls._actions:
|
||||
# Allow unknown actions (like 'notify') to pass through
|
||||
return
|
||||
|
||||
action_class = cls._actions[action_type]
|
||||
action_instance = action_class()
|
||||
config = action.get("config", action) # Support both nested and flat config
|
||||
action_instance.validate_config(config, db)
|
||||
|
||||
@classmethod
|
||||
def execute_action(
|
||||
cls,
|
||||
db: Session,
|
||||
task: Task,
|
||||
action: Dict[str, Any],
|
||||
context: Dict[str, Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Execute a single action.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
task: Target task
|
||||
action: Action dict with 'type' and other config
|
||||
context: Execution context
|
||||
|
||||
Returns:
|
||||
Execution result dict or None if action type not registered
|
||||
|
||||
Raises:
|
||||
ActionExecutionError: If execution fails
|
||||
"""
|
||||
action_type = action.get("type")
|
||||
|
||||
if action_type not in cls._actions:
|
||||
# Not a registered action (might be 'notify' handled elsewhere)
|
||||
return None
|
||||
|
||||
action_class = cls._actions[action_type]
|
||||
action_instance = action_class()
|
||||
|
||||
# Extract config - support both nested and flat config
|
||||
config = action.get("config", action)
|
||||
|
||||
try:
|
||||
result = action_instance.execute(db, task, config, context)
|
||||
return result
|
||||
except ActionExecutionError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error executing {action_type} action")
|
||||
raise ActionExecutionError(f"Failed to execute {action_type}: {str(e)}")
|
||||
|
||||
|
||||
# Register built-in actions
|
||||
ActionExecutor.register(UpdateFieldAction)
|
||||
ActionExecutor.register(AutoAssignAction)
|
||||
@@ -87,16 +87,17 @@ class ReportService:
|
||||
next_week_tasks = []
|
||||
|
||||
for task in all_tasks:
|
||||
status_name = task.status.name.lower() if task.status else ""
|
||||
is_done = status_name in ["done", "completed", "完成"]
|
||||
# Use TaskStatus.is_done flag instead of magic strings
|
||||
is_done = task.status.is_done if task.status else False
|
||||
|
||||
# Check if completed (updated this week)
|
||||
if is_done:
|
||||
if task.updated_at and task.updated_at >= week_start:
|
||||
completed_tasks.append(task)
|
||||
else:
|
||||
# Check if in progress
|
||||
if status_name in ["in progress", "進行中", "doing"]:
|
||||
# Check if task has active status (not done, not blocked)
|
||||
# Tasks without a done status are considered in progress
|
||||
if task.status and not task.status.is_done:
|
||||
in_progress_tasks.append(task)
|
||||
|
||||
# Check if overdue
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import uuid
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Trigger, TriggerLog, Task, User, Project
|
||||
from app.services.notification_service import NotificationService
|
||||
from app.services.action_executor import (
|
||||
ActionExecutor,
|
||||
ActionExecutionError,
|
||||
ActionValidationError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TriggerService:
|
||||
@@ -74,23 +82,78 @@ class TriggerService:
|
||||
old_values: Dict[str, Any],
|
||||
new_values: Dict[str, Any],
|
||||
) -> TriggerLog:
|
||||
"""Execute trigger actions and log the result."""
|
||||
"""Execute trigger actions and log the result.
|
||||
|
||||
Uses a database savepoint to ensure atomicity - if any action fails,
|
||||
all previously executed actions within this trigger are rolled back.
|
||||
"""
|
||||
actions = trigger.actions if isinstance(trigger.actions, list) else [trigger.actions]
|
||||
executed_actions = []
|
||||
error_message = None
|
||||
|
||||
# Build execution context
|
||||
context = {
|
||||
"old_values": old_values,
|
||||
"new_values": new_values,
|
||||
"current_user": current_user,
|
||||
"trigger": trigger,
|
||||
}
|
||||
|
||||
# Use savepoint for transaction atomicity - if any action fails,
|
||||
# all changes made by previous actions will be rolled back
|
||||
savepoint = db.begin_nested()
|
||||
try:
|
||||
for action in actions:
|
||||
action_type = action.get("type")
|
||||
|
||||
# Handle built-in notify action
|
||||
if action_type == "notify":
|
||||
TriggerService._execute_notify_action(db, action, task, current_user, old_values, new_values)
|
||||
executed_actions.append({"type": action_type, "status": "success"})
|
||||
|
||||
# Handle update_field action (FEAT-014)
|
||||
elif action_type == "update_field":
|
||||
result = ActionExecutor.execute_action(db, task, action, context)
|
||||
if result:
|
||||
executed_actions.append(result)
|
||||
logger.info(
|
||||
f"Trigger '{trigger.name}' executed update_field: "
|
||||
f"field={result.get('field')}, new_value={result.get('new_value')}"
|
||||
)
|
||||
|
||||
# Handle auto_assign action (FEAT-015)
|
||||
elif action_type == "auto_assign":
|
||||
result = ActionExecutor.execute_action(db, task, action, context)
|
||||
if result:
|
||||
executed_actions.append(result)
|
||||
logger.info(
|
||||
f"Trigger '{trigger.name}' executed auto_assign: "
|
||||
f"strategy={result.get('strategy')}, assignee={result.get('new_assignee_id')}"
|
||||
)
|
||||
|
||||
# Try to execute via ActionExecutor for extensibility
|
||||
else:
|
||||
result = ActionExecutor.execute_action(db, task, action, context)
|
||||
if result:
|
||||
executed_actions.append(result)
|
||||
|
||||
# All actions succeeded, commit the savepoint
|
||||
savepoint.commit()
|
||||
status = "success"
|
||||
except Exception as e:
|
||||
except ActionExecutionError as e:
|
||||
# Rollback all changes made by previously executed actions
|
||||
savepoint.rollback()
|
||||
status = "failed"
|
||||
error_message = str(e)
|
||||
executed_actions.append({"type": "error", "message": str(e)})
|
||||
logger.error(f"Trigger '{trigger.name}' action execution failed, rolling back: {e}")
|
||||
except Exception as e:
|
||||
# Rollback all changes made by previously executed actions
|
||||
savepoint.rollback()
|
||||
status = "failed"
|
||||
error_message = str(e)
|
||||
executed_actions.append({"type": "error", "message": str(e)})
|
||||
logger.exception(f"Trigger '{trigger.name}' unexpected error, rolling back: {e}")
|
||||
|
||||
log = TriggerLog(
|
||||
id=str(uuid.uuid4()),
|
||||
@@ -198,3 +261,36 @@ class TriggerService:
|
||||
)
|
||||
db.add(log)
|
||||
return log
|
||||
|
||||
@staticmethod
|
||||
def validate_actions(actions: List[Dict[str, Any]], db: Session) -> None:
|
||||
"""Validate trigger actions configuration.
|
||||
|
||||
Args:
|
||||
actions: List of action configurations
|
||||
db: Database session
|
||||
|
||||
Raises:
|
||||
ActionValidationError: If any action is invalid
|
||||
"""
|
||||
valid_action_types = ["notify", "update_field", "auto_assign"]
|
||||
|
||||
for action in actions:
|
||||
action_type = action.get("type")
|
||||
if not action_type:
|
||||
raise ActionValidationError("Missing action 'type'")
|
||||
|
||||
if action_type not in valid_action_types:
|
||||
raise ActionValidationError(
|
||||
f"Invalid action type '{action_type}'. "
|
||||
f"Valid types: {valid_action_types}"
|
||||
)
|
||||
|
||||
# Validate via ActionExecutor for extensible actions
|
||||
if action_type in ["update_field", "auto_assign"]:
|
||||
ActionExecutor.validate_action(action, db)
|
||||
|
||||
@staticmethod
|
||||
def get_supported_action_types() -> List[str]:
|
||||
"""Get list of all supported action types."""
|
||||
return ["notify"] + ActionExecutor.get_supported_actions()
|
||||
|
||||
700
backend/tests/test_action_executor.py
Normal file
700
backend/tests/test_action_executor.py
Normal file
@@ -0,0 +1,700 @@
|
||||
"""Tests for action_executor.py - FEAT-014 and FEAT-015.
|
||||
|
||||
This module tests the action executor framework and the two new action types:
|
||||
- update_field: Update task field values
|
||||
- auto_assign: Automatic task assignment with multiple strategies
|
||||
"""
|
||||
import pytest
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models import User, Space, Project, Task, TaskStatus, Trigger, TriggerLog, Department
|
||||
from app.services.action_executor import (
|
||||
ActionExecutor,
|
||||
UpdateFieldAction,
|
||||
AutoAssignAction,
|
||||
ActionExecutionError,
|
||||
ActionValidationError,
|
||||
)
|
||||
from app.services.trigger_service import TriggerService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_department(db):
|
||||
"""Create a test department."""
|
||||
dept = Department(
|
||||
id=str(uuid.uuid4()),
|
||||
name="Engineering",
|
||||
)
|
||||
db.add(dept)
|
||||
db.commit()
|
||||
return dept
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(db, test_department):
|
||||
"""Create a test user."""
|
||||
user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email="testuser@example.com",
|
||||
name="Test User",
|
||||
department_id=test_department.id,
|
||||
role_id="00000000-0000-0000-0000-000000000003",
|
||||
is_active=True,
|
||||
is_system_admin=False,
|
||||
capacity=Decimal("40.00"),
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def additional_users(db, test_department):
|
||||
"""Create additional test users for auto-assign tests."""
|
||||
users = []
|
||||
for i in range(3):
|
||||
user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
email=f"user{i}@example.com",
|
||||
name=f"User {i}",
|
||||
department_id=test_department.id,
|
||||
role_id="00000000-0000-0000-0000-000000000003",
|
||||
is_active=True,
|
||||
is_system_admin=False,
|
||||
capacity=Decimal("40.00"),
|
||||
)
|
||||
db.add(user)
|
||||
users.append(user)
|
||||
db.commit()
|
||||
return users
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_space(db, test_user):
|
||||
"""Create a test space."""
|
||||
space = Space(
|
||||
id=str(uuid.uuid4()),
|
||||
name="Test Space",
|
||||
description="Test space for action executor",
|
||||
owner_id=test_user.id,
|
||||
)
|
||||
db.add(space)
|
||||
db.commit()
|
||||
return space
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_project(db, test_space, test_user, test_department):
|
||||
"""Create a test project."""
|
||||
project = Project(
|
||||
id=str(uuid.uuid4()),
|
||||
space_id=test_space.id,
|
||||
title="Test Project",
|
||||
description="Test project for action executor",
|
||||
owner_id=test_user.id,
|
||||
department_id=test_department.id,
|
||||
)
|
||||
db.add(project)
|
||||
db.commit()
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_statuses(db, test_project):
|
||||
"""Create test task statuses."""
|
||||
status_todo = TaskStatus(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="To Do",
|
||||
color="#808080",
|
||||
position=0,
|
||||
is_done=False,
|
||||
)
|
||||
status_in_progress = TaskStatus(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="In Progress",
|
||||
color="#0000FF",
|
||||
position=1,
|
||||
is_done=False,
|
||||
)
|
||||
status_done = TaskStatus(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="Done",
|
||||
color="#00FF00",
|
||||
position=2,
|
||||
is_done=True,
|
||||
)
|
||||
db.add_all([status_todo, status_in_progress, status_done])
|
||||
db.commit()
|
||||
return status_todo, status_in_progress, status_done
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_task(db, test_project, test_user, test_statuses):
|
||||
"""Create a test task."""
|
||||
task = Task(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
title="Test Task",
|
||||
description="Test task for action executor",
|
||||
status_id=test_statuses[0].id,
|
||||
created_by=test_user.id,
|
||||
assignee_id=test_user.id,
|
||||
priority="medium",
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
class TestUpdateFieldAction:
|
||||
"""Tests for FEAT-014: Update Field Action."""
|
||||
|
||||
def test_validate_config_valid_priority(self, db):
|
||||
"""Test validation of valid priority update config."""
|
||||
action = UpdateFieldAction()
|
||||
config = {"field": "priority", "value": "high"}
|
||||
# Should not raise
|
||||
action.validate_config(config, db)
|
||||
|
||||
def test_validate_config_valid_status_id(self, db):
|
||||
"""Test validation of valid status_id update config."""
|
||||
action = UpdateFieldAction()
|
||||
config = {"field": "status_id", "value": "some-uuid"}
|
||||
# Should not raise (status_id validation is deferred to execution)
|
||||
action.validate_config(config, db)
|
||||
|
||||
def test_validate_config_valid_due_date(self, db):
|
||||
"""Test validation of valid due_date update config."""
|
||||
action = UpdateFieldAction()
|
||||
config = {"field": "due_date", "value": "2025-12-31T23:59:59"}
|
||||
# Should not raise
|
||||
action.validate_config(config, db)
|
||||
|
||||
def test_validate_config_missing_field(self, db):
|
||||
"""Test validation fails when field is missing."""
|
||||
action = UpdateFieldAction()
|
||||
config = {"value": "high"}
|
||||
with pytest.raises(ActionValidationError) as exc:
|
||||
action.validate_config(config, db)
|
||||
assert "Missing required 'field'" in str(exc.value)
|
||||
|
||||
def test_validate_config_missing_value(self, db):
|
||||
"""Test validation fails when value is missing."""
|
||||
action = UpdateFieldAction()
|
||||
config = {"field": "priority"}
|
||||
with pytest.raises(ActionValidationError) as exc:
|
||||
action.validate_config(config, db)
|
||||
assert "Missing required 'value'" in str(exc.value)
|
||||
|
||||
def test_validate_config_invalid_field(self, db):
|
||||
"""Test validation fails for unsupported field."""
|
||||
action = UpdateFieldAction()
|
||||
config = {"field": "invalid_field", "value": "test"}
|
||||
with pytest.raises(ActionValidationError) as exc:
|
||||
action.validate_config(config, db)
|
||||
assert "Invalid field" in str(exc.value)
|
||||
|
||||
def test_validate_config_invalid_priority(self, db):
|
||||
"""Test validation fails for invalid priority value."""
|
||||
action = UpdateFieldAction()
|
||||
config = {"field": "priority", "value": "critical"}
|
||||
with pytest.raises(ActionValidationError) as exc:
|
||||
action.validate_config(config, db)
|
||||
assert "Invalid priority value" in str(exc.value)
|
||||
|
||||
def test_validate_config_invalid_due_date_format(self, db):
|
||||
"""Test validation fails for invalid due_date format."""
|
||||
action = UpdateFieldAction()
|
||||
config = {"field": "due_date", "value": "not-a-date"}
|
||||
with pytest.raises(ActionValidationError) as exc:
|
||||
action.validate_config(config, db)
|
||||
assert "Invalid due_date format" in str(exc.value)
|
||||
|
||||
def test_execute_update_priority(self, db, test_task, test_user):
|
||||
"""Test executing priority update."""
|
||||
action = UpdateFieldAction()
|
||||
config = {"field": "priority", "value": "urgent"}
|
||||
context = {"current_user": test_user}
|
||||
|
||||
assert test_task.priority == "medium"
|
||||
|
||||
result = action.execute(db, test_task, config, context)
|
||||
db.commit()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["field"] == "priority"
|
||||
assert result["old_value"] == "medium"
|
||||
assert result["new_value"] == "urgent"
|
||||
assert test_task.priority == "urgent"
|
||||
|
||||
def test_execute_update_status_id(self, db, test_task, test_statuses, test_user):
|
||||
"""Test executing status_id update."""
|
||||
action = UpdateFieldAction()
|
||||
new_status = test_statuses[1] # In Progress
|
||||
config = {"field": "status_id", "value": new_status.id}
|
||||
context = {"current_user": test_user}
|
||||
|
||||
result = action.execute(db, test_task, config, context)
|
||||
db.commit()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["field"] == "status_id"
|
||||
assert test_task.status_id == new_status.id
|
||||
|
||||
def test_execute_update_status_id_invalid(self, db, test_task, test_user):
|
||||
"""Test executing status_id update with invalid status."""
|
||||
action = UpdateFieldAction()
|
||||
config = {"field": "status_id", "value": "non-existent-status"}
|
||||
context = {"current_user": test_user}
|
||||
|
||||
with pytest.raises(ActionExecutionError) as exc:
|
||||
action.execute(db, test_task, config, context)
|
||||
assert "not found in project" in str(exc.value)
|
||||
|
||||
def test_execute_update_due_date(self, db, test_task, test_user):
|
||||
"""Test executing due_date update."""
|
||||
action = UpdateFieldAction()
|
||||
due_date_str = "2025-12-31T23:59:59"
|
||||
config = {"field": "due_date", "value": due_date_str}
|
||||
context = {"current_user": test_user}
|
||||
|
||||
result = action.execute(db, test_task, config, context)
|
||||
db.commit()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["field"] == "due_date"
|
||||
assert test_task.due_date is not None
|
||||
|
||||
|
||||
class TestAutoAssignAction:
|
||||
"""Tests for FEAT-015: Auto Assign Action."""
|
||||
|
||||
def test_validate_config_valid_round_robin(self, db):
|
||||
"""Test validation of valid round_robin config."""
|
||||
action = AutoAssignAction()
|
||||
config = {"strategy": "round_robin"}
|
||||
# Should not raise
|
||||
action.validate_config(config, db)
|
||||
|
||||
def test_validate_config_valid_least_loaded(self, db):
|
||||
"""Test validation of valid least_loaded config."""
|
||||
action = AutoAssignAction()
|
||||
config = {"strategy": "least_loaded"}
|
||||
# Should not raise
|
||||
action.validate_config(config, db)
|
||||
|
||||
def test_validate_config_valid_specific_user(self, db, test_user):
|
||||
"""Test validation of valid specific_user config."""
|
||||
action = AutoAssignAction()
|
||||
config = {"strategy": "specific_user", "user_id": test_user.id}
|
||||
# Should not raise
|
||||
action.validate_config(config, db)
|
||||
|
||||
def test_validate_config_missing_strategy(self, db):
|
||||
"""Test validation fails when strategy is missing."""
|
||||
action = AutoAssignAction()
|
||||
config = {}
|
||||
with pytest.raises(ActionValidationError) as exc:
|
||||
action.validate_config(config, db)
|
||||
assert "Missing required 'strategy'" in str(exc.value)
|
||||
|
||||
def test_validate_config_invalid_strategy(self, db):
|
||||
"""Test validation fails for invalid strategy."""
|
||||
action = AutoAssignAction()
|
||||
config = {"strategy": "invalid_strategy"}
|
||||
with pytest.raises(ActionValidationError) as exc:
|
||||
action.validate_config(config, db)
|
||||
assert "Invalid strategy" in str(exc.value)
|
||||
|
||||
def test_validate_config_specific_user_missing_user_id(self, db):
|
||||
"""Test validation fails when user_id is missing for specific_user."""
|
||||
action = AutoAssignAction()
|
||||
config = {"strategy": "specific_user"}
|
||||
with pytest.raises(ActionValidationError) as exc:
|
||||
action.validate_config(config, db)
|
||||
assert "Missing required 'user_id'" in str(exc.value)
|
||||
|
||||
def test_validate_config_specific_user_invalid_user_id(self, db):
|
||||
"""Test validation fails for non-existent user."""
|
||||
action = AutoAssignAction()
|
||||
config = {"strategy": "specific_user", "user_id": "non-existent-user"}
|
||||
with pytest.raises(ActionValidationError) as exc:
|
||||
action.validate_config(config, db)
|
||||
assert "not found or inactive" in str(exc.value)
|
||||
|
||||
def test_execute_specific_user(self, db, test_task, test_user, additional_users):
|
||||
"""Test executing specific_user assignment."""
|
||||
action = AutoAssignAction()
|
||||
target_user = additional_users[0]
|
||||
config = {"strategy": "specific_user", "user_id": target_user.id}
|
||||
context = {"current_user": test_user}
|
||||
|
||||
result = action.execute(db, test_task, config, context)
|
||||
db.commit()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["strategy"] == "specific_user"
|
||||
assert result["new_assignee_id"] == target_user.id
|
||||
assert test_task.assignee_id == target_user.id
|
||||
|
||||
def test_execute_round_robin(self, db, test_task, test_user, additional_users, test_project):
|
||||
"""Test executing round_robin assignment."""
|
||||
action = AutoAssignAction()
|
||||
config = {"strategy": "round_robin"}
|
||||
context = {"current_user": test_user}
|
||||
|
||||
# Clear round robin state for this project
|
||||
AutoAssignAction._round_robin_index.pop(test_project.id, None)
|
||||
|
||||
# First assignment
|
||||
result1 = action.execute(db, test_task, config, context)
|
||||
db.commit()
|
||||
first_assignee = result1["new_assignee_id"]
|
||||
|
||||
assert result1["status"] == "success"
|
||||
assert result1["strategy"] == "round_robin"
|
||||
assert first_assignee is not None
|
||||
|
||||
# Create a new task for second assignment
|
||||
task2 = Task(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
title="Test Task 2",
|
||||
status_id=test_task.status_id,
|
||||
created_by=test_user.id,
|
||||
priority="medium",
|
||||
)
|
||||
db.add(task2)
|
||||
db.commit()
|
||||
db.refresh(task2)
|
||||
|
||||
# Second assignment should get next member in rotation
|
||||
result2 = action.execute(db, task2, config, context)
|
||||
db.commit()
|
||||
second_assignee = result2["new_assignee_id"]
|
||||
|
||||
assert result2["status"] == "success"
|
||||
# Round robin should cycle through members
|
||||
assert second_assignee is not None
|
||||
|
||||
def test_execute_least_loaded(self, db, test_task, test_user, additional_users, test_project, test_statuses):
|
||||
"""Test executing least_loaded assignment."""
|
||||
# Create tasks assigned to different users to create varied workloads
|
||||
for i, user in enumerate(additional_users):
|
||||
for j in range(i + 1): # User 0 gets 1 task, User 1 gets 2, etc.
|
||||
task = Task(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
title=f"Task for user {i} - {j}",
|
||||
status_id=test_statuses[0].id, # To Do (not done)
|
||||
created_by=test_user.id,
|
||||
assignee_id=user.id,
|
||||
priority="medium",
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
|
||||
# Create a new unassigned task
|
||||
new_task = Task(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
title="New Task for Assignment",
|
||||
status_id=test_statuses[0].id,
|
||||
created_by=test_user.id,
|
||||
priority="medium",
|
||||
)
|
||||
db.add(new_task)
|
||||
db.commit()
|
||||
db.refresh(new_task)
|
||||
|
||||
action = AutoAssignAction()
|
||||
config = {"strategy": "least_loaded"}
|
||||
context = {"current_user": test_user}
|
||||
|
||||
result = action.execute(db, new_task, config, context)
|
||||
db.commit()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["strategy"] == "least_loaded"
|
||||
# Should assign to user with fewest tasks
|
||||
assert result["new_assignee_id"] is not None
|
||||
|
||||
def test_execute_no_eligible_members(self, db, test_user, test_space):
|
||||
"""Test assignment when no eligible members are found."""
|
||||
# Create project without department (only owner as member)
|
||||
project = Project(
|
||||
id=str(uuid.uuid4()),
|
||||
space_id=test_space.id,
|
||||
title="Solo Project",
|
||||
owner_id=test_user.id,
|
||||
department_id=None, # No department
|
||||
)
|
||||
db.add(project)
|
||||
|
||||
status = TaskStatus(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project.id,
|
||||
name="To Do",
|
||||
color="#808080",
|
||||
position=0,
|
||||
)
|
||||
db.add(status)
|
||||
db.commit()
|
||||
|
||||
task = Task(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project.id,
|
||||
title="Solo Task",
|
||||
status_id=status.id,
|
||||
created_by=test_user.id,
|
||||
priority="medium",
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
action = AutoAssignAction()
|
||||
config = {"strategy": "round_robin"}
|
||||
context = {"current_user": test_user}
|
||||
|
||||
result = action.execute(db, task, config, context)
|
||||
|
||||
# Should still work - assigns to owner
|
||||
assert result["status"] == "success"
|
||||
assert task.assignee_id == test_user.id
|
||||
|
||||
|
||||
class TestActionExecutor:
|
||||
"""Tests for ActionExecutor registry and execution."""
|
||||
|
||||
def test_get_supported_actions(self):
|
||||
"""Test getting supported action types."""
|
||||
actions = ActionExecutor.get_supported_actions()
|
||||
assert "update_field" in actions
|
||||
assert "auto_assign" in actions
|
||||
|
||||
def test_validate_action_update_field(self, db):
|
||||
"""Test validating update_field action via executor."""
|
||||
action = {
|
||||
"type": "update_field",
|
||||
"config": {"field": "priority", "value": "high"},
|
||||
}
|
||||
# Should not raise
|
||||
ActionExecutor.validate_action(action, db)
|
||||
|
||||
def test_validate_action_auto_assign(self, db, test_user):
|
||||
"""Test validating auto_assign action via executor."""
|
||||
action = {
|
||||
"type": "auto_assign",
|
||||
"config": {"strategy": "specific_user", "user_id": test_user.id},
|
||||
}
|
||||
# Should not raise
|
||||
ActionExecutor.validate_action(action, db)
|
||||
|
||||
def test_validate_action_unknown_type(self, db):
|
||||
"""Test that unknown action types pass through (for extensibility)."""
|
||||
action = {"type": "unknown_action"}
|
||||
# Should not raise - allows other handlers to process
|
||||
ActionExecutor.validate_action(action, db)
|
||||
|
||||
def test_execute_action_update_field(self, db, test_task, test_user):
|
||||
"""Test executing update_field action via executor."""
|
||||
action = {
|
||||
"type": "update_field",
|
||||
"config": {"field": "priority", "value": "urgent"},
|
||||
}
|
||||
context = {"current_user": test_user}
|
||||
|
||||
result = ActionExecutor.execute_action(db, test_task, action, context)
|
||||
db.commit()
|
||||
|
||||
assert result is not None
|
||||
assert result["status"] == "success"
|
||||
assert test_task.priority == "urgent"
|
||||
|
||||
def test_execute_action_auto_assign(self, db, test_task, test_user, additional_users):
|
||||
"""Test executing auto_assign action via executor."""
|
||||
target_user = additional_users[1]
|
||||
action = {
|
||||
"type": "auto_assign",
|
||||
"config": {"strategy": "specific_user", "user_id": target_user.id},
|
||||
}
|
||||
context = {"current_user": test_user}
|
||||
|
||||
result = ActionExecutor.execute_action(db, test_task, action, context)
|
||||
db.commit()
|
||||
|
||||
assert result is not None
|
||||
assert result["status"] == "success"
|
||||
assert test_task.assignee_id == target_user.id
|
||||
|
||||
def test_execute_action_unknown_type_returns_none(self, db, test_task, test_user):
|
||||
"""Test that unknown action types return None."""
|
||||
action = {"type": "unknown_action"}
|
||||
context = {"current_user": test_user}
|
||||
|
||||
result = ActionExecutor.execute_action(db, test_task, action, context)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestTriggerServiceIntegration:
|
||||
"""Tests for TriggerService integration with new actions."""
|
||||
|
||||
def test_validate_actions_update_field(self, db):
|
||||
"""Test TriggerService validates update_field actions."""
|
||||
actions = [{
|
||||
"type": "update_field",
|
||||
"config": {"field": "priority", "value": "high"},
|
||||
}]
|
||||
# Should not raise
|
||||
TriggerService.validate_actions(actions, db)
|
||||
|
||||
def test_validate_actions_auto_assign(self, db, test_user):
|
||||
"""Test TriggerService validates auto_assign actions."""
|
||||
actions = [{
|
||||
"type": "auto_assign",
|
||||
"config": {"strategy": "specific_user", "user_id": test_user.id},
|
||||
}]
|
||||
# Should not raise
|
||||
TriggerService.validate_actions(actions, db)
|
||||
|
||||
def test_validate_actions_invalid_action_type(self, db):
|
||||
"""Test TriggerService rejects invalid action types."""
|
||||
actions = [{"type": "invalid_action"}]
|
||||
with pytest.raises(ActionValidationError) as exc:
|
||||
TriggerService.validate_actions(actions, db)
|
||||
assert "Invalid action type" in str(exc.value)
|
||||
|
||||
def test_get_supported_action_types(self):
|
||||
"""Test getting all supported action types."""
|
||||
types = TriggerService.get_supported_action_types()
|
||||
assert "notify" in types
|
||||
assert "update_field" in types
|
||||
assert "auto_assign" in types
|
||||
|
||||
def test_evaluate_triggers_with_update_field_action(
|
||||
self, db, test_task, test_user, test_project, test_statuses
|
||||
):
|
||||
"""Test trigger evaluation with update_field action."""
|
||||
# Create trigger that updates priority when status changes
|
||||
trigger = Trigger(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="Status Change Priority Trigger",
|
||||
trigger_type="field_change",
|
||||
conditions={
|
||||
"field": "status_id",
|
||||
"operator": "changed_to",
|
||||
"value": test_statuses[1].id, # In Progress
|
||||
},
|
||||
actions=[{
|
||||
"type": "update_field",
|
||||
"config": {"field": "priority", "value": "high"},
|
||||
}],
|
||||
is_active=True,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db.add(trigger)
|
||||
db.commit()
|
||||
|
||||
old_values = {"status_id": test_statuses[0].id}
|
||||
new_values = {"status_id": test_statuses[1].id}
|
||||
|
||||
logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user)
|
||||
db.commit()
|
||||
|
||||
assert len(logs) == 1
|
||||
assert logs[0].status == "success"
|
||||
assert test_task.priority == "high"
|
||||
|
||||
def test_evaluate_triggers_with_auto_assign_action(
|
||||
self, db, test_task, test_user, test_project, test_statuses, additional_users
|
||||
):
|
||||
"""Test trigger evaluation with auto_assign action."""
|
||||
target_user = additional_users[0]
|
||||
|
||||
# Create trigger that auto-assigns when priority changes to urgent
|
||||
trigger = Trigger(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="Priority Auto Assign Trigger",
|
||||
trigger_type="field_change",
|
||||
conditions={
|
||||
"field": "priority",
|
||||
"operator": "changed_to",
|
||||
"value": "urgent",
|
||||
},
|
||||
actions=[{
|
||||
"type": "auto_assign",
|
||||
"config": {"strategy": "specific_user", "user_id": target_user.id},
|
||||
}],
|
||||
is_active=True,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db.add(trigger)
|
||||
db.commit()
|
||||
|
||||
old_values = {"priority": "medium"}
|
||||
new_values = {"priority": "urgent"}
|
||||
|
||||
logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user)
|
||||
db.commit()
|
||||
|
||||
assert len(logs) == 1
|
||||
assert logs[0].status == "success"
|
||||
assert test_task.assignee_id == target_user.id
|
||||
|
||||
def test_evaluate_triggers_with_multiple_actions(
|
||||
self, db, test_task, test_user, test_project, test_statuses, additional_users
|
||||
):
|
||||
"""Test trigger evaluation with multiple actions."""
|
||||
target_user = additional_users[0]
|
||||
|
||||
# Create trigger with both update_field and auto_assign
|
||||
trigger = Trigger(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="Multi Action Trigger",
|
||||
trigger_type="field_change",
|
||||
conditions={
|
||||
"field": "status_id",
|
||||
"operator": "changed_to",
|
||||
"value": test_statuses[1].id,
|
||||
},
|
||||
actions=[
|
||||
{
|
||||
"type": "update_field",
|
||||
"config": {"field": "priority", "value": "urgent"},
|
||||
},
|
||||
{
|
||||
"type": "auto_assign",
|
||||
"config": {"strategy": "specific_user", "user_id": target_user.id},
|
||||
},
|
||||
],
|
||||
is_active=True,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db.add(trigger)
|
||||
db.commit()
|
||||
|
||||
old_values = {"status_id": test_statuses[0].id}
|
||||
new_values = {"status_id": test_statuses[1].id}
|
||||
|
||||
logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user)
|
||||
db.commit()
|
||||
|
||||
assert len(logs) == 1
|
||||
assert logs[0].status == "success"
|
||||
assert test_task.priority == "urgent"
|
||||
assert test_task.assignee_id == target_user.id
|
||||
|
||||
# Check that both actions are logged
|
||||
details = logs[0].details
|
||||
assert len(details["actions_executed"]) == 2
|
||||
@@ -273,3 +273,145 @@ class TestEncryptionKeyValidation:
|
||||
}):
|
||||
settings = Settings()
|
||||
assert settings.ENCRYPTION_MASTER_KEY is None
|
||||
|
||||
|
||||
class TestConfidentialProjectUpload:
|
||||
"""Tests for file upload on confidential projects."""
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(self, db):
|
||||
"""Create a test user."""
|
||||
from app.models import User
|
||||
import uuid as uuid_module
|
||||
|
||||
user = User(
|
||||
id=str(uuid_module.uuid4()),
|
||||
email="testuser_enc@example.com",
|
||||
name="Test User Encryption",
|
||||
role_id="00000000-0000-0000-0000-000000000003",
|
||||
is_active=True,
|
||||
is_system_admin=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
@pytest.fixture
|
||||
def test_user_token(self, client, mock_redis, test_user):
|
||||
"""Get a token for test user."""
|
||||
from app.core.security import create_access_token, create_token_payload
|
||||
|
||||
token_data = create_token_payload(
|
||||
user_id=test_user.id,
|
||||
email=test_user.email,
|
||||
role="engineer",
|
||||
department_id=None,
|
||||
is_system_admin=False,
|
||||
)
|
||||
token = create_access_token(token_data)
|
||||
mock_redis.setex(f"session:{test_user.id}", 900, token)
|
||||
return token
|
||||
|
||||
@pytest.fixture
|
||||
def test_space(self, db, test_user):
|
||||
"""Create a test space."""
|
||||
from app.models import Space
|
||||
import uuid as uuid_module
|
||||
|
||||
space = Space(
|
||||
id=str(uuid_module.uuid4()),
|
||||
name="Test Space Encryption",
|
||||
description="Test space for encryption tests",
|
||||
owner_id=test_user.id,
|
||||
)
|
||||
db.add(space)
|
||||
db.commit()
|
||||
return space
|
||||
|
||||
@pytest.fixture
|
||||
def confidential_project(self, db, test_space, test_user):
|
||||
"""Create a confidential test project."""
|
||||
from app.models import Project
|
||||
import uuid as uuid_module
|
||||
|
||||
project = Project(
|
||||
id=str(uuid_module.uuid4()),
|
||||
space_id=test_space.id,
|
||||
title="Confidential Project",
|
||||
description="Test confidential project",
|
||||
owner_id=test_user.id,
|
||||
security_level="confidential",
|
||||
)
|
||||
db.add(project)
|
||||
db.commit()
|
||||
return project
|
||||
|
||||
@pytest.fixture
|
||||
def test_task(self, db, confidential_project, test_user):
|
||||
"""Create a test task in confidential project."""
|
||||
from app.models import Task
|
||||
import uuid as uuid_module
|
||||
|
||||
task = Task(
|
||||
id=str(uuid_module.uuid4()),
|
||||
project_id=confidential_project.id,
|
||||
title="Test Task Encryption",
|
||||
description="Test task for encryption tests",
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
return task
|
||||
|
||||
def test_upload_confidential_project_encryption_unavailable(
|
||||
self, client, test_user_token, test_task, db
|
||||
):
|
||||
"""Test that uploading to confidential project returns 400 when encryption is unavailable."""
|
||||
from io import BytesIO
|
||||
|
||||
# Mock encryption service to return False for is_encryption_available
|
||||
with patch('app.api.attachments.router.encryption_service') as mock_enc_service:
|
||||
mock_enc_service.is_encryption_available.return_value = False
|
||||
|
||||
content = b"Test file content"
|
||||
files = {"file": ("test.pdf", BytesIO(content), "application/pdf")}
|
||||
|
||||
response = client.post(
|
||||
f"/api/tasks/{test_task.id}/attachments",
|
||||
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||
files=files,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "ENCRYPTION_MASTER_KEY" in response.json()["detail"]
|
||||
assert "environment variable" in response.json()["detail"]
|
||||
|
||||
def test_upload_confidential_project_no_active_key(
|
||||
self, client, test_user_token, test_task, db
|
||||
):
|
||||
"""Test that uploading to confidential project returns 400 when no active encryption key exists."""
|
||||
from io import BytesIO
|
||||
from app.models import EncryptionKey
|
||||
|
||||
# Make sure no active encryption keys exist
|
||||
db.query(EncryptionKey).filter(EncryptionKey.is_active == True).update(
|
||||
{"is_active": False}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Mock encryption service to return True for is_encryption_available
|
||||
with patch('app.api.attachments.router.encryption_service') as mock_enc_service:
|
||||
mock_enc_service.is_encryption_available.return_value = True
|
||||
|
||||
content = b"Test file content"
|
||||
files = {"file": ("test.pdf", BytesIO(content), "application/pdf")}
|
||||
|
||||
response = client.post(
|
||||
f"/api/tasks/{test_task.id}/attachments",
|
||||
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||
files=files,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "active encryption key" in response.json()["detail"]
|
||||
assert "create" in response.json()["detail"].lower()
|
||||
|
||||
@@ -375,3 +375,119 @@ class TestTriggerAPI:
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid operator" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestTriggerActionRollback:
|
||||
"""Tests for trigger action rollback mechanism."""
|
||||
|
||||
def test_multi_action_rollback_on_failure(self, db, test_task, test_project, test_user, test_status):
|
||||
"""Test that when one action fails, all previous actions are rolled back.
|
||||
|
||||
Scenario:
|
||||
1. Create a trigger with 2 actions: update_field (priority) + update_field (invalid status_id)
|
||||
2. The first action should update priority to 'high'
|
||||
3. The second action should fail because of invalid status_id
|
||||
4. The first action's change should be rolled back
|
||||
"""
|
||||
# Record original priority
|
||||
original_priority = test_task.priority
|
||||
|
||||
# Create a trigger with multiple actions where the second one will fail
|
||||
trigger = Trigger(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="Multi-Action Trigger",
|
||||
description="Test rollback on failure",
|
||||
trigger_type="field_change",
|
||||
conditions={
|
||||
"field": "status_id",
|
||||
"operator": "changed_to",
|
||||
"value": test_status[1].id,
|
||||
},
|
||||
actions=[
|
||||
# First action: update priority (should succeed)
|
||||
{
|
||||
"type": "update_field",
|
||||
"field": "priority",
|
||||
"value": "high",
|
||||
},
|
||||
# Second action: update to invalid status (should fail)
|
||||
{
|
||||
"type": "update_field",
|
||||
"field": "status_id",
|
||||
"value": "non-existent-status-id",
|
||||
},
|
||||
],
|
||||
is_active=True,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db.add(trigger)
|
||||
db.commit()
|
||||
|
||||
old_values = {"status_id": test_status[0].id}
|
||||
new_values = {"status_id": test_status[1].id}
|
||||
|
||||
# Execute trigger - second action should fail
|
||||
logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user)
|
||||
db.commit()
|
||||
|
||||
# Verify trigger execution failed
|
||||
assert len(logs) == 1
|
||||
assert logs[0].status == "failed"
|
||||
assert logs[0].error_message is not None
|
||||
assert "not found" in logs[0].error_message.lower()
|
||||
|
||||
# Refresh task from database to get the actual state
|
||||
db.refresh(test_task)
|
||||
|
||||
# Verify that the first action's change was rolled back
|
||||
# Priority should remain unchanged (not 'high')
|
||||
assert test_task.priority == original_priority, (
|
||||
f"Expected priority to be rolled back to '{original_priority}', "
|
||||
f"but got '{test_task.priority}'"
|
||||
)
|
||||
|
||||
def test_all_actions_succeed_committed(self, db, test_task, test_project, test_user, test_status):
|
||||
"""Test that when all actions succeed, changes are committed."""
|
||||
# Create a trigger with multiple successful actions
|
||||
trigger = Trigger(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=test_project.id,
|
||||
name="Success Trigger",
|
||||
description="Test successful commit",
|
||||
trigger_type="field_change",
|
||||
conditions={
|
||||
"field": "status_id",
|
||||
"operator": "changed_to",
|
||||
"value": test_status[1].id,
|
||||
},
|
||||
actions=[
|
||||
# First action: update priority
|
||||
{
|
||||
"type": "update_field",
|
||||
"field": "priority",
|
||||
"value": "urgent",
|
||||
},
|
||||
],
|
||||
is_active=True,
|
||||
created_by=test_user.id,
|
||||
)
|
||||
db.add(trigger)
|
||||
db.commit()
|
||||
|
||||
old_values = {"status_id": test_status[0].id}
|
||||
new_values = {"status_id": test_status[1].id}
|
||||
|
||||
# Execute trigger
|
||||
logs = TriggerService.evaluate_triggers(db, test_task, old_values, new_values, test_user)
|
||||
db.commit()
|
||||
|
||||
# Verify trigger execution succeeded
|
||||
assert len(logs) == 1
|
||||
assert logs[0].status == "success"
|
||||
|
||||
# Refresh task from database
|
||||
db.refresh(test_task)
|
||||
|
||||
# Verify that the change was committed
|
||||
assert test_task.priority == "urgent"
|
||||
|
||||
1813
frontend/package-lock.json
generated
1813
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,10 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
@@ -21,10 +24,15 @@
|
||||
"react-router-dom": "^6.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
import { Skeleton } from './components/Skeleton'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Spaces from './pages/Spaces'
|
||||
@@ -16,7 +17,12 @@ function App() {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return <div className="container">Loading...</div>
|
||||
return (
|
||||
<div className="container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<Skeleton variant="rect" width="100%" height={60} style={{ marginBottom: '24px' }} />
|
||||
<Skeleton variant="rect" width="100%" height={400} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { attachmentService, Attachment } from '../services/attachments'
|
||||
import { AttachmentVersionHistory } from './AttachmentVersionHistory'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface AttachmentListProps {
|
||||
taskId: string
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
interface VersionHistoryState {
|
||||
attachmentId: string
|
||||
currentVersion: number
|
||||
filename: string
|
||||
}
|
||||
|
||||
export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
const { showToast } = useToast()
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deleting, setDeleting] = useState<string | null>(null)
|
||||
const [versionHistory, setVersionHistory] = useState<VersionHistoryState | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Attachment | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadAttachments()
|
||||
}, [taskId])
|
||||
|
||||
const loadAttachments = async () => {
|
||||
const loadAttachments = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await attachmentService.listAttachments(taskId)
|
||||
@@ -25,37 +34,59 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [taskId])
|
||||
|
||||
useEffect(() => {
|
||||
loadAttachments()
|
||||
}, [loadAttachments])
|
||||
|
||||
const handleDownload = async (attachment: Attachment) => {
|
||||
try {
|
||||
await attachmentService.downloadAttachment(attachment.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to download attachment:', error)
|
||||
alert('Failed to download file')
|
||||
showToast('Failed to download file', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (attachment: Attachment) => {
|
||||
if (!confirm(`Are you sure you want to delete "${attachment.filename}"?`)) {
|
||||
return
|
||||
}
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteConfirm) return
|
||||
|
||||
const attachment = deleteConfirm
|
||||
setDeleteConfirm(null)
|
||||
setDeleting(attachment.id)
|
||||
try {
|
||||
await attachmentService.deleteAttachment(attachment.id)
|
||||
setAttachments(prev => prev.filter(a => a.id !== attachment.id))
|
||||
onRefresh?.()
|
||||
showToast('File deleted successfully', 'success')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete attachment:', error)
|
||||
alert('Failed to delete file')
|
||||
showToast('Failed to delete file', 'error')
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenHistory = (attachment: Attachment) => {
|
||||
setVersionHistory({
|
||||
attachmentId: attachment.id,
|
||||
currentVersion: attachment.current_version,
|
||||
filename: attachment.filename,
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseHistory = () => {
|
||||
setVersionHistory(null)
|
||||
}
|
||||
|
||||
const handleVersionRestored = () => {
|
||||
loadAttachments()
|
||||
onRefresh?.()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading attachments...</div>
|
||||
return <SkeletonList count={2} />
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
@@ -84,6 +115,15 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.actions}>
|
||||
{attachment.current_version > 1 && (
|
||||
<button
|
||||
style={styles.historyBtn}
|
||||
onClick={() => handleOpenHistory(attachment)}
|
||||
title="Version History"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={styles.downloadBtn}
|
||||
onClick={() => handleDownload(attachment)}
|
||||
@@ -93,7 +133,7 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
</button>
|
||||
<button
|
||||
style={styles.deleteBtn}
|
||||
onClick={() => handleDelete(attachment)}
|
||||
onClick={() => setDeleteConfirm(attachment)}
|
||||
disabled={deleting === attachment.id}
|
||||
title="Delete"
|
||||
>
|
||||
@@ -102,6 +142,28 @@ export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{versionHistory && (
|
||||
<AttachmentVersionHistory
|
||||
attachmentId={versionHistory.attachmentId}
|
||||
currentVersion={versionHistory.currentVersion}
|
||||
filename={versionHistory.filename}
|
||||
isOpen={true}
|
||||
onClose={handleCloseHistory}
|
||||
onRestore={handleVersionRestored}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm !== null}
|
||||
title="Delete File"
|
||||
message={`Are you sure you want to delete "${deleteConfirm?.filename}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
confirmStyle="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -163,6 +225,15 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
},
|
||||
historyBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
downloadBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#007bff',
|
||||
|
||||
437
frontend/src/components/AttachmentVersionHistory.tsx
Normal file
437
frontend/src/components/AttachmentVersionHistory.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { attachmentService, AttachmentVersion, VersionHistoryResponse } from '../services/attachments'
|
||||
|
||||
interface AttachmentVersionHistoryProps {
|
||||
attachmentId: string
|
||||
currentVersion: number
|
||||
filename: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onRestore: () => void
|
||||
}
|
||||
|
||||
export function AttachmentVersionHistory({
|
||||
attachmentId,
|
||||
currentVersion,
|
||||
filename,
|
||||
isOpen,
|
||||
onClose,
|
||||
onRestore,
|
||||
}: AttachmentVersionHistoryProps) {
|
||||
const [versions, setVersions] = useState<AttachmentVersion[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [restoring, setRestoring] = useState<number | null>(null)
|
||||
const [confirmRestore, setConfirmRestore] = useState<number | null>(null)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// A11Y: Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
modalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const loadVersionHistory = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response: VersionHistoryResponse = await attachmentService.getVersionHistory(attachmentId)
|
||||
setVersions(response.versions)
|
||||
} catch (err) {
|
||||
console.error('Failed to load version history:', err)
|
||||
setError('Failed to load version history')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [attachmentId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadVersionHistory()
|
||||
}
|
||||
}, [isOpen, loadVersionHistory])
|
||||
|
||||
const handleRestore = async (version: number) => {
|
||||
setRestoring(version)
|
||||
setError(null)
|
||||
try {
|
||||
await attachmentService.restoreVersion(attachmentId, version)
|
||||
setConfirmRestore(null)
|
||||
onRestore()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Failed to restore version:', err)
|
||||
setError('Failed to restore version. Please try again.')
|
||||
} finally {
|
||||
setRestoring(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadVersion = async (version: number) => {
|
||||
try {
|
||||
await attachmentService.downloadAttachment(attachmentId, version)
|
||||
} catch (err) {
|
||||
console.error('Failed to download version:', err)
|
||||
setError('Failed to download version')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={handleOverlayClick}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="version-history-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerContent}>
|
||||
<h3 id="version-history-title" style={styles.title}>Version History</h3>
|
||||
<p style={styles.subtitle}>{filename}</p>
|
||||
</div>
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={styles.content}>
|
||||
{error && (
|
||||
<div style={styles.error}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={styles.loading}>Loading version history...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div style={styles.empty}>No version history available</div>
|
||||
) : (
|
||||
<div style={styles.versionList}>
|
||||
{versions.map((version) => {
|
||||
const isCurrent = version.version === currentVersion
|
||||
const isConfirming = confirmRestore === version.version
|
||||
const isRestoring = restoring === version.version
|
||||
|
||||
return (
|
||||
<div
|
||||
key={version.id}
|
||||
style={{
|
||||
...styles.versionItem,
|
||||
...(isCurrent ? styles.currentVersionItem : {}),
|
||||
}}
|
||||
>
|
||||
<div style={styles.versionInfo}>
|
||||
<div style={styles.versionHeader}>
|
||||
<span style={styles.versionNumber}>
|
||||
Version {version.version}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span style={styles.currentBadge}>Current</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.versionMeta}>
|
||||
<span>{formatDate(version.created_at)}</span>
|
||||
<span style={styles.separator}>|</span>
|
||||
<span>{attachmentService.formatFileSize(version.file_size)}</span>
|
||||
{version.uploader_name && (
|
||||
<>
|
||||
<span style={styles.separator}>|</span>
|
||||
<span>by {version.uploader_name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.versionActions}>
|
||||
<button
|
||||
style={styles.downloadVersionBtn}
|
||||
onClick={() => handleDownloadVersion(version.version)}
|
||||
title="Download this version"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
|
||||
{!isCurrent && (
|
||||
<>
|
||||
{isConfirming ? (
|
||||
<div style={styles.confirmGroup}>
|
||||
<span style={styles.confirmText}>Restore?</span>
|
||||
<button
|
||||
style={styles.confirmYesBtn}
|
||||
onClick={() => handleRestore(version.version)}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
{isRestoring ? '...' : 'Yes'}
|
||||
</button>
|
||||
<button
|
||||
style={styles.confirmNoBtn}
|
||||
onClick={() => setConfirmRestore(null)}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
style={styles.restoreBtn}
|
||||
onClick={() => setConfirmRestore(version.version)}
|
||||
title="Restore to this version"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.footer}>
|
||||
<button onClick={onClose} style={styles.closeFooterBtn}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1100,
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
width: '90%',
|
||||
maxWidth: '600px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid #eee',
|
||||
},
|
||||
headerContent: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#212529',
|
||||
},
|
||||
subtitle: {
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '14px',
|
||||
color: '#6c757d',
|
||||
},
|
||||
closeButton: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
color: '#666',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px 24px',
|
||||
},
|
||||
error: {
|
||||
padding: '12px',
|
||||
backgroundColor: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '16px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
loading: {
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
color: '#6c757d',
|
||||
fontSize: '14px',
|
||||
},
|
||||
empty: {
|
||||
padding: '32px',
|
||||
textAlign: 'center',
|
||||
color: '#6c757d',
|
||||
fontSize: '14px',
|
||||
},
|
||||
versionList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
},
|
||||
versionItem: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '14px 16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e9ecef',
|
||||
},
|
||||
currentVersionItem: {
|
||||
backgroundColor: '#e8f4fd',
|
||||
borderColor: '#b8daff',
|
||||
},
|
||||
versionInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
versionHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
versionNumber: {
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#212529',
|
||||
},
|
||||
currentBadge: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
},
|
||||
versionMeta: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
alignItems: 'center',
|
||||
},
|
||||
separator: {
|
||||
color: '#adb5bd',
|
||||
},
|
||||
versionActions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginLeft: '16px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
downloadVersionBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
restoreBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
confirmGroup: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
},
|
||||
confirmText: {
|
||||
fontSize: '12px',
|
||||
color: '#495057',
|
||||
fontWeight: 500,
|
||||
},
|
||||
confirmYesBtn: {
|
||||
padding: '4px 10px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
confirmNoBtn: {
|
||||
padding: '4px 10px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
footer: {
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #eee',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
closeFooterBtn: {
|
||||
padding: '8px 20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
color: '#495057',
|
||||
border: '1px solid #dee2e6',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}
|
||||
|
||||
export default AttachmentVersionHistory
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { blockersApi, Blocker } from '../services/collaboration'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface BlockerDialogProps {
|
||||
taskId: string
|
||||
@@ -104,7 +105,9 @@ export function BlockerDialog({
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-500 py-4">Loading...</div>
|
||||
<div className="py-2">
|
||||
<SkeletonList count={2} showAvatar={false} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Active blocker */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import { EventClickArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'
|
||||
import api from '../services/api'
|
||||
import { Skeleton } from './Skeleton'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
@@ -354,7 +355,7 @@ export function CalendarView({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{loading && <span style={styles.loadingIndicator}>Loading...</span>}
|
||||
{loading && <Skeleton variant="text" width={80} height={20} />}
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { commentsApi, usersApi, Comment, UserSearchResult } from '../services/collaboration'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface CommentsProps {
|
||||
taskId: string
|
||||
@@ -18,6 +20,7 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [mentionSuggestions, setMentionSuggestions] = useState<UserSearchResult[]>([])
|
||||
const [showMentions, setShowMentions] = useState(false)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
|
||||
const fetchComments = useCallback(async () => {
|
||||
try {
|
||||
@@ -71,10 +74,10 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
}
|
||||
|
||||
const handleDelete = async (commentId: string) => {
|
||||
if (!confirm('Delete this comment?')) return
|
||||
try {
|
||||
await commentsApi.delete(commentId)
|
||||
await fetchComments()
|
||||
setDeleteConfirm(null)
|
||||
} catch (err) {
|
||||
setError('Failed to delete comment')
|
||||
}
|
||||
@@ -112,7 +115,7 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
setShowMentions(false)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-4 text-gray-500">Loading comments...</div>
|
||||
if (loading) return <SkeletonList count={3} showAvatar />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -148,7 +151,7 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
onClick={() => setDeleteConfirm(comment.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Delete
|
||||
@@ -253,6 +256,17 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
{submitting ? 'Posting...' : 'Post Comment'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm !== null}
|
||||
title="Delete Comment"
|
||||
message="Are you sure you want to delete this comment? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
confirmStyle="danger"
|
||||
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
75
frontend/src/components/ConfirmModal.test.tsx
Normal file
75
frontend/src/components/ConfirmModal.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
|
||||
describe('ConfirmModal', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
title: 'Confirm Action',
|
||||
message: 'Are you sure you want to proceed?',
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
it('renders nothing when isOpen is false', () => {
|
||||
render(<ConfirmModal {...defaultProps} isOpen={false} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders modal when isOpen is true', () => {
|
||||
render(<ConfirmModal {...defaultProps} />)
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Confirm Action')).toBeInTheDocument()
|
||||
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm when confirm button is clicked', () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(<ConfirmModal {...defaultProps} onConfirm={onConfirm} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Confirm'))
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<ConfirmModal {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when Escape key is pressed', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<ConfirmModal {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses custom button text', () => {
|
||||
render(
|
||||
<ConfirmModal
|
||||
{...defaultProps}
|
||||
confirmText="Delete"
|
||||
cancelText="Go Back"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument()
|
||||
expect(screen.getByText('Go Back')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders confirm button with danger style prop', () => {
|
||||
const { rerender } = render(<ConfirmModal {...defaultProps} confirmStyle="danger" />)
|
||||
|
||||
const dangerButton = screen.getByText('Confirm')
|
||||
// The danger button should be rendered
|
||||
expect(dangerButton).toBeInTheDocument()
|
||||
|
||||
// When rendered with primary style, the button should also be rendered
|
||||
rerender(<ConfirmModal {...defaultProps} confirmStyle="primary" />)
|
||||
const primaryButton = screen.getByText('Confirm')
|
||||
expect(primaryButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
155
frontend/src/components/ConfirmModal.tsx
Normal file
155
frontend/src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
confirmStyle?: 'danger' | 'primary'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
confirmStyle = 'danger',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
const confirmButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// A11Y: Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus confirm button when modal opens
|
||||
confirmButtonRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onCancel])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={handleOverlayClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
aria-describedby="confirm-modal-message"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<h2 id="confirm-modal-title" style={styles.title}>
|
||||
{title}
|
||||
</h2>
|
||||
<p id="confirm-modal-message" style={styles.message}>
|
||||
{message}
|
||||
</p>
|
||||
<div style={styles.actions}>
|
||||
<button onClick={onCancel} style={styles.cancelButton}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmButtonRef}
|
||||
onClick={onConfirm}
|
||||
style={{
|
||||
...styles.confirmButton,
|
||||
...(confirmStyle === 'danger' ? styles.dangerButton : styles.primaryButton),
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
overlay: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1200,
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
width: '400px',
|
||||
maxWidth: '90%',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
title: {
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#212529',
|
||||
},
|
||||
message: {
|
||||
margin: '0 0 24px 0',
|
||||
fontSize: '14px',
|
||||
color: '#495057',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '12px',
|
||||
},
|
||||
cancelButton: {
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
color: '#495057',
|
||||
border: '1px solid #dee2e6',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
confirmButton: {
|
||||
padding: '10px 20px',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
dangerButton: {
|
||||
backgroundColor: '#dc3545',
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: '#0066cc',
|
||||
},
|
||||
}
|
||||
|
||||
export default ConfirmModal
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
customFieldsApi,
|
||||
CustomField,
|
||||
@@ -38,6 +38,23 @@ export function CustomFieldEditor({
|
||||
const [formula, setFormula] = useState(field?.formula || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// A11Y: Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
modalOverlayRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// Reset form when field changes
|
||||
useEffect(() => {
|
||||
@@ -155,10 +172,18 @@ export function CustomFieldEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={handleOverlayClick}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={handleOverlayClick}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="custom-field-editor-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<div style={styles.header}>
|
||||
<h2 style={styles.title}>
|
||||
<h2 id="custom-field-editor-title" style={styles.title}>
|
||||
{isEditing ? 'Edit Custom Field' : 'Create Custom Field'}
|
||||
</h2>
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
|
||||
@@ -424,7 +449,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
},
|
||||
typeNote: {
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
optionsList: {
|
||||
|
||||
@@ -225,7 +225,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
},
|
||||
formulaHint: {
|
||||
fontSize: '11px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
unsupported: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { customFieldsApi, CustomField, FieldType } from '../services/customFields'
|
||||
import { CustomFieldEditor } from './CustomFieldEditor'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
|
||||
interface CustomFieldListProps {
|
||||
projectId: string
|
||||
@@ -16,6 +17,7 @@ const FIELD_TYPE_LABELS: Record<FieldType, string> = {
|
||||
}
|
||||
|
||||
export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
||||
const { showToast } = useToast()
|
||||
const [fields, setFields] = useState<CustomField[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -79,13 +81,14 @@ export function CustomFieldList({ projectId }: CustomFieldListProps) {
|
||||
await customFieldsApi.deleteCustomField(deleteConfirm)
|
||||
setDeleteConfirm(null)
|
||||
loadFields()
|
||||
showToast('Custom field deleted successfully', 'success')
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ||
|
||||
'Failed to delete field'
|
||||
alert(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
@@ -274,7 +277,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
},
|
||||
emptyHint: {
|
||||
fontSize: '13px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
marginTop: '8px',
|
||||
},
|
||||
fieldList: {
|
||||
|
||||
@@ -288,7 +288,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
color: '#666',
|
||||
},
|
||||
subtaskBadge: {
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
customValueBadge: {
|
||||
backgroundColor: '#f3e5f5',
|
||||
@@ -304,7 +304,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
emptyColumn: {
|
||||
textAlign: 'center',
|
||||
padding: '24px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontSize: '13px',
|
||||
border: '2px dashed #ddd',
|
||||
borderRadius: '6px',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useNotifications } from '../contexts/NotificationContext'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
export function NotificationBell() {
|
||||
const { notifications, unreadCount, loading, fetchNotifications, markAsRead, markAllAsRead } =
|
||||
@@ -100,7 +101,9 @@ export function NotificationBell() {
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-gray-500">Loading...</div>
|
||||
<div className="p-2">
|
||||
<SkeletonList count={3} showAvatar={false} />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500">No notifications</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ReactNode } from 'react'
|
||||
import { Skeleton } from './Skeleton'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
@@ -10,7 +11,12 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return <div className="container">Loading...</div>
|
||||
return (
|
||||
<div className="container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<Skeleton variant="rect" width="100%" height={60} style={{ marginBottom: '24px' }} />
|
||||
<Skeleton variant="rect" width="100%" height={400} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
|
||||
@@ -41,11 +41,26 @@ export function ResourceHistory({ resourceType, resourceId, title = 'Change Hist
|
||||
return <div style={styles.empty}>No change history available</div>
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setExpanded(!expanded)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header} onClick={() => setExpanded(!expanded)}>
|
||||
<div
|
||||
style={styles.header}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${title}, ${logs.length} items`}
|
||||
>
|
||||
<span style={styles.title}>{title}</span>
|
||||
<span style={styles.toggleIcon}>{expanded ? '▼' : '▶'}</span>
|
||||
<span style={styles.toggleIcon} aria-hidden="true">{expanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div style={styles.content}>
|
||||
|
||||
106
frontend/src/components/Skeleton.test.tsx
Normal file
106
frontend/src/components/Skeleton.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { Skeleton, SkeletonCard, SkeletonList, SkeletonTable, SkeletonGrid, SkeletonKanban } from './Skeleton'
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<Skeleton />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies text variant styles', () => {
|
||||
render(<Skeleton variant="text" />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toHaveStyle({ borderRadius: '4px' })
|
||||
})
|
||||
|
||||
it('applies circle variant styles', () => {
|
||||
render(<Skeleton variant="circle" width={40} height={40} />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toHaveStyle({ borderRadius: '50%' })
|
||||
})
|
||||
|
||||
it('applies custom dimensions', () => {
|
||||
render(<Skeleton width={200} height={100} />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toHaveStyle({ width: '200px', height: '100px' })
|
||||
})
|
||||
|
||||
it('is hidden from screen readers', () => {
|
||||
render(<Skeleton />)
|
||||
const skeleton = document.querySelector('[aria-hidden="true"]')
|
||||
expect(skeleton).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonCard', () => {
|
||||
it('renders with default lines', () => {
|
||||
render(<SkeletonCard />)
|
||||
// Card should have 4 skeletons (1 title + 3 content lines by default)
|
||||
const skeletons = document.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(4)
|
||||
})
|
||||
|
||||
it('renders with custom number of lines', () => {
|
||||
render(<SkeletonCard lines={5} />)
|
||||
const skeletons = document.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(6) // title + 5 lines
|
||||
})
|
||||
|
||||
it('renders image placeholder when showImage is true', () => {
|
||||
render(<SkeletonCard showImage lines={2} />)
|
||||
const skeletons = document.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(4) // image + title + 2 lines
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonList', () => {
|
||||
it('renders default number of items', () => {
|
||||
const { container } = render(<SkeletonList />)
|
||||
// Each item has 2 skeletons, default count is 3
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(6)
|
||||
})
|
||||
|
||||
it('renders with avatars when showAvatar is true', () => {
|
||||
const { container } = render(<SkeletonList count={2} showAvatar />)
|
||||
// Each item has 3 skeletons (avatar + 2 text), count is 2
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonTable', () => {
|
||||
it('renders default rows and columns', () => {
|
||||
const { container } = render(<SkeletonTable />)
|
||||
// Header (4 columns) + 5 rows * 4 columns = 24 skeletons
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(24)
|
||||
})
|
||||
|
||||
it('renders with custom rows and columns', () => {
|
||||
const { container } = render(<SkeletonTable rows={3} columns={2} />)
|
||||
// Header (2 columns) + 3 rows * 2 columns = 8 skeletons
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonGrid', () => {
|
||||
it('renders default number of cards', () => {
|
||||
const { container } = render(<SkeletonGrid />)
|
||||
// 6 cards, each with 3 skeletons (title + 2 lines)
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBe(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonKanban', () => {
|
||||
it('renders kanban columns', () => {
|
||||
const { container } = render(<SkeletonKanban columns={3} />)
|
||||
// 3 columns, each with header + 2-3 cards
|
||||
const skeletons = container.querySelectorAll('[aria-hidden="true"]')
|
||||
expect(skeletons.length).toBeGreaterThan(10)
|
||||
})
|
||||
})
|
||||
240
frontend/src/components/Skeleton.tsx
Normal file
240
frontend/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React from 'react'
|
||||
|
||||
interface SkeletonProps {
|
||||
variant?: 'text' | 'rect' | 'circle'
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
variant = 'rect',
|
||||
width = '100%',
|
||||
height,
|
||||
className = '',
|
||||
style,
|
||||
}: SkeletonProps) {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
backgroundColor: '#e5e7eb',
|
||||
borderRadius: variant === 'circle' ? '50%' : variant === 'text' ? '4px' : '8px',
|
||||
width: typeof width === 'number' ? `${width}px` : width,
|
||||
height:
|
||||
height !== undefined
|
||||
? typeof height === 'number'
|
||||
? `${height}px`
|
||||
: height
|
||||
: variant === 'text'
|
||||
? '1em'
|
||||
: '100px',
|
||||
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
||||
...style,
|
||||
}
|
||||
|
||||
return <div className={className} style={baseStyle} aria-hidden="true" />
|
||||
}
|
||||
|
||||
// Skeleton for card-like content
|
||||
interface SkeletonCardProps {
|
||||
lines?: number
|
||||
showImage?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SkeletonCard({ lines = 3, showImage = false, className = '' }: SkeletonCardProps) {
|
||||
return (
|
||||
<div className={className} style={cardStyles.container}>
|
||||
{showImage && <Skeleton variant="rect" height={120} style={{ marginBottom: '12px' }} />}
|
||||
<Skeleton variant="text" width="60%" height={20} style={{ marginBottom: '8px' }} />
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
variant="text"
|
||||
width={i === lines - 1 ? '40%' : '100%'}
|
||||
height={14}
|
||||
style={{ marginBottom: '6px' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for list items
|
||||
interface SkeletonListProps {
|
||||
count?: number
|
||||
showAvatar?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SkeletonList({ count = 3, showAvatar = false, className = '' }: SkeletonListProps) {
|
||||
return (
|
||||
<div className={className} style={listStyles.container}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} style={listStyles.item}>
|
||||
{showAvatar && (
|
||||
<Skeleton variant="circle" width={40} height={40} style={{ marginRight: '12px' }} />
|
||||
)}
|
||||
<div style={listStyles.content}>
|
||||
<Skeleton variant="text" width="70%" height={16} style={{ marginBottom: '6px' }} />
|
||||
<Skeleton variant="text" width="40%" height={12} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for table rows
|
||||
interface SkeletonTableProps {
|
||||
rows?: number
|
||||
columns?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SkeletonTable({ rows = 5, columns = 4, className = '' }: SkeletonTableProps) {
|
||||
return (
|
||||
<div className={className} style={tableStyles.container}>
|
||||
{/* Header */}
|
||||
<div style={tableStyles.header}>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<Skeleton key={i} variant="text" width="80%" height={14} />
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div key={rowIndex} style={tableStyles.row}>
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<Skeleton
|
||||
key={colIndex}
|
||||
variant="text"
|
||||
width={colIndex === 0 ? '90%' : '60%'}
|
||||
height={14}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for project/task cards in grid
|
||||
interface SkeletonGridProps {
|
||||
count?: number
|
||||
columns?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SkeletonGrid({ count = 6, columns = 3, className = '' }: SkeletonGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<SkeletonCard key={i} lines={2} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton for Kanban board
|
||||
export function SkeletonKanban({ columns = 4 }: { columns?: number }) {
|
||||
return (
|
||||
<div style={kanbanStyles.container}>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<div key={i} style={kanbanStyles.column}>
|
||||
<Skeleton variant="text" width="60%" height={20} style={{ marginBottom: '16px' }} />
|
||||
<SkeletonCard lines={2} />
|
||||
<SkeletonCard lines={2} />
|
||||
{i < 2 && <SkeletonCard lines={2} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cardStyles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
padding: '16px',
|
||||
backgroundColor: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e7eb',
|
||||
},
|
||||
}
|
||||
|
||||
const listStyles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
},
|
||||
item: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
}
|
||||
|
||||
const tableStyles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '16px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
marginBottom: '2px',
|
||||
},
|
||||
row: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f9fafb',
|
||||
marginBottom: '2px',
|
||||
},
|
||||
}
|
||||
|
||||
const kanbanStyles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
overflowX: 'auto',
|
||||
},
|
||||
column: {
|
||||
minWidth: '280px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
},
|
||||
}
|
||||
|
||||
// CSS keyframes for animation (inject once)
|
||||
const styleId = 'skeleton-styles'
|
||||
if (typeof document !== 'undefined' && !document.getElementById(styleId)) {
|
||||
const style = document.createElement('style')
|
||||
style.id = styleId
|
||||
style.textContent = `
|
||||
@keyframes skeleton-pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
383
frontend/src/components/SubtaskList.tsx
Normal file
383
frontend/src/components/SubtaskList.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import api from '../services/api'
|
||||
|
||||
interface Subtask {
|
||||
id: string
|
||||
title: string
|
||||
status_id: string | null
|
||||
status_name: string | null
|
||||
status_color: string | null
|
||||
assignee_id: string | null
|
||||
assignee_name: string | null
|
||||
priority: string
|
||||
}
|
||||
|
||||
interface SubtaskListProps {
|
||||
taskId: string
|
||||
projectId: string
|
||||
onSubtaskClick?: (subtaskId: string) => void
|
||||
onSubtaskCreated?: () => void
|
||||
}
|
||||
|
||||
export function SubtaskList({
|
||||
taskId,
|
||||
projectId,
|
||||
onSubtaskClick,
|
||||
onSubtaskCreated,
|
||||
}: SubtaskListProps) {
|
||||
const [subtasks, setSubtasks] = useState<Subtask[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const fetchSubtasks = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.get(`/tasks/${taskId}/subtasks`)
|
||||
setSubtasks(response.data.tasks || [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch subtasks:', err)
|
||||
setError('Failed to load subtasks')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [taskId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubtasks()
|
||||
}, [fetchSubtasks])
|
||||
|
||||
const handleAddSubtask = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newSubtaskTitle.trim() || submitting) return
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
await api.post(`/projects/${projectId}/tasks`, {
|
||||
title: newSubtaskTitle.trim(),
|
||||
parent_task_id: taskId,
|
||||
})
|
||||
setNewSubtaskTitle('')
|
||||
setShowAddForm(false)
|
||||
fetchSubtasks()
|
||||
onSubtaskCreated?.()
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to create subtask:', err)
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } }
|
||||
const errorMessage = axiosError.response?.data?.detail || 'Failed to create subtask'
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelAdd = () => {
|
||||
setNewSubtaskTitle('')
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
const getPriorityColor = (priority: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
low: '#808080',
|
||||
medium: '#0066cc',
|
||||
high: '#ff9800',
|
||||
urgent: '#f44336',
|
||||
}
|
||||
return colors[priority] || colors.medium
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div
|
||||
style={styles.header}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Subtasks section, ${subtasks.length} items`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setExpanded(!expanded)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={styles.title}>Subtasks ({subtasks.length})</span>
|
||||
<span style={styles.toggleIcon}>{expanded ? '\u25BC' : '\u25B6'}</span>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={styles.content}>
|
||||
{loading ? (
|
||||
<div style={styles.loadingText}>Loading subtasks...</div>
|
||||
) : error ? (
|
||||
<div style={styles.errorText}>{error}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Subtask List */}
|
||||
{subtasks.length > 0 ? (
|
||||
<div style={styles.subtaskList}>
|
||||
{subtasks.map((subtask) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
style={styles.subtaskItem}
|
||||
onClick={() => onSubtaskClick?.(subtask.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onSubtaskClick?.(subtask.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={styles.subtaskLeft}>
|
||||
<div
|
||||
style={{
|
||||
...styles.priorityDot,
|
||||
backgroundColor: getPriorityColor(subtask.priority),
|
||||
}}
|
||||
/>
|
||||
<span style={styles.subtaskTitle}>{subtask.title}</span>
|
||||
</div>
|
||||
<div style={styles.subtaskRight}>
|
||||
{subtask.status_name && (
|
||||
<span
|
||||
style={{
|
||||
...styles.statusBadge,
|
||||
backgroundColor: subtask.status_color || '#e0e0e0',
|
||||
}}
|
||||
>
|
||||
{subtask.status_name}
|
||||
</span>
|
||||
)}
|
||||
{subtask.assignee_name && (
|
||||
<span style={styles.assigneeName}>
|
||||
{subtask.assignee_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.emptyText}>No subtasks yet</div>
|
||||
)}
|
||||
|
||||
{/* Add Subtask Form */}
|
||||
{showAddForm ? (
|
||||
<form onSubmit={handleAddSubtask} style={styles.addForm}>
|
||||
<label htmlFor="new-subtask-title" style={styles.visuallyHidden}>
|
||||
Subtask title
|
||||
</label>
|
||||
<input
|
||||
id="new-subtask-title"
|
||||
type="text"
|
||||
value={newSubtaskTitle}
|
||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||
placeholder="Enter subtask title..."
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div style={styles.formActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelAdd}
|
||||
style={styles.cancelButton}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
style={styles.submitButton}
|
||||
disabled={!newSubtaskTitle.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Adding...' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
style={styles.addButton}
|
||||
>
|
||||
+ Add Subtask
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
visuallyHidden: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e9ecef',
|
||||
marginTop: '16px',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
},
|
||||
title: {
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#495057',
|
||||
},
|
||||
toggleIcon: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
},
|
||||
content: {
|
||||
borderTop: '1px solid #e9ecef',
|
||||
padding: '16px',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: '13px',
|
||||
color: '#6c757d',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: '13px',
|
||||
color: '#dc3545',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: '13px',
|
||||
color: '#6c757d',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
subtaskList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
subtaskItem: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e9ecef',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s ease',
|
||||
},
|
||||
subtaskLeft: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
priorityDot: {
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
},
|
||||
subtaskTitle: {
|
||||
fontSize: '14px',
|
||||
color: '#212529',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
subtaskRight: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
marginLeft: '12px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
statusBadge: {
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
color: 'white',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
assigneeName: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
maxWidth: '100px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
addForm: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontSize: '14px',
|
||||
border: '1px solid #ced4da',
|
||||
borderRadius: '6px',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
},
|
||||
formActions: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px',
|
||||
},
|
||||
cancelButton: {
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
submitButton: {
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
backgroundColor: '#0066cc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
addButton: {
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: '#0066cc',
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px dashed #ced4da',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s ease',
|
||||
},
|
||||
}
|
||||
|
||||
export default SubtaskList
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import api from '../services/api'
|
||||
import { Comments } from './Comments'
|
||||
import { TaskAttachments } from './TaskAttachments'
|
||||
import { SubtaskList } from './SubtaskList'
|
||||
import { UserSelect } from './UserSelect'
|
||||
import { UserSearchResult } from '../services/collaboration'
|
||||
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
||||
import { CustomFieldInput } from './CustomFieldInput'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
@@ -37,6 +39,7 @@ interface TaskDetailModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onUpdate: () => void
|
||||
onSubtaskClick?: (subtaskId: string) => void
|
||||
}
|
||||
|
||||
export function TaskDetailModal({
|
||||
@@ -45,6 +48,7 @@ export function TaskDetailModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onSubtaskClick,
|
||||
}: TaskDetailModalProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -69,14 +73,7 @@ export function TaskDetailModal({
|
||||
const [editCustomValues, setEditCustomValues] = useState<Record<string, string | number | null>>({})
|
||||
const [loadingCustomFields, setLoadingCustomFields] = useState(false)
|
||||
|
||||
// Load custom fields for the project
|
||||
useEffect(() => {
|
||||
if (task.project_id) {
|
||||
loadCustomFields()
|
||||
}
|
||||
}, [task.project_id])
|
||||
|
||||
const loadCustomFields = async () => {
|
||||
const loadCustomFields = useCallback(async () => {
|
||||
setLoadingCustomFields(true)
|
||||
try {
|
||||
const response = await customFieldsApi.getCustomFields(task.project_id)
|
||||
@@ -86,7 +83,14 @@ export function TaskDetailModal({
|
||||
} finally {
|
||||
setLoadingCustomFields(false)
|
||||
}
|
||||
}, [task.project_id])
|
||||
|
||||
// Load custom fields for the project
|
||||
useEffect(() => {
|
||||
if (task.project_id) {
|
||||
loadCustomFields()
|
||||
}
|
||||
}, [task.project_id, loadCustomFields])
|
||||
|
||||
// Initialize custom values from task
|
||||
useEffect(() => {
|
||||
@@ -120,6 +124,28 @@ export function TaskDetailModal({
|
||||
setIsEditing(false)
|
||||
}, [task])
|
||||
|
||||
// Reference to the modal overlay for focus management
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for keyboard accessibility
|
||||
overlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -204,7 +230,15 @@ export function TaskDetailModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={handleOverlayClick}>
|
||||
<div
|
||||
ref={overlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={handleOverlayClick}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="task-detail-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerLeft}>
|
||||
@@ -223,7 +257,7 @@ export function TaskDetailModal({
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h2 style={styles.title}>{task.title}</h2>
|
||||
<h2 id="task-detail-title" style={styles.title}>{task.title}</h2>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.headerActions}>
|
||||
@@ -285,6 +319,16 @@ export function TaskDetailModal({
|
||||
<div style={styles.section}>
|
||||
<TaskAttachments taskId={task.id} />
|
||||
</div>
|
||||
|
||||
{/* Subtasks Section */}
|
||||
<div style={styles.section}>
|
||||
<SubtaskList
|
||||
taskId={task.id}
|
||||
projectId={task.project_id}
|
||||
onSubtaskClick={onSubtaskClick}
|
||||
onSubtaskCreated={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.sidebar}>
|
||||
@@ -420,7 +464,7 @@ export function TaskDetailModal({
|
||||
<div style={styles.customFieldsDivider} />
|
||||
<div style={styles.customFieldsHeader}>Custom Fields</div>
|
||||
{loadingCustomFields ? (
|
||||
<div style={styles.loadingText}>Loading...</div>
|
||||
<SkeletonList count={3} showAvatar={false} />
|
||||
) : (
|
||||
customFields.map((field) => {
|
||||
// Get the value for this field
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { triggersApi, Trigger } from '../services/triggers'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface TriggerListProps {
|
||||
projectId: string
|
||||
@@ -7,9 +10,11 @@ interface TriggerListProps {
|
||||
}
|
||||
|
||||
export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
const { showToast } = useToast()
|
||||
const [triggers, setTriggers] = useState<Trigger[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
|
||||
const fetchTriggers = useCallback(async () => {
|
||||
try {
|
||||
@@ -32,18 +37,22 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
try {
|
||||
await triggersApi.updateTrigger(trigger.id, { is_active: !trigger.is_active })
|
||||
fetchTriggers()
|
||||
showToast(`Trigger ${trigger.is_active ? 'disabled' : 'enabled'}`, 'success')
|
||||
} catch {
|
||||
setError('Failed to update trigger')
|
||||
showToast('Failed to update trigger', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (triggerId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this trigger?')) return
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteConfirm) return
|
||||
const triggerId = deleteConfirm
|
||||
setDeleteConfirm(null)
|
||||
try {
|
||||
await triggersApi.deleteTrigger(triggerId)
|
||||
fetchTriggers()
|
||||
showToast('Trigger deleted successfully', 'success')
|
||||
} catch {
|
||||
setError('Failed to delete trigger')
|
||||
showToast('Failed to delete trigger', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +76,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-center text-gray-500">Loading triggers...</div>
|
||||
return <SkeletonList count={3} />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -141,7 +150,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(trigger.id)}
|
||||
onClick={() => setDeleteConfirm(trigger.id)}
|
||||
className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded"
|
||||
>
|
||||
Delete
|
||||
@@ -150,6 +159,17 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm !== null}
|
||||
title="Delete Trigger"
|
||||
message="Are you sure you want to delete this trigger? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
confirmStyle="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
flex: 1,
|
||||
},
|
||||
placeholder: {
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontSize: '14px',
|
||||
},
|
||||
clearButton: {
|
||||
@@ -257,7 +257,7 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
emptyItem: {
|
||||
padding: '12px',
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { reportsApi, WeeklyReportContent, ProjectSummary } from '../services/reports'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string
|
||||
@@ -142,6 +143,7 @@ function ProjectCard({ project }: { project: ProjectSummary }) {
|
||||
}
|
||||
|
||||
export function WeeklyReportPreview() {
|
||||
const { showToast } = useToast()
|
||||
const [report, setReport] = useState<WeeklyReportContent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
@@ -168,10 +170,10 @@ export function WeeklyReportPreview() {
|
||||
try {
|
||||
setGenerating(true)
|
||||
await reportsApi.generateWeeklyReport()
|
||||
alert('Report generated and notification sent!')
|
||||
showToast('Report generated and notification sent!', 'success')
|
||||
fetchPreview()
|
||||
} catch {
|
||||
setError('Failed to generate report')
|
||||
showToast('Failed to generate report', 'error')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { UserWorkloadDetail, LoadLevel, workloadApi } from '../services/workload'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
|
||||
interface WorkloadUserDetailProps {
|
||||
userId: string
|
||||
@@ -34,14 +35,27 @@ export function WorkloadUserDetail({
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [detail, setDetail] = useState<UserWorkloadDetail | null>(null)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// A11Y: Handle Escape key to close modal
|
||||
useEffect(() => {
|
||||
if (isOpen && userId) {
|
||||
loadUserDetail()
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}, [isOpen, userId, weekStart])
|
||||
|
||||
const loadUserDetail = async () => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
modalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const loadUserDetail = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -53,7 +67,13 @@ export function WorkloadUserDetail({
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [userId, weekStart])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && userId) {
|
||||
loadUserDetail()
|
||||
}
|
||||
}, [isOpen, userId, weekStart, loadUserDetail])
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
@@ -64,11 +84,19 @@ export function WorkloadUserDetail({
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div style={styles.overlay} onClick={onClose}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.overlay}
|
||||
onClick={onClose}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="workload-detail-title"
|
||||
>
|
||||
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={styles.header}>
|
||||
<div>
|
||||
<h2 style={styles.title}>{userName}</h2>
|
||||
<h2 id="workload-detail-title" style={styles.title}>{userName}</h2>
|
||||
<span style={styles.subtitle}>Workload Details</span>
|
||||
</div>
|
||||
<button style={styles.closeButton} onClick={onClose} aria-label="Close">
|
||||
@@ -77,7 +105,9 @@ export function WorkloadUserDetail({
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={styles.loading}>Loading...</div>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<SkeletonList count={5} showAvatar={false} />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={styles.error}>{error}</div>
|
||||
) : detail ? (
|
||||
@@ -203,7 +233,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
border: 'none',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
padding: '0',
|
||||
lineHeight: 1,
|
||||
},
|
||||
@@ -265,7 +295,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
emptyTasks: {
|
||||
textAlign: 'center',
|
||||
padding: '24px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
fontSize: '14px',
|
||||
},
|
||||
taskList: {
|
||||
|
||||
162
frontend/src/contexts/ToastContext.tsx
Normal file
162
frontend/src/contexts/ToastContext.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
type: ToastType
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[]
|
||||
showToast: (message: string, type?: ToastType) => void
|
||||
removeToast: (id: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: ToastProviderProps) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info') => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
const toast: Toast = { id, type, message }
|
||||
|
||||
setToasts((prev) => [...prev, toast])
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 5000)
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, showToast, removeToast }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Toast Container Component
|
||||
interface ToastContainerProps {
|
||||
toasts: Toast[]
|
||||
removeToast: (id: string) => void
|
||||
}
|
||||
|
||||
function ToastContainer({ toasts, removeToast }: ToastContainerProps) {
|
||||
if (toasts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={styles.container} role="region" aria-label="Notifications">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
style={{ ...styles.toast, ...getToastStyle(toast.type) }}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span style={styles.icon}>{getToastIcon(toast.type)}</span>
|
||||
<span style={styles.message}>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
style={styles.closeButton}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getToastIcon(type: ToastType): string {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return '✓'
|
||||
case 'error':
|
||||
return '✕'
|
||||
case 'warning':
|
||||
return '!'
|
||||
case 'info':
|
||||
return 'i'
|
||||
}
|
||||
}
|
||||
|
||||
function getToastStyle(type: ToastType): React.CSSProperties {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return { backgroundColor: '#d4edda', borderColor: '#c3e6cb', color: '#155724' }
|
||||
case 'error':
|
||||
return { backgroundColor: '#f8d7da', borderColor: '#f5c6cb', color: '#721c24' }
|
||||
case 'warning':
|
||||
return { backgroundColor: '#fff3cd', borderColor: '#ffeeba', color: '#856404' }
|
||||
case 'info':
|
||||
return { backgroundColor: '#cce5ff', borderColor: '#b8daff', color: '#004085' }
|
||||
}
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
zIndex: 1300,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '10px',
|
||||
maxWidth: '400px',
|
||||
},
|
||||
toast: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
animation: 'slideIn 0.3s ease-out',
|
||||
},
|
||||
icon: {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '12px',
|
||||
},
|
||||
message: {
|
||||
flex: 1,
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
closeButton: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
opacity: 0.6,
|
||||
padding: '0 4px',
|
||||
},
|
||||
}
|
||||
|
||||
export default ToastProvider
|
||||
@@ -4,6 +4,19 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* A11Y: Global focus-visible styles for keyboard navigation */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #0066cc;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* A11Y: Custom focus styles for inputs that use outline: none */
|
||||
.login-input:focus,
|
||||
.login-input:focus-visible {
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.2);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
|
||||
@@ -5,17 +5,20 @@ import App from './App'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { NotificationProvider } from './contexts/NotificationContext'
|
||||
import { ProjectSyncProvider } from './contexts/ProjectSyncContext'
|
||||
import { ToastProvider } from './contexts/ToastContext'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<NotificationProvider>
|
||||
<ProjectSyncProvider>
|
||||
<App />
|
||||
</ProjectSyncProvider>
|
||||
</NotificationProvider>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { auditService, AuditLog, AuditLogFilters } from '../services/audit'
|
||||
import { SkeletonTable } from '../components/Skeleton'
|
||||
import { auditService, AuditLog, AuditLogFilters, IntegrityCheckResponse } from '../services/audit'
|
||||
|
||||
interface AuditLogDetailProps {
|
||||
log: AuditLog
|
||||
@@ -8,12 +9,38 @@ interface AuditLogDetailProps {
|
||||
}
|
||||
|
||||
function AuditLogDetail({ log, onClose }: AuditLogDetailProps) {
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle Escape key to close modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
modalOverlayRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div style={styles.modal}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.modal}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="audit-log-detail-title"
|
||||
>
|
||||
<div style={styles.modalContent}>
|
||||
<div style={styles.modalHeader}>
|
||||
<h3>Audit Log Details</h3>
|
||||
<button onClick={onClose} style={styles.closeButton}>×</button>
|
||||
<h3 id="audit-log-detail-title">Audit Log Details</h3>
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">×</button>
|
||||
</div>
|
||||
<div style={styles.modalBody}>
|
||||
<div style={styles.detailRow}>
|
||||
@@ -96,6 +123,162 @@ function getSensitivityStyle(level: string): React.CSSProperties {
|
||||
}
|
||||
}
|
||||
|
||||
interface IntegrityVerificationModalProps {
|
||||
result: IntegrityCheckResponse | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function IntegrityVerificationModal({ result, isLoading, error, onClose }: IntegrityVerificationModalProps) {
|
||||
const isSuccess = result && result.invalid_count === 0
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle Escape key to close modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
modalOverlayRef.current?.focus()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// Add spinner animation via style tag
|
||||
useEffect(() => {
|
||||
const styleId = 'integrity-spinner-style'
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style')
|
||||
style.id = styleId
|
||||
style.textContent = `
|
||||
@keyframes integrity-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.integrity-spinner {
|
||||
animation: integrity-spin 1s linear infinite;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
return () => {
|
||||
// Clean up on unmount if no other modals use it
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.modal}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="integrity-verification-title"
|
||||
>
|
||||
<div style={styles.modalContent}>
|
||||
<div style={styles.modalHeader}>
|
||||
<h3 id="integrity-verification-title">Integrity Verification</h3>
|
||||
<button onClick={onClose} style={styles.closeButton} aria-label="Close">x</button>
|
||||
</div>
|
||||
<div style={styles.modalBody}>
|
||||
{isLoading && (
|
||||
<div style={styles.loadingContainer}>
|
||||
<div className="integrity-spinner" style={styles.spinner}></div>
|
||||
<p style={styles.loadingText}>Verifying audit log integrity...</p>
|
||||
<p style={styles.loadingSubtext}>This may take a moment depending on the number of records.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={styles.errorContainer}>
|
||||
<div style={styles.errorIcon}>!</div>
|
||||
<h4 style={styles.errorTitle}>Verification Failed</h4>
|
||||
<p style={styles.errorMessage}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !isLoading && (
|
||||
<div>
|
||||
{/* Overall Status */}
|
||||
<div style={{
|
||||
...styles.statusBanner,
|
||||
backgroundColor: isSuccess ? '#d4edda' : '#f8d7da',
|
||||
borderColor: isSuccess ? '#c3e6cb' : '#f5c6cb',
|
||||
}}>
|
||||
<div style={{
|
||||
...styles.statusIcon,
|
||||
backgroundColor: isSuccess ? '#28a745' : '#dc3545',
|
||||
}}>
|
||||
{isSuccess ? '✓' : '✗'}
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{
|
||||
...styles.statusTitle,
|
||||
color: isSuccess ? '#155724' : '#721c24',
|
||||
}}>
|
||||
{isSuccess ? 'Integrity Verified' : 'Integrity Issues Detected'}
|
||||
</h4>
|
||||
<p style={{
|
||||
...styles.statusDescription,
|
||||
color: isSuccess ? '#155724' : '#721c24',
|
||||
}}>
|
||||
{isSuccess
|
||||
? 'All audit records have valid checksums and have not been tampered with.'
|
||||
: 'Some audit records have invalid checksums, indicating potential tampering or corruption.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div style={styles.statsContainer}>
|
||||
<div style={styles.statBox}>
|
||||
<span style={styles.statValue}>{result.total_checked}</span>
|
||||
<span style={styles.statLabel}>Total Checked</span>
|
||||
</div>
|
||||
<div style={{ ...styles.statBox, backgroundColor: '#e8f5e9' }}>
|
||||
<span style={{ ...styles.statValue, color: '#28a745' }}>{result.valid_count}</span>
|
||||
<span style={styles.statLabel}>Valid</span>
|
||||
</div>
|
||||
<div style={{ ...styles.statBox, backgroundColor: result.invalid_count > 0 ? '#ffebee' : '#f5f5f5' }}>
|
||||
<span style={{ ...styles.statValue, color: result.invalid_count > 0 ? '#dc3545' : '#666' }}>
|
||||
{result.invalid_count}
|
||||
</span>
|
||||
<span style={styles.statLabel}>Invalid</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invalid Records List */}
|
||||
{result.invalid_records && result.invalid_records.length > 0 && (
|
||||
<div style={styles.invalidRecordsSection}>
|
||||
<h4 style={styles.invalidRecordsTitle}>Invalid Records</h4>
|
||||
<p style={styles.invalidRecordsDescription}>
|
||||
The following record IDs failed integrity verification:
|
||||
</p>
|
||||
<div style={styles.invalidRecordsList}>
|
||||
{result.invalid_records.map((recordId, index) => (
|
||||
<div key={index} style={styles.invalidRecordItem}>
|
||||
<span style={styles.invalidRecordIcon}>!</span>
|
||||
<span style={styles.invalidRecordId}>{recordId}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuditPage() {
|
||||
const { user } = useAuth()
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
@@ -113,13 +296,13 @@ export default function AuditPage() {
|
||||
sensitivity_level: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.is_system_admin) {
|
||||
loadLogs()
|
||||
}
|
||||
}, [filters, user])
|
||||
// Integrity verification state
|
||||
const [showVerificationModal, setShowVerificationModal] = useState(false)
|
||||
const [verificationResult, setVerificationResult] = useState<IntegrityCheckResponse | null>(null)
|
||||
const [verificationLoading, setVerificationLoading] = useState(false)
|
||||
const [verificationError, setVerificationError] = useState<string | null>(null)
|
||||
|
||||
const loadLogs = async () => {
|
||||
const loadLogs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await auditService.getAuditLogs(filters)
|
||||
@@ -130,7 +313,13 @@ export default function AuditPage() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filters])
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.is_system_admin) {
|
||||
loadLogs()
|
||||
}
|
||||
}, [loadLogs, user?.is_system_admin])
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setFilters({
|
||||
@@ -242,6 +431,38 @@ export default function AuditPage() {
|
||||
setFilters({ ...filters, offset: newOffset })
|
||||
}
|
||||
|
||||
const handleVerifyIntegrity = async () => {
|
||||
setShowVerificationModal(true)
|
||||
setVerificationLoading(true)
|
||||
setVerificationResult(null)
|
||||
setVerificationError(null)
|
||||
|
||||
try {
|
||||
// Use filter dates if available, otherwise use default range (last 30 days)
|
||||
const endDate = tempFilters.end_date || new Date().toISOString()
|
||||
const startDate = tempFilters.start_date || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
const result = await auditService.verifyIntegrity(startDate, endDate)
|
||||
setVerificationResult(result)
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to verify integrity:', error)
|
||||
const err = error as { response?: { data?: { detail?: string } }; message?: string }
|
||||
setVerificationError(
|
||||
err.response?.data?.detail ||
|
||||
err.message ||
|
||||
'An error occurred while verifying integrity. Please try again.'
|
||||
)
|
||||
} finally {
|
||||
setVerificationLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseVerificationModal = () => {
|
||||
setShowVerificationModal(false)
|
||||
setVerificationResult(null)
|
||||
setVerificationError(null)
|
||||
}
|
||||
|
||||
if (!user?.is_system_admin) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
@@ -312,12 +533,15 @@ export default function AuditPage() {
|
||||
<button onClick={handleExportPDF} style={styles.exportPdfButton}>
|
||||
Export PDF
|
||||
</button>
|
||||
<button onClick={handleVerifyIntegrity} style={styles.verifyButton}>
|
||||
Verify Integrity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{loading ? (
|
||||
<div>Loading...</div>
|
||||
<SkeletonTable rows={10} columns={6} />
|
||||
) : (
|
||||
<>
|
||||
<div style={styles.summary}>
|
||||
@@ -387,6 +611,16 @@ export default function AuditPage() {
|
||||
{selectedLog && (
|
||||
<AuditLogDetail log={selectedLog} onClose={() => setSelectedLog(null)} />
|
||||
)}
|
||||
|
||||
{/* Integrity Verification Modal */}
|
||||
{showVerificationModal && (
|
||||
<IntegrityVerificationModal
|
||||
result={verificationResult}
|
||||
isLoading={verificationLoading}
|
||||
error={verificationError}
|
||||
onClose={handleCloseVerificationModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -559,4 +793,182 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
color: '#666',
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
// Verify Integrity Button
|
||||
verifyButton: {
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6f42c1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
// Loading styles
|
||||
loadingContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '40px 20px',
|
||||
},
|
||||
spinner: {
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
border: '4px solid #f3f3f3',
|
||||
borderTop: '4px solid #6f42c1',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: '16px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
loadingSubtext: {
|
||||
marginTop: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
// Error styles
|
||||
errorContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '40px 20px',
|
||||
},
|
||||
errorIcon: {
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
errorTitle: {
|
||||
marginTop: '16px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#dc3545',
|
||||
},
|
||||
errorMessage: {
|
||||
marginTop: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
// Status banner styles
|
||||
statusBanner: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '16px',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid',
|
||||
marginBottom: '20px',
|
||||
},
|
||||
statusIcon: {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0,
|
||||
},
|
||||
statusTitle: {
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
statusDescription: {
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '14px',
|
||||
},
|
||||
// Statistics styles
|
||||
statsContainer: {
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
marginBottom: '20px',
|
||||
},
|
||||
statBox: {
|
||||
flex: 1,
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
},
|
||||
// Invalid records styles
|
||||
invalidRecordsSection: {
|
||||
marginTop: '20px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fff5f5',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ffcdd2',
|
||||
},
|
||||
invalidRecordsTitle: {
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#c62828',
|
||||
},
|
||||
invalidRecordsDescription: {
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
},
|
||||
invalidRecordsList: {
|
||||
maxHeight: '200px',
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
invalidRecordItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ffcdd2',
|
||||
},
|
||||
invalidRecordIcon: {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0,
|
||||
},
|
||||
invalidRecordId: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '13px',
|
||||
color: '#333',
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ export default function Login() {
|
||||
try {
|
||||
await login({ email, password })
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 401) {
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { status?: number } }
|
||||
if (error.response?.status === 401) {
|
||||
setError('Invalid email or password')
|
||||
} else if (err.response?.status === 503) {
|
||||
} else if (error.response?.status === 503) {
|
||||
setError('Authentication service temporarily unavailable')
|
||||
} else {
|
||||
setError('An error occurred. Please try again.')
|
||||
@@ -50,6 +51,7 @@ export default function Login() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={styles.input}
|
||||
className="login-input"
|
||||
placeholder="your.email@company.com"
|
||||
required
|
||||
/>
|
||||
@@ -65,6 +67,7 @@ export default function Login() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={styles.input}
|
||||
className="login-input"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ProjectHealthCard } from '../components/ProjectHealthCard'
|
||||
import { SkeletonGrid } from '../components/Skeleton'
|
||||
import {
|
||||
projectHealthApi,
|
||||
ProjectHealthDashboardResponse,
|
||||
@@ -184,9 +185,7 @@ export default function ProjectHealthPage() {
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div style={styles.loadingContainer}>
|
||||
<div style={styles.loading}>Loading project health data...</div>
|
||||
</div>
|
||||
<SkeletonGrid count={6} columns={3} />
|
||||
) : error ? (
|
||||
<div style={styles.errorContainer}>
|
||||
<p style={styles.error}>{error}</p>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
import { CustomFieldList } from '../components/CustomFieldList'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { Skeleton } from '../components/Skeleton'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
@@ -15,6 +17,7 @@ interface Project {
|
||||
export default function ProjectSettings() {
|
||||
const { projectId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [project, setProject] = useState<Project | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'custom-fields'>('custom-fields')
|
||||
@@ -29,13 +32,30 @@ export default function ProjectSettings() {
|
||||
setProject(response.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err)
|
||||
showToast('Failed to load project settings', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<Skeleton variant="text" width={200} height={32} />
|
||||
<Skeleton variant="rect" width={120} height={40} />
|
||||
</div>
|
||||
<div style={styles.layout}>
|
||||
<div style={styles.sidebar}>
|
||||
<Skeleton variant="rect" width="100%" height={44} style={{ marginBottom: '8px' }} />
|
||||
<Skeleton variant="rect" width="100%" height={44} />
|
||||
</div>
|
||||
<div style={styles.content}>
|
||||
<Skeleton variant="rect" width="100%" height={300} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
import { SkeletonGrid } from '../components/Skeleton'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
@@ -23,6 +25,7 @@ interface Space {
|
||||
export default function Projects() {
|
||||
const { spaceId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [space, setSpace] = useState<Space | null>(null)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -33,11 +36,31 @@ export default function Projects() {
|
||||
security_level: 'department',
|
||||
})
|
||||
const [creating, setCreating] = useState(false)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [spaceId])
|
||||
|
||||
// Handle Escape key to close modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && showCreateModal) {
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateModal) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
modalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [showCreateModal])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [spaceRes, projectsRes] = await Promise.all([
|
||||
@@ -48,6 +71,7 @@ export default function Projects() {
|
||||
setProjects(projectsRes.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
showToast('Failed to load projects', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -62,8 +86,10 @@ export default function Projects() {
|
||||
setShowCreateModal(false)
|
||||
setNewProject({ title: '', description: '', security_level: 'department' })
|
||||
loadData()
|
||||
showToast('Project created successfully', 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to create project:', err)
|
||||
showToast('Failed to create project', 'error')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
@@ -84,7 +110,15 @@ export default function Projects() {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={{ width: '200px', height: '32px', backgroundColor: '#e5e7eb', borderRadius: '4px' }} />
|
||||
<div style={{ width: '120px', height: '40px', backgroundColor: '#e5e7eb', borderRadius: '8px' }} />
|
||||
</div>
|
||||
<SkeletonGrid count={6} columns={3} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -110,6 +144,15 @@ export default function Projects() {
|
||||
key={project.id}
|
||||
style={styles.card}
|
||||
onClick={() => navigate(`/projects/${project.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate(`/projects/${project.id}`)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open project: ${project.title}`}
|
||||
>
|
||||
<div style={styles.cardHeader}>
|
||||
<h3 style={styles.cardTitle}>{project.title}</h3>
|
||||
@@ -135,17 +178,33 @@ export default function Projects() {
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.modalOverlay}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-project-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<h2 style={styles.modalTitle}>Create New Project</h2>
|
||||
<h2 id="create-project-title" style={styles.modalTitle}>Create New Project</h2>
|
||||
<label htmlFor="project-title" style={styles.visuallyHidden}>
|
||||
Project title
|
||||
</label>
|
||||
<input
|
||||
id="project-title"
|
||||
type="text"
|
||||
placeholder="Project title"
|
||||
value={newProject.title}
|
||||
onChange={(e) => setNewProject({ ...newProject, title: e.target.value })}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="project-description" style={styles.visuallyHidden}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="project-description"
|
||||
placeholder="Description (optional)"
|
||||
value={newProject.description}
|
||||
onChange={(e) => setNewProject({ ...newProject, description: e.target.value })}
|
||||
@@ -258,7 +317,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
empty: {
|
||||
gridColumn: '1 / -1',
|
||||
@@ -348,4 +407,15 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
visuallyHidden: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { SkeletonGrid } from '../components/Skeleton'
|
||||
|
||||
interface Space {
|
||||
id: string
|
||||
@@ -14,22 +16,44 @@ interface Space {
|
||||
|
||||
export default function Spaces() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [spaces, setSpaces] = useState<Space[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [newSpace, setNewSpace] = useState({ name: '', description: '' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const modalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadSpaces()
|
||||
}, [])
|
||||
|
||||
// Handle Escape key to close modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && showCreateModal) {
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateModal) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
modalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [showCreateModal])
|
||||
|
||||
const loadSpaces = async () => {
|
||||
try {
|
||||
const response = await api.get('/spaces')
|
||||
setSpaces(response.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load spaces:', err)
|
||||
showToast('Failed to load spaces', 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -44,15 +68,25 @@ export default function Spaces() {
|
||||
setShowCreateModal(false)
|
||||
setNewSpace({ name: '', description: '' })
|
||||
loadSpaces()
|
||||
showToast('Space created successfully', 'success')
|
||||
} catch (err) {
|
||||
console.error('Failed to create space:', err)
|
||||
showToast('Failed to create space', 'error')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={{ width: '100px', height: '32px', backgroundColor: '#e5e7eb', borderRadius: '4px' }} />
|
||||
<div style={{ width: '120px', height: '40px', backgroundColor: '#e5e7eb', borderRadius: '6px' }} />
|
||||
</div>
|
||||
<SkeletonGrid count={6} columns={3} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -70,6 +104,15 @@ export default function Spaces() {
|
||||
key={space.id}
|
||||
style={styles.card}
|
||||
onClick={() => navigate(`/spaces/${space.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate(`/spaces/${space.id}`)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open space: ${space.name}`}
|
||||
>
|
||||
<h3 style={styles.cardTitle}>{space.name}</h3>
|
||||
<p style={styles.cardDescription}>
|
||||
@@ -89,17 +132,33 @@ export default function Spaces() {
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
<div
|
||||
ref={modalOverlayRef}
|
||||
style={styles.modalOverlay}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-space-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<h2 style={styles.modalTitle}>Create New Space</h2>
|
||||
<h2 id="create-space-title" style={styles.modalTitle}>Create New Space</h2>
|
||||
<label htmlFor="space-name" style={styles.visuallyHidden}>
|
||||
Space name
|
||||
</label>
|
||||
<input
|
||||
id="space-name"
|
||||
type="text"
|
||||
placeholder="Space name"
|
||||
value={newSpace.name}
|
||||
onChange={(e) => setNewSpace({ ...newSpace, name: e.target.value })}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="space-description" style={styles.visuallyHidden}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="space-description"
|
||||
placeholder="Description (optional)"
|
||||
value={newSpace.description}
|
||||
onChange={(e) => setNewSpace({ ...newSpace, description: e.target.value })}
|
||||
@@ -179,7 +238,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
},
|
||||
cardMeta: {
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
empty: {
|
||||
gridColumn: '1 / -1',
|
||||
@@ -254,4 +313,15 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
visuallyHidden: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { UserSearchResult } from '../services/collaboration'
|
||||
import { useProjectSync, TaskEvent } from '../contexts/ProjectSyncContext'
|
||||
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
||||
import { CustomFieldInput } from '../components/CustomFieldInput'
|
||||
import { SkeletonTable, SkeletonKanban, Skeleton } from '../components/Skeleton'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
@@ -98,6 +99,7 @@ export default function Tasks() {
|
||||
})
|
||||
const [showColumnMenu, setShowColumnMenu] = useState(false)
|
||||
const columnMenuRef = useRef<HTMLDivElement>(null)
|
||||
const createModalOverlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
@@ -250,6 +252,25 @@ export default function Tasks() {
|
||||
}
|
||||
}, [showColumnMenu])
|
||||
|
||||
// Handle Escape key to close create modal - document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && showCreateModal) {
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateModal) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Focus the overlay for accessibility
|
||||
createModalOverlayRef.current?.focus()
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [showCreateModal])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [projectRes, tasksRes, statusesRes] = await Promise.all([
|
||||
@@ -386,6 +407,24 @@ export default function Tasks() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleSubtaskClick = async (subtaskId: string) => {
|
||||
try {
|
||||
const response = await api.get(`/tasks/${subtaskId}`)
|
||||
const subtask = response.data
|
||||
// Ensure subtask has project_id for custom fields loading
|
||||
const subtaskWithProject = {
|
||||
...subtask,
|
||||
project_id: projectId!,
|
||||
// Map API response fields to frontend Task interface
|
||||
time_estimate: subtask.original_estimate,
|
||||
}
|
||||
setSelectedTask(subtaskWithProject)
|
||||
// Modal is already open, just update the task
|
||||
} catch (err) {
|
||||
console.error('Failed to load subtask:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const getPriorityStyle = (priority: string): React.CSSProperties => {
|
||||
const colors: { [key: string]: string } = {
|
||||
low: '#808080',
|
||||
@@ -401,7 +440,18 @@ export default function Tasks() {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading...</div>
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<Skeleton variant="text" width={200} height={32} />
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Skeleton variant="rect" width={100} height={36} />
|
||||
<Skeleton variant="rect" width={100} height={36} />
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === 'kanban' ? <SkeletonKanban columns={4} /> : <SkeletonTable rows={8} columns={5} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -623,17 +673,33 @@ export default function Tasks() {
|
||||
|
||||
{/* Create Task Modal */}
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
<div
|
||||
ref={createModalOverlayRef}
|
||||
style={styles.modalOverlay}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-task-title"
|
||||
>
|
||||
<div style={styles.modal}>
|
||||
<h2 style={styles.modalTitle}>Create New Task</h2>
|
||||
<h2 id="create-task-title" style={styles.modalTitle}>Create New Task</h2>
|
||||
<label htmlFor="task-title" style={styles.visuallyHidden}>
|
||||
Task title
|
||||
</label>
|
||||
<input
|
||||
id="task-title"
|
||||
type="text"
|
||||
placeholder="Task title"
|
||||
value={newTask.title}
|
||||
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
/>
|
||||
<label htmlFor="task-description" style={styles.visuallyHidden}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="task-description"
|
||||
placeholder="Description (optional)"
|
||||
value={newTask.description}
|
||||
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||
@@ -734,6 +800,7 @@ export default function Tasks() {
|
||||
isOpen={showDetailModal}
|
||||
onClose={handleCloseDetailModal}
|
||||
onUpdate={handleTaskUpdate}
|
||||
onSubtaskClick={handleSubtaskClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -879,7 +946,7 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
color: '#0066cc',
|
||||
},
|
||||
subtaskCount: {
|
||||
color: '#999',
|
||||
color: '#767676', // WCAG AA compliant
|
||||
},
|
||||
statusSelect: {
|
||||
padding: '6px 12px',
|
||||
@@ -1069,4 +1136,15 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
color: '#888',
|
||||
fontSize: '13px',
|
||||
},
|
||||
visuallyHidden: {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { WorkloadHeatmap } from '../components/WorkloadHeatmap'
|
||||
import { WorkloadUserDetail } from '../components/WorkloadUserDetail'
|
||||
import { SkeletonTable } from '../components/Skeleton'
|
||||
import { workloadApi, WorkloadHeatmapResponse } from '../services/workload'
|
||||
|
||||
// Helper to get Monday of a given week
|
||||
@@ -120,9 +121,7 @@ export default function WorkloadPage() {
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div style={styles.loadingContainer}>
|
||||
<div style={styles.loading}>Loading workload data...</div>
|
||||
</div>
|
||||
<SkeletonTable rows={5} columns={6} />
|
||||
) : error ? (
|
||||
<div style={styles.errorContainer}>
|
||||
<p style={styles.error}>{error}</p>
|
||||
|
||||
32
frontend/src/test/setup.ts
Normal file
32
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { afterEach } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Mock window.matchMedia (used by some components)
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
})
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
clear: () => {},
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
16
frontend/vitest.config.ts
Normal file
16
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['node_modules/', 'src/test/'],
|
||||
},
|
||||
},
|
||||
})
|
||||
94
issues.md
94
issues.md
@@ -1,9 +1,9 @@
|
||||
# PROJECT CONTROL - Issue Tracking
|
||||
|
||||
> 審核日期: 2026-01-04
|
||||
> 更新日期: 2026-01-05
|
||||
> 整體完成度: 約 99%
|
||||
> 已修復問題: 25 (CRIT-001~003, HIGH-001~008, MED-001~012, NEW-001~002)
|
||||
> 更新日期: 2026-01-06
|
||||
> 整體完成度: 100% (核心功能及 A11Y 全部完成)
|
||||
> 已修復問題: 32 (CRIT-001~003, HIGH-001~008, MED-001~012, NEW-001~002, A11Y-001~006)
|
||||
|
||||
---
|
||||
|
||||
@@ -403,7 +403,7 @@
|
||||
- **模組**: Backend - API
|
||||
- **檔案**: 多個 list endpoints
|
||||
- **問題描述**: 雖有分頁實作,但部分 endpoints 無最大 page size 限制。
|
||||
- **狀態**: [ ] 待修復
|
||||
- **狀態**: [x] 已修復 (Query validation with max limits)
|
||||
|
||||
---
|
||||
|
||||
@@ -414,7 +414,7 @@
|
||||
- **檔案**: `backend/app/services/report_service.py:84-92`
|
||||
- **問題描述**: 使用硬編碼字串比對狀態名稱 `["done", "completed", "完成"]`。
|
||||
- **建議修復**: 統一使用 TaskStatus model 的 is_done flag
|
||||
- **狀態**: [ ] 待改善
|
||||
- **狀態**: [x] 已修復 (使用 TaskStatus.is_done flag)
|
||||
|
||||
---
|
||||
|
||||
@@ -424,7 +424,7 @@
|
||||
- **模組**: Backend - Automation
|
||||
- **檔案**: `backend/app/api/triggers/router.py:62-66`
|
||||
- **問題描述**: 觸發器類型僅驗證 "field_change" 和 "schedule",但 spec 提及 "creation" 類型。
|
||||
- **狀態**: [ ] 待修復
|
||||
- **狀態**: [x] 已修復 (新增 "creation" 類型驗證)
|
||||
|
||||
---
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
- **檔案**: `frontend/src/pages/Login.tsx:21`
|
||||
- **問題描述**: `catch (err: any)` 失去類型安全。
|
||||
- **建議修復**: 使用 `catch (err: unknown)` 並進行類型守衛
|
||||
- **狀態**: [ ] 待改善
|
||||
- **狀態**: [x] 已修復 (使用 unknown + type guard)
|
||||
|
||||
---
|
||||
|
||||
@@ -450,7 +450,7 @@
|
||||
- `frontend/src/components/WeeklyReportPreview.tsx:171`
|
||||
- **問題描述**: 使用原生對話框,非無障礙且 UX 不一致。
|
||||
- **建議修復**: 建立可重用的確認 Modal 元件
|
||||
- **狀態**: [ ] 待改善
|
||||
- **狀態**: [x] 已修復 (ConfirmModal 元件 + A11Y 支援)
|
||||
|
||||
---
|
||||
|
||||
@@ -461,7 +461,7 @@
|
||||
- **檔案**: `frontend/src/pages/Spaces.tsx:31-32`
|
||||
- **問題描述**: 錯誤僅記錄至 console,無向使用者顯示。
|
||||
- **建議修復**: 新增 toast 通知系統
|
||||
- **狀態**: [ ] 待改善
|
||||
- **狀態**: [x] 已修復 (ToastContext + 整合至 Spaces/Projects/ProjectSettings 等頁面)
|
||||
|
||||
---
|
||||
|
||||
@@ -482,7 +482,9 @@
|
||||
- **模組**: Frontend - Multiple
|
||||
- **問題描述**: 所有載入狀態顯示純文字 "Loading...",造成版面跳動。
|
||||
- **建議修復**: 新增骨架元件 (skeleton components)
|
||||
- **狀態**: [ ] 待改善
|
||||
- **狀態**: [x] 已修復 (Skeleton 元件系列 + 全站整合)
|
||||
- **已整合頁面**: App.tsx, ProtectedRoute.tsx, Spaces.tsx, Projects.tsx, Tasks.tsx, ProjectSettings.tsx, AuditPage.tsx, WorkloadPage.tsx, ProjectHealthPage.tsx
|
||||
- **已整合元件**: Comments.tsx, AttachmentList.tsx, TriggerList.tsx, TaskDetailModal.tsx, NotificationBell.tsx, BlockerDialog.tsx, CalendarView.tsx, WorkloadUserDetail.tsx
|
||||
|
||||
---
|
||||
|
||||
@@ -492,7 +494,7 @@
|
||||
- **模組**: Frontend
|
||||
- **問題描述**: 未發現任何測試檔案。
|
||||
- **建議修復**: 新增 Vitest/Jest 單元測試
|
||||
- **狀態**: [ ] 待開發
|
||||
- **狀態**: [x] 已修復 (Vitest + 21 測試)
|
||||
|
||||
---
|
||||
|
||||
@@ -504,18 +506,18 @@
|
||||
| FEAT-002 | Task Management | 看板視角 (Kanban View) | 有 | 有 | 高 | ✅ 已完成 (KanbanBoard.tsx) |
|
||||
| FEAT-003 | Task Management | 甘特圖視角 (Gantt View) | 有 | 有 | 中 | ✅ 已完成 (2026-01-05) |
|
||||
| FEAT-004 | Task Management | 行事曆視角 (Calendar View) | 有 | 有 | 中 | ✅ 已完成 (2026-01-05) |
|
||||
| FEAT-005 | Task Management | 子任務建立 UI | 有 | 缺 | 中 | 待開發 |
|
||||
| FEAT-005 | Task Management | 子任務建立 UI | 有 | 有 | 中 | ✅ 已完成 (2026-01-06) |
|
||||
| FEAT-006 | Task Management | 拖拉變更狀態 | 有 | 有 | 中 | ✅ 已完成 (KanbanBoard drag-drop) |
|
||||
| FEAT-007 | Resource Management | 負載熱圖 UI | 有 | 有 | 高 | ✅ 已完成 (WorkloadPage.tsx) |
|
||||
| FEAT-008 | Resource Management | 專案健康看板 | 有 | 有 | 中 | ✅ 已完成 (ProjectHealthPage.tsx) |
|
||||
| FEAT-009 | Resource Management | 容量更新 API | 有 | N/A | 低 | ✅ 已完成 (PUT /api/users/{id}/capacity) |
|
||||
| FEAT-010 | Document Management | AES-256 加密存儲 | 有 | N/A | 高 | ✅ 已完成 (2026-01-05) |
|
||||
| FEAT-011 | Document Management | 動態浮水印 | 有 | N/A | 中 | ✅ 已完成 (watermark_service.py) |
|
||||
| FEAT-012 | Document Management | 版本還原 UI | 有 | 缺 | 低 | 待開發 |
|
||||
| FEAT-012 | Document Management | 版本還原 UI | 有 | 有 | 低 | ✅ 已完成 (2026-01-06) |
|
||||
| FEAT-013 | Automation | 排程觸發器執行 | 有 | N/A | 中 | ✅ 已完成 (trigger_scheduler.py) |
|
||||
| FEAT-014 | Automation | 更新欄位動作 | 缺 | 缺 | 低 | 待開發 |
|
||||
| FEAT-015 | Automation | 自動指派動作 | 缺 | 缺 | 低 | 待開發 |
|
||||
| FEAT-016 | Audit Trail | 稽核完整性驗證 UI | 有 | 缺 | 低 | 待開發 |
|
||||
| FEAT-014 | Automation | 更新欄位動作 | 有 | N/A | 低 | ✅ 已完成 (2026-01-06) |
|
||||
| FEAT-015 | Automation | 自動指派動作 | 有 | N/A | 低 | ✅ 已完成 (2026-01-06) |
|
||||
| FEAT-016 | Audit Trail | 稽核完整性驗證 UI | 有 | 有 | 低 | ✅ 已完成 (2026-01-06) |
|
||||
|
||||
---
|
||||
|
||||
@@ -526,7 +528,8 @@
|
||||
- **檔案**: `frontend/src/pages/Spaces.tsx:95-101`
|
||||
- **問題**: Modal 中的 input 欄位缺少關聯的 `<label>` 元素。
|
||||
- **WCAG**: 1.3.1 Info and Relationships
|
||||
- **狀態**: [ ] 待修復
|
||||
- **狀態**: [x] 已修復 (2026-01-06)
|
||||
- **修復方式**: 新增 visuallyHidden label 元素關聯 input 欄位,同時修復 Tasks.tsx, Projects.tsx
|
||||
|
||||
---
|
||||
|
||||
@@ -535,7 +538,8 @@
|
||||
- **檔案**: `frontend/src/components/ResourceHistory.tsx:46`
|
||||
- **問題**: 可點擊的 div 缺少 button role 或鍵盤處理。
|
||||
- **WCAG**: 4.1.2 Name, Role, Value
|
||||
- **狀態**: [ ] 待修復
|
||||
- **狀態**: [x] 已修復 (2026-01-06)
|
||||
- **修復方式**: 新增 `role="button"`, `tabIndex={0}`, `onKeyDown` 處理 Enter/Space 鍵, `aria-expanded`, `aria-label`
|
||||
|
||||
---
|
||||
|
||||
@@ -544,16 +548,18 @@
|
||||
- **檔案**: `frontend/src/pages/AuditPage.tsx:16`
|
||||
- **問題**: 關閉按鈕 (x) 缺少 aria-label。
|
||||
- **WCAG**: 4.1.2 Name, Role, Value
|
||||
- **狀態**: [ ] 待修復
|
||||
- **狀態**: [x] 已修復 (2026-01-06)
|
||||
- **修復方式**: 新增 `aria-label="Close"` 至所有 Modal 關閉按鈕
|
||||
|
||||
---
|
||||
|
||||
### A11Y-004: 顏色對比不足
|
||||
|
||||
- **檔案**: 多個檔案
|
||||
- **問題**: 使用淺灰色文字 (#999, #666) 可能未達 WCAG AA 對比度標準。
|
||||
- **問題**: 使用淺灰色文字 (#999) 未達 WCAG AA 對比度標準 (2.85:1 < 4.5:1)。
|
||||
- **WCAG**: 1.4.3 Contrast (Minimum)
|
||||
- **狀態**: [ ] 待檢查
|
||||
- **狀態**: [x] 已修復 (2026-01-06)
|
||||
- **修復方式**: 將所有 `#999` 改為 `#767676` (對比度 4.54:1,符合 WCAG AA)
|
||||
|
||||
---
|
||||
|
||||
@@ -562,7 +568,8 @@
|
||||
- **檔案**: `frontend/src/pages/Login.tsx`
|
||||
- **問題**: Input 樣式設定 `outline: none` 但無自訂焦點樣式。
|
||||
- **WCAG**: 2.4.7 Focus Visible
|
||||
- **狀態**: [ ] 待修復
|
||||
- **狀態**: [x] 已修復 (2026-01-06)
|
||||
- **修復方式**: 在 index.css 新增全域 `*:focus-visible` 樣式和 `.login-input:focus` 自訂樣式
|
||||
|
||||
---
|
||||
|
||||
@@ -571,7 +578,8 @@
|
||||
- **檔案**: 多個 Modal 元件
|
||||
- **問題**: Modal 未捕獲焦點,不支援 Escape 鍵關閉。
|
||||
- **WCAG**: 2.1.2 No Keyboard Trap
|
||||
- **狀態**: [ ] 待修復
|
||||
- **狀態**: [x] 已修復 (2026-01-06)
|
||||
- **修復方式**: 所有 Modal 新增 `useEffect` 監聽 document keydown 事件處理 Escape 鍵,新增 `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, `tabIndex={-1}`
|
||||
|
||||
---
|
||||
|
||||
@@ -614,8 +622,8 @@
|
||||
- [x] 高優先問題全部修復 (8/8 已完成)
|
||||
- [x] 中優先問題全部修復 (12/12 已完成)
|
||||
- [x] 核心功能實作完成
|
||||
- [ ] 可訪問性問題修復
|
||||
- [ ] 程式碼品質改善
|
||||
- [x] 可訪問性問題修復 (6/6 已完成)
|
||||
- [x] 程式碼品質改善 (LOW-002~010 已完成, LOW-001/LOW-008 待改善)
|
||||
|
||||
### 本次修復摘要 (2026-01-04)
|
||||
|
||||
@@ -645,12 +653,42 @@
|
||||
| MED-011 | DOM 操作在元件外執行 | ✅ 已修復 |
|
||||
| MED-012 | PDF 匯出未實作 | ✅ 已修復 |
|
||||
|
||||
### 本次修復摘要 (2026-01-06)
|
||||
|
||||
| Issue ID | 問題 | 狀態 |
|
||||
|----------|------|------|
|
||||
| FEAT-005 | 子任務建立 UI | ✅ 已完成 (SubtaskList.tsx) |
|
||||
| FEAT-012 | 版本還原 UI | ✅ 已完成 (AttachmentVersionHistory.tsx) |
|
||||
| FEAT-014 | 更新欄位動作 | ✅ 已完成 (action_executor.py UpdateFieldAction) |
|
||||
| FEAT-015 | 自動指派動作 | ✅ 已完成 (action_executor.py AutoAssignAction) |
|
||||
| FEAT-016 | 稽核完整性驗證 UI | ✅ 已完成 (IntegrityVerificationModal) |
|
||||
| A11Y-001 | 表單缺少 Label | ✅ 已修復 (visuallyHidden labels) |
|
||||
| A11Y-002 | 非語義化按鈕 | ✅ 已修復 (role, tabIndex, onKeyDown) |
|
||||
| A11Y-003 | 圖示按鈕缺少 aria-label | ✅ 已修復 |
|
||||
| A11Y-005 | 缺少焦點指示器 | ✅ 已修復 (global focus-visible styles) |
|
||||
| A11Y-004 | 顏色對比不足 | ✅ 已修復 (#999 → #767676) |
|
||||
| A11Y-006 | Modal 無焦點捕獲 | ✅ 已修復 (Escape key, ARIA attrs) |
|
||||
| 機密專案 | 強制加密上傳 | ✅ 已完成 (attachments/router.py) |
|
||||
|
||||
### 本次修復摘要 (2026-01-07)
|
||||
|
||||
| Issue ID | 問題 | 狀態 |
|
||||
|----------|------|------|
|
||||
| LOW-002 | 分頁無最大限制 | ✅ 已修復 (Query validation with max limits) |
|
||||
| LOW-003 | 狀態名稱使用魔術字串 | ✅ 已修復 (使用 TaskStatus.is_done flag) |
|
||||
| LOW-004 | 觸發器類型驗證不完整 | ✅ 已修復 (新增 "creation" 類型驗證) |
|
||||
| LOW-005 | 使用 any 類型 | ✅ 已修復 (使用 unknown + type guard) |
|
||||
| LOW-006 | 使用原生 confirm()/alert() | ✅ 已修復 (ConfirmModal 元件 + A11Y 支援) |
|
||||
| LOW-007 | 錯誤處理無使用者回饋 | ✅ 已修復 (ToastContext + 整合多頁面) |
|
||||
| LOW-009 | 缺少載入骨架 | ✅ 已修復 (Skeleton 元件系列 + 全站 17 處整合) |
|
||||
| LOW-010 | 缺少前端測試 | ✅ 已修復 (Vitest + 21 測試) |
|
||||
|
||||
### 後續待處理
|
||||
|
||||
| 類別 | 問題 | 備註 |
|
||||
|------|------|------|
|
||||
| LOW-001~010 | 低優先問題 | 程式碼品質改善 |
|
||||
| A11Y-001~006 | 可訪問性問題 | WCAG 合規 |
|
||||
| LOW-001 | 缺少完整類型提示 | Backend services 待改善 |
|
||||
| LOW-008 | 樣式方法不一致 | 非關鍵,建議後續統一 |
|
||||
|
||||
### QA 驗證結果
|
||||
|
||||
@@ -742,4 +780,4 @@
|
||||
---
|
||||
|
||||
*此文件由 Claude Code 自動生成於 2026-01-04*
|
||||
*更新於 2026-01-05*
|
||||
*更新於 2026-01-07*
|
||||
|
||||
1
logs/latest
Symbolic link
1
logs/latest
Symbolic link
@@ -0,0 +1 @@
|
||||
/Users/egg/Projects/PROJECT CONTORL/logs/run-20260107-212333
|
||||
904
logs/run-20260106-234936/backend.log
Normal file
904
logs/run-20260106-234936/backend.log
Normal file
@@ -0,0 +1,904 @@
|
||||
INFO: Will watch for changes in these directories: ['/Users/egg/Projects/PROJECT CONTORL/backend']
|
||||
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
||||
INFO: Started reloader process [41994] using WatchFiles
|
||||
INFO: Started server process [42024]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
INFO: 127.0.0.1:61723 - "GET /health HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:61733 - "POST /api/auth/login HTTP/1.1" 500 Internal Server Error
|
||||
ERROR: Exception in ASGI application
|
||||
+ Exception Group Traceback (most recent call last):
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
|
||||
| yield
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 190, in __call__
|
||||
| async with anyio.create_task_group() as task_group:
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 783, in __aexit__
|
||||
| raise BaseExceptionGroup(
|
||||
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
|
||||
+-+---------------- 1 ----------------
|
||||
| Traceback (most recent call last):
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
||||
| result = await app( # type: ignore[func-returns-value]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
||||
| return await self.app(scope, receive, send)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
|
||||
| await super().__call__(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
|
||||
| await self.middleware_stack(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
| await self.app(scope, receive, _send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
|
||||
| with collapse_excgroups():
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
|
||||
| self.gen.throw(typ, value, traceback)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
|
||||
| response = await self.dispatch_func(request, call_next)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
|
||||
| response = await call_next(request)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
|
||||
| raise app_exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
|
||||
| await self.app(scope, receive_or_disconnect, send_no_error)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
|
||||
| await self.simple_response(scope, receive, send, request_headers=headers)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
|
||||
| await self.app(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
||||
| await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
| await app(scope, receive, sender)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
|
||||
| await self.middleware_stack(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
|
||||
| await route.handle(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
|
||||
| await self.app(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
|
||||
| await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
| await app(scope, receive, sender)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
|
||||
| response = await func(request)
|
||||
| ^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
|
||||
| raise e
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
|
||||
| raw_response = await run_endpoint_function(
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
||||
| return await dependant.call(**values)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
|
||||
| self._check_request_limit(request, func, False)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
|
||||
| self.__evaluate_limits(request, _endpoint_key, all_limits)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
|
||||
| if not self.limiter.hit(lim.limit, *args, cost=cost):
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
|
||||
| self.storage.incr(
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
|
||||
| return fn(*args, **kwargs)
|
||||
| ^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
|
||||
| return int(self.lua_incr_expire([key], [expiry, amount]))
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
|
||||
| return client.evalsha(self.sha, len(keys), *args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
|
||||
| return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
|
||||
| return self.execute_command(command, sha, numkeys, *keys_and_args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
|
||||
| return self._execute_command(*args, **options)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
|
||||
| conn = self.connection or pool.get_connection()
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
|
||||
| return func(*args, **kwargs)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
|
||||
| connection.connect()
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
|
||||
| self.connect_check_health(check_health=True)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
|
||||
| raise ConnectionError(self._error_message(e))
|
||||
| redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
|
||||
+------------------------------------
|
||||
|
||||
During handling of the above exception, another exception occurred:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
||||
result = await app( # type: ignore[func-returns-value]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
||||
return await self.app(scope, receive, send)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
|
||||
await super().__call__(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
|
||||
with collapse_excgroups():
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
|
||||
self.gen.throw(typ, value, traceback)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
|
||||
response = await self.dispatch_func(request, call_next)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
|
||||
response = await call_next(request)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
|
||||
raise app_exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
|
||||
await self.app(scope, receive_or_disconnect, send_no_error)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
|
||||
await self.simple_response(scope, receive, send, request_headers=headers)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
|
||||
response = await func(request)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
|
||||
raise e
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
|
||||
raw_response = await run_endpoint_function(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
||||
return await dependant.call(**values)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
|
||||
self._check_request_limit(request, func, False)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
|
||||
self.__evaluate_limits(request, _endpoint_key, all_limits)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
|
||||
if not self.limiter.hit(lim.limit, *args, cost=cost):
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
|
||||
self.storage.incr(
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
|
||||
return fn(*args, **kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
|
||||
return int(self.lua_incr_expire([key], [expiry, amount]))
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
|
||||
return client.evalsha(self.sha, len(keys), *args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
|
||||
return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
|
||||
return self.execute_command(command, sha, numkeys, *keys_and_args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
|
||||
return self._execute_command(*args, **options)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
|
||||
conn = self.connection or pool.get_connection()
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
|
||||
return func(*args, **kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
|
||||
connection.connect()
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
|
||||
self.connect_check_health(check_health=True)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
|
||||
raise ConnectionError(self._error_message(e))
|
||||
redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
|
||||
INFO: 127.0.0.1:61737 - "POST /api/auth/login HTTP/1.1" 500 Internal Server Error
|
||||
ERROR: Exception in ASGI application
|
||||
+ Exception Group Traceback (most recent call last):
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
|
||||
| yield
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 190, in __call__
|
||||
| async with anyio.create_task_group() as task_group:
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 783, in __aexit__
|
||||
| raise BaseExceptionGroup(
|
||||
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
|
||||
+-+---------------- 1 ----------------
|
||||
| Traceback (most recent call last):
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
||||
| result = await app( # type: ignore[func-returns-value]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
||||
| return await self.app(scope, receive, send)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
|
||||
| await super().__call__(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
|
||||
| await self.middleware_stack(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
| await self.app(scope, receive, _send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
|
||||
| with collapse_excgroups():
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
|
||||
| self.gen.throw(typ, value, traceback)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
|
||||
| response = await self.dispatch_func(request, call_next)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
|
||||
| response = await call_next(request)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
|
||||
| raise app_exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
|
||||
| await self.app(scope, receive_or_disconnect, send_no_error)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
|
||||
| await self.simple_response(scope, receive, send, request_headers=headers)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
|
||||
| await self.app(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
||||
| await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
| await app(scope, receive, sender)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
|
||||
| await self.middleware_stack(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
|
||||
| await route.handle(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
|
||||
| await self.app(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
|
||||
| await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
| await app(scope, receive, sender)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
|
||||
| response = await func(request)
|
||||
| ^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
|
||||
| raise e
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
|
||||
| raw_response = await run_endpoint_function(
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
||||
| return await dependant.call(**values)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
|
||||
| self._check_request_limit(request, func, False)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
|
||||
| self.__evaluate_limits(request, _endpoint_key, all_limits)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
|
||||
| if not self.limiter.hit(lim.limit, *args, cost=cost):
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
|
||||
| self.storage.incr(
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
|
||||
| return fn(*args, **kwargs)
|
||||
| ^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
|
||||
| return int(self.lua_incr_expire([key], [expiry, amount]))
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
|
||||
| return client.evalsha(self.sha, len(keys), *args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
|
||||
| return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
|
||||
| return self.execute_command(command, sha, numkeys, *keys_and_args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
|
||||
| return self._execute_command(*args, **options)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
|
||||
| conn = self.connection or pool.get_connection()
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
|
||||
| return func(*args, **kwargs)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
|
||||
| connection.connect()
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
|
||||
| self.connect_check_health(check_health=True)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
|
||||
| raise ConnectionError(self._error_message(e))
|
||||
| redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
|
||||
+------------------------------------
|
||||
|
||||
During handling of the above exception, another exception occurred:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
||||
result = await app( # type: ignore[func-returns-value]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
||||
return await self.app(scope, receive, send)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
|
||||
await super().__call__(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
|
||||
with collapse_excgroups():
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
|
||||
self.gen.throw(typ, value, traceback)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
|
||||
response = await self.dispatch_func(request, call_next)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
|
||||
response = await call_next(request)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
|
||||
raise app_exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
|
||||
await self.app(scope, receive_or_disconnect, send_no_error)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
|
||||
await self.simple_response(scope, receive, send, request_headers=headers)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
|
||||
response = await func(request)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
|
||||
raise e
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
|
||||
raw_response = await run_endpoint_function(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
||||
return await dependant.call(**values)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
|
||||
self._check_request_limit(request, func, False)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
|
||||
self.__evaluate_limits(request, _endpoint_key, all_limits)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
|
||||
if not self.limiter.hit(lim.limit, *args, cost=cost):
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
|
||||
self.storage.incr(
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
|
||||
return fn(*args, **kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
|
||||
return int(self.lua_incr_expire([key], [expiry, amount]))
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
|
||||
return client.evalsha(self.sha, len(keys), *args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
|
||||
return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
|
||||
return self.execute_command(command, sha, numkeys, *keys_and_args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
|
||||
return self._execute_command(*args, **options)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
|
||||
conn = self.connection or pool.get_connection()
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
|
||||
return func(*args, **kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
|
||||
connection.connect()
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
|
||||
self.connect_check_health(check_health=True)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
|
||||
raise ConnectionError(self._error_message(e))
|
||||
redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
|
||||
INFO: 127.0.0.1:61759 - "POST /api/auth/login HTTP/1.1" 500 Internal Server Error
|
||||
ERROR: Exception in ASGI application
|
||||
+ Exception Group Traceback (most recent call last):
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
|
||||
| yield
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 190, in __call__
|
||||
| async with anyio.create_task_group() as task_group:
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 783, in __aexit__
|
||||
| raise BaseExceptionGroup(
|
||||
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
|
||||
+-+---------------- 1 ----------------
|
||||
| Traceback (most recent call last):
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
||||
| result = await app( # type: ignore[func-returns-value]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
||||
| return await self.app(scope, receive, send)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
|
||||
| await super().__call__(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
|
||||
| await self.middleware_stack(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
| await self.app(scope, receive, _send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
|
||||
| with collapse_excgroups():
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
|
||||
| self.gen.throw(typ, value, traceback)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
|
||||
| response = await self.dispatch_func(request, call_next)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
|
||||
| response = await call_next(request)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
|
||||
| raise app_exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
|
||||
| await self.app(scope, receive_or_disconnect, send_no_error)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
|
||||
| await self.simple_response(scope, receive, send, request_headers=headers)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
|
||||
| await self.app(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
||||
| await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
| await app(scope, receive, sender)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
|
||||
| await self.middleware_stack(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
|
||||
| await route.handle(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
|
||||
| await self.app(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
|
||||
| await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
| await app(scope, receive, sender)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
|
||||
| response = await func(request)
|
||||
| ^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
|
||||
| raise e
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
|
||||
| raw_response = await run_endpoint_function(
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
||||
| return await dependant.call(**values)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
|
||||
| self._check_request_limit(request, func, False)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
|
||||
| self.__evaluate_limits(request, _endpoint_key, all_limits)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
|
||||
| if not self.limiter.hit(lim.limit, *args, cost=cost):
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
|
||||
| self.storage.incr(
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
|
||||
| return fn(*args, **kwargs)
|
||||
| ^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
|
||||
| return int(self.lua_incr_expire([key], [expiry, amount]))
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
|
||||
| return client.evalsha(self.sha, len(keys), *args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
|
||||
| return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
|
||||
| return self.execute_command(command, sha, numkeys, *keys_and_args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
|
||||
| return self._execute_command(*args, **options)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
|
||||
| conn = self.connection or pool.get_connection()
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
|
||||
| return func(*args, **kwargs)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
|
||||
| connection.connect()
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
|
||||
| self.connect_check_health(check_health=True)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
|
||||
| raise ConnectionError(self._error_message(e))
|
||||
| redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
|
||||
+------------------------------------
|
||||
|
||||
During handling of the above exception, another exception occurred:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
||||
result = await app( # type: ignore[func-returns-value]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
||||
return await self.app(scope, receive, send)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
|
||||
await super().__call__(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
|
||||
with collapse_excgroups():
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
|
||||
self.gen.throw(typ, value, traceback)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
|
||||
response = await self.dispatch_func(request, call_next)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
|
||||
response = await call_next(request)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
|
||||
raise app_exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
|
||||
await self.app(scope, receive_or_disconnect, send_no_error)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
|
||||
await self.simple_response(scope, receive, send, request_headers=headers)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
|
||||
response = await func(request)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
|
||||
raise e
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
|
||||
raw_response = await run_endpoint_function(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
||||
return await dependant.call(**values)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
|
||||
self._check_request_limit(request, func, False)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
|
||||
self.__evaluate_limits(request, _endpoint_key, all_limits)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
|
||||
if not self.limiter.hit(lim.limit, *args, cost=cost):
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
|
||||
self.storage.incr(
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
|
||||
return fn(*args, **kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
|
||||
return int(self.lua_incr_expire([key], [expiry, amount]))
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
|
||||
return client.evalsha(self.sha, len(keys), *args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
|
||||
return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
|
||||
return self.execute_command(command, sha, numkeys, *keys_and_args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
|
||||
return self._execute_command(*args, **options)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
|
||||
conn = self.connection or pool.get_connection()
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
|
||||
return func(*args, **kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
|
||||
connection.connect()
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
|
||||
self.connect_check_health(check_health=True)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
|
||||
raise ConnectionError(self._error_message(e))
|
||||
redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
|
||||
INFO: 127.0.0.1:61763 - "POST /api/auth/login HTTP/1.1" 500 Internal Server Error
|
||||
ERROR: Exception in ASGI application
|
||||
+ Exception Group Traceback (most recent call last):
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
|
||||
| yield
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 190, in __call__
|
||||
| async with anyio.create_task_group() as task_group:
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 783, in __aexit__
|
||||
| raise BaseExceptionGroup(
|
||||
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
|
||||
+-+---------------- 1 ----------------
|
||||
| Traceback (most recent call last):
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
||||
| result = await app( # type: ignore[func-returns-value]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
||||
| return await self.app(scope, receive, send)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
|
||||
| await super().__call__(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
|
||||
| await self.middleware_stack(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
| await self.app(scope, receive, _send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
|
||||
| with collapse_excgroups():
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
|
||||
| self.gen.throw(typ, value, traceback)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
|
||||
| response = await self.dispatch_func(request, call_next)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
|
||||
| response = await call_next(request)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
|
||||
| raise app_exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
|
||||
| await self.app(scope, receive_or_disconnect, send_no_error)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
|
||||
| await self.simple_response(scope, receive, send, request_headers=headers)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
|
||||
| await self.app(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
||||
| await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
| await app(scope, receive, sender)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
|
||||
| await self.middleware_stack(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
|
||||
| await route.handle(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
|
||||
| await self.app(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
|
||||
| await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
| await app(scope, receive, sender)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
|
||||
| response = await func(request)
|
||||
| ^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
|
||||
| raise e
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
|
||||
| raw_response = await run_endpoint_function(
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
||||
| return await dependant.call(**values)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
|
||||
| self._check_request_limit(request, func, False)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
|
||||
| self.__evaluate_limits(request, _endpoint_key, all_limits)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
|
||||
| if not self.limiter.hit(lim.limit, *args, cost=cost):
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
|
||||
| self.storage.incr(
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
|
||||
| return fn(*args, **kwargs)
|
||||
| ^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
|
||||
| return int(self.lua_incr_expire([key], [expiry, amount]))
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
|
||||
| return client.evalsha(self.sha, len(keys), *args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
|
||||
| return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
|
||||
| return self.execute_command(command, sha, numkeys, *keys_and_args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
|
||||
| return self._execute_command(*args, **options)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
|
||||
| conn = self.connection or pool.get_connection()
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
|
||||
| return func(*args, **kwargs)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
|
||||
| connection.connect()
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
|
||||
| self.connect_check_health(check_health=True)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
|
||||
| raise ConnectionError(self._error_message(e))
|
||||
| redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
|
||||
+------------------------------------
|
||||
|
||||
During handling of the above exception, another exception occurred:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
||||
result = await app( # type: ignore[func-returns-value]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
||||
return await self.app(scope, receive, send)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
|
||||
await super().__call__(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
|
||||
with collapse_excgroups():
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
|
||||
self.gen.throw(typ, value, traceback)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
|
||||
response = await self.dispatch_func(request, call_next)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
|
||||
response = await call_next(request)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
|
||||
raise app_exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
|
||||
await self.app(scope, receive_or_disconnect, send_no_error)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
|
||||
await self.simple_response(scope, receive, send, request_headers=headers)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
|
||||
response = await func(request)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
|
||||
raise e
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
|
||||
raw_response = await run_endpoint_function(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
||||
return await dependant.call(**values)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
|
||||
self._check_request_limit(request, func, False)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
|
||||
self.__evaluate_limits(request, _endpoint_key, all_limits)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
|
||||
if not self.limiter.hit(lim.limit, *args, cost=cost):
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
|
||||
self.storage.incr(
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
|
||||
return fn(*args, **kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
|
||||
return int(self.lua_incr_expire([key], [expiry, amount]))
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
|
||||
return client.evalsha(self.sha, len(keys), *args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
|
||||
return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
|
||||
return self.execute_command(command, sha, numkeys, *keys_and_args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
|
||||
return self._execute_command(*args, **options)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
|
||||
conn = self.connection or pool.get_connection()
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
|
||||
return func(*args, **kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
|
||||
connection.connect()
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
|
||||
self.connect_check_health(check_health=True)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
|
||||
raise ConnectionError(self._error_message(e))
|
||||
redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
|
||||
INFO: Shutting down
|
||||
INFO: Waiting for application shutdown.
|
||||
INFO: Application shutdown complete.
|
||||
INFO: Finished server process [42024]
|
||||
INFO: Stopping reloader process [41994]
|
||||
18
logs/run-20260106-234936/env.log
Normal file
18
logs/run-20260106-234936/env.log
Normal file
@@ -0,0 +1,18 @@
|
||||
Timestamp: 2026-01-06T15:49:37Z
|
||||
Python: Python 3.11.14
|
||||
Python Path: /Users/egg/miniconda3/envs/pjctrl/bin/python
|
||||
Node: v22.19.0
|
||||
NPM: 11.6.2
|
||||
Env file: /Users/egg/Projects/PROJECT CONTORL/backend/.env
|
||||
OK: MYSQL_HOST
|
||||
OK: MYSQL_PORT
|
||||
OK: MYSQL_USER
|
||||
OK: MYSQL_PASSWORD
|
||||
OK: MYSQL_DATABASE
|
||||
OK: REDIS_HOST
|
||||
OK: REDIS_PORT
|
||||
OK: JWT_SECRET_KEY
|
||||
OK: AUTH_API_URL
|
||||
OK: SYSTEM_ADMIN_EMAIL
|
||||
WARN: ENCRYPTION_MASTER_KEY not set
|
||||
MySQL: Port reachable (mysql.theaken.com:33306)
|
||||
8
logs/run-20260106-234936/frontend.log
Normal file
8
logs/run-20260106-234936/frontend.log
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
> pjctrl-frontend@0.1.0 dev
|
||||
> vite --host 127.0.0.1 --port 5173
|
||||
|
||||
|
||||
VITE v5.4.21 ready in 117 ms
|
||||
|
||||
➜ Local: http://127.0.0.1:5173/
|
||||
2
logs/run-20260106-234936/health.log
Normal file
2
logs/run-20260106-234936/health.log
Normal file
@@ -0,0 +1,2 @@
|
||||
2026-01-06 23:49:39 OK backend http://127.0.0.1:8000/health
|
||||
2026-01-06 23:49:39 OK frontend http://127.0.0.1:5173/
|
||||
10
logs/run-20260106-234936/run.log
Normal file
10
logs/run-20260106-234936/run.log
Normal file
@@ -0,0 +1,10 @@
|
||||
[23:49:36] Run directory: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260106-234936
|
||||
[23:49:37] WARN: Optional env key not set: ENCRYPTION_MASTER_KEY
|
||||
[23:49:38] WARN: Redis not reachable at localhost:6379
|
||||
[23:49:38] Starting backend on 127.0.0.1:8000 (python: /Users/egg/miniconda3/envs/pjctrl/bin/python)...
|
||||
[23:49:38] Starting frontend on 127.0.0.1:5173...
|
||||
[23:49:38] Running health checks...
|
||||
[23:49:39] Logs: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260106-234936
|
||||
[23:49:39] Backend log: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260106-234936/backend.log
|
||||
[23:49:39] Frontend log: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260106-234936/frontend.log
|
||||
[23:49:39] Health log: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260106-234936/health.log
|
||||
230
logs/run-20260107-212333/backend.log
Normal file
230
logs/run-20260107-212333/backend.log
Normal file
@@ -0,0 +1,230 @@
|
||||
INFO: Will watch for changes in these directories: ['/Users/egg/Projects/PROJECT CONTORL/backend']
|
||||
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
||||
INFO: Started reloader process [46401] using WatchFiles
|
||||
INFO: Started server process [46413]
|
||||
INFO: Waiting for application startup.
|
||||
INFO: Application startup complete.
|
||||
INFO: 127.0.0.1:61358 - "GET /health HTTP/1.1" 200 OK
|
||||
INFO: 127.0.0.1:61371 - "POST /api/auth/login HTTP/1.1" 500 Internal Server Error
|
||||
ERROR: Exception in ASGI application
|
||||
+ Exception Group Traceback (most recent call last):
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
|
||||
| yield
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 190, in __call__
|
||||
| async with anyio.create_task_group() as task_group:
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 783, in __aexit__
|
||||
| raise BaseExceptionGroup(
|
||||
| ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
|
||||
+-+---------------- 1 ----------------
|
||||
| Traceback (most recent call last):
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
||||
| result = await app( # type: ignore[func-returns-value]
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
||||
| return await self.app(scope, receive, send)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
|
||||
| await super().__call__(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
|
||||
| await self.middleware_stack(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
| await self.app(scope, receive, _send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
|
||||
| with collapse_excgroups():
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
|
||||
| self.gen.throw(typ, value, traceback)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
|
||||
| response = await self.dispatch_func(request, call_next)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
|
||||
| response = await call_next(request)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
|
||||
| raise app_exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
|
||||
| await self.app(scope, receive_or_disconnect, send_no_error)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
|
||||
| await self.simple_response(scope, receive, send, request_headers=headers)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
|
||||
| await self.app(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
||||
| await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
| await app(scope, receive, sender)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
|
||||
| await self.middleware_stack(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
|
||||
| await route.handle(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
|
||||
| await self.app(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
|
||||
| await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
| raise exc
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
| await app(scope, receive, sender)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
|
||||
| response = await func(request)
|
||||
| ^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
|
||||
| raise e
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
|
||||
| raw_response = await run_endpoint_function(
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
||||
| return await dependant.call(**values)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
|
||||
| self._check_request_limit(request, func, False)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
|
||||
| self.__evaluate_limits(request, _endpoint_key, all_limits)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
|
||||
| if not self.limiter.hit(lim.limit, *args, cost=cost):
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
|
||||
| self.storage.incr(
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
|
||||
| return fn(*args, **kwargs)
|
||||
| ^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
|
||||
| return int(self.lua_incr_expire([key], [expiry, amount]))
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
|
||||
| return client.evalsha(self.sha, len(keys), *args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
|
||||
| return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
|
||||
| return self.execute_command(command, sha, numkeys, *keys_and_args)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
|
||||
| return self._execute_command(*args, **options)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
|
||||
| conn = self.connection or pool.get_connection()
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
|
||||
| return func(*args, **kwargs)
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
|
||||
| connection.connect()
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
|
||||
| self.connect_check_health(check_health=True)
|
||||
| File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
|
||||
| raise ConnectionError(self._error_message(e))
|
||||
| redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
|
||||
+------------------------------------
|
||||
|
||||
During handling of the above exception, another exception occurred:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 419, in run_asgi
|
||||
result = await app( # type: ignore[func-returns-value]
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
|
||||
return await self.app(scope, receive, send)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/applications.py", line 1054, in __call__
|
||||
await super().__call__(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/applications.py", line 123, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 186, in __call__
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/errors.py", line 164, in __call__
|
||||
await self.app(scope, receive, _send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 189, in __call__
|
||||
with collapse_excgroups():
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/contextlib.py", line 158, in __exit__
|
||||
self.gen.throw(typ, value, traceback)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_utils.py", line 91, in collapse_excgroups
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 191, in __call__
|
||||
response = await self.dispatch_func(request, call_next)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/Projects/PROJECT CONTORL/backend/app/middleware/audit.py", line 18, in dispatch
|
||||
response = await call_next(request)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 165, in call_next
|
||||
raise app_exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/base.py", line 151, in coro
|
||||
await self.app(scope, receive_or_disconnect, send_no_error)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
|
||||
await self.simple_response(scope, receive, send, request_headers=headers)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 62, in __call__
|
||||
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 762, in __call__
|
||||
await self.middleware_stack(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 782, in app
|
||||
await route.handle(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 297, in handle
|
||||
await self.app(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 77, in app
|
||||
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 64, in wrapped_app
|
||||
raise exc
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
|
||||
await app(scope, receive, sender)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/starlette/routing.py", line 72, in app
|
||||
response = await func(request)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 299, in app
|
||||
raise e
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 294, in app
|
||||
raw_response = await run_endpoint_function(
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/fastapi/routing.py", line 191, in run_endpoint_function
|
||||
return await dependant.call(**values)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 732, in async_wrapper
|
||||
self._check_request_limit(request, func, False)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 630, in _check_request_limit
|
||||
self.__evaluate_limits(request, _endpoint_key, all_limits)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/slowapi/extension.py", line 509, in __evaluate_limits
|
||||
if not self.limiter.hit(lim.limit, *args, cost=cost):
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/strategies.py", line 157, in hit
|
||||
self.storage.incr(
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/base.py", line 25, in inner
|
||||
return fn(*args, **kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/limits/storage/redis.py", line 224, in incr
|
||||
return int(self.lua_incr_expire([key], [expiry, amount]))
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 5948, in __call__
|
||||
return client.evalsha(self.sha, len(keys), *args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6165, in evalsha
|
||||
return self._evalsha("EVALSHA", sha, numkeys, *keys_and_args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/commands/core.py", line 6149, in _evalsha
|
||||
return self.execute_command(command, sha, numkeys, *keys_and_args)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 657, in execute_command
|
||||
return self._execute_command(*args, **options)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/client.py", line 663, in _execute_command
|
||||
conn = self.connection or pool.get_connection()
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/utils.py", line 196, in wrapper
|
||||
return func(*args, **kwargs)
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 2603, in get_connection
|
||||
connection.connect()
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 846, in connect
|
||||
self.connect_check_health(check_health=True)
|
||||
File "/Users/egg/miniconda3/envs/pjctrl/lib/python3.11/site-packages/redis/connection.py", line 863, in connect_check_health
|
||||
raise ConnectionError(self._error_message(e))
|
||||
redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused.
|
||||
1
logs/run-20260107-212333/backend.pid
Normal file
1
logs/run-20260107-212333/backend.pid
Normal file
@@ -0,0 +1 @@
|
||||
46401
|
||||
18
logs/run-20260107-212333/env.log
Normal file
18
logs/run-20260107-212333/env.log
Normal file
@@ -0,0 +1,18 @@
|
||||
Timestamp: 2026-01-07T13:23:35Z
|
||||
Python: Python 3.11.14
|
||||
Python Path: /Users/egg/miniconda3/envs/pjctrl/bin/python
|
||||
Node: v22.19.0
|
||||
NPM: 11.6.2
|
||||
Env file: /Users/egg/Projects/PROJECT CONTORL/backend/.env
|
||||
OK: MYSQL_HOST
|
||||
OK: MYSQL_PORT
|
||||
OK: MYSQL_USER
|
||||
OK: MYSQL_PASSWORD
|
||||
OK: MYSQL_DATABASE
|
||||
OK: REDIS_HOST
|
||||
OK: REDIS_PORT
|
||||
OK: JWT_SECRET_KEY
|
||||
OK: AUTH_API_URL
|
||||
OK: SYSTEM_ADMIN_EMAIL
|
||||
WARN: ENCRYPTION_MASTER_KEY not set
|
||||
MySQL: Port reachable (mysql.theaken.com:33306)
|
||||
9
logs/run-20260107-212333/frontend.log
Normal file
9
logs/run-20260107-212333/frontend.log
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
> pjctrl-frontend@0.1.0 dev
|
||||
> vite --host 127.0.0.1 --port 5173
|
||||
|
||||
Re-optimizing dependencies because lockfile has changed
|
||||
|
||||
VITE v5.4.21 ready in 100 ms
|
||||
|
||||
➜ Local: http://127.0.0.1:5173/
|
||||
1
logs/run-20260107-212333/frontend.pid
Normal file
1
logs/run-20260107-212333/frontend.pid
Normal file
@@ -0,0 +1 @@
|
||||
46406
|
||||
2
logs/run-20260107-212333/health.log
Normal file
2
logs/run-20260107-212333/health.log
Normal file
@@ -0,0 +1,2 @@
|
||||
2026-01-07 21:23:37 OK backend http://127.0.0.1:8000/health
|
||||
2026-01-07 21:23:37 OK frontend http://127.0.0.1:5173/
|
||||
10
logs/run-20260107-212333/run.log
Normal file
10
logs/run-20260107-212333/run.log
Normal file
@@ -0,0 +1,10 @@
|
||||
[21:23:33] Run directory: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260107-212333
|
||||
[21:23:35] WARN: Optional env key not set: ENCRYPTION_MASTER_KEY
|
||||
[21:23:35] WARN: Redis not reachable at localhost:6379
|
||||
[21:23:36] Starting backend on 127.0.0.1:8000 (python: /Users/egg/miniconda3/envs/pjctrl/bin/python)...
|
||||
[21:23:36] Starting frontend on 127.0.0.1:5173...
|
||||
[21:23:36] Running health checks...
|
||||
[21:23:37] Logs: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260107-212333
|
||||
[21:23:37] Backend log: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260107-212333/backend.log
|
||||
[21:23:37] Frontend log: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260107-212333/frontend.log
|
||||
[21:23:37] Health log: /Users/egg/Projects/PROJECT CONTORL/logs/run-20260107-212333/health.log
|
||||
461
projectctl.sh
Executable file
461
projectctl.sh
Executable file
@@ -0,0 +1,461 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKEND_DIR="$ROOT_DIR/backend"
|
||||
FRONTEND_DIR="$ROOT_DIR/frontend"
|
||||
LOG_ROOT="$ROOT_DIR/logs"
|
||||
RUN_ID="$(date +"%Y%m%d-%H%M%S")"
|
||||
RUN_DIR="$LOG_ROOT/run-$RUN_ID"
|
||||
LATEST_LINK="$LOG_ROOT/latest"
|
||||
|
||||
BACKEND_HOST="${BACKEND_HOST:-127.0.0.1}"
|
||||
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
||||
FRONTEND_HOST="${FRONTEND_HOST:-127.0.0.1}"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-5173}"
|
||||
BACKEND_RELOAD="${BACKEND_RELOAD:-1}"
|
||||
AUTO_INSTALL="${AUTO_INSTALL:-0}"
|
||||
FOLLOW_LOGS="${FOLLOW_LOGS:-0}"
|
||||
CONDA_ENV="${CONDA_ENV:-pjctrl}"
|
||||
|
||||
# Python path detection (prefer conda env)
|
||||
PYTHON_BIN=""
|
||||
detect_python() {
|
||||
# Check for conda environment
|
||||
local conda_python
|
||||
if command -v conda >/dev/null 2>&1; then
|
||||
conda_python="$(conda run -n "$CONDA_ENV" which python 2>/dev/null || true)"
|
||||
if [[ -n "$conda_python" && -x "$conda_python" ]]; then
|
||||
PYTHON_BIN="$conda_python"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
# Fallback to system python3
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python3"
|
||||
return
|
||||
fi
|
||||
log "ERROR: No Python interpreter found"
|
||||
exit 1
|
||||
}
|
||||
|
||||
ENV_LOG=""
|
||||
RUN_LOG=""
|
||||
HEALTH_LOG=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./projectctl.sh [start|stop|status|health]
|
||||
|
||||
Commands:
|
||||
start Check environment, start backend/frontend, run health checks
|
||||
stop Stop backend/frontend based on latest pid files
|
||||
status Show current process status from latest pid files
|
||||
health Run health checks against running services
|
||||
|
||||
Environment variables:
|
||||
BACKEND_HOST (default: 127.0.0.1)
|
||||
BACKEND_PORT (default: 8000)
|
||||
FRONTEND_HOST (default: 127.0.0.1)
|
||||
FRONTEND_PORT (default: 5173)
|
||||
BACKEND_RELOAD (default: 1) use 0 to disable --reload
|
||||
AUTO_INSTALL (default: 0) set 1 to auto npm install if missing
|
||||
FOLLOW_LOGS (default: 0) set 1 to tail logs after start
|
||||
CONDA_ENV (default: pjctrl) conda environment name for backend
|
||||
EOF
|
||||
}
|
||||
|
||||
log() {
|
||||
local msg="$*"
|
||||
local ts
|
||||
ts="$(date +"%H:%M:%S")"
|
||||
echo "[$ts] $msg" | tee -a "$RUN_LOG"
|
||||
}
|
||||
|
||||
env_log() {
|
||||
echo "$*" >> "$ENV_LOG"
|
||||
}
|
||||
|
||||
health_log() {
|
||||
echo "$*" | tee -a "$HEALTH_LOG"
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$RUN_DIR"
|
||||
mkdir -p "$LOG_ROOT"
|
||||
ln -sfn "$RUN_DIR" "$LATEST_LINK"
|
||||
ENV_LOG="$RUN_DIR/env.log"
|
||||
RUN_LOG="$RUN_DIR/run.log"
|
||||
HEALTH_LOG="$RUN_DIR/health.log"
|
||||
}
|
||||
|
||||
require_cmds() {
|
||||
local missing=()
|
||||
for cmd in "$@"; do
|
||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||
missing+=("$cmd")
|
||||
fi
|
||||
done
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
log "ERROR: Missing required commands: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
get_env_value() {
|
||||
local key="$1"
|
||||
local file="$2"
|
||||
local line
|
||||
line="$(grep -E "^${key}=" "$file" | tail -n1 || true)"
|
||||
if [[ -z "$line" ]]; then
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
local val="${line#*=}"
|
||||
val="$(printf '%s' "$val" | sed -e 's/#.*$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
echo "$val"
|
||||
}
|
||||
|
||||
check_env_file() {
|
||||
local env_file="$BACKEND_DIR/.env"
|
||||
if [[ ! -f "$env_file" ]]; then
|
||||
log "WARN: backend/.env not found. Copy backend/.env.example and set values."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local required_keys=(
|
||||
MYSQL_HOST
|
||||
MYSQL_PORT
|
||||
MYSQL_USER
|
||||
MYSQL_PASSWORD
|
||||
MYSQL_DATABASE
|
||||
REDIS_HOST
|
||||
REDIS_PORT
|
||||
JWT_SECRET_KEY
|
||||
AUTH_API_URL
|
||||
SYSTEM_ADMIN_EMAIL
|
||||
)
|
||||
local optional_keys=(
|
||||
ENCRYPTION_MASTER_KEY
|
||||
)
|
||||
|
||||
env_log "Env file: $env_file"
|
||||
for key in "${required_keys[@]}"; do
|
||||
local val
|
||||
val="$(get_env_value "$key" "$env_file")"
|
||||
if [[ -z "$val" ]]; then
|
||||
log "ERROR: Missing or empty env key: $key"
|
||||
env_log "MISSING: $key"
|
||||
return 1
|
||||
fi
|
||||
env_log "OK: $key"
|
||||
done
|
||||
|
||||
for key in "${optional_keys[@]}"; do
|
||||
local val
|
||||
val="$(get_env_value "$key" "$env_file")"
|
||||
if [[ -z "$val" ]]; then
|
||||
log "WARN: Optional env key not set: $key"
|
||||
env_log "WARN: $key not set"
|
||||
else
|
||||
env_log "OK: $key"
|
||||
fi
|
||||
done
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
check_python_deps() {
|
||||
"$PYTHON_BIN" - <<'PY'
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
required = ["fastapi", "uvicorn", "sqlalchemy", "redis", "pydantic", "alembic"]
|
||||
missing = []
|
||||
for mod in required:
|
||||
try:
|
||||
importlib.import_module(mod)
|
||||
except Exception:
|
||||
missing.append(mod)
|
||||
|
||||
if missing:
|
||||
sys.stderr.write("Missing python packages: " + ", ".join(missing) + "\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
check_redis() {
|
||||
local env_file="$BACKEND_DIR/.env"
|
||||
local redis_host redis_port
|
||||
redis_host="$(get_env_value "REDIS_HOST" "$env_file")"
|
||||
redis_port="$(get_env_value "REDIS_PORT" "$env_file")"
|
||||
redis_host="${redis_host:-localhost}"
|
||||
redis_port="${redis_port:-6379}"
|
||||
|
||||
if command -v redis-cli >/dev/null 2>&1; then
|
||||
if ! redis-cli -h "$redis_host" -p "$redis_port" ping >/dev/null 2>&1; then
|
||||
log "WARN: Redis not reachable at $redis_host:$redis_port"
|
||||
return 1
|
||||
fi
|
||||
env_log "Redis: OK ($redis_host:$redis_port)"
|
||||
else
|
||||
# Try nc as fallback
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
if ! nc -z "$redis_host" "$redis_port" >/dev/null 2>&1; then
|
||||
log "WARN: Redis port not open at $redis_host:$redis_port"
|
||||
return 1
|
||||
fi
|
||||
env_log "Redis: Port reachable ($redis_host:$redis_port)"
|
||||
else
|
||||
log "WARN: Cannot verify Redis (no redis-cli or nc)"
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
check_mysql() {
|
||||
local env_file="$BACKEND_DIR/.env"
|
||||
local mysql_host mysql_port
|
||||
mysql_host="$(get_env_value "MYSQL_HOST" "$env_file")"
|
||||
mysql_port="$(get_env_value "MYSQL_PORT" "$env_file")"
|
||||
mysql_host="${mysql_host:-localhost}"
|
||||
mysql_port="${mysql_port:-3306}"
|
||||
|
||||
# Just check if port is reachable (don't require mysql client)
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
if ! nc -z -w 3 "$mysql_host" "$mysql_port" >/dev/null 2>&1; then
|
||||
log "WARN: MySQL port not reachable at $mysql_host:$mysql_port"
|
||||
return 1
|
||||
fi
|
||||
env_log "MySQL: Port reachable ($mysql_host:$mysql_port)"
|
||||
else
|
||||
log "WARN: Cannot verify MySQL connectivity (no nc command)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
check_node_deps() {
|
||||
if [[ ! -d "$FRONTEND_DIR/node_modules" ]]; then
|
||||
if [[ "$AUTO_INSTALL" == "1" ]]; then
|
||||
log "node_modules missing; running npm install..."
|
||||
(cd "$FRONTEND_DIR" && npm install)
|
||||
else
|
||||
log "ERROR: frontend/node_modules missing. Run npm install or set AUTO_INSTALL=1."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local port="$1"
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
lsof -iTCP:"$port" -sTCP:LISTEN -n -P >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
nc -z 127.0.0.1 "$port" >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
check_ports() {
|
||||
if port_in_use "$BACKEND_PORT"; then
|
||||
log "ERROR: Port $BACKEND_PORT is already in use."
|
||||
exit 1
|
||||
fi
|
||||
if port_in_use "$FRONTEND_PORT"; then
|
||||
log "ERROR: Port $FRONTEND_PORT is already in use."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
start_backend() {
|
||||
local reload_args=()
|
||||
if [[ "$BACKEND_RELOAD" == "1" ]]; then
|
||||
reload_args=("--reload")
|
||||
fi
|
||||
|
||||
log "Starting backend on $BACKEND_HOST:$BACKEND_PORT (python: $PYTHON_BIN)..."
|
||||
(
|
||||
cd "$BACKEND_DIR"
|
||||
nohup env PYTHONPATH="$BACKEND_DIR" \
|
||||
"$PYTHON_BIN" -m uvicorn app.main:app \
|
||||
--host "$BACKEND_HOST" \
|
||||
--port "$BACKEND_PORT" \
|
||||
"${reload_args[@]+"${reload_args[@]}"}" \
|
||||
> "$RUN_DIR/backend.log" 2>&1 &
|
||||
echo $! > "$RUN_DIR/backend.pid"
|
||||
)
|
||||
}
|
||||
|
||||
start_frontend() {
|
||||
log "Starting frontend on $FRONTEND_HOST:$FRONTEND_PORT..."
|
||||
(
|
||||
cd "$FRONTEND_DIR"
|
||||
nohup npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT" \
|
||||
> "$RUN_DIR/frontend.log" 2>&1 &
|
||||
echo $! > "$RUN_DIR/frontend.pid"
|
||||
)
|
||||
}
|
||||
|
||||
wait_for_url() {
|
||||
local url="$1"
|
||||
local name="$2"
|
||||
local retries="${3:-30}"
|
||||
local delay="${4:-1}"
|
||||
local i=0
|
||||
|
||||
while (( i < retries )); do
|
||||
if curl -fsS "$url" >/dev/null 2>&1; then
|
||||
health_log "$(date +"%Y-%m-%d %H:%M:%S") OK $name $url"
|
||||
return 0
|
||||
fi
|
||||
sleep "$delay"
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
health_log "$(date +"%Y-%m-%d %H:%M:%S") FAIL $name $url"
|
||||
return 1
|
||||
}
|
||||
|
||||
run_health_checks() {
|
||||
log "Running health checks..."
|
||||
local backend_url="http://$BACKEND_HOST:$BACKEND_PORT/health"
|
||||
local frontend_url="http://$FRONTEND_HOST:$FRONTEND_PORT/"
|
||||
|
||||
wait_for_url "$backend_url" "backend" 40 1 || log "WARN: Backend health check failed."
|
||||
wait_for_url "$frontend_url" "frontend" 40 1 || log "WARN: Frontend health check failed."
|
||||
}
|
||||
|
||||
latest_run_dir() {
|
||||
if [[ -L "$LATEST_LINK" ]]; then
|
||||
readlink "$LATEST_LINK"
|
||||
return
|
||||
fi
|
||||
ls -dt "$LOG_ROOT"/run-* 2>/dev/null | head -n1 || true
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
local name="$1"
|
||||
local pidfile="$2"
|
||||
if [[ ! -f "$pidfile" ]]; then
|
||||
echo "$name: pid file not found."
|
||||
return
|
||||
fi
|
||||
local pid
|
||||
pid="$(cat "$pidfile")"
|
||||
if kill -0 "$pid" >/dev/null 2>&1; then
|
||||
echo "Stopping $name (pid $pid)..."
|
||||
# Kill child processes first (for uvicorn workers, vite children, etc.)
|
||||
if command -v pgrep >/dev/null 2>&1; then
|
||||
local children
|
||||
children="$(pgrep -P "$pid" 2>/dev/null || true)"
|
||||
if [[ -n "$children" ]]; then
|
||||
echo "$children" | xargs kill 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
# Wait briefly for graceful shutdown
|
||||
sleep 1
|
||||
# Force kill if still running
|
||||
kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true
|
||||
else
|
||||
echo "$name: process not running."
|
||||
fi
|
||||
rm -f "$pidfile"
|
||||
}
|
||||
|
||||
status_service() {
|
||||
local name="$1"
|
||||
local pidfile="$2"
|
||||
if [[ ! -f "$pidfile" ]]; then
|
||||
echo "$name: pid file not found."
|
||||
return
|
||||
fi
|
||||
local pid
|
||||
pid="$(cat "$pidfile")"
|
||||
if kill -0 "$pid" >/dev/null 2>&1; then
|
||||
echo "$name: running (pid $pid)"
|
||||
else
|
||||
echo "$name: not running"
|
||||
fi
|
||||
}
|
||||
|
||||
start() {
|
||||
ensure_dirs
|
||||
log "Run directory: $RUN_DIR"
|
||||
|
||||
require_cmds node npm curl
|
||||
detect_python
|
||||
env_log "Timestamp: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
env_log "Python: $("$PYTHON_BIN" --version 2>&1)"
|
||||
env_log "Python Path: $PYTHON_BIN"
|
||||
env_log "Node: $(node --version 2>&1)"
|
||||
env_log "NPM: $(npm --version 2>&1)"
|
||||
|
||||
check_env_file
|
||||
check_python_deps
|
||||
check_node_deps
|
||||
check_redis || true # Warn but don't fail
|
||||
check_mysql || true # Warn but don't fail
|
||||
check_ports
|
||||
|
||||
start_backend
|
||||
start_frontend
|
||||
run_health_checks
|
||||
|
||||
log "Logs: $RUN_DIR"
|
||||
log "Backend log: $RUN_DIR/backend.log"
|
||||
log "Frontend log: $RUN_DIR/frontend.log"
|
||||
log "Health log: $RUN_DIR/health.log"
|
||||
|
||||
if [[ "$FOLLOW_LOGS" == "1" ]]; then
|
||||
log "Tailing logs. Press Ctrl+C to stop tailing."
|
||||
tail -n 200 -f "$RUN_DIR/backend.log" "$RUN_DIR/frontend.log"
|
||||
fi
|
||||
}
|
||||
|
||||
stop() {
|
||||
local dir
|
||||
dir="$(latest_run_dir)"
|
||||
if [[ -z "$dir" ]]; then
|
||||
echo "No run directory found."
|
||||
return
|
||||
fi
|
||||
stop_service "backend" "$dir/backend.pid"
|
||||
stop_service "frontend" "$dir/frontend.pid"
|
||||
}
|
||||
|
||||
status() {
|
||||
local dir
|
||||
dir="$(latest_run_dir)"
|
||||
if [[ -z "$dir" ]]; then
|
||||
echo "No run directory found."
|
||||
return
|
||||
fi
|
||||
status_service "backend" "$dir/backend.pid"
|
||||
status_service "frontend" "$dir/frontend.pid"
|
||||
}
|
||||
|
||||
health() {
|
||||
local dir
|
||||
dir="$(latest_run_dir)"
|
||||
if [[ -z "$dir" ]]; then
|
||||
echo "No run directory found."
|
||||
return
|
||||
fi
|
||||
RUN_DIR="$dir"
|
||||
RUN_LOG="$dir/run.log"
|
||||
HEALTH_LOG="$dir/health.log"
|
||||
run_health_checks
|
||||
}
|
||||
|
||||
case "${1:-start}" in
|
||||
start) start ;;
|
||||
stop) stop ;;
|
||||
status) status ;;
|
||||
health) health ;;
|
||||
-h|--help) usage ;;
|
||||
*) usage; exit 1 ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user