Skip to main content

Generic comments and attachments system for FastAPI applications

Project description

SWM Comments & Attachments

A generic, reusable Python package for adding comments and file attachments to any entity in FastAPI applications.

Features

  • Generic Entity Support: Attach comments/files to any entity using entity_type and entity_id
  • Hierarchical Attachments: Attach files directly to comments (comment → attachments relationship)
  • Multiple Storage Backends: Azure Blob Storage, AWS S3, or local filesystem
  • FastAPI Integration: Ready-to-use routers and dependencies
  • Multi-tenancy: Built-in organization-level isolation
  • Type-Safe: Full TypeScript-style typing with Pydantic v2
  • Async: Fully async/await compatible
  • Presigned URLs: Secure file access without exposing storage credentials
  • Document Management: Track document types, categories, virus scanning, and checksums

Installation

Option 1: Install from PyPI (when published)

pip install swm-comments-attachments

Option 2: Install from local repository

cd swm-comments-attachments
pip install -e .

Option 3: Add as dependency in pyproject.toml

[project.dependencies]
swm-comments-attachments = { path = "../swm-comments-attachments", develop = true }

Database Setup

Option 1: Run SQL Migration (Recommended for existing projects)

# Apply the migration to your database
mysql -u username -p database_name < database/migrations/001_create_comments_attachments_tables.sql

Option 2: Use Alembic (if your project uses Alembic)

# Copy the migration to your project's alembic/versions/
cp alembic/versions/2025_01_20_0001-initial_comments_attachments_tables.py your_project/alembic/versions/

# Run migration
alembic upgrade head

Option 3: Auto-create with SQLAlchemy

from swm_comments.models import Base
from your_app.database import engine

# Create tables
async with engine.begin() as conn:
    await conn.run_sync(Base.metadata.create_all)

Quick Start

1. Configure Storage Backend

import os
from swm_comments.storage import AzureBlobStorage, LocalStorage

def get_storage_backend():
    """Get storage based on environment."""
    azure_connection = os.getenv("AZURE_STORAGE_CONNECTION_STRING")

    if azure_connection:
        # Production/Dev: Use Azure Blob Storage
        return AzureBlobStorage(
            connection_string=azure_connection,
            container_name=os.getenv("AZURE_STORAGE_CONTAINER_NAME", "attachments")
        )
    else:
        # Local: Use filesystem
        return LocalStorage(base_path="./uploads")

storage = get_storage_backend()

2. Create Dependency Functions

from fastapi import Depends
from your_app.auth import get_current_user
from your_app.database import get_db

async def get_current_user_id(current_user = Depends(get_current_user)) -> int:
    return current_user.id

async def get_current_organisation_id(current_user = Depends(get_current_user)) -> int:
    return current_user.organisation_id

3. Add Routers to FastAPI App

from fastapi import FastAPI
from swm_comments.routers import create_comment_router, create_attachment_router

app = FastAPI()

# Create routers with your dependencies
comment_router = create_comment_router(
    db_session_dependency=Depends(get_db),
    user_id_dependency=Depends(get_current_user_id),
    org_id_dependency=Depends(get_current_organisation_id),
)

attachment_router = create_attachment_router(
    storage_backend=storage,
    db_session_dependency=Depends(get_db),
    user_id_dependency=Depends(get_current_user_id),
    org_id_dependency=Depends(get_current_organisation_id),
)

# Include routers
app.include_router(comment_router, prefix="/api/comments", tags=["comments"])
app.include_router(attachment_router, prefix="/api/attachments", tags=["attachments"])

4. Use the API

Add a comment to any entity

POST /api/comments
Content-Type: application/json

{
    "entity_type": "task",
    "entity_id": "123",
    "comment_text": "This task is blocked waiting for approval",
    "is_internal": false
}

Upload an attachment

POST /api/attachments
Content-Type: multipart/form-data

entity_type: task
entity_id: 123
file: <binary file>
description: Supporting document

Get download URL for attachment

GET /api/attachments/456/download

# Returns:
{
    "url": "https://...presigned-url...",
    "expires_at": "2025-01-20T15:30:00",
    "file_name": "document.pdf"
}

Storage Backends

Azure Blob Storage

from swm_comments.storage import AzureBlobStorage

storage = AzureBlobStorage(
    connection_string="DefaultEndpointsProtocol=https;...",
    container_name="attachments"
)

AWS S3

from swm_comments.storage import S3Storage

storage = S3Storage(
    bucket_name="my-attachments",
    region="us-east-1",
    access_key_id="...",
    secret_access_key="..."
)

Local Filesystem

from swm_comments.storage import LocalStorage

storage = LocalStorage(base_path="/var/uploads")

Database Models

The package provides two SQLAlchemy models:

  • Comment: Text comments with optional internal/external visibility

    • id (int): Primary key
    • entity_type (str): Type of entity (e.g., "task", "property", "invoice")
    • entity_id (str): ID of the entity
    • comment_text (text): Comment content
    • is_internal (bool): Internal vs external visibility
    • organisation_id (int): Multi-tenancy isolation
    • created_by_id (int): User who created the comment
    • created_at, updated_at (datetime): Timestamps
    • is_deleted (bool): Soft delete flag
    • context (JSON): Optional additional metadata
    • attachment_count (computed): Number of attachments on this comment
  • Attachment: File metadata with storage references

    • id (int): Primary key
    • entity_type (str): Type of entity
    • entity_id (str): ID of the entity
    • comment_id (int, optional): Link to parent comment (hierarchical)
    • file_name (str): Original filename
    • file_size (int): File size in bytes
    • content_type (str): MIME type
    • storage_backend (str): Storage backend used
    • storage_path (str): Path in storage
    • storage_container (str): Container/bucket name
    • document_type_id (int, optional): Document type classification
    • document_category (enum): "normal" or "restricted"
    • virus_scan_status (enum): "clean", "infected", "quarantined", "skipped", "pending"
    • checksum (str): File integrity checksum (SHA-256)
    • description (str, optional): User-provided description
    • organisation_id (int): Multi-tenancy isolation
    • uploaded_by_id (int): User who uploaded the file
    • uploaded_at (datetime): Upload timestamp
    • is_deleted (bool): Soft delete flag
    • context (JSON): Optional additional metadata

Hierarchical Attachments (Comment → Attachments)

New in v2.0: Attachments can now be linked directly to comments using the comment_id field. This creates a hierarchical relationship where files are associated with specific comments rather than just the entity.

Benefits:

  • Better organization: Files grouped under relevant comments
  • Cascade delete: Deleting a comment automatically deletes its attachments
  • Visual indicators: Show attachment count on comments
  • Contextual uploads: Users can attach files while discussing specific topics

Usage:

# Upload an attachment to a comment
POST /api/attachments
Content-Type: multipart/form-data

entity_type: task
entity_id: 123
comment_id: 456  # Link to comment #456
file: <binary file>

Backward Compatibility: The comment_id field is optional. Attachments can still be linked directly to entities without a comment (entity-level attachments).

API Examples

List comments for an entity

GET /api/comments?entity_type=task&entity_id=123

Delete a comment

DELETE /api/comments/{comment_id}

Get attachment download URL

GET /api/attachments/{attachment_id}/download
# Returns presigned URL valid for 1 hour

Frontend Integration (React/TypeScript)

API Client

// api/comments.ts
import { api } from "./api";

export interface CommentCreate {
  entity_type: string;
  entity_id: string;
  comment_text: string;
  is_internal?: boolean;
  context?: Record<string, any>;
}

export interface Comment {
  id: number;
  entity_type: string;
  entity_id: string;
  comment_text: string;
  is_internal: boolean;
  created_by_id: number;
  created_at: string;
  updated_at?: string;
  organisation_id: number;
}

export const commentsApi = {
  create: async (data: CommentCreate): Promise<Comment> => {
    const response = await api.post("/api/comments", data);
    return response.data;
  },

  list: async (entityType: string, entityId: string): Promise<Comment[]> => {
    const response = await api.get("/api/comments", {
      params: { entity_type: entityType, entity_id: entityId },
    });
    return response.data;
  },

  update: async (id: number, data: Partial<CommentCreate>): Promise<Comment> => {
    const response = await api.patch(`/api/comments/${id}`, data);
    return response.data;
  },

  delete: async (id: number): Promise<void> => {
    await api.delete(`/api/comments/${id}`);
  },
};

export const attachmentsApi = {
  upload: async (
    file: File,
    entityType: string,
    entityId: string,
    description?: string
  ) => {
    const formData = new FormData();
    formData.append("file", file);
    formData.append("entity_type", entityType);
    formData.append("entity_id", entityId);
    if (description) formData.append("description", description);

    const response = await api.post("/api/attachments", formData, {
      headers: { "Content-Type": "multipart/form-data" },
    });
    return response.data;
  },

  list: async (entityType: string, entityId: string) => {
    const response = await api.get("/api/attachments", {
      params: { entity_type: entityType, entity_id: entityId },
    });
    return response.data;
  },

  getDownloadUrl: async (id: number) => {
    const response = await api.get(`/api/attachments/${id}/download`);
    return response.data;
  },

  delete: async (id: number): Promise<void> => {
    await api.delete(`/api/attachments/${id}`);
  },
};

React Component Example

// components/TaskComments.tsx
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { commentsApi } from "@/api/comments";

export const TaskComments = ({ taskId }: { taskId: number }) => {
  const queryClient = useQueryClient();

  const { data: comments, isLoading } = useQuery({
    queryKey: ["comments", "task", taskId],
    queryFn: () => commentsApi.list("task", String(taskId)),
  });

  const createMutation = useMutation({
    mutationFn: commentsApi.create,
    onSuccess: () => {
      queryClient.invalidateQueries(["comments", "task", taskId]);
    },
  });

  const deleteMutation = useMutation({
    mutationFn: commentsApi.delete,
    onSuccess: () => {
      queryClient.invalidateQueries(["comments", "task", taskId]);
    },
  });

  const handleAddComment = (text: string) => {
    createMutation.mutate({
      entity_type: "task",
      entity_id: String(taskId),
      comment_text: text,
      is_internal: false,
    });
  };

  if (isLoading) return <div>Loading comments...</div>;

  return (
    <div className="space-y-4">
      <div className="space-y-2">
        {comments?.map((comment) => (
          <div key={comment.id} className="border rounded p-3">
            <p className="text-sm">{comment.comment_text}</p>
            <div className="flex items-center justify-between mt-2">
              <span className="text-xs text-grey-600">
                {new Date(comment.created_at).toLocaleString()}
              </span>
              <button
                onClick={() => deleteMutation.mutate(comment.id)}
                className="text-xs text-red-600"
              >
                Delete
              </button>
            </div>
          </div>
        ))}
      </div>
      <CommentInput onSubmit={handleAddComment} />
    </div>
  );
};

Multi-Tenancy

All operations are automatically scoped to the organization from the authenticated user:

from swm_comments.routers import create_comment_router

# The router automatically filters all queries by organisation_id
# Users can only see comments from their own organization
comment_router = create_comment_router(
    db_session_dependency=Depends(get_db),
    user_id_dependency=Depends(get_current_user_id),
    org_id_dependency=Depends(get_current_organisation_id),  # Filters all queries
)

Security Features

  • Automatic Organisation Filtering: All queries filtered by organisation_id
  • Permission Checks: Users can only edit/delete their own comments/attachments
  • Soft Deletes: Data is never permanently deleted, just marked as deleted
  • Presigned URLs: Secure file access with time-limited URLs

Development

# Install dev dependencies
pip install -e ".[dev]"

# Run tests
pytest

# Type checking
mypy src

# Linting
ruff check src

License

MIT License - see LICENSE file for details.

Contributing

Contributions welcome! Please see CONTRIBUTING.md for guidelines.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

swm_comments_attachments-0.3.1.tar.gz (33.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

swm_comments_attachments-0.3.1-py3-none-any.whl (24.1 kB view details)

Uploaded Python 3

File details

Details for the file swm_comments_attachments-0.3.1.tar.gz.

File metadata

File hashes

Hashes for swm_comments_attachments-0.3.1.tar.gz
Algorithm Hash digest
SHA256 12fdb81f9e69b18372d2a4d69a032c508646175a23423cc4a2788286d0599c02
MD5 a1d12aa6cafb9267fa44a2dc97759f40
BLAKE2b-256 fd2269cb597de62412d205449151d65c344be73968c669c4fc11336a932041b5

See more details on using hashes here.

File details

Details for the file swm_comments_attachments-0.3.1-py3-none-any.whl.

File metadata

File hashes

Hashes for swm_comments_attachments-0.3.1-py3-none-any.whl
Algorithm Hash digest
SHA256 92203aede318af1b06328349b70cc0ec87465abea2dd3f929788add80455178d
MD5 2053cdc274b84584eefd619158e1d8dc
BLAKE2b-256 2d35a7657da23df5b8cb2b8cd3345872ce104c32a8dd00473bd50341753ea1e0

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page