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
6 changes: 5 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,13 @@ A Project's one group chat (spec #91): short messages of its Chat people, shown
_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.
Whom a Project's Chat is for: its Members, its Product Owner and its Guests. 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

**Guest**:
Any active User added to a Project's Chat on top of its team, by the Product Owner, a Teacher or the Superuser (never by Members). A Guest reads the whole history and writes like the team, can leave, and can be removed by the same staff; archiving the Project ends their access.
_Avoid_: Visitor, external member, invitee

**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
Expand Down
42 changes: 42 additions & 0 deletions backend/migrations/versions/0015_chat_guests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""chat guests

Revision ID: 0015
Revises: 0014
Create Date: 2026-10-22 15:25:00

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

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


def upgrade() -> None:
"""Upgrade schema."""
# Guests in a Project's Chat (spec #91), on top of its Chat people.
op.create_table(
"chat_guests",
sa.Column("project_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column(
"added_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
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_guests")
75 changes: 72 additions & 3 deletions backend/src/pmp_backend/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ class ChatOut(BaseModel):
title: str
# Archived Projects' Chats are read-only.
read_only: bool
# Teachers and the Superuser add and remove Guests.
can_manage_guests: bool
# The User is a Guest here, so they may leave.
is_guest: bool


class ChatPersonOut(PersonOut):
# "Member", "Product Owner" or "Guest".
kind: str


class GuestIn(BaseModel):
user_id: int


class MessageIn(BaseModel):
Expand Down Expand Up @@ -69,13 +82,57 @@ class UnreadOut(BaseModel):
@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)
read_only = project.status == ProjectStatus.ARCHIVED.value
return ChatOut(
project_id=project.id,
title=project.title,
read_only=project.status == ProjectStatus.ARCHIVED.value,
read_only=read_only,
can_manage_guests=chat.is_staff(user) and not read_only,
is_guest=chat.is_guest(session, user, project) and not read_only,
)


@router.get("/projects/{project_id}/chat/people")
def chat_people(
project_id: int, session: SessionDep, user: CurrentUserDep
) -> list[ChatPersonOut]:
"""The Product Owner, the Members, then the Guests."""
return [
ChatPersonOut(**PersonOut.of(person.user).model_dump(), kind=person.kind)
for person in chat.people(session, user, project_id)
]


@router.get("/projects/{project_id}/chat/guests/candidates")
def guest_candidates(
project_id: int, session: SessionDep, user: CurrentUserDep
) -> list[PersonOut]:
"""Active Users who could join as a Guest (Teachers and the Superuser)."""
return [
PersonOut.of(candidate)
for candidate in chat.guest_candidates(session, user, project_id)
]


@router.post("/projects/{project_id}/chat/guests", status_code=status.HTTP_201_CREATED)
def add_guest(
project_id: int, body: GuestIn, session: SessionDep, user: CurrentUserDep
) -> PersonOut:
return PersonOut.of(chat.add_guest(session, user, project_id, body.user_id))


@router.delete(
"/projects/{project_id}/chat/guests/{user_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def remove_guest(
project_id: int, user_id: int, session: SessionDep, user: CurrentUserDep
) -> Response:
"""Teachers and the Superuser remove a Guest; a Guest leaves with their own id."""
chat.remove_guest(session, user, project_id, user_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)


@router.get("/projects/{project_id}/chat/messages")
def list_messages(
project_id: int,
Expand Down Expand Up @@ -105,16 +162,28 @@ def mark_read(
return Response(status_code=status.HTTP_204_NO_CONTENT)


@router.post(
"/projects/{project_id}/chat/leave", status_code=status.HTTP_204_NO_CONTENT
)
def leave_chat(project_id: int, session: SessionDep, user: CurrentUserDep) -> Response:
"""A Guest leaves the Chat."""
chat.remove_guest(session, user, project_id, user.id)
return Response(status_code=status.HTTP_204_NO_CONTENT)


@router.get("/chats")
def list_chats(session: SessionDep, user: CurrentUserDep) -> list[ChatSummaryOut]:
def list_chats(
session: SessionDep, user: CurrentUserDep, mine: bool = False
) -> list[ChatSummaryOut]:
"""With `mine`: only the Chats where the User is one of the Chat people."""
return [
ChatSummaryOut(
project_id=summary.project_id,
title=summary.title,
unread=summary.unread,
mine=summary.mine,
)
for summary in chat.chats(session, user)
for summary in chat.chats(session, user, mine=mine)
]


Expand Down
19 changes: 19 additions & 0 deletions backend/src/pmp_backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,3 +352,22 @@ class ChatRead(Base):
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
last_read_id: Mapped[int] = mapped_column(default=0)


class ChatGuest(Base):
"""A Guest in a Project's Chat: any User added by the Product Owner, a
Teacher or the Superuser, on top of the Members and the Product Owner."""

__tablename__ = "chat_guests"

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
)
added_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)

user: Mapped[User] = relationship(lazy="joined")
Loading
Loading