feat(tasks): share board groups collaboratively
This commit is contained in:
parent
f34f92cd7d
commit
8cad1832cd
@ -1,6 +1,6 @@
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from django.db.models import Count, Max
|
||||
from django.db.models import Count, Max, Q
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
@ -860,22 +860,19 @@ class TaskBoardSerializer(serializers.ModelSerializer):
|
||||
|
||||
@extend_schema_field(serializers.ListField(child=serializers.IntegerField()))
|
||||
def get_group_ids(self, obj):
|
||||
placements = getattr(obj, "current_user_placements", None)
|
||||
if placements is not None:
|
||||
placement = placements[0] if placements else None
|
||||
else:
|
||||
groups = getattr(obj, "current_user_groups", None)
|
||||
if groups is None:
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return []
|
||||
placement = (
|
||||
TaskBoardPlacement.objects.filter(user=user, board=obj)
|
||||
.prefetch_related("groups")
|
||||
.first()
|
||||
groups = TaskBoardGroup.objects.filter(
|
||||
Q(user=user) | Q(is_shared=True),
|
||||
boards=obj,
|
||||
).exclude(
|
||||
hidden_by=user,
|
||||
)
|
||||
if not placement:
|
||||
return []
|
||||
return sorted(group.id for group in placement.groups.all())
|
||||
return sorted(group.id for group in groups)
|
||||
|
||||
@extend_schema_field(serializers.IntegerField(allow_null=True))
|
||||
def get_group_id(self, obj):
|
||||
@ -1003,6 +1000,8 @@ class TaskBoardSummarySerializer(TaskBoardSerializer):
|
||||
class TaskBoardGroupSerializer(serializers.ModelSerializer):
|
||||
boards_count = serializers.IntegerField(read_only=True, default=0)
|
||||
is_default_for_current_user = serializers.SerializerMethodField()
|
||||
is_hidden = serializers.SerializerMethodField()
|
||||
is_owner = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TaskBoardGroup
|
||||
@ -1010,8 +1009,11 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer):
|
||||
"id",
|
||||
"name",
|
||||
"position",
|
||||
"is_shared",
|
||||
"boards_count",
|
||||
"is_default_for_current_user",
|
||||
"is_hidden",
|
||||
"is_owner",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@ -1022,10 +1024,12 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer):
|
||||
if not name:
|
||||
raise serializers.ValidationError("Укажите название группы.")
|
||||
request = self.context.get("request")
|
||||
queryset = TaskBoardGroup.objects.filter(
|
||||
user=getattr(request, "user", None),
|
||||
name__iexact=name,
|
||||
owner_id = (
|
||||
self.instance.user_id
|
||||
if self.instance
|
||||
else getattr(getattr(request, "user", None), "id", None)
|
||||
)
|
||||
queryset = TaskBoardGroup.objects.filter(user_id=owner_id, name__iexact=name)
|
||||
if self.instance:
|
||||
queryset = queryset.exclude(id=self.instance.id)
|
||||
if queryset.exists():
|
||||
@ -1048,6 +1052,25 @@ class TaskBoardGroupSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
return self._default_group_id == obj.id
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_is_hidden(self, obj):
|
||||
hidden_by = getattr(obj, "current_user_hidden_by", None)
|
||||
if hidden_by is not None:
|
||||
return bool(hidden_by)
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return bool(
|
||||
user
|
||||
and getattr(user, "is_authenticated", False)
|
||||
and obj.hidden_by.filter(id=user.id).exists()
|
||||
)
|
||||
|
||||
@extend_schema_field(serializers.BooleanField())
|
||||
def get_is_owner(self, obj):
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
return bool(user and obj.user_id == getattr(user, "id", None))
|
||||
|
||||
|
||||
class TaskLinkedPostSerializer(serializers.ModelSerializer):
|
||||
created_by = EmployeeBriefSerializer(read_only=True)
|
||||
|
||||
@ -37,6 +37,7 @@ from rest_framework.response import Response
|
||||
from schedule.models import Event
|
||||
|
||||
from tasks.access import (
|
||||
task_board_group_access_q,
|
||||
task_board_access_q,
|
||||
user_can_access_calendar_event,
|
||||
user_can_access_document,
|
||||
@ -339,9 +340,20 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
"user_placements",
|
||||
queryset=TaskBoardPlacement.objects.filter(
|
||||
user=self.request.user,
|
||||
).prefetch_related("groups"),
|
||||
),
|
||||
to_attr="current_user_placements",
|
||||
),
|
||||
Prefetch(
|
||||
"board_groups",
|
||||
queryset=(
|
||||
TaskBoardGroup.objects.filter(
|
||||
Q(user=self.request.user) | Q(is_shared=True),
|
||||
)
|
||||
.exclude(hidden_by=self.request.user)
|
||||
.distinct()
|
||||
),
|
||||
to_attr="current_user_groups",
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
current_user_is_pinned=Coalesce(
|
||||
@ -627,9 +639,8 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
group = get_object_or_404(
|
||||
TaskBoardGroup,
|
||||
TaskBoardGroup.objects.filter(task_board_group_access_q(request.user)),
|
||||
id=group_id,
|
||||
user=request.user,
|
||||
)
|
||||
return Response(self._set_board_groups(board, [group]))
|
||||
|
||||
@ -649,12 +660,12 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
{"groups": ["Идентификаторы групп должны быть числами."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
groups = list(
|
||||
TaskBoardGroup.objects.filter(
|
||||
user=request.user,
|
||||
id__in=group_ids,
|
||||
).order_by("id")
|
||||
available_groups = (
|
||||
TaskBoardGroup.objects.filter(task_board_group_access_q(request.user))
|
||||
.exclude(hidden_by=request.user)
|
||||
.distinct()
|
||||
)
|
||||
groups = list(available_groups.filter(id__in=group_ids).order_by("id"))
|
||||
if len(groups) != len(group_ids):
|
||||
return Response(
|
||||
{"groups": ["Одна или несколько групп не найдены."]},
|
||||
@ -664,27 +675,30 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def _set_board_groups(self, board, groups):
|
||||
with transaction.atomic():
|
||||
placement = TaskBoardPlacement.objects.select_for_update().filter(
|
||||
user=self.request.user,
|
||||
board=board,
|
||||
).first()
|
||||
if placement is None and groups:
|
||||
placement = TaskBoardPlacement.objects.create(
|
||||
user=self.request.user,
|
||||
board=board,
|
||||
TaskBoard.objects.select_for_update().get(pk=board.pk)
|
||||
editable_groups = (
|
||||
TaskBoardGroup.objects.filter(
|
||||
task_board_group_access_q(self.request.user),
|
||||
boards=board,
|
||||
)
|
||||
if placement:
|
||||
placement.groups.set(groups)
|
||||
if (
|
||||
not groups
|
||||
and not placement.is_pinned
|
||||
and placement.notifications_enabled
|
||||
and not placement.view_state
|
||||
):
|
||||
placement.delete()
|
||||
else:
|
||||
placement.save(update_fields=["updated_at"])
|
||||
group_ids = sorted(group.id for group in groups)
|
||||
.exclude(hidden_by=self.request.user)
|
||||
.distinct()
|
||||
)
|
||||
desired_ids = {group.id for group in groups}
|
||||
removable_groups = list(editable_groups.exclude(id__in=desired_ids))
|
||||
if removable_groups:
|
||||
board.board_groups.remove(*removable_groups)
|
||||
if groups:
|
||||
board.board_groups.add(*groups)
|
||||
group_ids = sorted(
|
||||
TaskBoardGroup.objects.filter(
|
||||
Q(user=self.request.user) | Q(is_shared=True),
|
||||
boards=board,
|
||||
)
|
||||
.exclude(hidden_by=self.request.user)
|
||||
.values_list("id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
return {
|
||||
"board": board.id,
|
||||
"group_ids": group_ids,
|
||||
@ -703,7 +717,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
is_pinned = placement.is_pinned
|
||||
if (
|
||||
not placement.is_pinned
|
||||
and not placement.groups.exists()
|
||||
and placement.notifications_enabled
|
||||
and not placement.view_state
|
||||
):
|
||||
@ -723,7 +736,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
notifications_enabled = placement.notifications_enabled
|
||||
if (
|
||||
placement.notifications_enabled
|
||||
and not placement.groups.exists()
|
||||
and not placement.is_pinned
|
||||
and not placement.view_state
|
||||
):
|
||||
@ -803,7 +815,6 @@ class TaskBoardViewSet(viewsets.ModelViewSet):
|
||||
placement.view_state = state
|
||||
if (
|
||||
not state
|
||||
and not placement.groups.exists()
|
||||
and not placement.is_pinned
|
||||
and placement.notifications_enabled
|
||||
):
|
||||
@ -840,9 +851,28 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = TaskBoardGroupSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
accessible_boards = TaskBoard.objects.filter(
|
||||
task_board_access_q(self.request.user)
|
||||
)
|
||||
return (
|
||||
TaskBoardGroup.objects.filter(user=self.request.user)
|
||||
.annotate(boards_count=Count("board_placements", distinct=True))
|
||||
TaskBoardGroup.objects.filter(
|
||||
task_board_group_access_q(self.request.user)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"hidden_by",
|
||||
queryset=Employee.objects.filter(id=self.request.user.id),
|
||||
to_attr="current_user_hidden_by",
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
boards_count=Count(
|
||||
"boards",
|
||||
filter=Q(boards__in=accessible_boards),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
.order_by("position", "name", "id")
|
||||
)
|
||||
|
||||
@ -855,6 +885,7 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet):
|
||||
@action(detail=True, methods=["post"], url_path="set-default")
|
||||
def set_default(self, request, pk=None):
|
||||
group = self.get_object()
|
||||
group.hidden_by.remove(request.user)
|
||||
settings_obj, _ = TaskUserSettings.objects.get_or_create(user=request.user)
|
||||
is_default = settings_obj.default_group_id != group.id
|
||||
settings_obj.default_group = group if is_default else None
|
||||
@ -867,16 +898,25 @@ class TaskBoardGroupViewSet(viewsets.ModelViewSet):
|
||||
}
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
user = self.request.user
|
||||
instance.delete()
|
||||
TaskBoardPlacement.objects.filter(
|
||||
user=user,
|
||||
groups__isnull=True,
|
||||
is_pinned=False,
|
||||
notifications_enabled=True,
|
||||
view_state={},
|
||||
).delete()
|
||||
@action(detail=True, methods=["post"], url_path="toggle-hidden")
|
||||
def toggle_hidden(self, request, pk=None):
|
||||
group = self.get_object()
|
||||
is_hidden = group.hidden_by.filter(id=request.user.id).exists()
|
||||
if is_hidden:
|
||||
group.hidden_by.remove(request.user)
|
||||
else:
|
||||
group.hidden_by.add(request.user)
|
||||
TaskUserSettings.objects.filter(
|
||||
user=request.user,
|
||||
default_group=group,
|
||||
).update(default_group=None)
|
||||
group.current_user_hidden_by = [] if is_hidden else [request.user]
|
||||
return Response(
|
||||
{
|
||||
"group": self.get_serializer(group).data,
|
||||
"is_hidden": not is_hidden,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TaskColumnViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@ -51,6 +51,15 @@ def task_board_access_q(user):
|
||||
return q
|
||||
|
||||
|
||||
def task_board_group_access_q(user):
|
||||
"""Groups owned by the user or shared through at least one accessible board."""
|
||||
accessible_boards = TaskBoard.objects.filter(task_board_access_q(user))
|
||||
return Q(user=user) | Q(
|
||||
is_shared=True,
|
||||
boards__in=accessible_boards,
|
||||
)
|
||||
|
||||
|
||||
def user_can_access_task_board(user, board: TaskBoard | None) -> bool:
|
||||
if not user or not user.is_authenticated or board is None:
|
||||
return False
|
||||
|
||||
@ -43,30 +43,26 @@ class TaskBoardAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(TaskBoardGroup)
|
||||
class TaskBoardGroupAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "user", "position", "created_at"]
|
||||
list_display = ["name", "user", "is_shared", "position", "created_at"]
|
||||
list_filter = ["is_shared"]
|
||||
search_fields = ["name", "user__email"]
|
||||
autocomplete_fields = ["user"]
|
||||
autocomplete_fields = ["user", "boards", "hidden_by"]
|
||||
|
||||
|
||||
@admin.register(TaskBoardPlacement)
|
||||
class TaskBoardPlacementAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"board",
|
||||
"group_names",
|
||||
"user",
|
||||
"is_pinned",
|
||||
"notifications_enabled",
|
||||
"updated_at",
|
||||
]
|
||||
list_filter = ["is_pinned", "notifications_enabled", "groups"]
|
||||
search_fields = ["board__name", "groups__name", "user__email"]
|
||||
autocomplete_fields = ["board", "groups", "user"]
|
||||
list_filter = ["is_pinned", "notifications_enabled"]
|
||||
search_fields = ["board__name", "user__email"]
|
||||
autocomplete_fields = ["board", "user"]
|
||||
readonly_fields = ["view_state"]
|
||||
|
||||
@admin.display(description="Группы")
|
||||
def group_names(self, obj):
|
||||
return ", ".join(obj.groups.values_list("name", flat=True)) or "—"
|
||||
|
||||
|
||||
@admin.register(TaskColumn)
|
||||
class TaskColumnAdmin(admin.ModelAdmin):
|
||||
|
||||
95
backend/tasks/migrations/0037_shared_task_board_groups.py
Normal file
95
backend/tasks/migrations/0037_shared_task_board_groups.py
Normal file
@ -0,0 +1,95 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-07 14:41
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copy_personal_group_boards(apps, schema_editor):
|
||||
placement_model = apps.get_model("tasks", "TaskBoardPlacement")
|
||||
group_model = apps.get_model("tasks", "TaskBoardGroup")
|
||||
placement_boards = dict(
|
||||
placement_model.objects.values_list("id", "board_id")
|
||||
)
|
||||
old_through = placement_model.groups.through
|
||||
new_through = group_model.boards.through
|
||||
links = []
|
||||
for placement_id, group_id in old_through.objects.values_list(
|
||||
"taskboardplacement_id",
|
||||
"taskboardgroup_id",
|
||||
).iterator():
|
||||
board_id = placement_boards.get(placement_id)
|
||||
if board_id:
|
||||
links.append(
|
||||
new_through(
|
||||
taskboardgroup_id=group_id,
|
||||
taskboard_id=board_id,
|
||||
)
|
||||
)
|
||||
new_through.objects.bulk_create(links, ignore_conflicts=True)
|
||||
|
||||
|
||||
def restore_personal_group_placements(apps, schema_editor):
|
||||
placement_model = apps.get_model("tasks", "TaskBoardPlacement")
|
||||
group_model = apps.get_model("tasks", "TaskBoardGroup")
|
||||
old_through = placement_model.groups.through
|
||||
new_through = group_model.boards.through
|
||||
group_owners = dict(group_model.objects.values_list("id", "user_id"))
|
||||
for group_id, board_id in new_through.objects.values_list(
|
||||
"taskboardgroup_id",
|
||||
"taskboard_id",
|
||||
).iterator():
|
||||
owner_id = group_owners.get(group_id)
|
||||
if not owner_id:
|
||||
continue
|
||||
placement, _ = placement_model.objects.get_or_create(
|
||||
user_id=owner_id,
|
||||
board_id=board_id,
|
||||
)
|
||||
old_through.objects.get_or_create(
|
||||
taskboardplacement_id=placement.id,
|
||||
taskboardgroup_id=group_id,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0036_taskboardplacement_multiple_groups'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="taskboardgroup",
|
||||
name="boards",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="board_groups",
|
||||
to="tasks.taskboard",
|
||||
verbose_name="Доски",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="taskboardgroup",
|
||||
name="hidden_by",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="hidden_task_board_groups",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Скрыта пользователями",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="taskboardgroup",
|
||||
name="is_shared",
|
||||
field=models.BooleanField(default=False, verbose_name="Общая"),
|
||||
),
|
||||
migrations.RunPython(
|
||||
copy_personal_group_boards,
|
||||
restore_personal_group_placements,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="taskboardplacement",
|
||||
name="groups",
|
||||
),
|
||||
]
|
||||
@ -114,6 +114,19 @@ class TaskBoardGroup(models.Model):
|
||||
related_name="task_board_groups",
|
||||
verbose_name="Пользователь",
|
||||
)
|
||||
boards = models.ManyToManyField(
|
||||
TaskBoard,
|
||||
blank=True,
|
||||
related_name="board_groups",
|
||||
verbose_name="Доски",
|
||||
)
|
||||
is_shared = models.BooleanField("Общая", default=False)
|
||||
hidden_by = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
related_name="hidden_task_board_groups",
|
||||
verbose_name="Скрыта пользователями",
|
||||
)
|
||||
position = models.PositiveIntegerField("Позиция", default=0)
|
||||
created_at = models.DateTimeField("Создано", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
@ -146,12 +159,6 @@ class TaskBoardPlacement(models.Model):
|
||||
related_name="user_placements",
|
||||
verbose_name="Доска",
|
||||
)
|
||||
groups = models.ManyToManyField(
|
||||
TaskBoardGroup,
|
||||
blank=True,
|
||||
related_name="board_placements",
|
||||
verbose_name="Группы",
|
||||
)
|
||||
is_pinned = models.BooleanField("Закреплена", default=False)
|
||||
pinned_at = models.DateTimeField("Закреплена в", null=True, blank=True)
|
||||
notifications_enabled = models.BooleanField("Уведомления", default=True)
|
||||
|
||||
@ -338,8 +338,7 @@ def test_board_groups_are_personal_and_board_placement_is_independent(
|
||||
)
|
||||
assert groups_response.status_code == status.HTTP_200_OK
|
||||
assert groups_response.data["group_ids"] == sorted([group_id, second_group_id])
|
||||
placement = TaskBoardPlacement.objects.get(user=user, board=board)
|
||||
assert set(placement.groups.values_list("id", flat=True)) == {
|
||||
assert set(board.board_groups.values_list("id", flat=True)) == {
|
||||
group_id,
|
||||
second_group_id,
|
||||
}
|
||||
@ -390,8 +389,7 @@ def test_board_groups_are_personal_and_board_placement_is_independent(
|
||||
)
|
||||
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not TaskBoardGroup.objects.filter(id=group_id).exists()
|
||||
placement.refresh_from_db()
|
||||
assert list(placement.groups.values_list("id", flat=True)) == [second_group_id]
|
||||
assert list(board.board_groups.values_list("id", flat=True)) == [second_group_id]
|
||||
|
||||
grouped_summary_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-list"),
|
||||
@ -411,6 +409,164 @@ def test_board_groups_are_personal_and_board_placement_is_independent(
|
||||
assert not TaskBoardPlacement.objects.filter(user=user, board=board).exists()
|
||||
|
||||
|
||||
def test_shared_board_group_is_collaborative_without_granting_board_access(
|
||||
api_client,
|
||||
):
|
||||
owner = make_user("shared-group-owner@example.com", "+79994440110")
|
||||
first_member = make_user("shared-group-first@example.com", "+79994440111")
|
||||
second_member = make_user("shared-group-second@example.com", "+79994440112")
|
||||
role_member = make_user("shared-group-role@example.com", "+79994440113")
|
||||
outsider = make_user("shared-group-outsider@example.com", "+79994440114")
|
||||
department = Department.objects.create(name="Общий проектный отдел")
|
||||
role = department.roles.create(name="Участник общей группы")
|
||||
RoleAssignment.objects.create(
|
||||
employee=role_member,
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
first_board = TaskBoard.objects.create(
|
||||
name="Первая закрытая доска",
|
||||
created_by=owner,
|
||||
access_scope="restricted",
|
||||
)
|
||||
first_board.members.add(first_member)
|
||||
role_board = TaskBoard.objects.create(
|
||||
name="Доска отдела",
|
||||
created_by=owner,
|
||||
access_scope="restricted",
|
||||
)
|
||||
role_board.departments.add(department)
|
||||
second_board = TaskBoard.objects.create(
|
||||
name="Добавленная участником доска",
|
||||
created_by=first_member,
|
||||
access_scope="restricted",
|
||||
)
|
||||
second_board.members.add(second_member)
|
||||
|
||||
api_client.force_authenticate(user=owner)
|
||||
create_response = api_client.post(
|
||||
reverse("api:v1:tasks:task-board-group-list"),
|
||||
{"name": "Общая работа", "is_shared": True},
|
||||
format="json",
|
||||
)
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
group_id = create_response.data["id"]
|
||||
for board in (first_board, role_board):
|
||||
response = api_client.post(
|
||||
reverse("api:v1:tasks:task-board-set-groups", kwargs={"pk": board.id}),
|
||||
{"groups": [group_id]},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
api_client.force_authenticate(user=first_member)
|
||||
group_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-group-list")
|
||||
)
|
||||
assert group_response.data["results"][0]["id"] == group_id
|
||||
assert group_response.data["results"][0]["boards_count"] == 1
|
||||
board_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-list"),
|
||||
{"view": "summary"},
|
||||
)
|
||||
assert {item["id"] for item in board_response.data["results"]} == {
|
||||
first_board.id,
|
||||
second_board.id,
|
||||
}
|
||||
assert next(
|
||||
item for item in board_response.data["results"] if item["id"] == first_board.id
|
||||
)["group_ids"] == [group_id]
|
||||
assert next(
|
||||
item for item in board_response.data["results"] if item["id"] == second_board.id
|
||||
)["group_ids"] == []
|
||||
|
||||
add_response = api_client.post(
|
||||
reverse(
|
||||
"api:v1:tasks:task-board-set-groups",
|
||||
kwargs={"pk": second_board.id},
|
||||
),
|
||||
{"groups": [group_id]},
|
||||
format="json",
|
||||
)
|
||||
assert add_response.status_code == status.HTTP_200_OK
|
||||
rename_response = api_client.patch(
|
||||
reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id}),
|
||||
{"name": "Переименована участником"},
|
||||
format="json",
|
||||
)
|
||||
assert rename_response.status_code == status.HTTP_200_OK
|
||||
|
||||
hide_response = api_client.post(
|
||||
reverse("api:v1:tasks:task-board-group-toggle-hidden", kwargs={"pk": group_id})
|
||||
)
|
||||
assert hide_response.status_code == status.HTTP_200_OK
|
||||
assert hide_response.data["is_hidden"] is True
|
||||
hidden_boards_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-list"),
|
||||
{"view": "summary"},
|
||||
)
|
||||
assert all(
|
||||
group_id not in item["group_ids"]
|
||||
for item in hidden_boards_response.data["results"]
|
||||
)
|
||||
unhide_response = api_client.post(
|
||||
reverse("api:v1:tasks:task-board-group-toggle-hidden", kwargs={"pk": group_id})
|
||||
)
|
||||
assert unhide_response.data["is_hidden"] is False
|
||||
default_response = api_client.post(
|
||||
reverse("api:v1:tasks:task-board-group-set-default", kwargs={"pk": group_id})
|
||||
)
|
||||
assert default_response.status_code == status.HTTP_200_OK
|
||||
assert TaskUserSettings.objects.get(user=first_member).default_group_id == group_id
|
||||
remove_board_response = api_client.post(
|
||||
reverse(
|
||||
"api:v1:tasks:task-board-set-groups",
|
||||
kwargs={"pk": first_board.id},
|
||||
),
|
||||
{"groups": []},
|
||||
format="json",
|
||||
)
|
||||
assert remove_board_response.status_code == status.HTTP_200_OK
|
||||
assert not first_board.board_groups.filter(id=group_id).exists()
|
||||
|
||||
api_client.force_authenticate(user=role_member)
|
||||
role_groups_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-group-list")
|
||||
)
|
||||
assert role_groups_response.data["results"][0]["id"] == group_id
|
||||
role_boards_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-list"),
|
||||
{"view": "summary"},
|
||||
)
|
||||
assert [item["id"] for item in role_boards_response.data["results"]] == [
|
||||
role_board.id
|
||||
]
|
||||
assert role_boards_response.data["results"][0]["group_ids"] == [group_id]
|
||||
|
||||
api_client.force_authenticate(user=outsider)
|
||||
outsider_groups_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-group-list")
|
||||
)
|
||||
assert outsider_groups_response.data["results"] == []
|
||||
outsider_update_response = api_client.patch(
|
||||
reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id}),
|
||||
{"name": "Недоступное изменение"},
|
||||
format="json",
|
||||
)
|
||||
assert outsider_update_response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
api_client.force_authenticate(user=second_member)
|
||||
second_groups_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-group-list")
|
||||
)
|
||||
assert second_groups_response.data["results"][0]["id"] == group_id
|
||||
delete_response = api_client.delete(
|
||||
reverse("api:v1:tasks:task-board-group-detail", kwargs={"pk": group_id})
|
||||
)
|
||||
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not TaskBoardGroup.objects.filter(id=group_id).exists()
|
||||
|
||||
|
||||
def test_user_can_toggle_personal_default_board_group(api_client, user):
|
||||
other_user = make_user("task-default-group-other@example.com", "+79994440095")
|
||||
api_client.force_authenticate(user=user)
|
||||
@ -496,7 +652,6 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings(
|
||||
assert notifications_response.status_code == status.HTTP_200_OK
|
||||
assert notifications_response.data["notifications_enabled"] is False
|
||||
placement = TaskBoardPlacement.objects.get(user=user, board=pinned_board)
|
||||
assert not placement.groups.exists()
|
||||
assert placement.is_pinned is True
|
||||
assert placement.notifications_enabled is False
|
||||
|
||||
@ -522,7 +677,6 @@ def test_board_pin_and_notifications_are_personal_and_keep_ungrouped_settings(
|
||||
format="json",
|
||||
)
|
||||
placement.refresh_from_db()
|
||||
assert not placement.groups.exists()
|
||||
assert placement.is_pinned is True
|
||||
assert placement.notifications_enabled is False
|
||||
|
||||
|
||||
@ -161,6 +161,7 @@ type BoardFormState = {
|
||||
type BoardGroupFormState = {
|
||||
id: number | null;
|
||||
name: string;
|
||||
is_shared: boolean;
|
||||
};
|
||||
|
||||
type ColumnFormState = {
|
||||
@ -280,6 +281,7 @@ const emptyBoardForm: BoardFormState = {
|
||||
const emptyBoardGroupForm: BoardGroupFormState = {
|
||||
id: null,
|
||||
name: "",
|
||||
is_shared: false,
|
||||
};
|
||||
|
||||
const taskDescriptionPreviewClass =
|
||||
@ -5171,7 +5173,11 @@ function TasksPageContent() {
|
||||
}, []);
|
||||
|
||||
const openEditBoardGroup = useCallback((group: TaskBoardGroup) => {
|
||||
setBoardGroupForm({ id: group.id, name: group.name });
|
||||
setBoardGroupForm({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
is_shared: group.is_shared,
|
||||
});
|
||||
setBoardGroupModalOpen(true);
|
||||
}, []);
|
||||
|
||||
@ -5508,11 +5514,17 @@ function TasksPageContent() {
|
||||
setError(null);
|
||||
try {
|
||||
if (boardGroupForm.id) {
|
||||
await apiClient.updateTaskBoardGroup(boardGroupForm.id, { name });
|
||||
await apiClient.updateTaskBoardGroup(boardGroupForm.id, {
|
||||
name,
|
||||
is_shared: boardGroupForm.is_shared,
|
||||
});
|
||||
} else {
|
||||
await apiClient.createTaskBoardGroup({ name });
|
||||
await apiClient.createTaskBoardGroup({
|
||||
name,
|
||||
is_shared: boardGroupForm.is_shared,
|
||||
});
|
||||
}
|
||||
await loadBoardGroups();
|
||||
await Promise.all([loadBoardGroups(), loadBoards()]);
|
||||
setBoardGroupModalOpen(false);
|
||||
setBoardGroupForm(emptyBoardGroupForm);
|
||||
} catch (groupError) {
|
||||
@ -5520,7 +5532,14 @@ function TasksPageContent() {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [boardGroupForm.id, boardGroupForm.name, loadBoardGroups, saving]);
|
||||
}, [
|
||||
boardGroupForm.id,
|
||||
boardGroupForm.is_shared,
|
||||
boardGroupForm.name,
|
||||
loadBoardGroups,
|
||||
loadBoards,
|
||||
saving,
|
||||
]);
|
||||
|
||||
const deleteBoardGroup = useCallback(async (group: TaskBoardGroup) => {
|
||||
if (saving) return;
|
||||
@ -5560,6 +5579,20 @@ function TasksPageContent() {
|
||||
}
|
||||
}, [saving]);
|
||||
|
||||
const toggleBoardGroupHidden = useCallback(async (group: TaskBoardGroup) => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.toggleTaskBoardGroupHidden(group.id);
|
||||
await Promise.all([loadBoardGroups(), loadBoards()]);
|
||||
} catch (groupError) {
|
||||
setError(getTaskError(groupError, "Не удалось изменить видимость группы"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [loadBoardGroups, loadBoards, saving]);
|
||||
|
||||
const setBoardGroupMemberships = useCallback(async (
|
||||
boardId: number,
|
||||
groupIds: number[],
|
||||
@ -7338,6 +7371,7 @@ function TasksPageContent() {
|
||||
onEditGroup={openEditBoardGroup}
|
||||
onDeleteGroup={deleteBoardGroup}
|
||||
onToggleDefaultGroup={(group) => void toggleDefaultBoardGroup(group)}
|
||||
onToggleHiddenGroup={(group) => void toggleBoardGroupHidden(group)}
|
||||
onEditBoard={openEditBoard}
|
||||
onDeleteBoard={(targetBoard) => void deleteBoard(targetBoard)}
|
||||
onSetDefaultBoard={(targetBoard) => void setDefaultBoard(targetBoard)}
|
||||
@ -8127,22 +8161,43 @@ function TasksPageContent() {
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Название группы</span>
|
||||
<input
|
||||
value={boardGroupForm.name}
|
||||
onChange={(event) => setBoardGroupForm((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void saveBoardGroup();
|
||||
}}
|
||||
className="app-input w-full rounded-xl px-3 py-2 text-sm"
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="app-text-muted mb-1 block text-xs font-medium">Название группы</span>
|
||||
<input
|
||||
value={boardGroupForm.name}
|
||||
onChange={(event) => setBoardGroupForm((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void saveBoardGroup();
|
||||
}}
|
||||
className="app-input w-full rounded-xl px-3 py-2 text-sm"
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
/>
|
||||
</label>
|
||||
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={boardGroupForm.is_shared}
|
||||
onChange={(event) => setBoardGroupForm((current) => ({
|
||||
...current,
|
||||
is_shared: event.target.checked,
|
||||
}))}
|
||||
className="mt-0.5 h-4 w-4"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-[var(--foreground)]">
|
||||
Общая группа
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 block text-xs leading-5">
|
||||
Участники увидят группу только вместе с доступными им досками.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
Filter,
|
||||
Folder,
|
||||
FolderInput,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Kanban,
|
||||
LockKeyhole,
|
||||
Maximize2,
|
||||
@ -57,6 +59,7 @@ type TaskBoardNavigatorProps = {
|
||||
onEditGroup: (group: TaskBoardGroup) => void;
|
||||
onDeleteGroup: (group: TaskBoardGroup) => void;
|
||||
onToggleDefaultGroup: (group: TaskBoardGroup) => void;
|
||||
onToggleHiddenGroup: (group: TaskBoardGroup) => void;
|
||||
onEditBoard: (board: TaskBoardSummary) => void;
|
||||
onDeleteBoard: (board: TaskBoardSummary) => void;
|
||||
onSetDefaultBoard: (board: TaskBoardSummary) => void;
|
||||
@ -304,6 +307,7 @@ export default function TaskBoardNavigator({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onToggleDefaultGroup,
|
||||
onToggleHiddenGroup,
|
||||
onEditBoard,
|
||||
onDeleteBoard,
|
||||
onSetDefaultBoard,
|
||||
@ -328,11 +332,20 @@ export default function TaskBoardNavigator({
|
||||
const [moveGroupQuery, setMoveGroupQuery] = useState("");
|
||||
const [moveSubmitting, setMoveSubmitting] = useState(false);
|
||||
const [participantsBoard, setParticipantsBoard] = useState<TaskBoardSummary | null>(null);
|
||||
const [hiddenGroupsOpen, setHiddenGroupsOpen] = useState(false);
|
||||
const menusRef = useRef<HTMLDivElement>(null);
|
||||
const defaultGroupInitializedRef = useRef(false);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const visibleGroups = useMemo(
|
||||
() => groups.filter((group) => !group.is_hidden),
|
||||
[groups],
|
||||
);
|
||||
const hiddenGroups = useMemo(
|
||||
() => groups.filter((group) => group.is_hidden),
|
||||
[groups],
|
||||
);
|
||||
const effectiveGroupFilter = (
|
||||
typeof groupFilter !== "number" || groups.some((group) => group.id === groupFilter)
|
||||
typeof groupFilter !== "number" || visibleGroups.some((group) => group.id === groupFilter)
|
||||
) ? groupFilter : "all";
|
||||
const filteredBoards = useMemo(() => boards.filter((board) => {
|
||||
const unread = unreadCounts[board.id] || 0;
|
||||
@ -359,31 +372,31 @@ export default function TaskBoardNavigator({
|
||||
return {
|
||||
all: buildStats(filteredBoards),
|
||||
ungrouped: buildStats(filteredBoards.filter((board) => boardGroupIds(board).length === 0)),
|
||||
byId: new Map(groups.map((group) => [
|
||||
byId: new Map(visibleGroups.map((group) => [
|
||||
group.id,
|
||||
buildStats(filteredBoards.filter((board) => boardGroupIds(board).includes(group.id))),
|
||||
])),
|
||||
};
|
||||
}, [filteredBoards, groups, unreadCounts]);
|
||||
}, [filteredBoards, unreadCounts, visibleGroups]);
|
||||
const groupNames = useMemo(
|
||||
() => new Map(groups.map((group) => [group.id, group.name])),
|
||||
[groups],
|
||||
() => new Map(visibleGroups.map((group) => [group.id, group.name])),
|
||||
[visibleGroups],
|
||||
);
|
||||
const activeFiltersCount = [unreadFilter !== "all", accessFilter !== "all"].filter(Boolean).length;
|
||||
const compactNavigator = wideMode && navigatorCollapsed;
|
||||
const filteredMoveGroups = useMemo(() => {
|
||||
const moveQuery = moveGroupQuery.trim().toLowerCase();
|
||||
return moveQuery
|
||||
? groups.filter((group) => group.name.toLowerCase().includes(moveQuery))
|
||||
: groups;
|
||||
}, [groups, moveGroupQuery]);
|
||||
? visibleGroups.filter((group) => group.name.toLowerCase().includes(moveQuery))
|
||||
: visibleGroups;
|
||||
}, [moveGroupQuery, visibleGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultGroupInitializedRef.current || groups.length === 0) return;
|
||||
const defaultGroup = groups.find((group) => group.is_default_for_current_user);
|
||||
if (defaultGroupInitializedRef.current || visibleGroups.length === 0) return;
|
||||
const defaultGroup = visibleGroups.find((group) => group.is_default_for_current_user);
|
||||
setGroupFilter(defaultGroup?.id ?? "all");
|
||||
defaultGroupInitializedRef.current = true;
|
||||
}, [groups]);
|
||||
}, [visibleGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
@ -612,7 +625,7 @@ export default function TaskBoardNavigator({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{groups.map((group) => {
|
||||
{visibleGroups.map((group) => {
|
||||
const active = effectiveGroupFilter === group.id;
|
||||
const stats = groupStats.byId.get(group.id) || { total: 0, unread: 0 };
|
||||
return (
|
||||
@ -628,6 +641,9 @@ export default function TaskBoardNavigator({
|
||||
className="inline-flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-7 text-xs font-medium"
|
||||
>
|
||||
<span className="max-w-28 truncate">{group.name}</span>
|
||||
{group.is_shared ? (
|
||||
<Users size={11} className="app-text-muted shrink-0" />
|
||||
) : null}
|
||||
<span className={`app-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold ${
|
||||
active ? "app-pill-count-active" : "app-pill-count"
|
||||
}`}>
|
||||
@ -679,6 +695,18 @@ export default function TaskBoardNavigator({
|
||||
<Pencil size={14} />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (groupFilter === group.id) setGroupFilter("all");
|
||||
onToggleHiddenGroup(group);
|
||||
setGroupMenuId(null);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||||
>
|
||||
<EyeOff size={14} />
|
||||
Скрыть лично
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@ -705,6 +733,20 @@ export default function TaskBoardNavigator({
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
{hiddenGroups.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHiddenGroupsOpen(true)}
|
||||
className="app-pill inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition"
|
||||
title="Показать скрытые группы"
|
||||
>
|
||||
<EyeOff size={12} />
|
||||
<span>Скрытые</span>
|
||||
<span className="app-badge app-pill-count px-1.5 py-0.5 text-[10px] font-bold">
|
||||
{hiddenGroups.length}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@ -774,6 +816,51 @@ export default function TaskBoardNavigator({
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Modal
|
||||
isOpen={hiddenGroupsOpen}
|
||||
onClose={() => setHiddenGroupsOpen(false)}
|
||||
title="Скрытые группы"
|
||||
size="sm"
|
||||
closeOnClickOutside
|
||||
>
|
||||
<div className="space-y-2 pb-5">
|
||||
{hiddenGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="app-surface-muted flex items-center gap-3 rounded-xl p-3"
|
||||
>
|
||||
<Folder size={16} className="app-text-muted shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium text-[var(--foreground)]">
|
||||
{group.name}
|
||||
</span>
|
||||
{group.is_shared ? (
|
||||
<Users size={12} className="app-text-muted shrink-0" />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="app-text-muted mt-0.5 text-xs">
|
||||
{group.boards_count} досок
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleHiddenGroup(group)}
|
||||
className="app-action-secondary inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-xs font-medium"
|
||||
>
|
||||
<Eye size={14} />
|
||||
Показать
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{hiddenGroups.length === 0 ? (
|
||||
<p className="app-text-muted py-6 text-center text-sm">
|
||||
Скрытых групп нет
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={Boolean(participantsBoard)}
|
||||
onClose={() => setParticipantsBoard(null)}
|
||||
@ -963,7 +1050,7 @@ export default function TaskBoardNavigator({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groups.length > 5 ? (
|
||||
{visibleGroups.length > 5 ? (
|
||||
<label className="relative block">
|
||||
<Search
|
||||
size={15}
|
||||
|
||||
@ -109,14 +109,14 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
TaskBoardGroup[] | { results: TaskBoardGroup[] }
|
||||
> =>
|
||||
request("/api/v1/tasks/board-groups/"),
|
||||
createTaskBoardGroup: (data: { name: string }): Promise<TaskBoardGroup> =>
|
||||
createTaskBoardGroup: (data: { name: string; is_shared?: boolean }): Promise<TaskBoardGroup> =>
|
||||
request("/api/v1/tasks/board-groups/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateTaskBoardGroup: (
|
||||
id: number,
|
||||
data: { name: string },
|
||||
data: { name: string; is_shared?: boolean },
|
||||
): Promise<TaskBoardGroup> =>
|
||||
request(`/api/v1/tasks/board-groups/${id}/`, {
|
||||
method: "PATCH",
|
||||
@ -133,6 +133,12 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
request(`/api/v1/tasks/board-groups/${id}/set-default/`, {
|
||||
method: "POST",
|
||||
}),
|
||||
toggleTaskBoardGroupHidden: (
|
||||
id: number,
|
||||
): Promise<{ group: TaskBoardGroup; is_hidden: boolean }> =>
|
||||
request(`/api/v1/tasks/board-groups/${id}/toggle-hidden/`, {
|
||||
method: "POST",
|
||||
}),
|
||||
markTaskBoardNotificationsRead: (
|
||||
id: number,
|
||||
): Promise<{ status: string; count: number; notification_ids: number[] }> =>
|
||||
|
||||
@ -400,8 +400,11 @@ export interface TaskBoardGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
position: number;
|
||||
is_shared: boolean;
|
||||
boards_count: number;
|
||||
is_default_for_current_user: boolean;
|
||||
is_hidden: boolean;
|
||||
is_owner: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user