Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,18 @@ _Avoid_: Appointment, event, session
A Project's one fixed weekly meeting: a weekday, a start and end time in Belgian time, and a place. Single dates can be skipped (holidays); clearing it stops it (e.g. during exams). Its meetings of the coming seven days show alongside the Deadlines and Meetings; an Archived Project has none.
_Avoid_: Recurring meeting, standup, timetable

**Chat**:
A Project's one group chat (spec #91): short messages of its Chat people, shown oldest first, with new ones appearing within seconds. Every Teacher and the Superuser can also read and write in every Chat without being one of its Chat people. An Archived Project's Chat is read-only.
_Avoid_: Forum, channel, conversation, group

**Chat people**:
Whom a Project's Chat is for: its Members and its Product Owner. Only they count for the Unread badge; a Member who is removed or moved loses access to the Chat at once, and a new Member sees its whole history.
_Avoid_: Participants, chat members

**Unread**:
The messages of others in a Chat after the last one a User has seen; opening the Chat reads them. The Chat item in the navigation shows how many there are in the User's own Chats (for a Teacher: those where they are one of the Chat people).
_Avoid_: New messages, notifications

**Makers**:
The permanent record of the Students (name, Programme, Year) who were Members of a Project when it was archived. Nobody is ever removed from it: archiving a restored Project again only adds the new Members (a Student already listed is not added twice). Shown as "Made by".
_Avoid_: History, former members, credits
Expand Down
59 changes: 59 additions & 0 deletions backend/migrations/versions/0014_chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""chat

Revision ID: 0014
Revises: 0013
Create Date: 2026-10-22 14:20:00

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "0014"
down_revision: str | Sequence[str] | None = "0013"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
# The Project Chat (spec #91): its messages, and how far each User read it.
op.create_table(
"chat_messages",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("project_id", sa.Integer(), nullable=False),
sa.Column("author_id", sa.Integer(), nullable=True),
sa.Column("body", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("char_length(body) <= 4000", name="ck_chat_messages_length"),
sa.ForeignKeyConstraint(["author_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_chat_messages_project_id"), "chat_messages", ["project_id"]
)
op.create_table(
"chat_reads",
sa.Column("project_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("last_read_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("project_id", "user_id"),
)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_table("chat_reads")
op.drop_index(op.f("ix_chat_messages_project_id"), table_name="chat_messages")
op.drop_table("chat_messages")
124 changes: 124 additions & 0 deletions backend/src/pmp_backend/api/chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from datetime import datetime
from typing import Annotated

from fastapi import APIRouter, Response, status
from pydantic import BaseModel, StringConstraints

from pmp_backend.api.deps import CurrentUserDep, SessionDep
from pmp_backend.api.schemas import PersonOut
from pmp_backend.domain import ProjectStatus
from pmp_backend.models import ChatMessage, User
from pmp_backend.services import chat

router = APIRouter(prefix="/api", tags=["chat"])


class ChatOut(BaseModel):
project_id: int
title: str
# Archived Projects' Chats are read-only.
read_only: bool


class MessageIn(BaseModel):
body: Annotated[
str,
StringConstraints(
strip_whitespace=True, min_length=1, max_length=chat.MAX_LENGTH
),
]


class MessageOut(BaseModel):
id: int
# None: a Deleted User.
author: PersonOut | None
body: str
created_at: datetime
mine: bool

@classmethod
def of(cls, message: ChatMessage, user: User) -> "MessageOut":
author = message.author
return cls(
id=message.id,
author=PersonOut.of(author) if author else None,
body=message.body,
created_at=message.created_at,
mine=message.author_id == user.id,
)


class ReadIn(BaseModel):
message_id: int


class ChatSummaryOut(BaseModel):
project_id: int
title: str
unread: int
mine: bool


class UnreadOut(BaseModel):
count: int
# Whether the User has any Chat (Students without a Project have none).
has_chat: bool


@router.get("/projects/{project_id}/chat")
def get_chat(project_id: int, session: SessionDep, user: CurrentUserDep) -> ChatOut:
project = chat.readable_project(session, user, project_id)
return ChatOut(
project_id=project.id,
title=project.title,
read_only=project.status == ProjectStatus.ARCHIVED.value,
)


@router.get("/projects/{project_id}/chat/messages")
def list_messages(
project_id: int,
session: SessionDep,
user: CurrentUserDep,
after: int | None = None,
) -> list[MessageOut]:
"""Oldest first: every message after `after`, or the newest ones."""
found = chat.messages(session, user, project_id, after)
return [MessageOut.of(message, user) for message in found]


@router.post(
"/projects/{project_id}/chat/messages", status_code=status.HTTP_201_CREATED
)
def send_message(
project_id: int, body: MessageIn, session: SessionDep, user: CurrentUserDep
) -> MessageOut:
return MessageOut.of(chat.send(session, user, project_id, body.body), user)


@router.post("/projects/{project_id}/chat/read", status_code=status.HTTP_204_NO_CONTENT)
def mark_read(
project_id: int, body: ReadIn, session: SessionDep, user: CurrentUserDep
) -> Response:
chat.mark_read(session, user, project_id, body.message_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)


@router.get("/chats")
def list_chats(session: SessionDep, user: CurrentUserDep) -> list[ChatSummaryOut]:
return [
ChatSummaryOut(
project_id=summary.project_id,
title=summary.title,
unread=summary.unread,
mine=summary.mine,
)
for summary in chat.chats(session, user)
]


@router.get("/chats/unread")
def unread(session: SessionDep, user: CurrentUserDep) -> UnreadOut:
count, has_chat = chat.unread_total(session, user)
return UnreadOut(count=count, has_chat=has_chat)
2 changes: 2 additions & 0 deletions backend/src/pmp_backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from pmp_backend.api import (
auth,
chat,
dev_login,
github,
planning,
Expand Down Expand Up @@ -69,6 +70,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.include_router(github.router)
app.include_router(trello.router)
app.include_router(planning.router)
app.include_router(chat.router)
if settings.dev_login:
app.include_router(dev_login.router)

Expand Down
39 changes: 39 additions & 0 deletions backend/src/pmp_backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,3 +313,42 @@ class DailyEmailSent(Base):
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
day: Mapped[date] = mapped_column(Date, primary_key=True)


class ChatMessage(Base):
"""A message in a Project's Chat (spec #91). A Deleted User leaves
`author_id` null: shown as "Deleted user"."""

__tablename__ = "chat_messages"
__table_args__ = (
CheckConstraint("char_length(body) <= 4000", name="ck_chat_messages_length"),
)

id: Mapped[int] = mapped_column(primary_key=True)
project_id: Mapped[int] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
author_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL")
)
body: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)

author: Mapped[User | None] = relationship(lazy="joined")


class ChatRead(Base):
"""How far a User has read a Project's Chat: the last message they saw.
Messages after it are Unread for them."""

__tablename__ = "chat_reads"

project_id: Mapped[int] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True
)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
last_read_id: Mapped[int] = mapped_column(default=0)
Loading
Loading