From 7d8e839dbea2e09d93350a4f986106e3679c8b11 Mon Sep 17 00:00:00 2001 From: DGSoft Date: Sun, 14 Dec 2025 13:20:01 +0100 Subject: [PATCH] UI: Standardize header heights and sidebar padding - py-3 for all sidebars, py-0.5 for content headers --- backend/alembic.ini | 35 + backend/alembic/env.py | 44 + .../versions/0001_create_last_seen_table.py | 31 + ..._create_direct_message_attachment_table.py | 42 + .../0003_add_assignee_to_kanban_card.py | 30 + .../versions/0004_add_kanban_activity_log.py | 37 + .../0005_add_is_archived_to_kanban_card.py | 24 + backend/app/main.py | 16 +- backend/app/models.py | 164 ++ backend/app/routers/admin.py | 40 +- backend/app/routers/departments.py | 28 +- backend/app/routers/direct_messages.py | 344 +++- backend/app/routers/kanban.py | 1491 ++++++++++++++++- backend/app/routers/last_seen.py | 103 ++ backend/app/schemas.py | 210 +++ frontend/package.json | 1 + frontend/src/components/Admin/AdminPanel.tsx | 131 +- frontend/src/components/Chat/ChatView.tsx | 10 +- .../components/Chat/DirectMessageInput.tsx | 249 +++ .../src/components/Chat/DirectMessageView.tsx | 321 ++-- .../components/Chat/DirectMessagesSidebar.tsx | 2 +- frontend/src/components/Chat/MessageList.tsx | 97 +- frontend/src/components/Chat/Sidebar.tsx | 9 +- .../src/components/Common/ConfirmDialog.tsx | 61 + .../components/Kanban/CreateCardDialog.tsx | 96 ++ .../components/Kanban/KanbanArchiveModal.tsx | 12 +- .../src/components/Kanban/KanbanBoard.tsx | 190 ++- frontend/src/components/Kanban/KanbanCard.tsx | 68 +- .../src/components/Kanban/KanbanCardModal.tsx | 817 ++++++--- .../src/components/Kanban/KanbanColumn.tsx | 8 +- .../src/components/Kanban/KanbanSidebar.tsx | 6 +- frontend/src/components/Layout/Layout.tsx | 20 +- .../components/common/BlinkingEnvelope.tsx | 21 +- frontend/src/contexts/AuthContext.tsx | 3 + .../src/contexts/UnreadMessagesContext.tsx | 64 +- frontend/src/services/api.ts | 200 ++- frontend/src/types/index.ts | 94 +- nginx-collabrix.conf | 4 + scripts/simulate_read_marker.py | 17 + start-backend-clean.sh | 12 + start-backend.sh | 2 +- start-frontend.sh | 2 +- 42 files changed, 4642 insertions(+), 514 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/versions/0001_create_last_seen_table.py create mode 100644 backend/alembic/versions/0002_create_direct_message_attachment_table.py create mode 100644 backend/alembic/versions/0003_add_assignee_to_kanban_card.py create mode 100644 backend/alembic/versions/0004_add_kanban_activity_log.py create mode 100644 backend/alembic/versions/0005_add_is_archived_to_kanban_card.py create mode 100644 backend/app/routers/last_seen.py create mode 100644 frontend/src/components/Chat/DirectMessageInput.tsx create mode 100644 frontend/src/components/Common/ConfirmDialog.tsx create mode 100644 frontend/src/components/Kanban/CreateCardDialog.tsx create mode 100644 scripts/simulate_read_marker.py create mode 100755 start-backend-clean.sh diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..3690c0e --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = backend/alembic + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +propagate = 0 + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..7952371 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,44 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context +import os +import sys + +# Add backend path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.models import SQLModel # noqa: E402 +from app.database import engine # noqa: E402 + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +fileConfig(config.config_file_name) + +target_metadata = SQLModel.metadata + +def run_migrations_offline(): + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = engine + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/versions/0001_create_last_seen_table.py b/backend/alembic/versions/0001_create_last_seen_table.py new file mode 100644 index 0000000..d898235 --- /dev/null +++ b/backend/alembic/versions/0001_create_last_seen_table.py @@ -0,0 +1,31 @@ +"""create last_seen table + +Revision ID: 0001_create_last_seen_table +Revises: +Create Date: 2025-12-12 00:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '0001_create_last_seen_table' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'last_seen', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('channel_id', sa.Integer(), nullable=True), + sa.Column('dm_user_id', sa.Integer(), nullable=True), + sa.Column('last_seen', sa.DateTime(), nullable=False), + ) + op.create_index(op.f('ix_last_seen_last_seen'), 'last_seen', ['last_seen'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_last_seen_last_seen'), table_name='last_seen') + op.drop_table('last_seen') diff --git a/backend/alembic/versions/0002_create_direct_message_attachment_table.py b/backend/alembic/versions/0002_create_direct_message_attachment_table.py new file mode 100644 index 0000000..705838d --- /dev/null +++ b/backend/alembic/versions/0002_create_direct_message_attachment_table.py @@ -0,0 +1,42 @@ +"""Create direct_message_attachment table + +Revision ID: 0002 +Revises: 0001 +Create Date: 2025-12-12 22:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0002' +down_revision = '0001' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create direct_message_attachment table + op.create_table( + 'direct_message_attachment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('filename', sa.String(), nullable=False), + sa.Column('original_filename', sa.String(), nullable=False), + sa.Column('mime_type', sa.String(), nullable=False), + sa.Column('file_size', sa.Integer(), nullable=False), + sa.Column('file_path', sa.String(), nullable=False), + sa.Column('direct_message_id', sa.Integer(), nullable=False), + sa.Column('uploader_id', sa.Integer(), nullable=True), + sa.Column('webdav_path', sa.String(), nullable=True), + sa.Column('upload_permission', sa.String(), nullable=False, server_default='read'), + sa.Column('is_editable', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('uploaded_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(['direct_message_id'], ['direct_message.id'], ), + sa.ForeignKeyConstraint(['uploader_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + +def downgrade() -> None: + op.drop_table('direct_message_attachment') diff --git a/backend/alembic/versions/0003_add_assignee_to_kanban_card.py b/backend/alembic/versions/0003_add_assignee_to_kanban_card.py new file mode 100644 index 0000000..2c83c69 --- /dev/null +++ b/backend/alembic/versions/0003_add_assignee_to_kanban_card.py @@ -0,0 +1,30 @@ +"""Add assignee_id to kanban_card table + +Revision ID: 0003 +Revises: 0001 +Create Date: 2024-12-13 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0003' +down_revision = '0001' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add assignee_id column to kanban_card table + op.add_column('kanban_card', sa.Column('assignee_id', sa.Integer(), nullable=True)) + # Add foreign key constraint + op.create_foreign_key('fk_kanban_card_assignee_id', 'kanban_card', 'user', ['assignee_id'], ['id']) + + +def downgrade() -> None: + # Drop foreign key constraint + op.drop_constraint('fk_kanban_card_assignee_id', 'kanban_card', type_='foreignkey') + # Remove assignee_id column + op.drop_column('kanban_card', 'assignee_id') diff --git a/backend/alembic/versions/0004_add_kanban_activity_log.py b/backend/alembic/versions/0004_add_kanban_activity_log.py new file mode 100644 index 0000000..e8d2040 --- /dev/null +++ b/backend/alembic/versions/0004_add_kanban_activity_log.py @@ -0,0 +1,37 @@ +"""Add kanban_card_activity_log table + +Revision ID: 0004 +Revises: 0003 +Create Date: 2024-12-13 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0004' +down_revision = '0003' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'kanban_card_activity_log', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('card_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('action', sa.String(), nullable=False), + sa.Column('field_name', sa.String(), nullable=True), + sa.Column('old_value', sa.String(), nullable=True), + sa.Column('new_value', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['card_id'], ['kanban_card.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + +def downgrade() -> None: + op.drop_table('kanban_card_activity_log') diff --git a/backend/alembic/versions/0005_add_is_archived_to_kanban_card.py b/backend/alembic/versions/0005_add_is_archived_to_kanban_card.py new file mode 100644 index 0000000..67dcc32 --- /dev/null +++ b/backend/alembic/versions/0005_add_is_archived_to_kanban_card.py @@ -0,0 +1,24 @@ +"""Add is_archived to kanban_card table + +Revision ID: 0005 +Revises: 0004 +Create Date: 2024-12-13 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0005' +down_revision = '0004' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('kanban_card', sa.Column('is_archived', sa.Boolean(), nullable=False, server_default='false')) + + +def downgrade() -> None: + op.drop_column('kanban_card', 'is_archived') diff --git a/backend/app/main.py b/backend/app/main.py index e2e461c..6edf252 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi.staticfiles import StaticFiles from pathlib import Path from app.database import create_db_and_tables, get_session from app.config import get_settings -from app.routers import auth, departments, channels, messages, files, websocket, snippets, admin, direct_messages, kanban +from app.routers import auth, departments, channels, messages, files, websocket, snippets, admin, direct_messages, kanban, last_seen from app.auth import get_current_user from app.models import User, DirectMessage, Department from sqlmodel import Session, select @@ -17,6 +17,14 @@ app = FastAPI( version="1.0.0" ) +try: + from starlette.middleware.proxy_headers import ProxyHeadersMiddleware + # Honor proxy headers (X-Forwarded-For, X-Forwarded-Proto) when behind nginx + app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*") +except ImportError: + import logging + logging.getLogger(__name__).warning("ProxyHeadersMiddleware not available. Start uvicorn with --proxy-headers or install 'starlette' to enable forwarded header handling.") + # CORS middleware app.add_middleware( CORSMiddleware, @@ -59,6 +67,12 @@ app.include_router(files.router) app.include_router(snippets.router) app.include_router(kanban.router) app.include_router(websocket.router) +app.include_router(last_seen.router) + +# Mount uploads directory for file serving +uploads_dir = Path(__file__).parent.parent / "uploads" +uploads_dir.mkdir(parents=True, exist_ok=True) +app.mount("/files", StaticFiles(directory=uploads_dir), name="files") @app.get("/") diff --git a/backend/app/models.py b/backend/app/models.py index 1ce0894..6332514 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -148,6 +148,19 @@ class DirectMessage(SQLModel, table=True): sender: User = Relationship(back_populates="sent_direct_messages", sa_relationship_kwargs={"foreign_keys": "DirectMessage.sender_id"}) receiver: User = Relationship(back_populates="received_direct_messages", sa_relationship_kwargs={"foreign_keys": "DirectMessage.receiver_id"}) snippet: Optional["Snippet"] = Relationship() + reply_to: Optional["DirectMessage"] = Relationship(back_populates="replies", sa_relationship_kwargs={"foreign_keys": "DirectMessage.reply_to_id", "remote_side": "DirectMessage.id"}) + replies: List["DirectMessage"] = Relationship(back_populates="reply_to") + attachments: List["DirectMessageAttachment"] = Relationship(back_populates="direct_message") + + +class LastSeen(SQLModel, table=True): + __tablename__ = "last_seen" + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + channel_id: Optional[int] = Field(default=None) + dm_user_id: Optional[int] = Field(default=None) + last_seen: datetime = Field(default_factory=datetime.utcnow, index=True) class FileAttachment(SQLModel, table=True): @@ -186,6 +199,27 @@ class FilePermission(SQLModel, table=True): user: User = Relationship(back_populates="file_permissions") +class DirectMessageAttachment(SQLModel, table=True): + __tablename__ = "direct_message_attachment" + + id: Optional[int] = Field(default=None, primary_key=True) + filename: str + original_filename: str + mime_type: str + file_size: int + file_path: str + direct_message_id: int = Field(foreign_key="direct_message.id") + uploader_id: Optional[int] = Field(default=None, foreign_key="user.id") + webdav_path: Optional[str] = None + upload_permission: str = Field(default="read") # "read" or "write" + is_editable: bool = Field(default=False) + uploaded_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationships + direct_message: DirectMessage = Relationship(back_populates="attachments") + uploader: Optional[User] = Relationship() + + class Snippet(SQLModel, table=True): __tablename__ = "snippet" @@ -221,6 +255,8 @@ class KanbanBoard(SQLModel, table=True): # Relationships channel: Channel = Relationship(back_populates="kanban_board") columns: List["KanbanColumn"] = Relationship(back_populates="board") + custom_fields: List["KanbanCustomField"] = Relationship(back_populates="board") + templates: List["KanbanCardTemplate"] = Relationship(back_populates="board") class KanbanColumn(SQLModel, table=True): @@ -251,6 +287,9 @@ class KanbanCard(SQLModel, table=True): due_date: Optional[datetime] = Field(default=None) priority: Optional[str] = Field(default="medium") # low, medium, high labels: Optional[str] = Field(default=None) # JSON string for labels/tags + estimated_time: Optional[int] = Field(default=None) # Estimated time in minutes + actual_time: Optional[int] = Field(default=None) # Actual time spent in minutes + is_archived: bool = Field(default=False) # Soft delete - card is archived but not deleted created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) @@ -258,6 +297,10 @@ class KanbanCard(SQLModel, table=True): column: KanbanColumn = Relationship(back_populates="cards") assignee: Optional[User] = Relationship() checklists: List["KanbanChecklist"] = Relationship(back_populates="card") + comments: List["KanbanCardComment"] = Relationship(back_populates="card") + attachments: List["KanbanCardAttachment"] = Relationship(back_populates="card") + time_entries: List["KanbanTimeEntry"] = Relationship(back_populates="card") + custom_field_values: List["KanbanCustomFieldValue"] = Relationship(back_populates="card") class KanbanChecklist(SQLModel, table=True): @@ -288,3 +331,124 @@ class KanbanChecklistItem(SQLModel, table=True): # Relationships checklist: KanbanChecklist = Relationship(back_populates="items") + + +# Kanban Card Comments +class KanbanCardComment(SQLModel, table=True): + __tablename__ = "kanban_card_comment" + + id: Optional[int] = Field(default=None, primary_key=True) + card_id: int = Field(foreign_key="kanban_card.id") + user_id: int = Field(foreign_key="user.id") + content: str + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationships + card: KanbanCard = Relationship(back_populates="comments") + user: User = Relationship() + + +# Kanban Card Attachments +class KanbanCardAttachment(SQLModel, table=True): + __tablename__ = "kanban_card_attachment" + + id: Optional[int] = Field(default=None, primary_key=True) + card_id: int = Field(foreign_key="kanban_card.id") + filename: str + original_filename: str + mime_type: str + file_size: int + file_path: str + uploader_id: int = Field(foreign_key="user.id") + uploaded_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationships + card: KanbanCard = Relationship(back_populates="attachments") + uploader: User = Relationship() + + +# Kanban Time Tracking +class KanbanTimeEntry(SQLModel, table=True): + __tablename__ = "kanban_time_entry" + + id: Optional[int] = Field(default=None, primary_key=True) + card_id: int = Field(foreign_key="kanban_card.id") + user_id: int = Field(foreign_key="user.id") + description: Optional[str] = Field(default=None) + start_time: datetime + end_time: Optional[datetime] = Field(default=None) + duration_minutes: Optional[int] = Field(default=None) # Calculated field + is_running: bool = Field(default=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationships + card: KanbanCard = Relationship(back_populates="time_entries") + user: User = Relationship() + + +# Kanban Custom Fields +class KanbanCustomField(SQLModel, table=True): + __tablename__ = "kanban_custom_field" + + id: Optional[int] = Field(default=None, primary_key=True) + board_id: int = Field(foreign_key="kanban_board.id") + name: str + field_type: str # 'text', 'number', 'date', 'select', 'multiselect', 'checkbox' + options: Optional[str] = Field(default=None) # JSON string for select options + is_required: bool = Field(default=False) + position: int = Field(default=0) + created_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationships + board: KanbanBoard = Relationship(back_populates="custom_fields") + values: List["KanbanCustomFieldValue"] = Relationship(back_populates="field") + + +class KanbanCustomFieldValue(SQLModel, table=True): + __tablename__ = "kanban_custom_field_value" + + id: Optional[int] = Field(default=None, primary_key=True) + field_id: int = Field(foreign_key="kanban_custom_field.id") + card_id: int = Field(foreign_key="kanban_card.id") + value: str # JSON string for the value + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationships + field: KanbanCustomField = Relationship(back_populates="values") + card: KanbanCard = Relationship(back_populates="custom_field_values") + + +# Kanban Card Activity Log +class KanbanCardActivityLog(SQLModel, table=True): + __tablename__ = "kanban_card_activity_log" + + id: Optional[int] = Field(default=None, primary_key=True) + card_id: int = Field(foreign_key="kanban_card.id") + user_id: int = Field(foreign_key="user.id") + action: str # 'created', 'moved', 'updated', 'commented', etc. + field_name: Optional[str] = Field(default=None) # Which field was changed + old_value: Optional[str] = Field(default=None) # Old value as string + new_value: Optional[str] = Field(default=None) # New value as string + created_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationships + card: KanbanCard = Relationship() + user: User = Relationship() + + +# Kanban Card Templates +class KanbanCardTemplate(SQLModel, table=True): + __tablename__ = "kanban_card_template" + + id: Optional[int] = Field(default=None, primary_key=True) + board_id: int = Field(foreign_key="kanban_board.id") + name: str + description: Optional[str] = Field(default=None) + template_data: str # JSON string containing template data + is_default: bool = Field(default=False) + created_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationships + board: KanbanBoard = Relationship(back_populates="templates") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index d1bf2fe..f50c8e8 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -78,7 +78,7 @@ def get_all_users( @router.patch("/users/{user_id}/role") def update_user_role( user_id: int, - role: UserRole, + body: UserAdminUpdate, session: Session = Depends(get_session), admin: User = Depends(require_superadmin) ): @@ -91,18 +91,18 @@ def update_user_role( ) # Prevent superadmin from demoting themselves - if admin.id == user_id and role != UserRole.SUPERADMIN: + if admin.id == user_id and body.role != UserRole.SUPERADMIN: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change your own superadmin role" ) - user.role = role + user.role = body.role session.add(user) session.commit() session.refresh(user) - return {"message": f"User {user.username} role updated to {role.value}", "role": role} + return {"message": f"User {user.username} role updated to {body.role.value}", "role": body.role} # ========== Department Management ========== @@ -344,6 +344,38 @@ def create_channel( return channel +@router.put("/channels/{channel_id}", response_model=ChannelResponse) +def update_channel( + channel_id: int, + channel_data: ChannelCreate, + session: Session = Depends(get_session), + admin: User = Depends(require_admin) +): + """Update a channel (Admin only)""" + channel = session.get(Channel, channel_id) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Channel not found" + ) + + # Verify department exists + department = session.get(Department, channel_data.department_id) + if not department: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Department not found" + ) + + channel.name = channel_data.name + channel.description = channel_data.description + channel.department_id = channel_data.department_id + session.add(channel) + session.commit() + session.refresh(channel) + return channel + + @router.delete("/channels/{channel_id}") def delete_channel( channel_id: int, diff --git a/backend/app/routers/departments.py b/backend/app/routers/departments.py index 108db4f..9c65143 100644 --- a/backend/app/routers/departments.py +++ b/backend/app/routers/departments.py @@ -3,7 +3,7 @@ from sqlmodel import Session, select from typing import List from app.database import get_session from app.models import Department, User -from app.schemas import DepartmentCreate, DepartmentResponse +from app.schemas import DepartmentCreate, DepartmentResponse, UserResponse from app.auth import get_current_user router = APIRouter(prefix="/departments", tags=["Departments"]) @@ -59,6 +59,32 @@ def get_my_departments( return user.departments if user else [] +@router.get("/{department_id}/users", response_model=List[UserResponse]) +def get_department_users( + department_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Get all users in a department""" + # Check if department exists + department = session.get(Department, department_id) + if not department: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Department not found" + ) + + # Check if current user has access to this department + user_departments = [dept.id for dept in current_user.departments] + if department_id not in user_departments and current_user.role not in ["admin", "superadmin"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + return department.users + + @router.post("/{department_id}/users/{user_id}") def add_user_to_department( department_id: int, diff --git a/backend/app/routers/direct_messages.py b/backend/app/routers/direct_messages.py index 3558b11..6947376 100644 --- a/backend/app/routers/direct_messages.py +++ b/backend/app/routers/direct_messages.py @@ -1,12 +1,15 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Form from sqlmodel import Session, select, or_, and_ from sqlalchemy.orm import joinedload -from typing import List +from typing import List, Optional from app.database import get_session -from app.models import DirectMessage, User, Snippet -from app.schemas import DirectMessageCreate, DirectMessageResponse +from app.models import DirectMessage, User, Snippet, DirectMessageAttachment +from app.schemas import DirectMessageCreate, DirectMessageResponse, DirectMessageAttachmentResponse from app.auth import get_current_user from app.websocket import manager +import os +import uuid +from pathlib import Path router = APIRouter(prefix="/direct-messages", tags=["Direct Messages"]) @@ -37,34 +40,83 @@ async def create_direct_message( content=message_data.content, sender_id=current_user.id, receiver_id=message_data.receiver_id, - snippet_id=message_data.snippet_id + snippet_id=message_data.snippet_id, + reply_to_id=message_data.reply_to_id ) session.add(new_message) session.commit() session.refresh(new_message) - # Build response - response = DirectMessageResponse.model_validate(new_message) - response.sender_username = current_user.username - response.receiver_username = receiver.username - response.sender_full_name = current_user.full_name - response.sender_profile_picture = current_user.profile_picture - # Load snippet data if present snippet_data = None if new_message.snippet_id: snippet = session.get(Snippet, new_message.snippet_id) if snippet: + snippet_owner = session.get(User, snippet.owner_id) snippet_data = { "id": snippet.id, "title": snippet.title, "content": snippet.content, "language": snippet.language, - "created_at": snippet.created_at.isoformat(), - "updated_at": snippet.updated_at.isoformat() + "tags": snippet.tags, + "visibility": snippet.visibility, + "department_id": snippet.department_id, + "owner_id": snippet.owner_id, + "owner_username": snippet_owner.username if snippet_owner else "Unknown", + "created_at": snippet.created_at, + "updated_at": snippet.updated_at } + # Load reply_to data if present + reply_to_data = None + if new_message.reply_to_id: + reply_to_msg = session.get(DirectMessage, new_message.reply_to_id) + if reply_to_msg: + reply_sender = session.get(User, reply_to_msg.sender_id) + reply_to_data = { + "id": reply_to_msg.id, + "content": reply_to_msg.content, + "sender_username": reply_sender.username if reply_sender else "Unknown", + "sender_full_name": reply_sender.full_name if reply_sender else None + } + + # Build attachment data + attachments_data = [ + { + "id": att.id, + "filename": att.filename, + "original_filename": att.original_filename, + "mime_type": att.mime_type, + "file_size": att.file_size, + "file_path": att.file_path, + "direct_message_id": att.direct_message_id, + "uploaded_at": att.uploaded_at, + "upload_permission": att.upload_permission, + "uploader_id": att.uploader_id, + "is_editable": att.is_editable + } + for att in new_message.attachments + ] + + # Build response using constructor + response = DirectMessageResponse( + id=new_message.id, + content=new_message.content, + sender_id=new_message.sender_id, + receiver_id=new_message.receiver_id, + snippet_id=new_message.snippet_id, + created_at=new_message.created_at, + is_read=new_message.is_read, + sender_username=current_user.username, + receiver_username=receiver.username, + sender_full_name=current_user.full_name, + sender_profile_picture=current_user.profile_picture, + snippet=snippet_data, + reply_to=reply_to_data, + attachments=attachments_data + ) + # Broadcast via WebSocket to receiver (using negative user ID as "channel") response_data = { "id": new_message.id, @@ -78,7 +130,10 @@ async def create_direct_message( "created_at": new_message.created_at.isoformat(), "is_read": new_message.is_read, "snippet_id": new_message.snippet_id, - "snippet": snippet_data + "snippet": snippet_data, + "reply_to_id": new_message.reply_to_id, + "reply_to": reply_to_data, + "attachments": attachments_data } # Broadcast to both sender and receiver using their user IDs as "channel" @@ -97,6 +152,46 @@ async def create_direct_message( return response +@router.delete("/{message_id}", status_code=status.HTTP_200_OK) +async def delete_direct_message( + message_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Delete a direct message (only sender can delete)""" + message = session.get(DirectMessage, message_id) + if not message: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Message not found" + ) + + # Only sender can delete + if message.sender_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only delete your own messages" + ) + + # Delete associated attachments and files + attachments = session.query(DirectMessageAttachment).filter( + DirectMessageAttachment.direct_message_id == message_id + ).all() + + for attachment in attachments: + # Delete file from disk + file_path = Path(attachment.file_path) + if file_path.exists(): + try: + file_path.unlink() + except Exception as e: + print(f"Failed to delete file: {e}") + session.delete(attachment) + + session.delete(message) + session.commit() + + return {"success": True, "message": "Message deleted"} @router.get("/conversation/{user_id}", response_model=List[DirectMessageResponse]) def get_conversation( user_id: int, @@ -140,13 +235,76 @@ def get_conversation( # Build responses responses = [] for msg in messages: - msg_response = DirectMessageResponse.model_validate(msg) sender = session.get(User, msg.sender_id) receiver = session.get(User, msg.receiver_id) - msg_response.sender_username = sender.username if sender else "Unknown" - msg_response.receiver_username = receiver.username if receiver else "Unknown" - msg_response.sender_full_name = sender.full_name if sender else None - msg_response.sender_profile_picture = sender.profile_picture if sender else None + + # Build reply_to data if it exists + reply_to_data = None + if msg.reply_to_id: + reply_to_msg = session.get(DirectMessage, msg.reply_to_id) + if reply_to_msg: + reply_sender = session.get(User, reply_to_msg.sender_id) + reply_to_data = { + "id": reply_to_msg.id, + "content": reply_to_msg.content, + "sender_username": reply_sender.username if reply_sender else "Unknown", + "sender_full_name": reply_sender.full_name if reply_sender else None + } + + # Build attachment data + attachments_data = [ + { + "id": att.id, + "filename": att.filename, + "original_filename": att.original_filename, + "mime_type": att.mime_type, + "file_size": att.file_size, + "file_path": att.file_path, + "direct_message_id": att.direct_message_id, + "uploaded_at": att.uploaded_at, + "upload_permission": att.upload_permission, + "uploader_id": att.uploader_id, + "is_editable": att.is_editable + } + for att in msg.attachments + ] + + # Build snippet data + snippet_data = None + if msg.snippet: + snippet_owner = session.get(User, msg.snippet.owner_id) + snippet_data = { + "id": msg.snippet.id, + "title": msg.snippet.title, + "content": msg.snippet.content, + "language": msg.snippet.language, + "tags": msg.snippet.tags, + "visibility": msg.snippet.visibility, + "department_id": msg.snippet.department_id, + "owner_id": msg.snippet.owner_id, + "owner_username": snippet_owner.username if snippet_owner else "Unknown", + "created_at": msg.snippet.created_at, + "updated_at": msg.snippet.updated_at + } + + # Create response using dict constructor + msg_response = DirectMessageResponse( + id=msg.id, + content=msg.content, + sender_id=msg.sender_id, + receiver_id=msg.receiver_id, + snippet_id=msg.snippet_id, + created_at=msg.created_at, + is_read=msg.is_read, + sender_username=sender.username if sender else "Unknown", + receiver_username=receiver.username if receiver else "Unknown", + sender_full_name=sender.full_name if sender else None, + sender_profile_picture=sender.profile_picture if sender else None, + snippet=snippet_data, + reply_to=reply_to_data, + attachments=attachments_data + ) + responses.append(msg_response) # Reverse to show oldest first @@ -205,3 +363,149 @@ def get_conversations( }) return conversations + + +@router.post("/{user_id}/upload", status_code=status.HTTP_201_CREATED) +async def upload_direct_message_file( + user_id: int, + file: UploadFile = File(...), + content: str = Form(default=""), + permission: str = Form(default="read"), + reply_to_id: str = Form(default=""), + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Upload a file with a direct message""" + # Convert reply_to_id from string to int or None + parsed_reply_to_id: Optional[int] = None + if reply_to_id and reply_to_id.strip(): + try: + parsed_reply_to_id = int(reply_to_id) + except ValueError: + parsed_reply_to_id = None + + # Check if receiver exists + receiver = session.get(User, user_id) + if not receiver: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Receiver not found" + ) + + # Can't send message to yourself + if user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot send message to yourself" + ) + + # Create uploads directory if it doesn't exist + upload_dir = Path(__file__).parent.parent.parent / "uploads" + upload_dir.mkdir(parents=True, exist_ok=True) + + # Generate unique filename + file_ext = Path(file.filename).suffix + unique_filename = f"{uuid.uuid4()}{file_ext}" + file_path = upload_dir / unique_filename + + # Save file to disk + file_content = await file.read() + with open(file_path, 'wb') as f: + f.write(file_content) + + # Create direct message + new_message = DirectMessage( + content=content or f"Shared file: {file.filename}", + sender_id=current_user.id, + receiver_id=user_id, + reply_to_id=parsed_reply_to_id + ) + + session.add(new_message) + session.commit() + session.refresh(new_message) + + # Create file attachment + attachment = DirectMessageAttachment( + filename=unique_filename, + original_filename=file.filename, + mime_type=file.content_type or "application/octet-stream", + file_size=len(file_content), + file_path=str(file_path), + direct_message_id=new_message.id, + uploader_id=current_user.id, + upload_permission=permission + ) + + session.add(attachment) + session.commit() + session.refresh(attachment) + + # Build response + response = DirectMessageResponse.model_validate(new_message) + response.sender_username = current_user.username + response.receiver_username = receiver.username + response.sender_full_name = current_user.full_name + response.sender_profile_picture = current_user.profile_picture + + # Add attachment to response + att_response = DirectMessageAttachmentResponse.model_validate(attachment) + response.attachments = [att_response] + + # Load reply_to data if present + reply_to_data = None + if new_message.reply_to_id: + reply_to_msg = session.get(DirectMessage, new_message.reply_to_id) + if reply_to_msg: + reply_sender = session.get(User, reply_to_msg.sender_id) + reply_to_data = { + "id": reply_to_msg.id, + "content": reply_to_msg.content, + "sender_username": reply_sender.username if reply_sender else "Unknown", + "sender_full_name": reply_sender.full_name if reply_sender else None + } + + # Broadcast via WebSocket + response_data = { + "id": new_message.id, + "content": new_message.content, + "sender_id": new_message.sender_id, + "receiver_id": new_message.receiver_id, + "sender_username": current_user.username, + "receiver_username": receiver.username, + "sender_full_name": current_user.full_name, + "sender_profile_picture": current_user.profile_picture, + "created_at": new_message.created_at.isoformat(), + "is_read": new_message.is_read, + "snippet_id": new_message.snippet_id, + "snippet": None, + "reply_to_id": new_message.reply_to_id, + "reply_to": reply_to_data, + "attachments": [{ + "id": attachment.id, + "filename": attachment.filename, + "original_filename": attachment.original_filename, + "mime_type": attachment.mime_type, + "file_size": attachment.file_size, + "uploaded_at": attachment.uploaded_at.isoformat(), + "direct_message_id": attachment.direct_message_id, + "upload_permission": attachment.upload_permission, + "uploader_id": attachment.uploader_id, + "is_editable": attachment.is_editable + }] + } + + # Broadcast to both sender and receiver + await manager.broadcast_to_channel( + {"type": "direct_message", "message": response_data}, + -user_id + ) + await manager.broadcast_to_channel( + {"type": "direct_message", "message": response_data}, + -current_user.id + ) + + # Update user activity + manager.update_activity(current_user.id) + + return response diff --git a/backend/app/routers/kanban.py b/backend/app/routers/kanban.py index 160eede..1b0cbb4 100644 --- a/backend/app/routers/kanban.py +++ b/backend/app/routers/kanban.py @@ -1,10 +1,13 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlmodel import Session, select -from typing import List +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File +from sqlmodel import Session, select, func +from typing import List, Optional from app.database import get_session from app.models import ( KanbanBoard, KanbanColumn, KanbanCard, Channel, User, - KanbanChecklist, KanbanChecklistItem, UserRole + KanbanChecklist, KanbanChecklistItem, UserRole, + KanbanCardComment, KanbanCardAttachment, KanbanTimeEntry, + KanbanCustomField, KanbanCustomFieldValue, KanbanCardTemplate, + KanbanCardActivityLog ) from app.schemas import ( KanbanBoardCreate, KanbanBoardUpdate, KanbanBoardResponse, @@ -13,13 +16,72 @@ from app.schemas import ( KanbanBoardWithColumns, KanbanColumnWithCards, KanbanChecklistCreate, KanbanChecklistUpdate, KanbanChecklistResponse, KanbanChecklistItemCreate, KanbanChecklistItemUpdate, KanbanChecklistItemResponse, - KanbanChecklistWithItems, KanbanCardWithChecklists + KanbanChecklistWithItems, KanbanCardWithChecklists, + KanbanCardCommentCreate, KanbanCardCommentUpdate, KanbanCardCommentResponse, + KanbanCardAttachmentResponse, KanbanTimeEntryCreate, KanbanTimeEntryUpdate, KanbanTimeEntryResponse, + KanbanCustomFieldCreate, KanbanCustomFieldUpdate, KanbanCustomFieldResponse, + KanbanCustomFieldValueCreate, KanbanCustomFieldValueUpdate, KanbanCustomFieldValueResponse, + KanbanCardTemplateCreate, KanbanCardTemplateUpdate, KanbanCardTemplateResponse, + KanbanCardExtendedResponse, KanbanBoardExtendedResponse ) from app.auth import get_current_user +import os +from pathlib import Path +from datetime import datetime router = APIRouter(prefix="/kanban", tags=["Kanban"]) +# Helper function to log card activity +def log_card_activity( + session: Session, + card_id: int, + user_id: int, + action: str, + field_name: Optional[str] = None, + old_value: Optional[str] = None, + new_value: Optional[str] = None +): + """Log a card activity for the activity log""" + try: + # Convert assignee_id to names for better readability + if field_name == 'assignee_id': + old_name = None + new_name = None + + if old_value and old_value != 'None': + try: + old_user = session.get(User, int(old_value)) + old_name = old_user.full_name if old_user else f"User {old_value}" + except: + old_name = str(old_value) + + if new_value and new_value != 'None': + try: + new_user = session.get(User, int(new_value)) + new_name = new_user.full_name if new_user else f"User {new_value}" + except: + new_name = str(new_value) + + old_value = old_name or "Nicht zugewiesen" + new_value = new_name or "Nicht zugewiesen" + field_name = 'Zugewiesen an' + + activity = KanbanCardActivityLog( + card_id=card_id, + user_id=user_id, + action=action, + field_name=field_name, + old_value=str(old_value) if old_value is not None else None, + new_value=str(new_value) if new_value is not None else None + ) + session.add(activity) + session.commit() + except Exception as e: + # Log errors but don't fail the main operation + print(f"Failed to log activity: {e}") + + # Board endpoints @router.post("/boards", response_model=KanbanBoardResponse, status_code=status.HTTP_201_CREATED) def create_board( @@ -66,38 +128,55 @@ def create_board( return new_board -@router.get("/boards/{channel_id}", response_model=KanbanBoardWithColumns) -def get_board_by_channel( - channel_id: int, +@router.get("/boards/{id}", response_model=KanbanBoardWithColumns) +def get_board_by_id_or_channel( + id: int, session: Session = Depends(get_session), current_user: User = Depends(get_current_user) ): - """Get kanban board for a specific channel""" - # Check if channel exists and user has access - channel = session.get(Channel, channel_id) - if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Channel not found" - ) + """Get kanban board by ID or by channel ID""" + # First try to get board directly by ID + board = session.get(KanbanBoard, id) + if board: + # Check access via channel + channel = session.get(Channel, board.channel_id) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board's associated channel not found" + ) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this board" + ) + else: + # If not found as board ID, try as channel ID + channel = session.get(Channel, id) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board or channel not found" + ) - user_departments = [dept.id for dept in current_user.departments] - if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this channel" - ) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this channel" + ) - # Get board with columns and cards - board = session.exec( - select(KanbanBoard).where(KanbanBoard.channel_id == channel_id) - ).first() + # Get board for this channel + board = session.exec( + select(KanbanBoard).where(KanbanBoard.channel_id == id) + ).first() - if not board: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="No kanban board found for this channel" - ) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No kanban board found for this channel" + ) # Load columns with cards columns = session.exec( @@ -106,18 +185,61 @@ def get_board_by_channel( .order_by(KanbanColumn.position) ).all() - board_data = KanbanBoardWithColumns.from_orm(board) - board_data.columns = [] + board_data = KanbanBoardWithColumns( + id=board.id, + channel_id=board.channel_id, + name=board.name, + created_at=board.created_at, + updated_at=board.updated_at, + columns=[] + ) for column in columns: cards = session.exec( select(KanbanCard) .where(KanbanCard.column_id == column.id) + .where(KanbanCard.is_archived == False) .order_by(KanbanCard.position) ).all() - column_data = KanbanColumnWithCards.from_orm(column) - column_data.cards = [KanbanCardResponse.from_orm(card) for card in cards] + column_data = KanbanColumnWithCards( + id=column.id, + board_id=column.board_id, + name=column.name, + position=column.position, + color=column.color, + created_at=column.created_at, + updated_at=column.updated_at, + cards=[] + ) + + for card in cards: + # Load assignee data explicitly + if card.assignee_id: + card.assignee = session.get(User, card.assignee_id) + + # Calculate counts + attachments_count = session.exec( + select(func.count(KanbanCardAttachment.id)) + .where(KanbanCardAttachment.card_id == card.id) + ).one() + + checklists_count = session.exec( + select(func.count(KanbanChecklist.id)) + .where(KanbanChecklist.card_id == card.id) + ).one() + + comments_count = session.exec( + select(func.count(KanbanCardComment.id)) + .where(KanbanCardComment.card_id == card.id) + ).one() + + card_response = KanbanCardResponse.from_orm(card) + card_response.attachments_count = attachments_count + card_response.checklists_count = checklists_count + card_response.comments_count = comments_count + + column_data.cards.append(card_response) board_data.columns.append(column_data) return board_data @@ -156,6 +278,59 @@ def update_board( return board +@router.get("/boards/by-id/{board_id}", response_model=KanbanBoardWithColumns) +def get_board_by_id( + board_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Get kanban board by board ID""" + board = session.get(KanbanBoard, board_id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found" + ) + + # Check access via channel + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this board" + ) + + # Load columns with cards + columns = session.exec( + select(KanbanColumn) + .where(KanbanColumn.board_id == board.id) + .order_by(KanbanColumn.position) + ).all() + + board_data = KanbanBoardWithColumns.from_orm(board) + board_data.columns = [] + + for column in columns: + cards = session.exec( + select(KanbanCard) + .where(KanbanCard.column_id == column.id) + .where(KanbanCard.is_archived == False) + .order_by(KanbanCard.position) + ).all() + + # Load assignee data for all cards + for card in cards: + if card.assignee_id: + card.assignee = session.get(User, card.assignee_id) + + column_data = KanbanColumnWithCards.from_orm(column) + column_data.cards = [KanbanCardResponse.from_orm(card) for card in cards] + board_data.columns.append(column_data) + + return board_data + + # Column endpoints @router.post("/columns", response_model=KanbanColumnResponse, status_code=status.HTTP_201_CREATED) def create_column( @@ -297,6 +472,17 @@ def create_card( session.commit() session.refresh(new_card) + # Log card creation + log_card_activity( + session, + new_card.id, + current_user.id, + 'created', + None, + None, + new_card.title + ) + return new_card @@ -327,7 +513,20 @@ def update_card( ) update_data = card_data.dict(exclude_unset=True) + + # Log each field change for field, value in update_data.items(): + old_value = getattr(card, field, None) + if old_value != value: + log_card_activity( + session, + card_id, + current_user.id, + 'updated', + field, + old_value, + value + ) setattr(card, field, value) session.commit() @@ -341,7 +540,7 @@ def delete_card( session: Session = Depends(get_session), current_user: User = Depends(get_current_user) ): - """Delete a kanban card""" + """Archive a kanban card (soft delete)""" card = session.get(KanbanCard, card_id) if not card: raise HTTPException( @@ -360,9 +559,22 @@ def delete_card( detail="Access denied" ) - session.delete(card) + # Archive the card instead of deleting + card.is_archived = True + + # Log the archive action + log_card_activity( + session, + card_id, + current_user.id, + 'archived', + None, + None, + card.title + ) + session.commit() - return {"message": "Card deleted successfully"} + return {"message": "Card archived successfully"} @router.put("/cards/{card_id}/move") @@ -408,14 +620,80 @@ def move_card( ) # Update card position + old_column_id = card.column_id + old_position = card.position + card.column_id = target_column_id card.position = new_position + # Log the move if column changed + if old_column_id != target_column_id: + old_column = session.get(KanbanColumn, old_column_id) + new_column = session.get(KanbanColumn, target_column_id) + log_card_activity( + session, + card_id, + current_user.id, + 'moved', + 'column', + old_column.name if old_column else 'Unknown', + new_column.name if new_column else 'Unknown' + ) + session.commit() session.refresh(card) return {"message": "Card moved successfully"} +@router.get("/boards/{board_id}/archived-cards", response_model=List[KanbanCardExtendedResponse]) +def get_archived_cards( + board_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Get all archived cards for a board""" + board = session.get(KanbanBoard, board_id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found" + ) + + # Check access via board -> channel -> department + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # Get all columns of this board + columns = session.exec( + select(KanbanColumn).where(KanbanColumn.board_id == board_id) + ).all() + + archived_cards = [] + for column in columns: + cards = session.exec( + select(KanbanCard) + .where(KanbanCard.column_id == column.id) + .where(KanbanCard.is_archived == True) + .order_by(KanbanCard.updated_at.desc()) + ).all() + + for card in cards: + # Load assignee data + if card.assignee_id: + card.assignee = session.get(User, card.assignee_id) + # Add column name to card response + card_dict = KanbanCardExtendedResponse.from_orm(card).dict() + card_dict['column_name'] = column.name + archived_cards.append(KanbanCardExtendedResponse(**card_dict)) + + return archived_cards + + # Checklist endpoints @router.post("/checklists", response_model=KanbanChecklistResponse, status_code=status.HTTP_201_CREATED) def create_checklist( @@ -702,4 +980,1139 @@ def get_card_checklists( checklist_data.items = [KanbanChecklistItemResponse.from_orm(item) for item in items] result.append(checklist_data) - return result \ No newline at end of file + return result + + +# Comment endpoints +@router.post("/cards/{card_id}/comments", response_model=KanbanCardCommentResponse, status_code=status.HTTP_201_CREATED) +def create_card_comment( + card_id: int, + comment_data: KanbanCardCommentCreate, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Create a comment on a kanban card""" + # Check if card exists and user has access + card = session.get(KanbanCard, card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Card not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, card.column.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + new_comment = KanbanCardComment( + card_id=card_id, + user_id=current_user.id, + content=comment_data.content + ) + + session.add(new_comment) + session.commit() + session.refresh(new_comment) + + return KanbanCardCommentResponse.from_orm(new_comment) + + +@router.get("/cards/{card_id}/comments", response_model=List[KanbanCardCommentResponse]) +def get_card_comments( + card_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Get all comments for a kanban card""" + # Check if card exists and user has access + card = session.get(KanbanCard, card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Card not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, card.column.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + comments = session.exec( + select(KanbanCardComment) + .where(KanbanCardComment.card_id == card_id) + .order_by(KanbanCardComment.created_at) + ).all() + + return [KanbanCardCommentResponse.from_orm(comment) for comment in comments] + + +@router.get("/cards/{card_id}/activity") +def get_card_activity( + card_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Get activity log for a kanban card""" + # Check if card exists and user has access + card = session.get(KanbanCard, card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Card not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, card.column.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + activities = session.exec( + select(KanbanCardActivityLog) + .where(KanbanCardActivityLog.card_id == card_id) + .order_by(KanbanCardActivityLog.created_at.desc()) + ).all() + + # Format response with user information + result = [] + for activity in activities: + user = session.get(User, activity.user_id) + result.append({ + 'id': activity.id, + 'action': activity.action, + 'field_name': activity.field_name, + 'old_value': activity.old_value, + 'new_value': activity.new_value, + 'created_at': activity.created_at, + 'user': { + 'id': user.id, + 'username': user.username, + 'full_name': user.full_name + } if user else None + }) + + return result + + +@router.put("/comments/{comment_id}", response_model=KanbanCardCommentResponse) +def update_card_comment( + comment_id: int, + comment_data: KanbanCardCommentUpdate, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Update a comment (only by the author)""" + comment = session.get(KanbanCardComment, comment_id) + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Comment not found" + ) + + # Only the author can update their comment + if comment.user_id != current_user.id and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Can only update your own comments" + ) + + if comment_data.content is not None: + comment.content = comment_data.content + comment.updated_at = datetime.utcnow() + + session.add(comment) + session.commit() + session.refresh(comment) + + return KanbanCardCommentResponse.from_orm(comment) + + +@router.delete("/comments/{comment_id}") +def delete_card_comment( + comment_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Delete a comment (by author or admin)""" + comment = session.get(KanbanCardComment, comment_id) + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Comment not found" + ) + + # Only the author or admin can delete + if comment.user_id != current_user.id and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Can only delete your own comments" + ) + + session.delete(comment) + session.commit() + + return {"message": "Comment deleted successfully"} + + +# Attachment endpoints +@router.post("/cards/{card_id}/attachments", response_model=KanbanCardAttachmentResponse, status_code=status.HTTP_201_CREATED) +def upload_card_attachment( + card_id: int, + file: UploadFile = File(...), + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Upload a file attachment to a kanban card""" + # Check if card exists and user has access + card = session.get(KanbanCard, card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Card not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, card.column.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # Create uploads directory if it doesn't exist + upload_dir = Path(__file__).parent.parent.parent / "uploads" / "kanban" + upload_dir.mkdir(parents=True, exist_ok=True) + + # Generate unique filename + file_extension = Path(file.filename).suffix + unique_filename = f"{datetime.utcnow().timestamp()}_{current_user.id}_{file.filename}" + file_path = upload_dir / unique_filename + + # Save file + try: + with open(file_path, "wb") as buffer: + content = file.file.read() + buffer.write(content) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to save file: {str(e)}" + ) + + # Create attachment record + attachment = KanbanCardAttachment( + card_id=card_id, + filename=unique_filename, + original_filename=file.filename, + mime_type=file.content_type or "application/octet-stream", + file_size=len(content), + file_path=str(file_path.relative_to(Path(__file__).parent.parent.parent)), + uploader_id=current_user.id + ) + + session.add(attachment) + session.commit() + session.refresh(attachment) + + return KanbanCardAttachmentResponse.from_orm(attachment) + + +@router.get("/cards/{card_id}/attachments", response_model=List[KanbanCardAttachmentResponse]) +def get_card_attachments( + card_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Get all attachments for a kanban card""" + # Check if card exists and user has access + card = session.get(KanbanCard, card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Card not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, card.column.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + attachments = session.exec( + select(KanbanCardAttachment) + .where(KanbanCardAttachment.card_id == card_id) + .order_by(KanbanCardAttachment.uploaded_at) + ).all() + + return [KanbanCardAttachmentResponse.from_orm(att) for att in attachments] + + +@router.delete("/attachments/{attachment_id}") +def delete_card_attachment( + attachment_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Delete an attachment (by uploader or admin)""" + attachment = session.get(KanbanCardAttachment, attachment_id) + if not attachment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Attachment not found" + ) + + # Only the uploader or admin can delete + if attachment.uploader_id != current_user.id and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Can only delete your own attachments" + ) + + # Delete file from disk + try: + file_path = Path(__file__).parent.parent.parent / attachment.file_path + if file_path.exists(): + file_path.unlink() + except Exception as e: + # Log error but don't fail the operation + print(f"Failed to delete file {attachment.file_path}: {e}") + + session.delete(attachment) + session.commit() + + return {"message": "Attachment deleted successfully"} + + +@router.get("/attachments/{attachment_id}/download") +def download_attachment( + attachment_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Download a file attachment""" + from fastapi.responses import FileResponse + + attachment = session.get(KanbanCardAttachment, attachment_id) + if not attachment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Attachment not found" + ) + + # Check access via card -> board -> channel -> department + card = session.get(KanbanCard, attachment.card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Card not found" + ) + + board = session.get(KanbanBoard, card.column.board_id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found" + ) + + channel = session.get(Channel, board.channel_id) + if not channel: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Channel not found" + ) + + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # Get file path + file_path = Path(__file__).parent.parent.parent / attachment.file_path + if not file_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="File not found on disk" + ) + + return FileResponse( + path=file_path, + filename=attachment.original_filename, + media_type=attachment.mime_type + ) + + +# Time tracking endpoints +@router.post("/cards/{card_id}/time/start", response_model=KanbanTimeEntryResponse, status_code=status.HTTP_201_CREATED) +def start_time_tracking( + card_id: int, + time_data: KanbanTimeEntryCreate, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Start time tracking for a kanban card""" + # Check if card exists and user has access + card = session.get(KanbanCard, card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Card not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, card.column.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # Check if user already has a running timer for this card + running_entry = session.exec( + select(KanbanTimeEntry) + .where( + KanbanTimeEntry.card_id == card_id, + KanbanTimeEntry.user_id == current_user.id, + KanbanTimeEntry.is_running == True + ) + ).first() + + if running_entry: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Time tracking already running for this card" + ) + + new_entry = KanbanTimeEntry( + card_id=card_id, + user_id=current_user.id, + description=time_data.description, + start_time=datetime.utcnow(), + is_running=True + ) + + session.add(new_entry) + session.commit() + session.refresh(new_entry) + + return KanbanTimeEntryResponse.from_orm(new_entry) + + +@router.put("/time/{entry_id}/stop", response_model=KanbanTimeEntryResponse) +def stop_time_tracking( + entry_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Stop time tracking for a kanban card""" + entry = session.get(KanbanTimeEntry, entry_id) + if not entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Time entry not found" + ) + + # Only the user who started tracking can stop it + if entry.user_id != current_user.id and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Can only stop your own time tracking" + ) + + if not entry.is_running: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Time tracking is not running" + ) + + end_time = datetime.utcnow() + duration = int((end_time - entry.start_time).total_seconds() / 60) # Duration in minutes + + entry.end_time = end_time + entry.duration_minutes = duration + entry.is_running = False + + session.add(entry) + session.commit() + session.refresh(entry) + + return KanbanTimeEntryResponse.from_orm(entry) + + +@router.get("/cards/{card_id}/time", response_model=List[KanbanTimeEntryResponse]) +def get_card_time_entries( + card_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Get all time entries for a kanban card""" + # Check if card exists and user has access + card = session.get(KanbanCard, card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Card not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, card.column.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + entries = session.exec( + select(KanbanTimeEntry) + .where(KanbanTimeEntry.card_id == card_id) + .order_by(KanbanTimeEntry.start_time.desc()) + ).all() + + return [KanbanTimeEntryResponse.from_orm(entry) for entry in entries] + + +# Custom field endpoints +@router.post("/boards/{board_id}/custom-fields", response_model=KanbanCustomFieldResponse, status_code=status.HTTP_201_CREATED) +def create_custom_field( + board_id: int, + field_data: KanbanCustomFieldCreate, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Create a custom field for a kanban board""" + # Check if board exists and user has access + board = session.get(KanbanBoard, board_id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found" + ) + + # Check access via channel -> department + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + new_field = KanbanCustomField( + board_id=board_id, + name=field_data.name, + field_type=field_data.field_type, + options=field_data.options, + is_required=field_data.is_required, + position=field_data.position + ) + + session.add(new_field) + session.commit() + session.refresh(new_field) + + return KanbanCustomFieldResponse.from_orm(new_field) + + +@router.get("/boards/{board_id}/custom-fields", response_model=List[KanbanCustomFieldResponse]) +def get_board_custom_fields( + board_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Get all custom fields for a kanban board""" + # Check if board exists and user has access + board = session.get(KanbanBoard, board_id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found" + ) + + # Check access via channel -> department + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + fields = session.exec( + select(KanbanCustomField) + .where(KanbanCustomField.board_id == board_id) + .order_by(KanbanCustomField.position) + ).all() + + return [KanbanCustomFieldResponse.from_orm(field) for field in fields] + + +@router.put("/custom-fields/{field_id}", response_model=KanbanCustomFieldResponse) +def update_custom_field( + field_id: int, + field_data: KanbanCustomFieldUpdate, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Update a custom field""" + field = session.get(KanbanCustomField, field_id) + if not field: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Custom field not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, field.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # Update fields + for attr, value in field_data.dict(exclude_unset=True).items(): + if value is not None: + setattr(field, attr, value) + + session.add(field) + session.commit() + session.refresh(field) + + return KanbanCustomFieldResponse.from_orm(field) + + +@router.delete("/custom-fields/{field_id}") +def delete_custom_field( + field_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Delete a custom field""" + field = session.get(KanbanCustomField, field_id) + if not field: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Custom field not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, field.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + session.delete(field) + session.commit() + + return {"message": "Custom field deleted successfully"} + + +# Custom field value endpoints +@router.put("/cards/{card_id}/custom-fields/{field_id}", response_model=KanbanCustomFieldValueResponse) +def set_custom_field_value( + card_id: int, + field_id: int, + value_data: KanbanCustomFieldValueCreate, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Set or update a custom field value for a card""" + # Check if card and field exist + card = session.get(KanbanCard, card_id) + field = session.get(KanbanCustomField, field_id) + + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Card not found" + ) + if not field: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Custom field not found" + ) + + # Check if field belongs to the card's board + board = session.get(KanbanBoard, card.column.board_id) + if field.board_id != board.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Field does not belong to this board" + ) + + # Check access via channel -> department + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # Find existing value or create new one + existing_value = session.exec( + select(KanbanCustomFieldValue) + .where( + KanbanCustomFieldValue.field_id == field_id, + KanbanCustomFieldValue.card_id == card_id + ) + ).first() + + if existing_value: + existing_value.value = value_data.value + existing_value.updated_at = datetime.utcnow() + session.add(existing_value) + session.commit() + session.refresh(existing_value) + return KanbanCustomFieldValueResponse.from_orm(existing_value) + else: + new_value = KanbanCustomFieldValue( + field_id=field_id, + card_id=card_id, + value=value_data.value + ) + session.add(new_value) + session.commit() + session.refresh(new_value) + return KanbanCustomFieldValueResponse.from_orm(new_value) + + +@router.get("/cards/{card_id}/custom-fields", response_model=List[KanbanCustomFieldValueResponse]) +def get_card_custom_field_values( + card_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Get all custom field values for a card""" + # Check if card exists and user has access + card = session.get(KanbanCard, card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Card not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, card.column.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + values = session.exec( + select(KanbanCustomFieldValue) + .where(KanbanCustomFieldValue.card_id == card_id) + ).all() + + return [KanbanCustomFieldValueResponse.from_orm(value) for value in values] + + +# Template endpoints +@router.post("/boards/{board_id}/templates", response_model=KanbanCardTemplateResponse, status_code=status.HTTP_201_CREATED) +def create_card_template( + board_id: int, + template_data: KanbanCardTemplateCreate, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Create a card template for a kanban board""" + # Check if board exists and user has access + board = session.get(KanbanBoard, board_id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found" + ) + + # Check access via channel -> department + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + new_template = KanbanCardTemplate( + board_id=board_id, + name=template_data.name, + description=template_data.description, + template_data=template_data.template_data, + is_default=template_data.is_default + ) + + session.add(new_template) + session.commit() + session.refresh(new_template) + + return KanbanCardTemplateResponse.from_orm(new_template) + + +@router.get("/boards/{board_id}/templates", response_model=List[KanbanCardTemplateResponse]) +def get_board_templates( + board_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Get all templates for a kanban board""" + # Check if board exists and user has access + board = session.get(KanbanBoard, board_id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found" + ) + + # Check access via channel -> department + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + templates = session.exec( + select(KanbanCardTemplate) + .where(KanbanCardTemplate.board_id == board_id) + .order_by(KanbanCardTemplate.name) + ).all() + + return [KanbanCardTemplateResponse.from_orm(template) for template in templates] + + +@router.post("/cards/from-template/{template_id}", response_model=KanbanCardResponse, status_code=status.HTTP_201_CREATED) +def create_card_from_template( + template_id: int, + column_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Create a card from a template""" + # Check if template and column exist + template = session.get(KanbanCardTemplate, template_id) + column = session.get(KanbanColumn, column_id) + + if not template: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Template not found" + ) + if not column: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Column not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, template.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # Parse template data (assuming it's JSON) + import json + try: + template_dict = json.loads(template.template_data) + except json.JSONDecodeError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid template data" + ) + + # Create card from template + new_card = KanbanCard( + column_id=column_id, + title=template_dict.get('title', template.name), + description=template_dict.get('description', ''), + priority=template_dict.get('priority', 'medium'), + labels=template_dict.get('labels', None), + estimated_time=template_dict.get('estimated_time'), + assignee_id=template_dict.get('assignee_id') + ) + + session.add(new_card) + session.commit() + session.refresh(new_card) + + return KanbanCardResponse.from_orm(new_card) + + +# Bulk operations endpoints +@router.post("/cards/bulk/move") +def bulk_move_cards( + move_data: dict, # {"card_ids": [1,2,3], "column_id": 5, "position": 0} + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Move multiple cards to a new column/position""" + card_ids = move_data.get('card_ids', []) + column_id = move_data.get('column_id') + position = move_data.get('position', 0) + + if not card_ids or not column_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="card_ids and column_id are required" + ) + + # Check if target column exists + column = session.get(KanbanColumn, column_id) + if not column: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Target column not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, column.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # Get and update cards + cards = session.exec( + select(KanbanCard).where(KanbanCard.id.in_(card_ids)) + ).all() + + updated_cards = [] + for i, card in enumerate(cards): + card.column_id = column_id + card.position = position + i + updated_cards.append(card) + + session.add_all(updated_cards) + session.commit() + + return {"message": f"Moved {len(updated_cards)} cards successfully"} + + +@router.delete("/cards/bulk") +def bulk_delete_cards( + delete_data: dict, # {"card_ids": [1,2,3]} + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Delete multiple cards""" + card_ids = delete_data.get('card_ids', []) + + if not card_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="card_ids are required" + ) + + # Get cards and check access + cards = session.exec( + select(KanbanCard).where(KanbanCard.id.in_(card_ids)) + ).all() + + if not cards: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No cards found" + ) + + # Check access for all cards (they should all be from the same board) + board_id = cards[0].column.board_id + board = session.get(KanbanBoard, board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # Delete cards (cascading will handle related data) + for card in cards: + session.delete(card) + + session.commit() + + return {"message": f"Deleted {len(cards)} cards successfully"} + + +# Search and filter endpoints +@router.get("/boards/{board_id}/search") +def search_cards( + board_id: int, + q: str = "", # Search query + assignee_id: Optional[int] = None, + priority: Optional[str] = None, + labels: Optional[str] = None, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Search and filter cards in a board""" + # Check if board exists and user has access + board = session.get(KanbanBoard, board_id) + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Board not found" + ) + + # Check access via channel -> department + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # Build query + query = select(KanbanCard).where(KanbanCard.column.has(KanbanColumn.board_id == board_id)) + + # Apply filters + if q: + query = query.where( + (KanbanCard.title.contains(q)) | + (KanbanCard.description.contains(q)) + ) + + if assignee_id: + query = query.where(KanbanCard.assignee_id == assignee_id) + + if priority: + query = query.where(KanbanCard.priority == priority) + + if labels: + # Search for cards that contain any of the specified labels + label_conditions = [] + for label in labels.split(','): + label_conditions.append(KanbanCard.labels.contains(label.strip())) + if label_conditions: + query = query.where(or_(*label_conditions)) + + cards = session.exec(query).all() + + return [KanbanCardResponse.from_orm(card) for card in cards] + + +# Extended card endpoint with all features +@router.get("/cards/{card_id}/extended", response_model=KanbanCardExtendedResponse) +def get_card_extended( + card_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Get a card with all extended features (comments, attachments, time entries, custom fields)""" + # Check if card exists and user has access + card = session.get(KanbanCard, card_id) + if not card: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Card not found" + ) + + # Check access via board -> channel -> department + board = session.get(KanbanBoard, card.column.board_id) + channel = session.get(Channel, board.channel_id) + user_departments = [dept.id for dept in current_user.departments] + if channel.department_id not in user_departments and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # Build extended response + response = KanbanCardExtendedResponse.from_orm(card) + + # Load comments + comments = session.exec( + select(KanbanCardComment) + .where(KanbanCardComment.card_id == card_id) + .order_by(KanbanCardComment.created_at) + ).all() + response.comments = [KanbanCardCommentResponse.from_orm(comment) for comment in comments] + + # Load attachments + attachments = session.exec( + select(KanbanCardAttachment) + .where(KanbanCardAttachment.card_id == card_id) + .order_by(KanbanCardAttachment.uploaded_at) + ).all() + response.attachments = [KanbanCardAttachmentResponse.from_orm(att) for att in attachments] + + # Load time entries + time_entries = session.exec( + select(KanbanTimeEntry) + .where(KanbanTimeEntry.card_id == card_id) + .order_by(KanbanTimeEntry.start_time.desc()) + ).all() + response.time_entries = [KanbanTimeEntryResponse.from_orm(entry) for entry in time_entries] + + # Load custom field values + custom_values = session.exec( + select(KanbanCustomFieldValue) + .where(KanbanCustomFieldValue.card_id == card_id) + ).all() + response.custom_field_values = [KanbanCustomFieldValueResponse.from_orm(value) for value in custom_values] + + # Load checklists with items + checklists = session.exec( + select(KanbanChecklist) + .where(KanbanChecklist.card_id == card_id) + .order_by(KanbanChecklist.position) + ).all() + + checklist_data = [] + for checklist in checklists: + items = session.exec( + select(KanbanChecklistItem) + .where(KanbanChecklistItem.checklist_id == checklist.id) + .order_by(KanbanChecklistItem.position) + ).all() + + checklist_response = KanbanChecklistWithItems.from_orm(checklist) + checklist_response.items = [KanbanChecklistItemResponse.from_orm(item) for item in items] + checklist_data.append(checklist_response) + + response.checklists = checklist_data + + return response \ No newline at end of file diff --git a/backend/app/routers/last_seen.py b/backend/app/routers/last_seen.py new file mode 100644 index 0000000..2e834fa --- /dev/null +++ b/backend/app/routers/last_seen.py @@ -0,0 +1,103 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel import Session, select +from datetime import datetime +from typing import Optional + +from app.database import get_session +from app.auth import get_current_user +from app.models import User, Channel, LastSeen +from app.websocket import manager + +router = APIRouter(prefix="/me/last-seen", tags=["LastSeen"]) + + +@router.post("/") +async def set_last_seen( + channel_id: Optional[int] = None, + dm_user_id: Optional[int] = None, + last_seen: Optional[str] = None, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + if not channel_id and not dm_user_id: + raise HTTPException(status_code=400, detail="channel_id or dm_user_id required") + + # Validate target exists + if channel_id: + channel = session.get(Channel, channel_id) + if not channel: + raise HTTPException(status_code=404, detail="Channel not found") + # Basic access check: user must belong to the channel's department + user_dept_ids = [d.id for d in current_user.departments] if current_user.departments else [] + if channel.department_id not in user_dept_ids: + raise HTTPException(status_code=403, detail="No access to channel") + + if dm_user_id: + other = session.get(User, dm_user_id) + if not other: + raise HTTPException(status_code=404, detail="Target user not found") + + ts = datetime.utcnow() if not last_seen else datetime.fromisoformat(last_seen) + + # Upsert last seen + if channel_id: + statement = select(LastSeen).where(LastSeen.user_id == current_user.id, LastSeen.channel_id == channel_id) + else: + statement = select(LastSeen).where(LastSeen.user_id == current_user.id, LastSeen.dm_user_id == dm_user_id) + + exists = session.exec(statement).first() + if exists: + exists.last_seen = ts + session.add(exists) + session.commit() + session.refresh(exists) + result = exists + else: + payload = LastSeen(user_id=current_user.id, channel_id=channel_id, dm_user_id=dm_user_id, last_seen=ts) + session.add(payload) + session.commit() + session.refresh(payload) + result = payload + + # Broadcast read_marker to relevant channel or DM partner + try: + message = { + "type": "read_marker", + "user_id": current_user.id, + "last_seen": result.last_seen.isoformat(), + } + if channel_id: + message["channel_id"] = channel_id + await manager.broadcast_to_channel(message, channel_id) + elif dm_user_id: + message["dm_user_id"] = dm_user_id + # partner listens on channel id = -their_user_id + await manager.broadcast_to_channel(message, -dm_user_id) + # also broadcast to presence channel 0 + await manager.broadcast_to_channel({**message, "type": "read_marker"}, 0) + except Exception: + pass + + return {"id": result.id, "user_id": result.user_id, "channel_id": result.channel_id, "dm_user_id": result.dm_user_id, "last_seen": result.last_seen.isoformat()} + + +@router.get("/") +def get_last_seen( + channel_id: Optional[int] = None, + dm_user_id: Optional[int] = None, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +): + if not channel_id and not dm_user_id: + raise HTTPException(status_code=400, detail="channel_id or dm_user_id required") + + if channel_id: + stmt = select(LastSeen).where(LastSeen.user_id == current_user.id, LastSeen.channel_id == channel_id) + else: + stmt = select(LastSeen).where(LastSeen.user_id == current_user.id, LastSeen.dm_user_id == dm_user_id) + + found = session.exec(stmt).first() + if not found: + raise HTTPException(status_code=404, detail="LastSeen not found") + + return {"id": found.id, "user_id": found.user_id, "channel_id": found.channel_id, "dm_user_id": found.dm_user_id, "last_seen": found.last_seen.isoformat()} diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 680a8c1..a897a67 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -148,6 +148,28 @@ class DirectMessageResponse(DirectMessageBase): sender_profile_picture: Optional[str] = None snippet: Optional["SnippetResponse"] = None reply_to: Optional[dict] = None # Contains replied message info + attachments: List["DirectMessageAttachmentResponse"] = [] + + class Config: + from_attributes = True + + +# Direct Message Attachment Schemas +class DirectMessageAttachmentCreate(BaseModel): + permission: str = "read" # "read" or "write" + + +class DirectMessageAttachmentResponse(BaseModel): + id: int + filename: str + original_filename: str + mime_type: str + file_size: int + uploaded_at: datetime + direct_message_id: int + upload_permission: Optional[str] = "read" + uploader_id: Optional[int] = None + is_editable: bool = False class Config: from_attributes = True @@ -303,6 +325,8 @@ class KanbanCardBase(BaseModel): due_date: Optional[datetime] = None priority: Optional[str] = "medium" labels: Optional[str] = None + estimated_time: Optional[int] = None + actual_time: Optional[int] = None class KanbanCardCreate(KanbanCardBase): @@ -317,6 +341,9 @@ class KanbanCardUpdate(BaseModel): due_date: Optional[datetime] = None priority: Optional[str] = None labels: Optional[str] = None + estimated_time: Optional[int] = None + actual_time: Optional[int] = None + is_archived: Optional[bool] = None class KanbanCardResponse(KanbanCardBase): @@ -324,6 +351,11 @@ class KanbanCardResponse(KanbanCardBase): column_id: int created_at: datetime updated_at: datetime + attachments_count: int = 0 + checklists_count: int = 0 + comments_count: int = 0 + is_archived: bool = False + assignee: Optional["UserResponse"] = None class Config: from_attributes = True @@ -395,3 +427,181 @@ class KanbanChecklistWithItems(KanbanChecklistResponse): class KanbanCardWithChecklists(KanbanCardResponse): checklists: List[KanbanChecklistWithItems] = [] + +# Comment Schemas +class KanbanCardCommentBase(BaseModel): + content: str + + +class KanbanCardCommentCreate(BaseModel): + content: str + + +class KanbanCardCommentUpdate(BaseModel): + content: Optional[str] = None + + +class KanbanCardCommentResponse(KanbanCardCommentBase): + id: int + card_id: int + user_id: int + created_at: datetime + updated_at: datetime + user: Optional[UserResponse] = None + + class Config: + from_attributes = True + + +# Attachment Schemas +class KanbanCardAttachmentBase(BaseModel): + filename: str + original_filename: str + mime_type: str + file_size: int + file_path: str + + +class KanbanCardAttachmentCreate(BaseModel): + card_id: int + file: bytes # This will be handled by FastAPI's UploadFile + + +class KanbanCardAttachmentResponse(KanbanCardAttachmentBase): + id: int + card_id: int + uploader_id: int + uploaded_at: datetime + uploader: Optional[UserResponse] = None + + class Config: + from_attributes = True + + +# Time Tracking Schemas +class KanbanTimeEntryBase(BaseModel): + description: Optional[str] = None + start_time: datetime + end_time: Optional[datetime] = None + duration_minutes: Optional[int] = None + + +class KanbanTimeEntryCreate(BaseModel): + card_id: int + description: Optional[str] = None + + +class KanbanTimeEntryUpdate(BaseModel): + description: Optional[str] = None + end_time: Optional[datetime] = None + + +class KanbanTimeEntryResponse(KanbanTimeEntryBase): + id: int + card_id: int + user_id: int + is_running: bool + created_at: datetime + user: Optional[UserResponse] = None + + class Config: + from_attributes = True + + +# Custom Field Schemas +class KanbanCustomFieldBase(BaseModel): + name: str + field_type: str + options: Optional[str] = None + is_required: bool = False + position: int = 0 + + +class KanbanCustomFieldCreate(KanbanCustomFieldBase): + board_id: int + + +class KanbanCustomFieldUpdate(BaseModel): + name: Optional[str] = None + field_type: Optional[str] = None + options: Optional[str] = None + is_required: Optional[bool] = None + position: Optional[int] = None + + +class KanbanCustomFieldResponse(KanbanCustomFieldBase): + id: int + board_id: int + created_at: datetime + + class Config: + from_attributes = True + + +class KanbanCustomFieldValueBase(BaseModel): + value: str + + +class KanbanCustomFieldValueCreate(KanbanCustomFieldValueBase): + field_id: int + card_id: int + + +class KanbanCustomFieldValueUpdate(BaseModel): + value: Optional[str] = None + + +class KanbanCustomFieldValueResponse(KanbanCustomFieldValueBase): + id: int + field_id: int + card_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Template Schemas +class KanbanCardTemplateBase(BaseModel): + name: str + description: Optional[str] = None + template_data: str + is_default: bool = False + + +class KanbanCardTemplateCreate(KanbanCardTemplateBase): + board_id: int + + +class KanbanCardTemplateUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + template_data: Optional[str] = None + is_default: Optional[bool] = None + + +class KanbanCardTemplateResponse(KanbanCardTemplateBase): + id: int + board_id: int + created_at: datetime + + class Config: + from_attributes = True + + +# Extended Card Response with all features +class KanbanCardExtendedResponse(KanbanCardResponse): + estimated_time: Optional[int] = None + actual_time: Optional[int] = None + comments: List[KanbanCardCommentResponse] = [] + attachments: List[KanbanCardAttachmentResponse] = [] + time_entries: List[KanbanTimeEntryResponse] = [] + custom_field_values: List[KanbanCustomFieldValueResponse] = [] + checklists: List[KanbanChecklistWithItems] = [] + + +class KanbanBoardExtendedResponse(KanbanBoardResponse): + custom_fields: List[KanbanCustomFieldResponse] = [] + templates: List[KanbanCardTemplateResponse] = [] + diff --git a/frontend/package.json b/frontend/package.json index 2d5fe6c..5e83418 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@mui/icons-material": "^7.3.6", "@mui/material": "^7.3.6", "axios": "^1.6.2", + "mdi-react": "^9.4.0", "prism-react-renderer": "^1.3.5", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/src/components/Admin/AdminPanel.tsx b/frontend/src/components/Admin/AdminPanel.tsx index d4f42ad..230d6ae 100644 --- a/frontend/src/components/Admin/AdminPanel.tsx +++ b/frontend/src/components/Admin/AdminPanel.tsx @@ -68,6 +68,11 @@ const AdminPanel: React.FC = () => { const [newChannelDesc, setNewChannelDesc] = useState(''); const [channelDeptId, setChannelDeptId] = useState(null); + const [editingChannel, setEditingChannel] = useState(null); + const [editChannelName, setEditChannelName] = useState(''); + const [editChannelDesc, setEditChannelDesc] = useState(''); + const [editChannelDeptId, setEditChannelDeptId] = useState(null); + const [selectedSnippetId, setSelectedSnippetId] = useState(null); const [snippetAccess, setSnippetAccess] = useState([]); @@ -470,6 +475,45 @@ const AdminPanel: React.FC = () => { [setGlobalError] ); + const startEditChannel = useCallback((channel: Channel) => { + setEditingChannel(channel); + setEditChannelName(channel.name); + setEditChannelDesc(channel.description || ''); + setEditChannelDeptId(channel.department_id); + }, []); + + const cancelEditChannel = useCallback(() => { + setEditingChannel(null); + setEditChannelName(''); + setEditChannelDesc(''); + setEditChannelDeptId(null); + }, []); + + const updateChannel = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + if (!editingChannel || !editChannelName.trim() || !editChannelDeptId) { + setGlobalError('Bitte geben Sie einen Namen an und wählen Sie eine Abteilung.'); + return; + } + setError(null); + try { + const response = await api.put(`/admin/channels/${editingChannel.id}`, { + name: editChannelName.trim(), + description: editChannelDesc.trim() || undefined, + department_id: editChannelDeptId, + }); + setChannels((prev) => + prev.map((channel) => (channel.id === editingChannel.id ? response.data : channel)) + ); + cancelEditChannel(); + } catch (err) { + setGlobalError('Channel konnte nicht aktualisiert werden.'); + } + }, + [editingChannel, editChannelName, editChannelDesc, editChannelDeptId, setGlobalError, cancelEditChannel] + ); + const toggleSnippetAccess = useCallback( async (snippetId: number, departmentId: number, enabled: boolean) => { setError(null); @@ -1044,7 +1088,72 @@ const AdminPanel: React.FC = () => {
{channels.map((channel) => { const dept = departments.find((item) => item.id === channel.department_id); - return ( + const isEditing = editingChannel?.id === channel.id; + return isEditing ? ( +
+

+ Channel bearbeiten +

+
+
+ + setEditChannelName(event.target.value)} + required + className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + /> +
+
+ +