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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ All notable changes to Sheaf are documented here. The format is based on [Keep a

## [Unreleased]

### Added

- **Groups and custom fields can now be put in the order you want.** Up/down arrows on the Groups page move a group among the groups next to it (its siblings, when nested), and the same arrows in Settings > Custom fields reorder your fields; dragging a group still moves it into or out of another group, so the two gestures stay distinct. The order you set is respected everywhere the app lists your groups - groups you have not rearranged stay alphabetical - and it travels with your backups, so a restore puts everything back in the order you left it. Public profiles and share links follow it too: groups and custom fields appear to visitors in the order you arranged them, the same as inside the app.

### Fixed

- **Dragging a group onto another to nest it now works in Firefox.** The drag never started there: Firefox requires drag data to be set when a drag begins, and the groups page never set any. Chrome-family browsers tolerated the omission, which is why the same gesture worked in one browser and was simply inert in the other.
- **Clearing a member's custom field now works from the web app.** Emptying a field and saving silently kept the old value: the web skipped empty fields from the save request entirely, and the server only touches the entries it is given, so nothing ever asked it to clear. The same skip also meant a ticked yes/no field could never be unticked, and a multiselect could never be emptied back to nothing. An emptied field that has a stored value is now sent as an explicit clear. (This is the web sibling of the Android clear-a-field bug; each client tripped over the same endpoint in its own way.)
- **Clearing a member's custom field no longer fails with some client libraries.** Clearing a field is expressed as `value: null` on `PUT /v1/members/{id}/fields` - but several client serialisers omit null fields entirely rather than writing them (the Android app's JSON library does this, which is how clearing a field from Android broke), and the server rejected an entry with no `value` at all. An omitted `value` is now accepted and clears the field exactly like an explicit null. Absence has no other meaning on this endpoint, so nothing changes for any client that already sends the null.
- **Front-change notifications with hidden co-fronters no longer say "and" twice.** A switch like five members starting with two of them hidden from the channel rendered "A, B, and C, and 2 others started fronting" - the visible names were joined into a finished list and the "N others" tail then bolted on with its own "and". The tail now joins the same list as the names, so the "and" lands exactly once, before the true final item: "A, B, C, and 2 others started fronting."
Expand Down
40 changes: 40 additions & 0 deletions alembic/versions/e4f5a6b7c8d9_add_group_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Add groups.order

Revision ID: e4f5a6b7c8d9
Revises: d3e4f5a6b7c8
Create Date: 2026-09-06

Groups had no user-controlled ordering: the list endpoint sorted by name and
that was the only order there was. This adds the same integer sort column
custom_field_definitions already carries, so the owner can arrange groups
themselves; the API sorts by (order, name), so rows still at the default keep
their alphabetical placing.

ADD COLUMN with a constant server_default is metadata-only on modern Postgres
(existing rows read the default without a table rewrite). It still briefly
takes ACCESS EXCLUSIVE, so fail fast rather than queue behind a long-running
session.
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "e4f5a6b7c8d9"
down_revision: Union[str, None] = "d3e4f5a6b7c8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.execute("SET lock_timeout = '3s'")
op.add_column(
"groups",
sa.Column("order", sa.Integer(), nullable=False, server_default="0"),
)


def downgrade() -> None:
op.execute("SET lock_timeout = '3s'")
op.drop_column("groups", "order")
68 changes: 62 additions & 6 deletions sheaf/api/v1/custom_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
MAX_CUSTOM_FIELD_VALUE_CHARS,
CustomFieldCreate,
CustomFieldRead,
CustomFieldReorder,
CustomFieldUpdate,
CustomFieldValueRead,
CustomFieldValueSet,
Expand Down Expand Up @@ -69,12 +70,12 @@ async def _get_user_system(user: User, db: AsyncSession) -> System:

# --- Field definitions ---

@router.get("/fields", response_model=list[CustomFieldRead])
async def list_fields(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
system = await _get_user_system(user, db)
async def _list_fields_read(
system: System, db: AsyncSession
) -> list[CustomFieldRead]:
"""The full field list as the list endpoint serves it, order-sorted with
pending-delete timestamps attached. Shared with the reorder endpoint so
its response is exactly what the next GET would return."""
result = await db.execute(
select(CustomFieldDefinition)
.where(CustomFieldDefinition.system_id == system.id)
Expand All @@ -92,6 +93,15 @@ async def list_fields(
return out


@router.get("/fields", response_model=list[CustomFieldRead])
async def list_fields(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
system = await _get_user_system(user, db)
return await _list_fields_read(system, db)


@router.post(
"/fields",
response_model=CustomFieldRead,
Expand Down Expand Up @@ -143,6 +153,52 @@ async def create_field(
return field


# Declared before the /fields/{field_id} routes so "reorder" is matched as
# this endpoint rather than parsed (and 422ed) as a field uuid.
@router.put(
"/fields/reorder",
response_model=list[CustomFieldRead],
dependencies=[Depends(require_scope("fields:write"))],
)
async def reorder_fields(
body: CustomFieldReorder,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Set the sort order of the named fields to their position in the list.

Fields not named keep the order they had, so a client can send just the
slice it re-arranged - though sending the full list is the reliable way
to get exactly the order on screen. One transaction: either every named
field moves or none do.
"""
system = await _get_user_system(user, db)
if len(set(body.field_ids)) != len(body.field_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Duplicate field IDs",
)
result = await db.execute(
select(CustomFieldDefinition).where(
CustomFieldDefinition.id.in_(body.field_ids),
CustomFieldDefinition.system_id == system.id,
)
)
owned = {f.id: f for f in result.scalars().all()}
if len(owned) != len(body.field_ids):
# One answer for unknown and foreign ids alike, naming neither, the
# same way set_member_field_values refuses - so this endpoint cannot
# be used to probe whether a uuid exists on someone else's account.
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="One or more field IDs are invalid",
)
for index, fid in enumerate(body.field_ids):
owned[fid].order = index
await db.commit()
return await _list_fields_read(system, db)


@router.get("/fields/{field_id}", response_model=CustomFieldRead)
async def get_field(
field_id: uuid.UUID,
Expand Down
1 change: 1 addition & 0 deletions sheaf/api/v1/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ async def export_all(
"description": g.description,
"color": g.color,
"parent_id": str(g.parent_id) if g.parent_id else None,
"order": g.order,
# The group's own exposure ceiling. The LIVE level only: a
# staged raise is mid-grace-window state, not curation, so a
# restore never resurrects a half-applied publish.
Expand Down
76 changes: 68 additions & 8 deletions sheaf/api/v1/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@
from sheaf.models.user import User
from sheaf.observability.metrics import groups_created_total
from sheaf.request import client_ip
from sheaf.schemas.group import GroupCreate, GroupMemberUpdate, GroupRead, GroupUpdate
from sheaf.schemas.group import (
GroupCreate,
GroupMemberUpdate,
GroupRead,
GroupReorder,
GroupUpdate,
)
from sheaf.schemas.member import MemberDeleteConfirm, MemberRead
from sheaf.services.members import decrypt_member_for_read
from sheaf.services.security_events import record_security_event
Expand Down Expand Up @@ -99,14 +105,14 @@ async def _get_own_group(
return group


@router.get("", response_model=list[GroupRead])
async def list_groups(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
system = await _get_user_system(user, db)
async def _list_groups_read(system: System, db: AsyncSession) -> list[GroupRead]:
"""The full group list as the list endpoint serves it, (order, name)
sorted with pending-delete timestamps attached. Shared with the reorder
endpoint so its response is exactly what the next GET would return."""
result = await db.execute(
select(Group).where(Group.system_id == system.id).order_by(Group.name)
select(Group)
.where(Group.system_id == system.id)
.order_by(Group.order, Group.name)
)
groups = list(result.scalars().all())
pending = await pending_finalize_after_by_target(
Expand All @@ -120,6 +126,15 @@ async def list_groups(
return out


@router.get("", response_model=list[GroupRead])
async def list_groups(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
system = await _get_user_system(user, db)
return await _list_groups_read(system, db)


@router.post(
"",
response_model=GroupRead,
Expand Down Expand Up @@ -188,6 +203,51 @@ async def create_group(
return group


# Declared before the /{group_id} routes so "reorder" is matched as this
# endpoint rather than parsed (and 422ed) as a group uuid.
@router.put(
"/reorder",
response_model=list[GroupRead],
dependencies=[Depends(require_scope("groups:write"))],
)
async def reorder_groups(
body: GroupReorder,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Set the sort order of the named groups to their position in the list.

Groups not named keep the order they had, so a client can send just the
slice it re-arranged - though sending the full list is the reliable way
to get exactly the order on screen. One transaction: either every named
group moves or none do.
"""
system = await _get_user_system(user, db)
if len(set(body.group_ids)) != len(body.group_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Duplicate group IDs",
)
result = await db.execute(
select(Group).where(
Group.id.in_(body.group_ids), Group.system_id == system.id
)
)
owned = {g.id: g for g in result.scalars().all()}
if len(owned) != len(body.group_ids):
# One answer for unknown and foreign ids alike, naming neither, the
# same way set_group_members refuses - so this endpoint cannot be
# used to probe whether a uuid exists on someone else's account.
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="One or more group IDs are invalid",
)
for index, gid in enumerate(body.group_ids):
owned[gid].order = index
await db.commit()
return await _list_groups_read(system, db)


@router.get("/{group_id}", response_model=GroupRead)
async def get_group(
group_id: uuid.UUID,
Expand Down
8 changes: 7 additions & 1 deletion sheaf/models/group.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime

from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship

Expand All @@ -23,6 +23,12 @@ class Group(UUIDMixin, TimestampMixin, Base):
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
color: Mapped[str | None] = mapped_column(String(7), nullable=True)
# Manual sort position, same vocabulary as CustomFieldDefinition.order.
# The list endpoint sorts by (order, name), so rows still at the default
# keep the alphabetical order groups always had.
order: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)

# Per-group privacy ceiling, in the same vocabulary a member and a member
# edge already speak - it is the same question, so it gets the same three
Expand Down
10 changes: 10 additions & 0 deletions sheaf/schemas/custom_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,16 @@ def _reject_explicit_null(cls, v):
return v


class CustomFieldReorder(BaseModel):
"""Body for PUT /v1/fields/reorder: the desired order, first to last.

Every id must be one of the caller's fields; fields not named keep the
order they had.
"""

field_ids: list[uuid.UUID]


class CustomFieldRead(BaseModel):
id: uuid.UUID
system_id: uuid.UUID
Expand Down
15 changes: 14 additions & 1 deletion sheaf/schemas/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class GroupCreate(BaseModel):
description: str | None = Field(default=None, max_length=20000)
color: str | None = Field(default=None, max_length=7)
parent_id: uuid.UUID | None = None
order: int = 0
# Born private unless asked otherwise, and asking otherwise runs the same
# gate the PATCH raise does (see api/v1/groups.create_group): creating a
# group already public and raising an existing one to public are the same
Expand Down Expand Up @@ -50,6 +51,7 @@ class GroupUpdate(BaseModel):
description: str | None = Field(default=None, max_length=20000)
color: str | None = Field(default=None, max_length=7)
parent_id: uuid.UUID | None = None
order: int | None = None
privacy: PrivacyLevel | None = None

# Step-up credentials for a raise that is actually deferred. NOT group
Expand All @@ -61,7 +63,7 @@ class GroupUpdate(BaseModel):
)
totp_code: str | None = None

@field_validator("name")
@field_validator("name", "order")
@classmethod
def _reject_explicit_null(cls, v):
if v is None:
Expand All @@ -82,6 +84,7 @@ class GroupRead(BaseModel):
description: str | None
color: str | None
parent_id: uuid.UUID | None
order: int
created_at: datetime
updated_at: datetime
privacy: PrivacyLevel
Expand All @@ -98,3 +101,13 @@ class GroupRead(BaseModel):

class GroupMemberUpdate(BaseModel):
member_ids: list[uuid.UUID]


class GroupReorder(BaseModel):
"""Body for PUT /v1/groups/reorder: the desired order, first to last.

Every id must be one of the caller's groups; groups not named keep the
order they had.
"""

group_ids: list[uuid.UUID]
10 changes: 8 additions & 2 deletions sheaf/services/openplural_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,9 +270,15 @@ def build_envelope(
# exposure ceiling rides the sheaf extension rather than being
# invented as a core key. Carried for the same reason
# `never_shareable` is carried on a member: a round-trip must
# never return somebody less protected than they left.
# never return somebody less protected than they left. The
# sort order has no v0.1 home either, so it rides along.
"extensions": {
EXT_NS: _prune({"privacy": _privacy(g.get("privacy"))})
EXT_NS: _prune(
{
"privacy": _privacy(g.get("privacy")),
"order": g.get("order"),
}
)
},
}
)
Expand Down
4 changes: 3 additions & 1 deletion sheaf/services/openplural_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,10 @@ def to_native(envelope: dict, assets: _AssetMap | None = None) -> dict:
# v0.1 has no core group privacy, so it rides the sheaf
# extension. A foreign file that carries none leaves this
# absent and the native importer's coercer defaults it to
# private, which is the direction to be wrong in.
# private, which is the direction to be wrong in. The sort
# order rides the same extension; absent coerces to 0.
"privacy": _ext(g).get("privacy"),
"order": _ext(g).get("order"),
"member_ids": group_members.get(g.get("id"), []),
}
)
Expand Down
Loading
Loading