DGSoft a7ff948e7e Beta Release: Complete Kanban system with auto-save, route persistence, and UI improvements
- Added complete Kanban board functionality with drag-and-drop
- Implemented auto-save for Kanban card editing (no more edit button)
- Added route persistence to remember last visited page on reload
- Improved Kanban UI design with slimmer borders and compact layout
- Added checklist functionality for Kanban cards
- Enhanced file upload and direct messaging features
- Improved authentication and user management
- Added toast notifications system
- Various UI/UX improvements and bug fixes
2025-12-10 23:17:07 +01:00

454 lines
15 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks
from fastapi.responses import FileResponse, JSONResponse
from sqlmodel import Session, select
from typing import List, Optional
import os
import uuid
import aiofiles
from urllib.parse import quote
from app.database import get_session
from app.models import FileAttachment, Message, User, Channel
from app.schemas import FileAttachmentResponse, MessageResponse
from app.auth import get_current_user
from app.config import get_settings
from app.routers.messages import user_has_channel_access
from app.websocket import manager
router = APIRouter(prefix="/files", tags=["Files"])
settings = get_settings()
# Ensure upload directory exists
os.makedirs(settings.upload_dir, exist_ok=True)
@router.post("/upload/{message_id}", response_model=FileAttachmentResponse)
async def upload_file(
message_id: int,
file: UploadFile = File(...),
permission: str = Form("read"), # "read" or "write"
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Upload a file and attach it to a message with permissions"""
# Validate permission
if permission not in ["read", "write"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Permission must be 'read' or 'write'"
)
# Get message
message = session.get(Message, message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Message not found"
)
# Check if user has access to the channel
if not user_has_channel_access(current_user, message.channel_id, session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this channel"
)
# Check file size
file.file.seek(0, 2) # Seek to end
file_size = file.file.tell()
file.file.seek(0) # Reset to beginning
if file_size > settings.max_upload_size:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File size exceeds maximum allowed size of {settings.max_upload_size} bytes"
)
# Generate unique filename
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{uuid.uuid4()}{file_extension}"
file_path = os.path.join(settings.upload_dir, unique_filename)
# Save file
try:
async with aiofiles.open(file_path, 'wb') as out_file:
content = await file.read()
await out_file.write(content)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Could not save file: {str(e)}"
)
# Create file attachment record with permission and uploader
file_attachment = FileAttachment(
filename=unique_filename,
original_filename=file.filename,
mime_type=file.content_type or "application/octet-stream",
file_size=file_size,
file_path=file_path,
message_id=message_id,
upload_permission=permission,
uploader_id=current_user.id
)
session.add(file_attachment)
session.commit()
session.refresh(file_attachment)
# Build response with can_edit flag
response = FileAttachmentResponse(
id=file_attachment.id,
filename=file_attachment.filename,
original_filename=file_attachment.original_filename,
mime_type=file_attachment.mime_type,
file_size=file_attachment.file_size,
uploaded_at=file_attachment.uploaded_at,
message_id=file_attachment.message_id,
upload_permission=permission,
uploader_id=current_user.id,
is_editable=(permission == "write")
)
return response
@router.get("/download/{file_id}")
async def download_file(
file_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Download a file"""
# Get file attachment
file_attachment = session.get(FileAttachment, file_id)
if not file_attachment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Get associated message to check access
message = session.get(Message, file_attachment.message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Associated message not found"
)
# Check if user has access to the channel
if not user_has_channel_access(current_user, message.channel_id, session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this file"
)
# Check if file exists
if not os.path.exists(file_attachment.file_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found on server"
)
return FileResponse(
path=file_attachment.file_path,
filename=file_attachment.original_filename,
media_type=file_attachment.mime_type
)
@router.get("/message/{message_id}", response_model=List[FileAttachmentResponse])
def get_message_files(
message_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Get all files attached to a message"""
# Get message
message = session.get(Message, message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Message not found"
)
# Check access
if not user_has_channel_access(current_user, message.channel_id, session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this message"
)
statement = select(FileAttachment).where(FileAttachment.message_id == message_id)
attachments = session.exec(statement).all()
return attachments
@router.post("/upload-with-message/{channel_id}", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
async def upload_file_with_message(
channel_id: int,
file: UploadFile = File(...),
permission: str = Form("read"),
content: str = Form(""),
reply_to_id: Optional[int] = Form(None),
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Create a message with file attachment in one request"""
# Validate permission
if permission not in ["read", "write"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Permission must be 'read' or 'write'"
)
# Check channel access
if not user_has_channel_access(current_user, channel_id, session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this channel"
)
# Create message
message_content = content or ""
new_message = Message(
content=message_content,
sender_id=current_user.id,
channel_id=channel_id,
reply_to_id=reply_to_id
)
session.add(new_message)
session.commit()
session.refresh(new_message)
# Validate file size
file_content = await file.read()
file_size = len(file_content)
if file_size > settings.max_upload_size:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File size exceeds maximum allowed size of {settings.max_upload_size} bytes"
)
# Generate unique filename
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{uuid.uuid4()}{file_extension}"
file_path = os.path.join(settings.upload_dir, unique_filename)
# Save file
async with aiofiles.open(file_path, 'wb') as f:
await f.write(file_content)
# Create file attachment
file_attachment = FileAttachment(
filename=unique_filename,
original_filename=file.filename,
file_path=file_path,
mime_type=file.content_type or "application/octet-stream",
file_size=file_size,
message_id=new_message.id,
uploader_id=current_user.id,
upload_permission=permission,
is_editable=(permission == "write")
)
session.add(file_attachment)
session.commit()
session.refresh(file_attachment)
# Build response
reply_to_data = None
if reply_to_id:
reply_msg = session.get(Message, reply_to_id)
if reply_msg:
reply_sender = session.get(User, reply_msg.sender_id)
reply_to_data = {
"id": reply_msg.id,
"content": reply_msg.content,
"sender_username": reply_sender.username if reply_sender else "Unknown"
}
attachment_data = {
"id": file_attachment.id,
"filename": file_attachment.filename,
"original_filename": file_attachment.original_filename,
"mime_type": file_attachment.mime_type,
"file_size": file_attachment.file_size,
"uploaded_at": file_attachment.uploaded_at.isoformat(),
"message_id": file_attachment.message_id,
"upload_permission": file_attachment.upload_permission,
"uploader_id": file_attachment.uploader_id,
"is_editable": file_attachment.is_editable
}
response_data = {
"id": new_message.id,
"content": new_message.content,
"channel_id": new_message.channel_id,
"sender_id": new_message.sender_id,
"sender_username": current_user.username,
"sender_full_name": current_user.full_name,
"sender_profile_picture": current_user.profile_picture,
"created_at": new_message.created_at.isoformat(),
"snippet_id": None,
"reply_to_id": reply_to_id,
"reply_to": reply_to_data,
"snippet": None,
"attachments": [attachment_data]
}
# Broadcast via WebSocket
await manager.broadcast_to_channel(
{
"type": "message",
"message": response_data
},
channel_id
)
return response_data
@router.get("/office-uri/{file_id}")
def get_office_uri(
file_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Generate Microsoft Office URI link for editing files"""
# Get file attachment
file_attachment = session.get(FileAttachment, file_id)
if not file_attachment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Get associated message to check access
message = session.get(Message, file_attachment.message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Associated message not found"
)
# Check if user has access to the channel
if not user_has_channel_access(current_user, message.channel_id, session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this file"
)
# Check if file has write permission
if file_attachment.upload_permission != "write":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This file is read-only"
)
# Generate file URL (using frontend URL from settings)
file_url = f"{settings.frontend_url.replace(':5173', ':8000')}/files/download/{file_id}"
encoded_url = quote(file_url, safe='')
# Determine Office app based on file extension
ext = os.path.splitext(file_attachment.original_filename)[1].lower()
office_apps = {
'.xlsx': 'ms-excel',
'.xls': 'ms-excel',
'.xlsm': 'ms-excel',
'.docx': 'ms-word',
'.doc': 'ms-word',
'.pptx': 'ms-powerpoint',
'.ppt': 'ms-powerpoint',
'.accdb': 'ms-access',
'.mpp': 'ms-project',
'.vsd': 'ms-visio',
'.vsdx': 'ms-visio'
}
app_protocol = office_apps.get(ext)
if not app_protocol:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {ext} is not supported for Office URI"
)
# Generate Office URI for editing
office_uri = f"{app_protocol}:ofe|u|{file_url}"
return JSONResponse({
"office_uri": office_uri,
"file_url": file_url,
"app": app_protocol
})
@router.put("/{file_id}/permission", response_model=FileAttachmentResponse)
async def update_file_permission(
file_id: int,
permission: str = Form(...),
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Update the permission of a file (only uploader or admin can change)"""
# Validate permission
if permission not in ["read", "write"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Permission must be 'read' or 'write'"
)
# Get file attachment
file_attachment = session.get(FileAttachment, file_id)
if not file_attachment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Check if user is the uploader or an admin
if file_attachment.uploader_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the uploader or an admin can change file permissions"
)
# Update permission
file_attachment.upload_permission = permission
file_attachment.is_editable = (permission == "write")
session.commit()
session.refresh(file_attachment)
# Get message for channel access check
message = session.get(Message, file_attachment.message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Associated message not found"
)
# Check if user still has access to the channel
if not user_has_channel_access(current_user, message.channel_id, session):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No access to this channel"
)
# Create response
response = FileAttachmentResponse(
id=file_attachment.id,
filename=file_attachment.filename,
original_filename=file_attachment.original_filename,
mime_type=file_attachment.mime_type,
file_size=file_attachment.file_size,
uploaded_at=file_attachment.uploaded_at,
message_id=file_attachment.message_id,
uploader_id=file_attachment.uploader_id,
upload_permission=file_attachment.upload_permission,
is_editable=file_attachment.is_editable
)
return response