Add role-based filtering and imporve code
This commit is contained in:
@@ -16,9 +16,13 @@ RUN apt-get update && apt-get install -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements
|
||||
COPY shared/requirements-tracing.txt /tmp/
|
||||
|
||||
COPY services/suppliers/requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements-tracing.txt
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy shared libraries from the shared stage
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.models.suppliers import PurchaseOrderStatus
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.routing import RouteBuilder
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('suppliers')
|
||||
@@ -26,6 +27,7 @@ route_builder = RouteBuilder('suppliers')
|
||||
|
||||
router = APIRouter(tags=["purchase-orders"])
|
||||
logger = structlog.get_logger()
|
||||
audit_logger = create_audit_logger("suppliers-service")
|
||||
|
||||
|
||||
@router.post(route_builder.build_base_route("purchase-orders"), response_model=PurchaseOrderResponse)
|
||||
@@ -158,26 +160,26 @@ async def update_purchase_order(
|
||||
):
|
||||
"""Update purchase order information"""
|
||||
# require_permissions(current_user, ["purchase_orders:update"])
|
||||
|
||||
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
|
||||
|
||||
# Check order exists and belongs to tenant
|
||||
existing_order = await service.get_purchase_order(po_id)
|
||||
if not existing_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
if existing_order.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
purchase_order = await service.update_purchase_order(
|
||||
po_id=po_id,
|
||||
po_data=po_data,
|
||||
updated_by=current_user.user_id
|
||||
)
|
||||
|
||||
|
||||
if not purchase_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
|
||||
return PurchaseOrderResponse.from_orm(purchase_order)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -188,3 +190,65 @@ async def update_purchase_order(
|
||||
raise HTTPException(status_code=500, detail="Failed to update purchase order")
|
||||
|
||||
|
||||
@router.delete(route_builder.build_resource_detail_route("purchase-orders", "po_id"))
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def delete_purchase_order(
|
||||
po_id: UUID = Path(..., description="Purchase order ID"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete purchase order (soft delete, Admin+ only)"""
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
|
||||
# Check order exists and belongs to tenant
|
||||
existing_order = await service.get_purchase_order(po_id)
|
||||
if not existing_order:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
if existing_order.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Capture PO data before deletion
|
||||
po_data = {
|
||||
"po_number": existing_order.order_number,
|
||||
"supplier_id": str(existing_order.supplier_id),
|
||||
"status": existing_order.status.value if existing_order.status else None,
|
||||
"total_amount": float(existing_order.total_amount) if existing_order.total_amount else 0.0,
|
||||
"expected_delivery_date": existing_order.expected_delivery_date.isoformat() if existing_order.expected_delivery_date else None
|
||||
}
|
||||
|
||||
# Delete purchase order (likely soft delete in service)
|
||||
success = await service.delete_purchase_order(po_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
|
||||
# Log audit event for purchase order deletion
|
||||
try:
|
||||
await audit_logger.log_deletion(
|
||||
db_session=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user["user_id"],
|
||||
resource_type="purchase_order",
|
||||
resource_id=str(po_id),
|
||||
resource_data=po_data,
|
||||
description=f"Admin {current_user.get('email', 'unknown')} deleted purchase order {po_data['po_number']}",
|
||||
endpoint=f"/purchase-orders/{po_id}",
|
||||
method="DELETE"
|
||||
)
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to log audit event", error=str(audit_error))
|
||||
|
||||
logger.info("Deleted purchase order",
|
||||
po_id=str(po_id),
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user["user_id"])
|
||||
|
||||
return {"message": "Purchase order deleted successfully"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error deleting purchase order", po_id=str(po_id), error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to delete purchase order")
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Supplier Business Operations API endpoints (BUSINESS)
|
||||
Handles approvals, status updates, active/top suppliers, and delivery/PO operations
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, Header
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
@@ -25,6 +25,7 @@ from app.models.suppliers import SupplierType
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.routing import RouteBuilder
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('suppliers')
|
||||
@@ -32,6 +33,7 @@ route_builder = RouteBuilder('suppliers')
|
||||
|
||||
router = APIRouter(tags=["supplier-operations"])
|
||||
logger = structlog.get_logger()
|
||||
audit_logger = create_audit_logger("suppliers-service")
|
||||
|
||||
|
||||
# ===== Supplier Operations =====
|
||||
@@ -441,7 +443,7 @@ async def update_purchase_order_status(
|
||||
|
||||
|
||||
@router.post(route_builder.build_nested_resource_route("purchase-orders", "po_id", "approve"), response_model=PurchaseOrderResponse)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def approve_purchase_order(
|
||||
approval_data: PurchaseOrderApproval,
|
||||
po_id: UUID = Path(..., description="Purchase order ID"),
|
||||
@@ -449,7 +451,7 @@ async def approve_purchase_order(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Approve or reject a purchase order"""
|
||||
"""Approve or reject a purchase order (Admin+ only)"""
|
||||
try:
|
||||
service = PurchaseOrderService(db)
|
||||
|
||||
@@ -460,12 +462,22 @@ async def approve_purchase_order(
|
||||
if existing_order.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Capture PO details for audit
|
||||
po_details = {
|
||||
"po_number": existing_order.order_number,
|
||||
"supplier_id": str(existing_order.supplier_id),
|
||||
"total_amount": float(existing_order.total_amount) if existing_order.total_amount else 0.0,
|
||||
"expected_delivery_date": existing_order.expected_delivery_date.isoformat() if existing_order.expected_delivery_date else None
|
||||
}
|
||||
|
||||
if approval_data.action == "approve":
|
||||
purchase_order = await service.approve_purchase_order(
|
||||
po_id=po_id,
|
||||
approved_by=current_user.user_id,
|
||||
approval_notes=approval_data.notes
|
||||
)
|
||||
action = "approve"
|
||||
description = f"Admin {current_user.get('email', 'unknown')} approved purchase order {po_details['po_number']}"
|
||||
elif approval_data.action == "reject":
|
||||
if not approval_data.notes:
|
||||
raise HTTPException(status_code=400, detail="Rejection reason is required")
|
||||
@@ -474,6 +486,8 @@ async def approve_purchase_order(
|
||||
rejection_reason=approval_data.notes,
|
||||
rejected_by=current_user.user_id
|
||||
)
|
||||
action = "reject"
|
||||
description = f"Admin {current_user.get('email', 'unknown')} rejected purchase order {po_details['po_number']}"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
@@ -483,6 +497,34 @@ async def approve_purchase_order(
|
||||
detail="Purchase order is not in pending approval status"
|
||||
)
|
||||
|
||||
# Log HIGH severity audit event for purchase order approval/rejection
|
||||
try:
|
||||
await audit_logger.log_event(
|
||||
db_session=db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user["user_id"],
|
||||
action=action,
|
||||
resource_type="purchase_order",
|
||||
resource_id=str(po_id),
|
||||
severity=AuditSeverity.HIGH.value,
|
||||
description=description,
|
||||
changes={
|
||||
"action": approval_data.action,
|
||||
"notes": approval_data.notes,
|
||||
"po_details": po_details
|
||||
},
|
||||
endpoint=f"/purchase-orders/{po_id}/approve",
|
||||
method="POST"
|
||||
)
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to log audit event", error=str(audit_error))
|
||||
|
||||
logger.info("Purchase order approval processed",
|
||||
po_id=str(po_id),
|
||||
action=approval_data.action,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user["user_id"])
|
||||
|
||||
return PurchaseOrderResponse.from_orm(purchase_order)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -672,3 +714,29 @@ async def get_top_purchased_inventory_products(
|
||||
except Exception as e:
|
||||
logger.error("Error getting top purchased inventory products", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve top purchased inventory products")
|
||||
|
||||
|
||||
@router.get(route_builder.build_operations_route("count"))
|
||||
async def get_supplier_count(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
x_internal_request: str = Header(None),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get total count of suppliers for a tenant
|
||||
Internal endpoint for subscription usage tracking
|
||||
"""
|
||||
if x_internal_request != "true":
|
||||
raise HTTPException(status_code=403, detail="Internal endpoint only")
|
||||
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
suppliers = await service.get_suppliers(tenant_id=current_user.tenant_id)
|
||||
count = len(suppliers)
|
||||
|
||||
return {"count": count}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting supplier count", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve supplier count")
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.schemas.suppliers import (
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.routing import RouteBuilder
|
||||
from shared.auth.access_control import require_user_role
|
||||
from shared.security import create_audit_logger, AuditSeverity, AuditAction
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('suppliers')
|
||||
@@ -25,6 +26,7 @@ route_builder = RouteBuilder('suppliers')
|
||||
|
||||
router = APIRouter(tags=["suppliers"])
|
||||
logger = structlog.get_logger()
|
||||
audit_logger = create_audit_logger("suppliers-service")
|
||||
|
||||
@router.post(route_builder.build_base_route("suppliers"), response_model=SupplierResponse)
|
||||
@require_user_role(['admin', 'owner', 'member'])
|
||||
@@ -142,9 +144,11 @@ async def update_supplier(
|
||||
@require_user_role(['admin', 'owner'])
|
||||
async def delete_supplier(
|
||||
supplier_id: UUID = Path(..., description="Supplier ID"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete supplier (soft delete)"""
|
||||
"""Delete supplier (soft delete, Admin+ only)"""
|
||||
try:
|
||||
service = SupplierService(db)
|
||||
|
||||
@@ -153,10 +157,46 @@ async def delete_supplier(
|
||||
if not existing_supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
# Capture supplier data before deletion
|
||||
supplier_data = {
|
||||
"supplier_name": existing_supplier.name,
|
||||
"supplier_type": existing_supplier.supplier_type,
|
||||
"contact_person": existing_supplier.contact_person,
|
||||
"email": existing_supplier.email
|
||||
}
|
||||
|
||||
success = await service.delete_supplier(supplier_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
# Log audit event for supplier deletion
|
||||
try:
|
||||
# Get sync db session for audit logging
|
||||
from app.core.database import SessionLocal
|
||||
sync_db = SessionLocal()
|
||||
try:
|
||||
await audit_logger.log_deletion(
|
||||
db_session=sync_db,
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user["user_id"],
|
||||
resource_type="supplier",
|
||||
resource_id=str(supplier_id),
|
||||
resource_data=supplier_data,
|
||||
description=f"Admin {current_user.get('email', 'unknown')} deleted supplier",
|
||||
endpoint=f"/suppliers/{supplier_id}",
|
||||
method="DELETE"
|
||||
)
|
||||
sync_db.commit()
|
||||
finally:
|
||||
sync_db.close()
|
||||
except Exception as audit_error:
|
||||
logger.warning("Failed to log audit event", error=str(audit_error))
|
||||
|
||||
logger.info("Deleted supplier",
|
||||
supplier_id=str(supplier_id),
|
||||
tenant_id=tenant_id,
|
||||
user_id=current_user["user_id"])
|
||||
|
||||
return {"message": "Supplier deleted successfully"}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
Models package for the Supplier service
|
||||
"""
|
||||
|
||||
# Import AuditLog model for this service
|
||||
from shared.security import create_audit_log_model
|
||||
from shared.database.base import Base
|
||||
|
||||
# Create audit log model for this service
|
||||
AuditLog = create_audit_log_model(Base)
|
||||
|
||||
from .suppliers import (
|
||||
Supplier, SupplierPriceList, PurchaseOrder, PurchaseOrderItem,
|
||||
Delivery, DeliveryItem, SupplierQualityReview, SupplierInvoice,
|
||||
@@ -49,5 +56,6 @@ __all__ = [
|
||||
'AlertType',
|
||||
'AlertStatus',
|
||||
'PerformanceMetricType',
|
||||
'PerformancePeriod'
|
||||
'PerformancePeriod',
|
||||
"AuditLog"
|
||||
]
|
||||
@@ -464,4 +464,33 @@ class PurchaseOrderService:
|
||||
"""Get most purchased inventory products"""
|
||||
return self.item_repository.get_top_purchased_inventory_products(
|
||||
tenant_id, days_back, limit
|
||||
)
|
||||
)
|
||||
|
||||
async def delete_purchase_order(self, po_id: UUID) -> bool:
|
||||
"""
|
||||
Delete (soft delete) a purchase order
|
||||
Only allows deletion of draft orders
|
||||
"""
|
||||
logger.info("Deleting purchase order", po_id=str(po_id))
|
||||
|
||||
po = self.repository.get_by_id(po_id)
|
||||
if not po:
|
||||
return False
|
||||
|
||||
# Only allow deletion of draft orders
|
||||
if po.status not in [PurchaseOrderStatus.DRAFT, PurchaseOrderStatus.CANCELLED]:
|
||||
raise ValueError(
|
||||
f"Cannot delete purchase order with status {po.status.value}. "
|
||||
"Only draft and cancelled orders can be deleted."
|
||||
)
|
||||
|
||||
# Perform soft delete
|
||||
try:
|
||||
self.repository.delete(po_id)
|
||||
self.db.commit()
|
||||
logger.info("Purchase order deleted successfully", po_id=str(po_id))
|
||||
return True
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
logger.error("Failed to delete purchase order", po_id=str(po_id), error=str(e))
|
||||
raise
|
||||
@@ -1,8 +1,8 @@
|
||||
"""initial_schema_20251009_2039
|
||||
"""initial_schema_20251015_1229
|
||||
|
||||
Revision ID: 52c9e6461ed9
|
||||
Revision ID: 93d6ea3dc888
|
||||
Revises:
|
||||
Create Date: 2025-10-09 20:39:09.709448+02:00
|
||||
Create Date: 2025-10-15 12:29:52.767171+02:00
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
@@ -12,7 +12,7 @@ import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '52c9e6461ed9'
|
||||
revision: str = '93d6ea3dc888'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
@@ -62,6 +62,38 @@ def upgrade() -> None:
|
||||
op.create_index('ix_alert_rules_tenant_active', 'alert_rules', ['tenant_id', 'is_active'], unique=False)
|
||||
op.create_index(op.f('ix_alert_rules_tenant_id'), 'alert_rules', ['tenant_id'], unique=False)
|
||||
op.create_index('ix_alert_rules_type_severity', 'alert_rules', ['alert_type', 'severity'], unique=False)
|
||||
op.create_table('audit_logs',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('action', sa.String(length=100), nullable=False),
|
||||
sa.Column('resource_type', sa.String(length=100), nullable=False),
|
||||
sa.Column('resource_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('severity', sa.String(length=20), nullable=False),
|
||||
sa.Column('service_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('changes', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('audit_metadata', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', sa.Text(), nullable=True),
|
||||
sa.Column('endpoint', sa.String(length=255), nullable=True),
|
||||
sa.Column('method', sa.String(length=10), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_audit_resource_type_action', 'audit_logs', ['resource_type', 'action'], unique=False)
|
||||
op.create_index('idx_audit_service_created', 'audit_logs', ['service_name', 'created_at'], unique=False)
|
||||
op.create_index('idx_audit_severity_created', 'audit_logs', ['severity', 'created_at'], unique=False)
|
||||
op.create_index('idx_audit_tenant_created', 'audit_logs', ['tenant_id', 'created_at'], unique=False)
|
||||
op.create_index('idx_audit_user_created', 'audit_logs', ['user_id', 'created_at'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_resource_id'), 'audit_logs', ['resource_id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_service_name'), 'audit_logs', ['service_name'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_severity'), 'audit_logs', ['severity'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_tenant_id'), 'audit_logs', ['tenant_id'], unique=False)
|
||||
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
|
||||
op.create_table('supplier_benchmarks',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('tenant_id', sa.UUID(), nullable=False),
|
||||
@@ -655,6 +687,20 @@ def downgrade() -> None:
|
||||
op.drop_index('ix_benchmarks_category', table_name='supplier_benchmarks')
|
||||
op.drop_index('ix_benchmarks_active', table_name='supplier_benchmarks')
|
||||
op.drop_table('supplier_benchmarks')
|
||||
op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_tenant_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_severity'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_service_name'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_resource_id'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
|
||||
op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs')
|
||||
op.drop_index('idx_audit_user_created', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_tenant_created', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_severity_created', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_service_created', table_name='audit_logs')
|
||||
op.drop_index('idx_audit_resource_type_action', table_name='audit_logs')
|
||||
op.drop_table('audit_logs')
|
||||
op.drop_index('ix_alert_rules_type_severity', table_name='alert_rules')
|
||||
op.drop_index(op.f('ix_alert_rules_tenant_id'), table_name='alert_rules')
|
||||
op.drop_index('ix_alert_rules_tenant_active', table_name='alert_rules')
|
||||
@@ -39,3 +39,4 @@ email-validator==2.1.0
|
||||
|
||||
# Development
|
||||
python-multipart==0.0.6
|
||||
redis==5.0.1
|
||||
|
||||
Reference in New Issue
Block a user