diff --git a/.gitignore b/.gitignore index 378331f6..f11e96b4 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,4 @@ credentials/ # Alembic # Note: Keep alembic/versions/ tracked for migrations # alembic/versions/*.pyc is already covered by __pycache__ +.aider* diff --git a/alembic/versions/0bd9ffaaced1_add_application_logs_table_for_hybrid_.py b/alembic/versions/0bd9ffaaced1_add_application_logs_table_for_hybrid_.py new file mode 100644 index 00000000..3d091068 --- /dev/null +++ b/alembic/versions/0bd9ffaaced1_add_application_logs_table_for_hybrid_.py @@ -0,0 +1,68 @@ +"""add application_logs table for hybrid logging + +Revision ID: 0bd9ffaaced1 +Revises: 7a7ce92593d5 +Create Date: 2025-11-29 12:44:55.427245 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0bd9ffaaced1' +down_revision: Union[str, None] = '7a7ce92593d5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create application_logs table + op.create_table( + 'application_logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.Column('level', sa.String(length=20), nullable=False), + sa.Column('logger_name', sa.String(length=200), nullable=False), + sa.Column('module', sa.String(length=200), nullable=True), + sa.Column('function_name', sa.String(length=100), nullable=True), + sa.Column('line_number', sa.Integer(), nullable=True), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('exception_type', sa.String(length=200), nullable=True), + sa.Column('exception_message', sa.Text(), nullable=True), + sa.Column('stack_trace', sa.Text(), nullable=True), + sa.Column('request_id', sa.String(length=100), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('vendor_id', sa.Integer(), nullable=True), + sa.Column('context', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + # Create indexes for better query performance + op.create_index(op.f('ix_application_logs_id'), 'application_logs', ['id'], unique=False) + op.create_index(op.f('ix_application_logs_timestamp'), 'application_logs', ['timestamp'], unique=False) + op.create_index(op.f('ix_application_logs_level'), 'application_logs', ['level'], unique=False) + op.create_index(op.f('ix_application_logs_logger_name'), 'application_logs', ['logger_name'], unique=False) + op.create_index(op.f('ix_application_logs_request_id'), 'application_logs', ['request_id'], unique=False) + op.create_index(op.f('ix_application_logs_user_id'), 'application_logs', ['user_id'], unique=False) + op.create_index(op.f('ix_application_logs_vendor_id'), 'application_logs', ['vendor_id'], unique=False) + + +def downgrade() -> None: + # Drop indexes + op.drop_index(op.f('ix_application_logs_vendor_id'), table_name='application_logs') + op.drop_index(op.f('ix_application_logs_user_id'), table_name='application_logs') + op.drop_index(op.f('ix_application_logs_request_id'), table_name='application_logs') + op.drop_index(op.f('ix_application_logs_logger_name'), table_name='application_logs') + op.drop_index(op.f('ix_application_logs_level'), table_name='application_logs') + op.drop_index(op.f('ix_application_logs_timestamp'), table_name='application_logs') + op.drop_index(op.f('ix_application_logs_id'), table_name='application_logs') + + # Drop table + op.drop_table('application_logs') diff --git a/alembic/versions/d0325d7c0f25_add_companies_table_and_restructure_.py b/alembic/versions/d0325d7c0f25_add_companies_table_and_restructure_.py new file mode 100644 index 00000000..ceb0bba0 --- /dev/null +++ b/alembic/versions/d0325d7c0f25_add_companies_table_and_restructure_.py @@ -0,0 +1,77 @@ +"""add_companies_table_and_restructure_vendors + +Revision ID: d0325d7c0f25 +Revises: 0bd9ffaaced1 +Create Date: 2025-11-30 14:58:17.165142 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd0325d7c0f25' +down_revision: Union[str, None] = '0bd9ffaaced1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create companies table + op.create_table( + 'companies', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('owner_user_id', sa.Integer(), nullable=False), + sa.Column('contact_email', sa.String(), nullable=False), + sa.Column('contact_phone', sa.String(), nullable=True), + sa.Column('website', sa.String(), nullable=True), + sa.Column('business_address', sa.Text(), nullable=True), + sa.Column('tax_number', sa.String(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('is_verified', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_companies_id'), 'companies', ['id'], unique=False) + op.create_index(op.f('ix_companies_name'), 'companies', ['name'], unique=False) + + # Use batch mode for SQLite to modify vendors table + with op.batch_alter_table('vendors', schema=None) as batch_op: + # Add company_id column + batch_op.add_column(sa.Column('company_id', sa.Integer(), nullable=True)) + batch_op.create_index(batch_op.f('ix_vendors_company_id'), ['company_id'], unique=False) + batch_op.create_foreign_key('fk_vendors_company_id', 'companies', ['company_id'], ['id']) + + # Remove old contact fields + batch_op.drop_column('contact_email') + batch_op.drop_column('contact_phone') + batch_op.drop_column('website') + batch_op.drop_column('business_address') + batch_op.drop_column('tax_number') + + +def downgrade() -> None: + # Use batch mode for SQLite to modify vendors table + with op.batch_alter_table('vendors', schema=None) as batch_op: + # Re-add contact fields to vendors + batch_op.add_column(sa.Column('tax_number', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('business_address', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('website', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('contact_phone', sa.String(), nullable=True)) + batch_op.add_column(sa.Column('contact_email', sa.String(), nullable=True)) + + # Remove company_id from vendors + batch_op.drop_constraint('fk_vendors_company_id', type_='foreignkey') + batch_op.drop_index(batch_op.f('ix_vendors_company_id')) + batch_op.drop_column('company_id') + + # Drop companies table + op.drop_index(op.f('ix_companies_name'), table_name='companies') + op.drop_index(op.f('ix_companies_id'), table_name='companies') + op.drop_table('companies') diff --git a/app/api/deps.py b/app/api/deps.py index 4036294b..f21987c2 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -271,17 +271,18 @@ def get_current_vendor_api( Get current vendor user from Authorization header ONLY. Used for vendor API endpoints that should not accept cookies. + Validates that user still has access to the vendor specified in the token. Args: credentials: Bearer token from Authorization header db: Database session Returns: - User: Authenticated vendor user + User: Authenticated vendor user (with token_vendor_id, token_vendor_code, token_vendor_role) Raises: InvalidTokenException: If no token or invalid token - InsufficientPermissionsException: If user is not vendor or is admin + InsufficientPermissionsException: If user is not vendor or lost access to vendor """ if not credentials: raise InvalidTokenException("Authorization header required for API calls") @@ -297,6 +298,24 @@ def get_current_vendor_api( logger.warning(f"Non-vendor user {user.username} attempted vendor API") raise InsufficientPermissionsException("Vendor privileges required") + # Validate vendor access if token is vendor-scoped + if hasattr(user, "token_vendor_id"): + vendor_id = user.token_vendor_id + + # Verify user still has access to this vendor + if not user.is_member_of(vendor_id): + logger.warning( + f"User {user.username} lost access to vendor_id={vendor_id}" + ) + raise InsufficientPermissionsException( + "Access to vendor has been revoked. Please login again." + ) + + logger.debug( + f"Vendor API access: user={user.username}, vendor_id={vendor_id}, " + f"vendor_code={getattr(user, 'token_vendor_code', 'N/A')}" + ) + return user diff --git a/app/api/v1/admin/logs.py b/app/api/v1/admin/logs.py new file mode 100644 index 00000000..dad0bd4b --- /dev/null +++ b/app/api/v1/admin/logs.py @@ -0,0 +1,342 @@ +# app/api/v1/admin/logs.py +""" +Log management endpoints for admin. + +Provides endpoints for: +- Viewing database logs with filters +- Reading file logs +- Log statistics +- Log settings management +- Log cleanup operations +""" + +import logging + +from fastapi import APIRouter, Depends, Query, Response +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.core.logging import reload_log_level +from app.services.admin_audit_service import admin_audit_service +from app.services.admin_settings_service import admin_settings_service +from app.services.log_service import log_service +from models.database.user import User +from models.schema.admin import ( + ApplicationLogFilters, + ApplicationLogListResponse, + FileLogResponse, + LogSettingsResponse, + LogSettingsUpdate, + LogStatistics, +) + +router = APIRouter(prefix="/logs") +logger = logging.getLogger(__name__) + + +# ============================================================================ +# DATABASE LOGS ENDPOINTS +# ============================================================================ + + +@router.get("/database", response_model=ApplicationLogListResponse) +def get_database_logs( + level: str | None = Query(None, description="Filter by log level"), + logger_name: str | None = Query(None, description="Filter by logger name"), + module: str | None = Query(None, description="Filter by module"), + user_id: int | None = Query(None, description="Filter by user ID"), + vendor_id: int | None = Query(None, description="Filter by vendor ID"), + search: str | None = Query(None, description="Search in message"), + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Get logs from database with filtering. + + Supports filtering by level, logger, module, user, vendor, and date range. + Returns paginated results. + """ + filters = ApplicationLogFilters( + level=level, + logger_name=logger_name, + module=module, + user_id=user_id, + vendor_id=vendor_id, + search=search, + skip=skip, + limit=limit, + ) + + return log_service.get_database_logs(db, filters) + + +@router.get("/statistics", response_model=LogStatistics) +def get_log_statistics( + days: int = Query(7, ge=1, le=90, description="Number of days to analyze"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Get log statistics for the last N days. + + Returns counts by level, module, and recent critical errors. + """ + return log_service.get_log_statistics(db, days) + + +@router.delete("/database/cleanup") +def cleanup_old_logs( + retention_days: int = Query(30, ge=1, le=365), + confirm: bool = Query(False, description="Must be true to confirm cleanup"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Delete logs older than retention period. + + Requires confirmation parameter. + """ + from fastapi import HTTPException + + if not confirm: + raise HTTPException( + status_code=400, + detail="Cleanup requires confirmation parameter: confirm=true", + ) + + deleted_count = log_service.cleanup_old_logs(db, retention_days) + + # Log action + admin_audit_service.log_action( + db=db, + admin_user_id=current_admin.id, + action="cleanup_logs", + target_type="application_logs", + target_id="bulk", + details={"retention_days": retention_days, "deleted_count": deleted_count}, + ) + + return { + "message": f"Deleted {deleted_count} log entries older than {retention_days} days", + "deleted_count": deleted_count, + } + + +@router.delete("/database/{log_id}") +def delete_log( + log_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Delete a specific log entry.""" + message = log_service.delete_log(db, log_id) + + # Log action + admin_audit_service.log_action( + db=db, + admin_user_id=current_admin.id, + action="delete_log", + target_type="application_log", + target_id=str(log_id), + details={}, + ) + + return {"message": message} + + +# ============================================================================ +# FILE LOGS ENDPOINTS +# ============================================================================ + + +@router.get("/files") +def list_log_files( + current_admin: User = Depends(get_current_admin_api), +): + """ + List all available log files. + + Returns list of log files with size and modification date. + """ + return {"files": log_service.list_log_files()} + + +@router.get("/files/{filename}", response_model=FileLogResponse) +def get_file_log( + filename: str, + lines: int = Query(500, ge=1, le=10000, description="Number of lines to read"), + current_admin: User = Depends(get_current_admin_api), +): + """ + Read log file content. + + Returns the last N lines from the specified log file. + """ + return log_service.get_file_logs(filename, lines) + + +@router.get("/files/{filename}/download") +def download_log_file( + filename: str, + current_admin: User = Depends(get_current_admin_api), +): + """ + Download log file. + + Returns the entire log file for download. + """ + from pathlib import Path + + from app.core.config import settings + from fastapi import HTTPException + from fastapi.responses import FileResponse + + # Determine log file path + log_file_path = settings.log_file + if log_file_path: + log_file = Path(log_file_path).parent / filename + else: + log_file = Path("logs") / filename + + if not log_file.exists(): + raise HTTPException(status_code=404, detail=f"Log file '{filename}' not found") + + # Log action + from app.core.database import get_db + + db_gen = get_db() + db = next(db_gen) + try: + admin_audit_service.log_action( + db=db, + admin_user_id=current_admin.id, + action="download_log_file", + target_type="log_file", + target_id=filename, + details={"size_bytes": log_file.stat().st_size}, + ) + finally: + db.close() + + return FileResponse( + log_file, + media_type="text/plain", + filename=filename, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# ============================================================================ +# LOG SETTINGS ENDPOINTS +# ============================================================================ + + +@router.get("/settings", response_model=LogSettingsResponse) +def get_log_settings( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Get current log configuration settings.""" + log_level = admin_settings_service.get_setting_value(db, "log_level", "INFO") + max_size_mb = admin_settings_service.get_setting_value( + db, "log_file_max_size_mb", 10 + ) + backup_count = admin_settings_service.get_setting_value( + db, "log_file_backup_count", 5 + ) + retention_days = admin_settings_service.get_setting_value( + db, "db_log_retention_days", 30 + ) + file_enabled = admin_settings_service.get_setting_value( + db, "file_logging_enabled", "true" + ) + db_enabled = admin_settings_service.get_setting_value( + db, "db_logging_enabled", "true" + ) + + return LogSettingsResponse( + log_level=str(log_level), + log_file_max_size_mb=int(max_size_mb), + log_file_backup_count=int(backup_count), + db_log_retention_days=int(retention_days), + file_logging_enabled=str(file_enabled).lower() == "true", + db_logging_enabled=str(db_enabled).lower() == "true", + ) + + +@router.put("/settings") +def update_log_settings( + settings_update: LogSettingsUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Update log configuration settings. + + Changes are applied immediately without restart (for log level). + File rotation settings require restart. + """ + from models.schema.admin import AdminSettingUpdate + + updated = [] + + # Update log level + if settings_update.log_level: + admin_settings_service.update_setting( + db, + "log_level", + AdminSettingUpdate(value=settings_update.log_level), + current_admin.id, + ) + updated.append("log_level") + + # Reload log level immediately + reload_log_level() + + # Update file rotation settings + if settings_update.log_file_max_size_mb: + admin_settings_service.update_setting( + db, + "log_file_max_size_mb", + AdminSettingUpdate(value=str(settings_update.log_file_max_size_mb)), + current_admin.id, + ) + updated.append("log_file_max_size_mb") + + if settings_update.log_file_backup_count is not None: + admin_settings_service.update_setting( + db, + "log_file_backup_count", + AdminSettingUpdate(value=str(settings_update.log_file_backup_count)), + current_admin.id, + ) + updated.append("log_file_backup_count") + + # Update retention + if settings_update.db_log_retention_days: + admin_settings_service.update_setting( + db, + "db_log_retention_days", + AdminSettingUpdate(value=str(settings_update.db_log_retention_days)), + current_admin.id, + ) + updated.append("db_log_retention_days") + + # Log action + admin_audit_service.log_action( + db=db, + admin_user_id=current_admin.id, + action="update_log_settings", + target_type="settings", + target_id="logging", + details={"updated_fields": updated}, + ) + + return { + "message": "Log settings updated successfully", + "updated_fields": updated, + "note": "Log level changes are applied immediately. File rotation settings require restart.", + } diff --git a/app/api/v1/vendor/auth.py b/app/api/v1/vendor/auth.py index 8f8bac33..69f79806 100644 --- a/app/api/v1/vendor/auth.py +++ b/app/api/v1/vendor/auth.py @@ -142,28 +142,36 @@ def vendor_login( f"for vendor {vendor.vendor_code} as {vendor_role}" ) + # Create vendor-scoped access token with vendor information + token_data = auth_service.auth_manager.create_access_token( + user=user, + vendor_id=vendor.id, + vendor_code=vendor.vendor_code, + vendor_role=vendor_role, + ) + # Set HTTP-only cookie for browser navigation # CRITICAL: path=/vendor restricts cookie to vendor routes only response.set_cookie( key="vendor_token", - value=login_result["token_data"]["access_token"], + value=token_data["access_token"], httponly=True, # JavaScript cannot access (XSS protection) secure=should_use_secure_cookies(), # HTTPS only in production/staging samesite="lax", # CSRF protection - max_age=login_result["token_data"]["expires_in"], # Match JWT expiry + max_age=token_data["expires_in"], # Match JWT expiry path="/vendor", # RESTRICTED TO VENDOR ROUTES ONLY ) logger.debug( - f"Set vendor_token cookie with {login_result['token_data']['expires_in']}s expiry " + f"Set vendor_token cookie with {token_data['expires_in']}s expiry " f"(path=/vendor, httponly=True, secure={should_use_secure_cookies()})" ) - # Return full login response + # Return full login response with vendor-scoped token return VendorLoginResponse( - access_token=login_result["token_data"]["access_token"], - token_type=login_result["token_data"]["token_type"], - expires_in=login_result["token_data"]["expires_in"], + access_token=token_data["access_token"], + token_type=token_data["token_type"], + expires_in=token_data["expires_in"], user={ "id": user.id, "username": user.username, diff --git a/app/api/v1/vendor/dashboard.py b/app/api/v1/vendor/dashboard.py index 98b9586e..7f38872a 100644 --- a/app/api/v1/vendor/dashboard.py +++ b/app/api/v1/vendor/dashboard.py @@ -32,31 +32,29 @@ def get_vendor_dashboard_stats( - Total customers - Revenue metrics - Vendor is determined from the authenticated user's vendor_user association. + Vendor is determined from the JWT token (vendor_id claim). Requires Authorization header (API endpoint). """ - # Get vendor from authenticated user's vendor_user record - from models.database.vendor import VendorUser - - vendor_user = ( - db.query(VendorUser).filter(VendorUser.user_id == current_user.id).first() - ) - - if not vendor_user: - from fastapi import HTTPException + from fastapi import HTTPException + # Get vendor ID from token (set by get_current_vendor_api) + if not hasattr(current_user, "token_vendor_id"): raise HTTPException( - status_code=403, detail="User is not associated with any vendor" + status_code=400, + detail="Token missing vendor information. Please login again.", ) - vendor = vendor_user.vendor - if not vendor or not vendor.is_active: - from fastapi import HTTPException + vendor_id = current_user.token_vendor_id + # Get vendor object to include in response + from models.database.vendor import Vendor + + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor or not vendor.is_active: raise HTTPException(status_code=404, detail="Vendor not found or inactive") # Get vendor-scoped statistics - stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor.id) + stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor_id) return { "vendor": { diff --git a/app/api/v1/vendor/orders.py b/app/api/v1/vendor/orders.py index 74d91f83..5760c29c 100644 --- a/app/api/v1/vendor/orders.py +++ b/app/api/v1/vendor/orders.py @@ -11,9 +11,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db from app.services.order_service import order_service -from middleware.vendor_context import require_vendor_context from models.database.user import User -from models.database.vendor import Vendor from models.schema.order import ( OrderDetailResponse, OrderListResponse, @@ -31,7 +29,6 @@ def get_vendor_orders( limit: int = Query(100, ge=1, le=1000), status: str | None = Query(None, description="Filter by order status"), customer_id: int | None = Query(None, description="Filter by customer"), - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -42,11 +39,23 @@ def get_vendor_orders( - status: Order status (pending, processing, shipped, delivered, cancelled) - customer_id: Filter orders from specific customer + Vendor is determined from JWT token (vendor_id claim). Requires Authorization header (API endpoint). """ + from fastapi import HTTPException + + # Get vendor ID from token + if not hasattr(current_user, "token_vendor_id"): + raise HTTPException( + status_code=400, + detail="Token missing vendor information. Please login again.", + ) + + vendor_id = current_user.token_vendor_id + orders, total = order_service.get_vendor_orders( db=db, - vendor_id=vendor.id, + vendor_id=vendor_id, skip=skip, limit=limit, status=status, @@ -64,7 +73,6 @@ def get_vendor_orders( @router.get("/{order_id}", response_model=OrderDetailResponse) def get_order_details( order_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -73,7 +81,18 @@ def get_order_details( Requires Authorization header (API endpoint). """ - order = order_service.get_order(db=db, vendor_id=vendor.id, order_id=order_id) + from fastapi import HTTPException + + # Get vendor ID from token + if not hasattr(current_user, "token_vendor_id"): + raise HTTPException( + status_code=400, + detail="Token missing vendor information. Please login again.", + ) + + vendor_id = current_user.token_vendor_id + + order = order_service.get_order(db=db, vendor_id=vendor_id, order_id=order_id) return OrderDetailResponse.model_validate(order) @@ -82,7 +101,6 @@ def get_order_details( def update_order_status( order_id: int, order_update: OrderUpdate, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -99,8 +117,19 @@ def update_order_status( Requires Authorization header (API endpoint). """ + from fastapi import HTTPException + + # Get vendor ID from token + if not hasattr(current_user, "token_vendor_id"): + raise HTTPException( + status_code=400, + detail="Token missing vendor information. Please login again.", + ) + + vendor_id = current_user.token_vendor_id + order = order_service.update_order_status( - db=db, vendor_id=vendor.id, order_id=order_id, order_update=order_update + db=db, vendor_id=vendor_id, order_id=order_id, order_update=order_update ) logger.info( diff --git a/app/api/v1/vendor/products.py b/app/api/v1/vendor/products.py index ed04f2fd..5fce2adc 100644 --- a/app/api/v1/vendor/products.py +++ b/app/api/v1/vendor/products.py @@ -11,9 +11,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.database import get_db from app.services.product_service import product_service -from middleware.vendor_context import require_vendor_context from models.database.user import User -from models.database.vendor import Vendor from models.schema.product import ( ProductCreate, ProductDetailResponse, @@ -32,7 +30,6 @@ def get_vendor_products( limit: int = Query(100, ge=1, le=1000), is_active: bool | None = Query(None), is_featured: bool | None = Query(None), - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -42,10 +39,23 @@ def get_vendor_products( Supports filtering by: - is_active: Filter active/inactive products - is_featured: Filter featured products + + Vendor is determined from JWT token (vendor_id claim). """ + from fastapi import HTTPException + + # Get vendor ID from token + if not hasattr(current_user, "token_vendor_id"): + raise HTTPException( + status_code=400, + detail="Token missing vendor information. Please login again.", + ) + + vendor_id = current_user.token_vendor_id + products, total = product_service.get_vendor_products( db=db, - vendor_id=vendor.id, + vendor_id=vendor_id, skip=skip, limit=limit, is_active=is_active, @@ -63,13 +73,23 @@ def get_vendor_products( @router.get("/{product_id}", response_model=ProductDetailResponse) def get_product_details( product_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get detailed product information including inventory.""" + from fastapi import HTTPException + + # Get vendor ID from token + if not hasattr(current_user, "token_vendor_id"): + raise HTTPException( + status_code=400, + detail="Token missing vendor information. Please login again.", + ) + + vendor_id = current_user.token_vendor_id + product = product_service.get_product( - db=db, vendor_id=vendor.id, product_id=product_id + db=db, vendor_id=vendor_id, product_id=product_id ) return ProductDetailResponse.model_validate(product) @@ -78,7 +98,6 @@ def get_product_details( @router.post("", response_model=ProductResponse) def add_product_to_catalog( product_data: ProductCreate, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -87,13 +106,24 @@ def add_product_to_catalog( This publishes a MarketplaceProduct to the vendor's public catalog. """ + from fastapi import HTTPException + + # Get vendor ID from token + if not hasattr(current_user, "token_vendor_id"): + raise HTTPException( + status_code=400, + detail="Token missing vendor information. Please login again.", + ) + + vendor_id = current_user.token_vendor_id + product = product_service.create_product( - db=db, vendor_id=vendor.id, product_data=product_data + db=db, vendor_id=vendor_id, product_data=product_data ) logger.info( f"Product {product.id} added to catalog by user {current_user.username} " - f"for vendor {vendor.vendor_code}" + f"for vendor {current_user.token_vendor_code}" ) return ProductResponse.model_validate(product) @@ -103,18 +133,28 @@ def add_product_to_catalog( def update_product( product_id: int, product_data: ProductUpdate, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Update product in vendor catalog.""" + from fastapi import HTTPException + + # Get vendor ID from token + if not hasattr(current_user, "token_vendor_id"): + raise HTTPException( + status_code=400, + detail="Token missing vendor information. Please login again.", + ) + + vendor_id = current_user.token_vendor_id + product = product_service.update_product( - db=db, vendor_id=vendor.id, product_id=product_id, product_update=product_data + db=db, vendor_id=vendor_id, product_id=product_id, product_update=product_data ) logger.info( f"Product {product_id} updated by user {current_user.username} " - f"for vendor {vendor.vendor_code}" + f"for vendor {current_user.token_vendor_code}" ) return ProductResponse.model_validate(product) @@ -123,16 +163,26 @@ def update_product( @router.delete("/{product_id}") def remove_product_from_catalog( product_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Remove product from vendor catalog.""" - product_service.delete_product(db=db, vendor_id=vendor.id, product_id=product_id) + from fastapi import HTTPException + + # Get vendor ID from token + if not hasattr(current_user, "token_vendor_id"): + raise HTTPException( + status_code=400, + detail="Token missing vendor information. Please login again.", + ) + + vendor_id = current_user.token_vendor_id + + product_service.delete_product(db=db, vendor_id=vendor_id, product_id=product_id) logger.info( f"Product {product_id} removed from catalog by user {current_user.username} " - f"for vendor {vendor.vendor_code}" + f"for vendor {current_user.token_vendor_code}" ) return {"message": f"Product {product_id} removed from catalog"} @@ -141,7 +191,6 @@ def remove_product_from_catalog( @router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse) def publish_from_marketplace( marketplace_product_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): @@ -150,17 +199,28 @@ def publish_from_marketplace( Shortcut endpoint for publishing directly from marketplace import. """ + from fastapi import HTTPException + + # Get vendor ID from token + if not hasattr(current_user, "token_vendor_id"): + raise HTTPException( + status_code=400, + detail="Token missing vendor information. Please login again.", + ) + + vendor_id = current_user.token_vendor_id + product_data = ProductCreate( marketplace_product_id=marketplace_product_id, is_active=True ) product = product_service.create_product( - db=db, vendor_id=vendor.id, product_data=product_data + db=db, vendor_id=vendor_id, product_data=product_data ) logger.info( f"Marketplace product {marketplace_product_id} published to catalog " - f"by user {current_user.username} for vendor {vendor.vendor_code}" + f"by user {current_user.username} for vendor {current_user.token_vendor_code}" ) return ProductResponse.model_validate(product) @@ -169,19 +229,29 @@ def publish_from_marketplace( @router.put("/{product_id}/toggle-active") def toggle_product_active( product_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Toggle product active status.""" - product = product_service.get_product(db, vendor.id, product_id) + from fastapi import HTTPException + + # Get vendor ID from token + if not hasattr(current_user, "token_vendor_id"): + raise HTTPException( + status_code=400, + detail="Token missing vendor information. Please login again.", + ) + + vendor_id = current_user.token_vendor_id + + product = product_service.get_product(db, vendor_id, product_id) product.is_active = not product.is_active db.commit() db.refresh(product) status = "activated" if product.is_active else "deactivated" - logger.info(f"Product {product_id} {status} for vendor {vendor.vendor_code}") + logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}") return {"message": f"Product {status}", "is_active": product.is_active} @@ -189,18 +259,28 @@ def toggle_product_active( @router.put("/{product_id}/toggle-featured") def toggle_product_featured( product_id: int, - vendor: Vendor = Depends(require_vendor_context()), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Toggle product featured status.""" - product = product_service.get_product(db, vendor.id, product_id) + from fastapi import HTTPException + + # Get vendor ID from token + if not hasattr(current_user, "token_vendor_id"): + raise HTTPException( + status_code=400, + detail="Token missing vendor information. Please login again.", + ) + + vendor_id = current_user.token_vendor_id + + product = product_service.get_product(db, vendor_id, product_id) product.is_featured = not product.is_featured db.commit() db.refresh(product) status = "featured" if product.is_featured else "unfeatured" - logger.info(f"Product {product_id} {status} for vendor {vendor.vendor_code}") + logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}") return {"message": f"Product {status}", "is_featured": product.is_featured} diff --git a/app/core/logging.py b/app/core/logging.py index 92f06788..660ba5d7 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -1,50 +1,235 @@ # app/core/logging.py -"""Summary description .... +"""Hybrid logging system with file rotation and database storage. This module provides classes and functions for: -- .... -- .... -- .... +- File-based logging with automatic rotation +- Database logging for critical events (WARNING, ERROR, CRITICAL) +- Dynamic log level configuration from database settings +- Log retention and cleanup policies """ import logging import sys +import traceback +from datetime import UTC, datetime +from logging.handlers import RotatingFileHandler from pathlib import Path from app.core.config import settings +class DatabaseLogHandler(logging.Handler): + """ + Custom logging handler that stores WARNING, ERROR, and CRITICAL logs in database. + + Runs asynchronously to avoid blocking application performance. + """ + + def __init__(self): + super().__init__() + self.setLevel(logging.WARNING) # Only log WARNING and above to database + + def emit(self, record): + """Emit a log record to the database.""" + try: + from app.core.database import SessionLocal + from models.database.admin import ApplicationLog + + # Skip if no database session available + db = SessionLocal() + if not db: + return + + try: + # Extract exception information if present + exception_type = None + exception_message = None + stack_trace = None + + if record.exc_info: + exception_type = record.exc_info[0].__name__ if record.exc_info[0] else None + exception_message = str(record.exc_info[1]) if record.exc_info[1] else None + stack_trace = "".join(traceback.format_exception(*record.exc_info)) + + # Extract context from record (if middleware added it) + user_id = getattr(record, "user_id", None) + vendor_id = getattr(record, "vendor_id", None) + request_id = getattr(record, "request_id", None) + context = getattr(record, "context", None) + + # Create log entry + log_entry = ApplicationLog( + timestamp=datetime.fromtimestamp(record.created, tz=UTC), + level=record.levelname, + logger_name=record.name, + module=record.module, + function_name=record.funcName, + line_number=record.lineno, + message=record.getMessage(), + exception_type=exception_type, + exception_message=exception_message, + stack_trace=stack_trace, + request_id=request_id, + user_id=user_id, + vendor_id=vendor_id, + context=context, + ) + + db.add(log_entry) + db.commit() + + except Exception as e: + # If database logging fails, don't crash the app + # Just print to stderr + print(f"Failed to write log to database: {e}", file=sys.stderr) + finally: + db.close() + + except Exception: + # Silently fail - logging should never crash the app + pass + + +def get_log_level_from_db(): + """ + Get log level from database settings. + Falls back to environment variable if not found. + """ + try: + from app.core.database import SessionLocal + from app.services.admin_settings_service import admin_settings_service + + db = SessionLocal() + if not db: + return settings.log_level + + try: + log_level = admin_settings_service.get_setting_value( + db, "log_level", default=settings.log_level + ) + return log_level.upper() if log_level else settings.log_level.upper() + finally: + db.close() + except Exception: + # If database not ready or error, fall back to settings + return settings.log_level.upper() + + +def get_rotation_settings_from_db(): + """ + Get log rotation settings from database. + Returns tuple: (max_bytes, backup_count) + """ + try: + from app.core.database import SessionLocal + from app.services.admin_settings_service import admin_settings_service + + db = SessionLocal() + if not db: + return (10 * 1024 * 1024, 5) # 10MB, 5 backups + + try: + max_mb = admin_settings_service.get_setting_value( + db, "log_file_max_size_mb", default=10 + ) + backup_count = admin_settings_service.get_setting_value( + db, "log_file_backup_count", default=5 + ) + return (int(max_mb) * 1024 * 1024, int(backup_count)) + finally: + db.close() + except Exception: + # Fall back to defaults + return (10 * 1024 * 1024, 5) + + +def reload_log_level(): + """ + Reload log level from database without restarting application. + Useful when log level is changed via admin panel. + """ + try: + new_level = get_log_level_from_db() + logger = logging.getLogger() + logger.setLevel(getattr(logging, new_level)) + logging.info(f"Log level changed to: {new_level}") + return new_level + except Exception as e: + logging.error(f"Failed to reload log level: {e}") + return None + + def setup_logging(): - """Configure application logging with file and console handlers.""" + """Configure application logging with file rotation and database handlers.""" + # Determine log file path + log_file_path = settings.log_file + if log_file_path: + log_file = Path(log_file_path) + else: + # Default to logs/app.log + log_file = Path("logs") / "app.log" + # Create logs directory if it doesn't exist - log_file = Path(settings.log_file) log_file.parent.mkdir(parents=True, exist_ok=True) + # Get log level from database (or fall back to env) + log_level = get_log_level_from_db() + + # Get rotation settings from database (or fall back to defaults) + max_bytes, backup_count = get_rotation_settings_from_db() + # Configure root logger logger = logging.getLogger() - logger.setLevel(getattr(logging, settings.log_level.upper())) + logger.setLevel(getattr(logging, log_level)) # Remove existing handlers for handler in logger.handlers[:]: logger.removeHandler(handler) # Create formatters - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + detailed_formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - [%(module)s:%(funcName)s:%(lineno)d] - %(message)s" + ) + simple_formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - %(message)s" ) - # Console handler + # Console handler (simple format) console_handler = logging.StreamHandler(sys.stdout) - console_handler.setFormatter(formatter) + console_handler.setFormatter(simple_formatter) logger.addHandler(console_handler) - # File handler - file_handler = logging.FileHandler(log_file) - file_handler.setFormatter(formatter) + # Rotating file handler (detailed format) + file_handler = RotatingFileHandler( + log_file, + maxBytes=max_bytes, + backupCount=backup_count, + encoding="utf-8" + ) + file_handler.setFormatter(detailed_formatter) logger.addHandler(file_handler) - # Configure specific loggers + # Database handler for critical events (WARNING and above) + try: + db_handler = DatabaseLogHandler() + db_handler.setFormatter(detailed_formatter) + logger.addHandler(db_handler) + except Exception as e: + # If database handler fails, just use file logging + print(f"Warning: Database logging handler could not be initialized: {e}", file=sys.stderr) + + # Configure specific loggers to reduce noise logging.getLogger("uvicorn.access").setLevel(logging.WARNING) logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) + # Log startup info + logger.info("=" * 80) + logger.info("LOGGING SYSTEM INITIALIZED") + logger.info(f"Log Level: {log_level}") + logger.info(f"Log File: {log_file}") + logger.info(f"Max File Size: {max_bytes / (1024 * 1024):.1f} MB") + logger.info(f"Backup Count: {backup_count}") + logger.info(f"Database Logging: Enabled (WARNING and above)") + logger.info("=" * 80) + return logging.getLogger(__name__) diff --git a/app/exceptions/__init__.py b/app/exceptions/__init__.py index 8dc654ff..6ebb8097 100644 --- a/app/exceptions/__init__.py +++ b/app/exceptions/__init__.py @@ -54,6 +54,18 @@ from .cart import ( ProductNotAvailableForCartException, ) +# Company exceptions +from .company import ( + CompanyAlreadyExistsException, + CompanyHasVendorsException, + CompanyNotActiveException, + CompanyNotFoundException, + CompanyNotVerifiedException, + CompanyValidationException, + InvalidCompanyDataException, + UnauthorizedCompanyAccessException, +) + # Customer exceptions from .customer import ( CustomerAlreadyExistsException, @@ -284,6 +296,15 @@ __all__ = [ "InsufficientInventoryForCartException", "InvalidCartQuantityException", "ProductNotAvailableForCartException", + # Company exceptions + "CompanyNotFoundException", + "CompanyAlreadyExistsException", + "CompanyNotActiveException", + "CompanyNotVerifiedException", + "UnauthorizedCompanyAccessException", + "InvalidCompanyDataException", + "CompanyValidationException", + "CompanyHasVendorsException", # MarketplaceProduct exceptions "MarketplaceProductNotFoundException", "MarketplaceProductAlreadyExistsException", diff --git a/app/exceptions/handler.py b/app/exceptions/handler.py index c5b4dc0e..e0203b02 100644 --- a/app/exceptions/handler.py +++ b/app/exceptions/handler.py @@ -364,8 +364,30 @@ def _redirect_to_login(request: Request) -> RedirectResponse: logger.debug("Redirecting to /admin/login") return RedirectResponse(url="/admin/login", status_code=302) if context_type == RequestContext.VENDOR_DASHBOARD: - logger.debug("Redirecting to /vendor/login") - return RedirectResponse(url="/vendor/login", status_code=302) + # Extract vendor code from the request path + # Path format: /vendor/{vendor_code}/... + path_parts = request.url.path.split('/') + vendor_code = None + + # Find vendor code in path + if len(path_parts) >= 3 and path_parts[1] == 'vendor': + vendor_code = path_parts[2] + + # Fallback: try to get from request state + if not vendor_code: + vendor = getattr(request.state, "vendor", None) + if vendor: + vendor_code = vendor.subdomain + + # Construct proper login URL with vendor code + if vendor_code: + login_url = f"/vendor/{vendor_code}/login" + else: + # Fallback if we can't determine vendor code + login_url = "/vendor/login" + + logger.debug(f"Redirecting to {login_url}") + return RedirectResponse(url=login_url, status_code=302) if context_type == RequestContext.SHOP: # For shop context, redirect to shop login (customer login) # Calculate base_url for proper routing (supports domain, subdomain, and path-based access) diff --git a/app/services/code_quality_service.py b/app/services/code_quality_service.py index a027eac9..71d0f76c 100644 --- a/app/services/code_quality_service.py +++ b/app/services/code_quality_service.py @@ -11,7 +11,7 @@ from datetime import datetime from sqlalchemy import desc, func from sqlalchemy.orm import Session -from app.models.architecture_scan import ( +from models.database.architecture_scan import ( ArchitectureScan, ArchitectureViolation, ViolationAssignment, diff --git a/app/services/log_service.py b/app/services/log_service.py new file mode 100644 index 00000000..c6a95f50 --- /dev/null +++ b/app/services/log_service.py @@ -0,0 +1,379 @@ +# app/services/log_service.py +""" +Log management service for viewing and managing application logs. + +This module provides functions for: +- Querying database logs with filters +- Reading file logs +- Log statistics and analytics +- Log retention and cleanup +- Downloading log files +""" + +import logging +import os +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from sqlalchemy import and_, func, or_ +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.exceptions import AdminOperationException, ResourceNotFoundException +from models.database.admin import ApplicationLog +from models.schema.admin import ( + ApplicationLogFilters, + ApplicationLogListResponse, + ApplicationLogResponse, + FileLogResponse, + LogStatistics, +) + +logger = logging.getLogger(__name__) + + +class LogService: + """Service for managing application logs.""" + + def get_database_logs( + self, db: Session, filters: ApplicationLogFilters + ) -> ApplicationLogListResponse: + """ + Get logs from database with filtering and pagination. + + Args: + db: Database session + filters: Filter criteria + + Returns: + Paginated list of logs + """ + try: + query = db.query(ApplicationLog) + + # Apply filters + conditions = [] + + if filters.level: + conditions.append(ApplicationLog.level == filters.level.upper()) + + if filters.logger_name: + conditions.append(ApplicationLog.logger_name.like(f"%{filters.logger_name}%")) + + if filters.module: + conditions.append(ApplicationLog.module.like(f"%{filters.module}%")) + + if filters.user_id: + conditions.append(ApplicationLog.user_id == filters.user_id) + + if filters.vendor_id: + conditions.append(ApplicationLog.vendor_id == filters.vendor_id) + + if filters.date_from: + conditions.append(ApplicationLog.timestamp >= filters.date_from) + + if filters.date_to: + conditions.append(ApplicationLog.timestamp <= filters.date_to) + + if filters.search: + search_pattern = f"%{filters.search}%" + conditions.append( + or_( + ApplicationLog.message.like(search_pattern), + ApplicationLog.exception_message.like(search_pattern), + ) + ) + + if conditions: + query = query.filter(and_(*conditions)) + + # Get total count + total = query.count() + + # Apply pagination and sorting + logs = ( + query.order_by(ApplicationLog.timestamp.desc()) + .offset(filters.skip) + .limit(filters.limit) + .all() + ) + + return ApplicationLogListResponse( + logs=[ApplicationLogResponse.model_validate(log) for log in logs], + total=total, + skip=filters.skip, + limit=filters.limit, + ) + + except Exception as e: + logger.error(f"Failed to get database logs: {e}") + raise AdminOperationException( + operation="get_database_logs", reason=f"Database query failed: {str(e)}" + ) + + def get_log_statistics(self, db: Session, days: int = 7) -> LogStatistics: + """ + Get statistics about logs from the last N days. + + Args: + db: Database session + days: Number of days to analyze + + Returns: + Log statistics + """ + try: + cutoff_date = datetime.now(UTC) - timedelta(days=days) + + # Total counts + total_count = ( + db.query(func.count(ApplicationLog.id)) + .filter(ApplicationLog.timestamp >= cutoff_date) + .scalar() + ) + + warning_count = ( + db.query(func.count(ApplicationLog.id)) + .filter( + and_( + ApplicationLog.timestamp >= cutoff_date, + ApplicationLog.level == "WARNING", + ) + ) + .scalar() + ) + + error_count = ( + db.query(func.count(ApplicationLog.id)) + .filter( + and_( + ApplicationLog.timestamp >= cutoff_date, + ApplicationLog.level == "ERROR", + ) + ) + .scalar() + ) + + critical_count = ( + db.query(func.count(ApplicationLog.id)) + .filter( + and_( + ApplicationLog.timestamp >= cutoff_date, + ApplicationLog.level == "CRITICAL", + ) + ) + .scalar() + ) + + # Count by level + by_level_raw = ( + db.query(ApplicationLog.level, func.count(ApplicationLog.id)) + .filter(ApplicationLog.timestamp >= cutoff_date) + .group_by(ApplicationLog.level) + .all() + ) + by_level = {level: count for level, count in by_level_raw} + + # Count by module (top 10) + by_module_raw = ( + db.query(ApplicationLog.module, func.count(ApplicationLog.id)) + .filter(ApplicationLog.timestamp >= cutoff_date) + .filter(ApplicationLog.module.isnot(None)) + .group_by(ApplicationLog.module) + .order_by(func.count(ApplicationLog.id).desc()) + .limit(10) + .all() + ) + by_module = {module: count for module, count in by_module_raw} + + # Recent errors (last 5) + recent_errors = ( + db.query(ApplicationLog) + .filter( + and_( + ApplicationLog.timestamp >= cutoff_date, + ApplicationLog.level.in_(["ERROR", "CRITICAL"]), + ) + ) + .order_by(ApplicationLog.timestamp.desc()) + .limit(5) + .all() + ) + + return LogStatistics( + total_count=total_count or 0, + warning_count=warning_count or 0, + error_count=error_count or 0, + critical_count=critical_count or 0, + by_level=by_level, + by_module=by_module, + recent_errors=[ + ApplicationLogResponse.model_validate(log) for log in recent_errors + ], + ) + + except Exception as e: + logger.error(f"Failed to get log statistics: {e}") + raise AdminOperationException( + operation="get_log_statistics", reason=f"Database query failed: {str(e)}" + ) + + def get_file_logs( + self, filename: str = "app.log", lines: int = 500 + ) -> FileLogResponse: + """ + Read logs from file. + + Args: + filename: Log filename (default: app.log) + lines: Number of lines to return from end of file + + Returns: + File log content + """ + try: + # Determine log file path + log_file_path = settings.log_file + if log_file_path: + log_file = Path(log_file_path) + else: + log_file = Path("logs") / "app.log" + + # Allow reading backup files + if filename != "app.log": + log_file = log_file.parent / filename + + if not log_file.exists(): + raise ResourceNotFoundException( + resource_type="log_file", identifier=str(log_file) + ) + + # Get file stats + stat = log_file.stat() + + # Read last N lines efficiently + with open(log_file, "r", encoding="utf-8", errors="replace") as f: + # For large files, seek to end and read backwards + all_lines = f.readlines() + log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines + + return FileLogResponse( + filename=log_file.name, + size_bytes=stat.st_size, + last_modified=datetime.fromtimestamp(stat.st_mtime, tz=UTC), + lines=[line.rstrip("\n") for line in log_lines], + total_lines=len(all_lines), + ) + + except ResourceNotFoundException: + raise + except Exception as e: + logger.error(f"Failed to read log file: {e}") + raise AdminOperationException( + operation="get_file_logs", reason=f"File read failed: {str(e)}" + ) + + def list_log_files(self) -> list[dict]: + """ + List all available log files. + + Returns: + List of log file info (name, size, modified date) + """ + try: + # Determine log directory + log_file_path = settings.log_file + if log_file_path: + log_dir = Path(log_file_path).parent + else: + log_dir = Path("logs") + + if not log_dir.exists(): + return [] + + files = [] + for log_file in log_dir.glob("*.log*"): + if log_file.is_file(): + stat = log_file.stat() + files.append( + { + "filename": log_file.name, + "size_bytes": stat.st_size, + "size_mb": round(stat.st_size / (1024 * 1024), 2), + "last_modified": datetime.fromtimestamp( + stat.st_mtime, tz=UTC + ).isoformat(), + } + ) + + # Sort by modified date (newest first) + files.sort(key=lambda x: x["last_modified"], reverse=True) + + return files + + except Exception as e: + logger.error(f"Failed to list log files: {e}") + raise AdminOperationException( + operation="list_log_files", reason=f"Directory read failed: {str(e)}" + ) + + def cleanup_old_logs(self, db: Session, retention_days: int) -> int: + """ + Delete logs older than retention period from database. + + Args: + db: Database session + retention_days: Days to retain logs + + Returns: + Number of logs deleted + """ + try: + cutoff_date = datetime.now(UTC) - timedelta(days=retention_days) + + deleted_count = ( + db.query(ApplicationLog) + .filter(ApplicationLog.timestamp < cutoff_date) + .delete() + ) + + db.commit() + + logger.info( + f"Cleaned up {deleted_count} logs older than {retention_days} days" + ) + + return deleted_count + + except Exception as e: + db.rollback() + logger.error(f"Failed to cleanup old logs: {e}") + raise AdminOperationException( + operation="cleanup_old_logs", reason=f"Delete operation failed: {str(e)}" + ) + + def delete_log(self, db: Session, log_id: int) -> str: + """Delete a specific log entry.""" + try: + log_entry = db.query(ApplicationLog).filter(ApplicationLog.id == log_id).first() + + if not log_entry: + raise ResourceNotFoundException(resource_type="log", identifier=str(log_id)) + + db.delete(log_entry) + db.commit() + + return f"Log entry {log_id} deleted successfully" + + except ResourceNotFoundException: + raise + except Exception as e: + db.rollback() + logger.error(f"Failed to delete log {log_id}: {e}") + raise AdminOperationException( + operation="delete_log", reason=f"Delete operation failed: {str(e)}" + ) + + +# Create service instance +log_service = LogService() diff --git a/app/templates/admin/code-quality-dashboard.html b/app/templates/admin/code-quality-dashboard.html index 9e8bde91..c68f6e01 100644 --- a/app/templates/admin/code-quality-dashboard.html +++ b/app/templates/admin/code-quality-dashboard.html @@ -71,7 +71,7 @@
-
+
@@ -87,7 +87,7 @@
- +

@@ -102,7 +102,7 @@

- +

@@ -122,7 +122,7 @@ 'text-yellow-500 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-500': stats.technical_debt_score >= 50 && stats.technical_debt_score < 80, 'text-red-500 bg-red-100 dark:text-red-100 dark:bg-red-500': stats.technical_debt_score < 50 }"> - +

@@ -275,7 +275,7 @@

diff --git a/app/templates/admin/code-quality-violation-detail.html b/app/templates/admin/code-quality-violation-detail.html index 457c018b..de38f46a 100644 --- a/app/templates/admin/code-quality-violation-detail.html +++ b/app/templates/admin/code-quality-violation-detail.html @@ -18,8 +18,9 @@ function codeQualityViolationDetail(violationId) { updating: false, commenting: false, newComment: '', - newStatus: '', - assignedTo: '', + assignUserId: '', + resolutionNote: '', + ignoreReason: '', async init() { await this.loadViolation(); @@ -30,10 +31,8 @@ function codeQualityViolationDetail(violationId) { this.error = null; try { - const response = await apiClient.get(`/api/v1/admin/code-quality/violations/${this.violationId}`); - this.violation = response.data; - this.newStatus = this.violation.status; - this.assignedTo = this.violation.assigned_to || ''; + const response = await apiClient.get(`/admin/code-quality/violations/${this.violationId}`); + this.violation = response; } catch (error) { window.LogConfig.logError(error, 'Load Violation'); this.error = error.response?.data?.message || 'Failed to load violation details'; @@ -42,41 +41,75 @@ function codeQualityViolationDetail(violationId) { } }, - async updateStatus() { - if (!this.newStatus) return; + async assignViolation() { + const userId = parseInt(this.assignUserId); + if (!userId || isNaN(userId)) { + Utils.showToast('Please enter a valid user ID', 'error'); + return; + } this.updating = true; try { - await apiClient.patch(`/api/v1/admin/code-quality/violations/${this.violationId}/status`, { - status: this.newStatus + await apiClient.post(`/admin/code-quality/violations/${this.violationId}/assign`, { + user_id: userId, + priority: 'medium' }); - Utils.showToast('Status updated successfully', 'success'); + this.assignUserId = ''; + Utils.showToast('Violation assigned successfully', 'success'); await this.loadViolation(); } catch (error) { - window.LogConfig.logError(error, 'Update Status'); - Utils.showToast(error.response?.data?.message || 'Failed to update status', 'error'); + window.LogConfig.logError(error, 'Assign Violation'); + Utils.showToast(error.message || 'Failed to assign violation', 'error'); } finally { this.updating = false; } }, - async assignViolation() { - if (!this.assignedTo) return; + async resolveViolation() { + if (!this.resolutionNote.trim()) { + Utils.showToast('Please enter a resolution note', 'error'); + return; + } this.updating = true; try { - await apiClient.patch(`/api/v1/admin/code-quality/violations/${this.violationId}/assign`, { - assigned_to: this.assignedTo + await apiClient.post(`/admin/code-quality/violations/${this.violationId}/resolve`, { + resolution_note: this.resolutionNote }); - Utils.showToast('Violation assigned successfully', 'success'); + this.resolutionNote = ''; + Utils.showToast('Violation resolved successfully', 'success'); await this.loadViolation(); } catch (error) { - window.LogConfig.logError(error, 'Assign Violation'); - Utils.showToast(error.response?.data?.message || 'Failed to assign violation', 'error'); + window.LogConfig.logError(error, 'Resolve Violation'); + Utils.showToast(error.message || 'Failed to resolve violation', 'error'); + } finally { + this.updating = false; + } + }, + + async ignoreViolation() { + if (!this.ignoreReason.trim()) { + Utils.showToast('Please enter a reason for ignoring', 'error'); + return; + } + + this.updating = true; + + try { + await apiClient.post(`/admin/code-quality/violations/${this.violationId}/ignore`, { + reason: this.ignoreReason + }); + + this.ignoreReason = ''; + Utils.showToast('Violation ignored successfully', 'success'); + await this.loadViolation(); + } catch (error) { + window.LogConfig.logError(error, 'Ignore Violation'); + Utils.showToast(error.message || 'Failed to ignore violation', 'error'); } finally { this.updating = false; } @@ -88,7 +121,7 @@ function codeQualityViolationDetail(violationId) { this.commenting = true; try { - await apiClient.post(`/api/v1/admin/code-quality/violations/${this.violationId}/comments`, { + await apiClient.post(`/admin/code-quality/violations/${this.violationId}/comments`, { comment: this.newComment }); @@ -97,7 +130,7 @@ function codeQualityViolationDetail(violationId) { await this.loadViolation(); } catch (error) { window.LogConfig.logError(error, 'Add Comment'); - Utils.showToast(error.response?.data?.message || 'Failed to add comment', 'error'); + Utils.showToast(error.message || 'Failed to add comment', 'error'); } finally { this.commenting = false; } @@ -222,48 +255,72 @@ function codeQualityViolationDetail(violationId) {

Manage Violation

-
- + +
+ +
+ + +
+

+ Currently assigned to user ID: +

+
+ + +
+
- -
- - -
+ + +
- +
- -
- - -
-

- Currently assigned to: -

+ + +
+ +
+

+ This violation has been +

+

+ Note: +

+

+ by user ID +

+
+
Comments
@@ -293,7 +350,9 @@ function codeQualityViolationDetail(violationId) {