Compare commits
17 Commits
8b230d2670
...
c3d2d78724
| Author | SHA1 | Date |
|---|---|---|
|
|
c3d2d78724 | |
|
|
d9f534efcd | |
|
|
a7606f2e9a | |
|
|
3e328531ec | |
|
|
490aa1bc04 | |
|
|
b945bc4d31 | |
|
|
9e2c2f065a | |
|
|
86b17b23c9 | |
|
|
347d95709c | |
|
|
7ac9a3dbd3 | |
|
|
9a91af372e | |
|
|
d867a89a1b | |
|
|
5b1fca5356 | |
|
|
a3aedb7c5d | |
|
|
323b4b964e | |
|
|
a13ff3b954 | |
|
|
ee8b5123d8 |
26
AGENTS.md
26
AGENTS.md
|
|
@ -79,6 +79,32 @@ UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименован
|
|||
- не переписывать историю
|
||||
- не объединять несколько отдельных этапов в один коммит постфактум
|
||||
|
||||
## Ведение карточек задач Codex
|
||||
|
||||
Карточка задачи должна разделять постановку и ход работ.
|
||||
|
||||
Основное тело карточки:
|
||||
- хранит общее описание задачи, цель, контекст, ограничения и критерии приемки
|
||||
- не превращается в журнал работ
|
||||
- остается читаемым входом в задачу после нескольких итераций
|
||||
|
||||
Подэлемент `Текущий статус работ`:
|
||||
- создается как текстовый блок через `Добавить подэлемент`
|
||||
- хранит фактический отчет по реализации
|
||||
- обновляется после каждого осмысленного этапа
|
||||
- фиксирует, что сделано, что проверено, какие файлы/модули затронуты, что осталось и какой следующий шаг
|
||||
|
||||
Подэлемент-чекер:
|
||||
- используется для подзадач, которые можно проверить отдельно
|
||||
- содержит короткие конкретные пункты без дублирования основного описания
|
||||
- закрывается по факту реализации и проверки, а не по намерению
|
||||
|
||||
Статус карточки:
|
||||
- `В работе` ставится только когда задача реально взята в исполнение
|
||||
- `Готово` ставится после проверки результата
|
||||
- `Отложено` используется для задач, которые больше не входят в текущий рабочий план
|
||||
- backlog не должен хранить уже сделанные или сознательно отложенные задачи как активные
|
||||
|
||||
## Публикация фронта
|
||||
|
||||
После каждой правки интерфейса агент должен:
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ class FileAssetSerializer(BaseSerializer):
|
|||
"page",
|
||||
"draft_issue",
|
||||
"user",
|
||||
"blob",
|
||||
"is_deleted",
|
||||
"deleted_at",
|
||||
"storage_metadata",
|
||||
|
|
|
|||
|
|
@ -678,6 +678,7 @@ class IssueAttachmentSerializer(BaseSerializer):
|
|||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"blob",
|
||||
"updated_by",
|
||||
"updated_at",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from rest_framework.response import Response
|
|||
from drf_spectacular.utils import OpenApiExample, OpenApiRequest
|
||||
|
||||
# Module Imports
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.db.models import FileAsset, User, Workspace
|
||||
from plane.api.views.base import BaseAPIView
|
||||
|
|
@ -44,6 +43,11 @@ from plane.utils.openapi import (
|
|||
)
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||
from plane.utils.file_dedup import (
|
||||
UploadedObjectMissing,
|
||||
confirm_uploaded_file_asset,
|
||||
release_file_asset_blob,
|
||||
)
|
||||
|
||||
|
||||
class UserAssetEndpoint(BaseAPIView):
|
||||
|
|
@ -53,9 +57,11 @@ class UserAssetEndpoint(BaseAPIView):
|
|||
asset = FileAsset.objects.filter(id=asset_id).first()
|
||||
if asset is None:
|
||||
return
|
||||
if not asset.is_uploaded:
|
||||
release_file_asset_blob(asset, delete_untracked_object=True)
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
|
||||
return
|
||||
|
||||
def entity_asset_delete(self, entity_type, asset, request):
|
||||
|
|
@ -209,15 +215,17 @@ class UserAssetEndpoint(BaseAPIView):
|
|||
"""
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
if not asset.storage_metadata:
|
||||
get_asset_object_metadata.delay(asset_id=str(asset_id))
|
||||
# update the attributes
|
||||
asset.attributes = request.data.get("attributes", asset.attributes)
|
||||
# save the asset
|
||||
asset.save(update_fields=["is_uploaded", "attributes"])
|
||||
try:
|
||||
confirm_uploaded_file_asset(
|
||||
asset,
|
||||
request=request,
|
||||
attributes=request.data.get("attributes", asset.attributes),
|
||||
)
|
||||
except UploadedObjectMissing:
|
||||
return Response(
|
||||
{"error": "The uploaded asset object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@asset_docs(
|
||||
|
|
@ -236,11 +244,13 @@ class UserAssetEndpoint(BaseAPIView):
|
|||
This performs a soft delete by marking the asset as deleted and updating the user's profile.
|
||||
"""
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
if not asset.is_uploaded:
|
||||
release_file_asset_blob(asset, request=request, delete_untracked_object=True)
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
@ -251,9 +261,11 @@ class UserServerAssetEndpoint(BaseAPIView):
|
|||
asset = FileAsset.objects.filter(id=asset_id).first()
|
||||
if asset is None:
|
||||
return
|
||||
if not asset.is_uploaded:
|
||||
release_file_asset_blob(asset, delete_untracked_object=True)
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
|
||||
return
|
||||
|
||||
def entity_asset_delete(self, entity_type, asset, request):
|
||||
|
|
@ -365,15 +377,17 @@ class UserServerAssetEndpoint(BaseAPIView):
|
|||
"""
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
if not asset.storage_metadata:
|
||||
get_asset_object_metadata.delay(asset_id=str(asset_id))
|
||||
# update the attributes
|
||||
asset.attributes = request.data.get("attributes", asset.attributes)
|
||||
# save the asset
|
||||
asset.save(update_fields=["is_uploaded", "attributes"])
|
||||
try:
|
||||
confirm_uploaded_file_asset(
|
||||
asset,
|
||||
request=request,
|
||||
attributes=request.data.get("attributes", asset.attributes),
|
||||
)
|
||||
except UploadedObjectMissing:
|
||||
return Response(
|
||||
{"error": "The uploaded asset object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@asset_docs(
|
||||
|
|
@ -393,11 +407,13 @@ class UserServerAssetEndpoint(BaseAPIView):
|
|||
asset as deleted and updating the user's profile.
|
||||
"""
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
if not asset.is_uploaded:
|
||||
release_file_asset_blob(asset, request=request, delete_untracked_object=True)
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
@ -604,15 +620,21 @@ class GenericAssetEndpoint(BaseAPIView):
|
|||
try:
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug, is_deleted=False)
|
||||
|
||||
# Update is_uploaded status
|
||||
asset.is_uploaded = request.data.get("is_uploaded", asset.is_uploaded)
|
||||
|
||||
# Update storage metadata if not present
|
||||
if not asset.storage_metadata:
|
||||
get_asset_object_metadata.delay(asset_id=str(asset_id))
|
||||
|
||||
asset.save(update_fields=["is_uploaded"])
|
||||
if request.data.get("is_uploaded", asset.is_uploaded):
|
||||
confirm_uploaded_file_asset(
|
||||
asset,
|
||||
request=request,
|
||||
attributes=request.data.get("attributes"),
|
||||
)
|
||||
else:
|
||||
asset.is_uploaded = False
|
||||
asset.save(update_fields=["is_uploaded", "updated_at"])
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except FileAsset.DoesNotExist:
|
||||
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
except UploadedObjectMissing:
|
||||
return Response(
|
||||
{"error": "The uploaded asset object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -82,11 +82,11 @@ from plane.db.models import (
|
|||
Workspace,
|
||||
)
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.issue_relation_mapper import get_actual_relation
|
||||
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
|
||||
from plane.utils.file_dedup import finalize_uploaded_file_asset, release_file_asset_blob, UploadedObjectMissing
|
||||
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.app.permissions import ROLE
|
||||
|
|
@ -2020,9 +2020,11 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||
if not issue_attachment.is_uploaded:
|
||||
release_file_asset_blob(issue_attachment, request=request, delete_untracked_object=True)
|
||||
issue_attachment.is_deleted = True
|
||||
issue_attachment.deleted_at = timezone.now()
|
||||
issue_attachment.save()
|
||||
issue_attachment.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
|
||||
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.deleted",
|
||||
|
|
@ -2036,10 +2038,6 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
|||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
# Get the storage metadata
|
||||
if not issue_attachment.storage_metadata:
|
||||
get_asset_object_metadata.delay(str(issue_attachment.id))
|
||||
issue_attachment.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@issue_attachment_docs(
|
||||
|
|
@ -2174,14 +2172,16 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
|||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
# Update the attachment
|
||||
issue_attachment.is_uploaded = True
|
||||
issue_attachment.created_by = request.user
|
||||
issue_attachment.save(update_fields=["created_by", "updated_at"])
|
||||
|
||||
# Get the storage metadata
|
||||
if not issue_attachment.storage_metadata:
|
||||
get_asset_object_metadata.delay(str(issue_attachment.id))
|
||||
issue_attachment.save()
|
||||
try:
|
||||
finalize_uploaded_file_asset(issue_attachment, request=request)
|
||||
except UploadedObjectMissing:
|
||||
return Response(
|
||||
{"error": "The uploaded attachment object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,4 +10,4 @@ class FileAssetSerializer(BaseSerializer):
|
|||
class Meta:
|
||||
model = FileAsset
|
||||
fields = "__all__"
|
||||
read_only_fields = ["created_by", "updated_by", "created_at", "updated_at"]
|
||||
read_only_fields = ["created_by", "updated_by", "created_at", "updated_at", "blob"]
|
||||
|
|
|
|||
|
|
@ -630,6 +630,7 @@ class IssueAttachmentSerializer(BaseSerializer):
|
|||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"blob",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from plane.app.views import (
|
|||
UserLastProjectWithWorkspaceEndpoint,
|
||||
WorkspaceThemeViewSet,
|
||||
WorkspaceUserProfileStatsEndpoint,
|
||||
WorkspaceActivityEndpoint,
|
||||
WorkspaceUserActivityEndpoint,
|
||||
WorkspaceUserProfileEndpoint,
|
||||
WorkspaceUserProfileIssuesEndpoint,
|
||||
|
|
@ -35,6 +36,7 @@ from plane.app.views import (
|
|||
UserRecentVisitViewSet,
|
||||
WorkspaceHomePreferenceViewSet,
|
||||
WorkspaceStickyViewSet,
|
||||
WorkspaceStorageSummaryEndpoint,
|
||||
WorkspaceUserPreferenceViewSet,
|
||||
)
|
||||
|
||||
|
|
@ -134,6 +136,11 @@ urlpatterns = [
|
|||
WorkspaceUserProfileStatsEndpoint.as_view(),
|
||||
name="workspace-user-stats",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/activity/",
|
||||
WorkspaceActivityEndpoint.as_view(),
|
||||
name="workspace-activity",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/user-activity/<uuid:user_id>/",
|
||||
WorkspaceUserActivityEndpoint.as_view(),
|
||||
|
|
@ -251,6 +258,11 @@ urlpatterns = [
|
|||
WorkspaceStickyViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
|
||||
name="workspace-sticky",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/storage/summary/",
|
||||
WorkspaceStorageSummaryEndpoint.as_view(),
|
||||
name="workspace-storage-summary",
|
||||
),
|
||||
# User Preference
|
||||
path(
|
||||
"workspaces/<str:slug>/sidebar-preferences/",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ from .workspace.label import WorkspaceLabelsEndpoint
|
|||
from .workspace.state import WorkspaceStatesEndpoint
|
||||
from .workspace.user import (
|
||||
UserLastProjectWithWorkspaceEndpoint,
|
||||
WorkspaceActivityEndpoint,
|
||||
WorkspaceUserProfileIssuesEndpoint,
|
||||
WorkspaceUserPropertiesEndpoint,
|
||||
WorkspaceUserProfileEndpoint,
|
||||
|
|
@ -82,6 +83,7 @@ from .workspace.module import WorkspaceModulesEndpoint
|
|||
from .workspace.cycle import WorkspaceCyclesEndpoint
|
||||
from .workspace.quick_link import QuickLinkViewSet
|
||||
from .workspace.sticky import WorkspaceStickyViewSet
|
||||
from .workspace.storage import WorkspaceStorageSummaryEndpoint
|
||||
|
||||
from .state.base import StateViewSet, IntakeStateEndpoint
|
||||
from .view.base import (
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
|||
from ..base import BaseAPIView, BaseViewSet
|
||||
from plane.db.models import FileAsset, Workspace
|
||||
from plane.app.serializers import FileAssetSerializer
|
||||
from plane.utils.file_dedup import UploadedObjectMissing, confirm_uploaded_file_asset, release_file_asset_blob
|
||||
|
||||
|
||||
class FileAssetEndpoint(BaseAPIView):
|
||||
|
|
@ -37,15 +38,24 @@ class FileAssetEndpoint(BaseAPIView):
|
|||
if serializer.is_valid():
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer.save(workspace_id=workspace.id)
|
||||
asset = serializer.save(workspace_id=workspace.id)
|
||||
try:
|
||||
confirm_uploaded_file_asset(asset, request=request)
|
||||
except UploadedObjectMissing:
|
||||
return Response(
|
||||
{"error": "The uploaded asset object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, workspace_id, asset_key):
|
||||
asset_key = str(workspace_id) + "/" + asset_key
|
||||
file_asset = FileAsset.objects.get(asset=asset_key)
|
||||
if not file_asset.is_uploaded:
|
||||
release_file_asset_blob(file_asset, request=request, delete_untracked_object=True)
|
||||
file_asset.is_deleted = True
|
||||
file_asset.save(update_fields=["is_deleted"])
|
||||
file_asset.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
@ -75,12 +85,21 @@ class UserAssetsEndpoint(BaseAPIView):
|
|||
def post(self, request):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
asset = serializer.save()
|
||||
try:
|
||||
confirm_uploaded_file_asset(asset, request=request)
|
||||
except UploadedObjectMissing:
|
||||
return Response(
|
||||
{"error": "The uploaded asset object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def delete(self, request, asset_key):
|
||||
file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user)
|
||||
if not file_asset.is_uploaded:
|
||||
release_file_asset_blob(file_asset, request=request, delete_untracked_object=True)
|
||||
file_asset.is_deleted = True
|
||||
file_asset.save(update_fields=["is_deleted"])
|
||||
file_asset.save(update_fields=["is_deleted", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
|
|
|||
|
|
@ -22,9 +22,14 @@ from plane.db.models import FileAsset, Workspace, Project, User
|
|||
from plane.settings.storage import S3Storage
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.cache import invalidate_cache_directly
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.throttles.asset import AssetRateThrottle
|
||||
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||
from plane.utils.file_dedup import (
|
||||
UploadedObjectMissing,
|
||||
attach_existing_blob_to_file_asset,
|
||||
confirm_uploaded_file_asset,
|
||||
release_file_asset_blob,
|
||||
)
|
||||
|
||||
|
||||
class UserAssetsV2Endpoint(BaseAPIView):
|
||||
|
|
@ -34,9 +39,11 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
|||
asset = FileAsset.objects.filter(id=asset_id).first()
|
||||
if asset is None:
|
||||
return
|
||||
if not asset.is_uploaded:
|
||||
release_file_asset_blob(asset, delete_untracked_object=True)
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
|
||||
return
|
||||
|
||||
def entity_asset_save(self, asset_id, entity_type, asset, request):
|
||||
|
|
@ -171,11 +178,17 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
|||
def patch(self, request, asset_id):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
if not asset.storage_metadata:
|
||||
get_asset_object_metadata.delay(asset_id=str(asset_id))
|
||||
try:
|
||||
asset = confirm_uploaded_file_asset(
|
||||
asset,
|
||||
request=request,
|
||||
attributes=request.data.get("attributes", asset.attributes),
|
||||
)
|
||||
except UploadedObjectMissing:
|
||||
return Response(
|
||||
{"error": "The uploaded asset object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_save(
|
||||
asset_id=asset_id,
|
||||
|
|
@ -183,19 +196,17 @@ class UserAssetsV2Endpoint(BaseAPIView):
|
|||
asset=asset,
|
||||
request=request,
|
||||
)
|
||||
# update the attributes
|
||||
asset.attributes = request.data.get("attributes", asset.attributes)
|
||||
# save the asset
|
||||
asset.save(update_fields=["is_uploaded", "attributes"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def delete(self, request, asset_id):
|
||||
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
|
||||
if not asset.is_uploaded:
|
||||
release_file_asset_blob(asset, request=request, delete_untracked_object=True)
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
@ -239,10 +250,12 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
|||
# Check if the asset exists
|
||||
if asset is None:
|
||||
return
|
||||
if not asset.is_uploaded:
|
||||
release_file_asset_blob(asset, delete_untracked_object=True)
|
||||
# Mark the asset as deleted
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
|
||||
return
|
||||
|
||||
def entity_asset_save(self, asset_id, entity_type, asset, request):
|
||||
|
|
@ -380,11 +393,17 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
|||
def patch(self, request, slug, asset_id):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
if not asset.storage_metadata:
|
||||
get_asset_object_metadata.delay(asset_id=str(asset_id))
|
||||
try:
|
||||
asset = confirm_uploaded_file_asset(
|
||||
asset,
|
||||
request=request,
|
||||
attributes=request.data.get("attributes", asset.attributes),
|
||||
)
|
||||
except UploadedObjectMissing:
|
||||
return Response(
|
||||
{"error": "The uploaded asset object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_save(
|
||||
asset_id=asset_id,
|
||||
|
|
@ -392,19 +411,17 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
|||
asset=asset,
|
||||
request=request,
|
||||
)
|
||||
# update the attributes
|
||||
asset.attributes = request.data.get("attributes", asset.attributes)
|
||||
# save the asset
|
||||
asset.save(update_fields=["is_uploaded", "attributes"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def delete(self, request, slug, asset_id):
|
||||
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
|
||||
if not asset.is_uploaded:
|
||||
release_file_asset_blob(asset, request=request, delete_untracked_object=True)
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# get the entity and save the asset id for the request field
|
||||
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def get(self, request, slug, asset_id):
|
||||
|
|
@ -581,27 +598,30 @@ class ProjectAssetEndpoint(BaseAPIView):
|
|||
def patch(self, request, slug, project_id, pk):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
if not asset.storage_metadata:
|
||||
get_asset_object_metadata.delay(asset_id=str(pk))
|
||||
|
||||
# update the attributes
|
||||
asset.attributes = request.data.get("attributes", asset.attributes)
|
||||
# save the asset
|
||||
asset.save(update_fields=["is_uploaded", "attributes"])
|
||||
try:
|
||||
confirm_uploaded_file_asset(
|
||||
asset,
|
||||
request=request,
|
||||
attributes=request.data.get("attributes", asset.attributes),
|
||||
)
|
||||
except UploadedObjectMissing:
|
||||
return Response(
|
||||
{"error": "The uploaded asset object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def delete(self, request, slug, project_id, pk):
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
|
||||
if not asset.is_uploaded:
|
||||
release_file_asset_blob(asset, request=request, delete_untracked_object=True)
|
||||
# Check deleted assets
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# Save the asset
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
|
|
@ -753,7 +773,7 @@ class DuplicateAssetEndpoint(BaseAPIView):
|
|||
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
storage = S3Storage(request=request)
|
||||
original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first()
|
||||
original_asset = FileAsset.objects.filter(id=asset_id, workspace=workspace, is_uploaded=True).first()
|
||||
|
||||
if not original_asset:
|
||||
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
|
@ -774,9 +794,15 @@ class DuplicateAssetEndpoint(BaseAPIView):
|
|||
storage_metadata=original_asset.storage_metadata,
|
||||
**self.get_entity_id_field(entity_type=entity_type, entity_id=entity_id),
|
||||
)
|
||||
storage.copy_object(original_asset.asset, destination_key)
|
||||
# Update the is_uploaded field for all newly created assets
|
||||
FileAsset.objects.filter(id=duplicated_asset.id).update(is_uploaded=True)
|
||||
if not attach_existing_blob_to_file_asset(original_asset, duplicated_asset):
|
||||
storage.copy_object(original_asset.asset, destination_key)
|
||||
try:
|
||||
confirm_uploaded_file_asset(duplicated_asset, request=request)
|
||||
except UploadedObjectMissing:
|
||||
return Response(
|
||||
{"error": "The source asset object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return Response({"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ from plane.db.models import FileAsset, Workspace
|
|||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
|
||||
from plane.utils.file_dedup import finalize_uploaded_file_asset, release_file_asset_blob, UploadedObjectMissing
|
||||
|
||||
|
||||
class IssueAttachmentEndpoint(BaseAPIView):
|
||||
|
|
@ -69,7 +69,9 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||
{"error": "Issue attachment not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
release_result = release_file_asset_blob(issue_attachment, request=request)
|
||||
if not release_result.had_blob:
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.deleted",
|
||||
|
|
@ -155,9 +157,11 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
|||
@allow_permission([ROLE.ADMIN], creator=True, model=FileAsset)
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||
if not issue_attachment.is_uploaded:
|
||||
release_file_asset_blob(issue_attachment, request=request, delete_untracked_object=True)
|
||||
issue_attachment.is_deleted = True
|
||||
issue_attachment.deleted_at = timezone.now()
|
||||
issue_attachment.save()
|
||||
issue_attachment.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
|
||||
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.deleted",
|
||||
|
|
@ -225,12 +229,14 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
|||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
# Update the attachment
|
||||
issue_attachment.is_uploaded = True
|
||||
issue_attachment.created_by = request.user
|
||||
issue_attachment.save(update_fields=["created_by", "updated_at"])
|
||||
|
||||
# Get the storage metadata
|
||||
if not issue_attachment.storage_metadata:
|
||||
get_asset_object_metadata.delay(str(issue_attachment.id))
|
||||
issue_attachment.save()
|
||||
try:
|
||||
finalize_uploaded_file_asset(issue_attachment, request=request)
|
||||
except UploadedObjectMissing:
|
||||
return Response(
|
||||
{"error": "The uploaded attachment object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
|
|
|||
|
|
@ -186,6 +186,39 @@ VOICE_TASK_ABSOLUTE_MONTH_DATE_PATTERN = re.compile(
|
|||
VOICE_TASK_NUMERIC_DATE_PATTERN = re.compile(
|
||||
r"(?<!\d)(?P<day>[0-3]?\d)[./-](?P<month>[01]?\d)(?:[./-](?P<year>\d{2,4}))?(?!\d)"
|
||||
)
|
||||
VOICE_TASK_WEEKDAYS = {
|
||||
"понедельник": 0,
|
||||
"понедельника": 0,
|
||||
"понедельнику": 0,
|
||||
"monday": 0,
|
||||
"вторник": 1,
|
||||
"вторника": 1,
|
||||
"вторнику": 1,
|
||||
"tuesday": 1,
|
||||
"среда": 2,
|
||||
"среду": 2,
|
||||
"среды": 2,
|
||||
"wednesday": 2,
|
||||
"четверг": 3,
|
||||
"четверга": 3,
|
||||
"четвергу": 3,
|
||||
"thursday": 3,
|
||||
"пятница": 4,
|
||||
"пятницу": 4,
|
||||
"пятницы": 4,
|
||||
"friday": 4,
|
||||
"суббота": 5,
|
||||
"субботу": 5,
|
||||
"субботы": 5,
|
||||
"saturday": 5,
|
||||
"воскресенье": 6,
|
||||
"воскресенья": 6,
|
||||
"воскресенью": 6,
|
||||
"sunday": 6,
|
||||
}
|
||||
VOICE_TASK_WEEKDAY_PATTERN = re.compile(
|
||||
rf"(?<![0-9a-zа-я])(?P<weekday>{'|'.join(sorted(VOICE_TASK_WEEKDAYS.keys(), key=len, reverse=True))})(?![0-9a-zа-я])"
|
||||
)
|
||||
|
||||
|
||||
def normalize_audio_content_type(content_type):
|
||||
|
|
@ -358,6 +391,11 @@ class VoiceTaskParserService:
|
|||
"For create_task, title must be a compact but meaning-preserving task name, not a 2-word summary. "
|
||||
"description should be a detailed structured summary that preserves the user's meaning; "
|
||||
"checklist should contain actionable bullet decomposition when the transcript includes multiple steps. "
|
||||
"Return title, description, labels, checklist, and questions in the same natural language as "
|
||||
"the transcript. If the transcript is Russian, all human-facing text must be Russian; never "
|
||||
"translate Russian task text into English. "
|
||||
"If the user says to assign the task to all employees/team members of a project/department, "
|
||||
"set assignee_hint to all_project_members. "
|
||||
"Use state_hint only for explicit status/state phrases like в работе, в реализации, active, backlog, done. "
|
||||
"Do not infer state_hint from project names. If no status is requested, return null. "
|
||||
"Never classify delete/remove/cancel-last-task commands as create_task. "
|
||||
|
|
@ -570,9 +608,91 @@ def derive_voice_task_title_from_text(value):
|
|||
return title or None
|
||||
|
||||
|
||||
def voice_task_text_has_cyrillic(value):
|
||||
return bool(normalize_string(value) and re.search(r"[А-Яа-яЁё]", value))
|
||||
|
||||
|
||||
def voice_task_text_looks_latin(value):
|
||||
normalized = normalize_string(value)
|
||||
if not normalized:
|
||||
return False
|
||||
|
||||
latin_count = len(re.findall(r"[A-Za-z]", normalized))
|
||||
cyrillic_count = len(re.findall(r"[А-Яа-яЁё]", normalized))
|
||||
return latin_count >= 12 and latin_count > cyrillic_count * 2
|
||||
|
||||
|
||||
def derive_voice_task_title_from_transcript(transcript):
|
||||
normalized = normalize_string(transcript, 1200)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
action_verbs = [
|
||||
"отправить",
|
||||
"сдать",
|
||||
"подготовить",
|
||||
"закрыть",
|
||||
"согласовать",
|
||||
"проверить",
|
||||
"добавить",
|
||||
"создать",
|
||||
"передать",
|
||||
"позвонить",
|
||||
"написать",
|
||||
"сделать",
|
||||
"разобрать",
|
||||
"обновить",
|
||||
"исправить",
|
||||
"сверить",
|
||||
"выгрузить",
|
||||
"загрузить",
|
||||
"оформить",
|
||||
]
|
||||
sentences = [sentence.strip(" :-,;") for sentence in re.split(r"[.!?\n]+", normalized) if sentence.strip()]
|
||||
for sentence in sentences:
|
||||
sentence_lower = sentence.lower().replace("ё", "е")
|
||||
if "задач" not in sentence_lower:
|
||||
continue
|
||||
best_index = None
|
||||
for verb in action_verbs:
|
||||
match = re.search(rf"(?<![а-яa-z]){verb}(?![а-яa-z])", sentence_lower)
|
||||
if match and (best_index is None or match.start() < best_index):
|
||||
best_index = match.start()
|
||||
if best_index is not None:
|
||||
title = sentence[best_index:].strip(" :-,;")
|
||||
return derive_voice_task_title_from_text(title)
|
||||
|
||||
return derive_voice_task_title_from_text(normalized)
|
||||
|
||||
|
||||
def enforce_voice_task_output_language(parsed, transcript):
|
||||
if parsed.get("intent") != "create_task" or not voice_task_text_has_cyrillic(transcript):
|
||||
return parsed
|
||||
|
||||
if voice_task_text_looks_latin(parsed.get("title")):
|
||||
parsed["title"] = derive_voice_task_title_from_transcript(transcript) or parsed.get("title")
|
||||
parsed["confidence"]["task"] = min(parsed["confidence"].get("task", 1.0), 0.9)
|
||||
|
||||
if voice_task_text_looks_latin(parsed.get("description")):
|
||||
parsed["description"] = normalize_string(transcript) or parsed.get("description")
|
||||
parsed["confidence"]["task"] = min(parsed["confidence"].get("task", 1.0), 0.9)
|
||||
|
||||
parsed["checklist"] = [
|
||||
item for item in parsed.get("checklist", []) if not voice_task_text_looks_latin(item)
|
||||
]
|
||||
parsed["questions"] = [
|
||||
question for question in parsed.get("questions", []) if not voice_task_text_looks_latin(question)
|
||||
]
|
||||
return parsed
|
||||
|
||||
|
||||
def harden_voice_task_intent(parsed, transcript):
|
||||
parsed = enforce_voice_task_output_language(parsed, transcript)
|
||||
|
||||
if parsed.get("intent") != "update_task":
|
||||
return parsed
|
||||
if parsed.get("target_task_id"):
|
||||
return parsed
|
||||
|
||||
target_memory_ref = normalize_string(parsed.get("target_memory_ref"), 80)
|
||||
has_explicit_issue_ref = bool(parse_issue_key_reference(target_memory_ref)) or transcript_has_issue_key_reference(
|
||||
|
|
@ -598,6 +718,9 @@ def harden_voice_task_intent(parsed, transcript):
|
|||
|
||||
|
||||
def voice_task_has_safe_existing_task_anchor(draft, transcript):
|
||||
if draft.get("target_task_id"):
|
||||
return True
|
||||
|
||||
target_memory_ref = normalize_string(draft.get("target_memory_ref"), 80)
|
||||
return bool(
|
||||
parse_issue_key_reference(target_memory_ref)
|
||||
|
|
@ -738,6 +861,14 @@ def serialize_resolved_assignee(user, confidence=0.0, source=None):
|
|||
}
|
||||
|
||||
|
||||
def get_voice_task_project_assignable_members(project):
|
||||
return (
|
||||
ProjectMember.objects.filter(project=project, is_active=True, role__gte=ROLE.MEMBER.value, member__is_active=True)
|
||||
.select_related("member")
|
||||
.order_by("member__display_name", "member__email")
|
||||
)
|
||||
|
||||
|
||||
def serialize_resolved_state(state, confidence=0.0, source=None):
|
||||
if not state:
|
||||
return None
|
||||
|
|
@ -820,11 +951,7 @@ def resolve_voice_task_assignee(project, draft):
|
|||
if not assignee_hint:
|
||||
return None
|
||||
|
||||
project_members = (
|
||||
ProjectMember.objects.filter(project=project, is_active=True, role__gte=ROLE.MEMBER.value, member__is_active=True)
|
||||
.select_related("member")
|
||||
.order_by("member__display_name", "member__email")
|
||||
)
|
||||
project_members = get_voice_task_project_assignable_members(project)
|
||||
best_member = None
|
||||
best_score = 0.0
|
||||
for project_member in project_members:
|
||||
|
|
@ -849,6 +976,57 @@ def resolve_voice_task_assignee(project, draft):
|
|||
return serialize_resolved_assignee(best_member, best_score, "assignee_hint")
|
||||
|
||||
|
||||
def resolve_explicit_voice_task_assignees(project, draft):
|
||||
if "assignee_ids" not in draft:
|
||||
return None
|
||||
|
||||
assignee_ids = draft.get("assignee_ids") if isinstance(draft.get("assignee_ids"), list) else []
|
||||
if not assignee_ids:
|
||||
return []
|
||||
|
||||
member_by_id = {
|
||||
str(project_member.member_id): project_member.member
|
||||
for project_member in get_voice_task_project_assignable_members(project)
|
||||
}
|
||||
|
||||
return [
|
||||
serialize_resolved_assignee(member_by_id[assignee_id], 1.0, "explicit_assignee_ids")
|
||||
for assignee_id in assignee_ids
|
||||
if assignee_id in member_by_id
|
||||
]
|
||||
|
||||
|
||||
def transcript_requests_all_project_assignees(transcript, draft=None):
|
||||
normalized = normalize_match_value(transcript)
|
||||
assignee_hint = normalize_match_value((draft or {}).get("assignee_hint"))
|
||||
if assignee_hint in {"all project members", "all_project_members", "all members", "all employees"}:
|
||||
return True
|
||||
if not normalized:
|
||||
return False
|
||||
|
||||
patterns = [
|
||||
r"\b(всех|всем|кажд\w*|all|everyone|everybody)\b.{0,48}\b(сотрудник\w*|участник\w*|исполнитель\w*|ответственн\w*|employees|members|assignees)\b",
|
||||
r"\b(назнач\w*|ответственн\w*|исполнитель\w*)\b.{0,48}\b(всех|всем|кажд\w*|all|everyone|everybody)\b",
|
||||
r"\b(сотрудник\w*|участник\w*|employees|members)\b.{0,24}\b(всех|всем|all|everyone|everybody)\b",
|
||||
]
|
||||
return any(re.search(pattern, normalized) for pattern in patterns)
|
||||
|
||||
|
||||
def resolve_voice_task_assignees(project, draft, transcript=None):
|
||||
explicit_assignees = resolve_explicit_voice_task_assignees(project, draft)
|
||||
if explicit_assignees is not None:
|
||||
return explicit_assignees
|
||||
|
||||
if transcript_requests_all_project_assignees(transcript, draft):
|
||||
return [
|
||||
serialize_resolved_assignee(project_member.member, 1.0, "all_project_members")
|
||||
for project_member in get_voice_task_project_assignable_members(project)
|
||||
]
|
||||
|
||||
assignee = resolve_voice_task_assignee(project, draft)
|
||||
return [assignee] if assignee else []
|
||||
|
||||
|
||||
def resolve_voice_task_labels(project, draft):
|
||||
label_names = draft.get("labels") if isinstance(draft.get("labels"), list) else []
|
||||
if not label_names:
|
||||
|
|
@ -932,8 +1110,14 @@ def resolve_voice_task_state(project, draft, allow_default=True):
|
|||
if not project:
|
||||
return None
|
||||
|
||||
state_hint = draft.get("state_hint")
|
||||
states = list(State.objects.filter(project=project).order_by("sequence"))
|
||||
explicit_state_id = normalize_uuid_string(draft.get("state_id"))
|
||||
if explicit_state_id:
|
||||
explicit_state = next((state for state in states if str(state.id) == explicit_state_id), None)
|
||||
if explicit_state:
|
||||
return serialize_resolved_state(explicit_state, 1.0, "explicit_state_id")
|
||||
|
||||
state_hint = draft.get("state_hint")
|
||||
if state_hint:
|
||||
best_state = None
|
||||
best_score = 0.0
|
||||
|
|
@ -1059,6 +1243,33 @@ def infer_voice_task_absolute_due_date(transcript, current_date):
|
|||
return None
|
||||
|
||||
|
||||
def infer_voice_task_weekday_due_date(transcript, current_date):
|
||||
normalized = normalize_match_value(transcript)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
has_due_context = bool(
|
||||
re.search(
|
||||
r"\b(срок|дедлайн|deadline|выполнен\w*|выполнить|закончить|завершить|до|к|конц\w*|by|end)\b",
|
||||
normalized,
|
||||
)
|
||||
)
|
||||
for match in VOICE_TASK_WEEKDAY_PATTERN.finditer(normalized):
|
||||
window_start = max(0, match.start() - 48)
|
||||
window_end = min(len(normalized), match.end() + 32)
|
||||
window = normalized[window_start:window_end]
|
||||
if not has_due_context and not re.search(r"\b(до|к|на|в|во|by)\b", window):
|
||||
continue
|
||||
|
||||
target_weekday = VOICE_TASK_WEEKDAYS[match.group("weekday")]
|
||||
days_ahead = (target_weekday - current_date.weekday()) % 7
|
||||
if days_ahead == 0 and re.search(r"\b(следующ\w*|next)\b", window):
|
||||
days_ahead = 7
|
||||
return (current_date + timedelta(days=days_ahead)).isoformat()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def infer_voice_task_relative_due_date(transcript, current_date, target_issue=None):
|
||||
normalized = normalize_match_value(transcript)
|
||||
if not normalized:
|
||||
|
|
@ -1155,6 +1366,11 @@ def hydrate_voice_task_due_date(draft, transcript, client_context, user, workspa
|
|||
draft["due_date"] = absolute_due_date
|
||||
return
|
||||
|
||||
weekday_due_date = infer_voice_task_weekday_due_date(transcript, current_date=current_date)
|
||||
if weekday_due_date:
|
||||
draft["due_date"] = weekday_due_date
|
||||
return
|
||||
|
||||
inferred_due_date = infer_voice_task_relative_due_date(
|
||||
transcript=transcript,
|
||||
current_date=current_date,
|
||||
|
|
@ -1271,9 +1487,22 @@ def find_latest_voice_task_issue(memory_sessions, project_id=None):
|
|||
|
||||
def resolve_voice_task_memory_target(workspace, user, draft, current_session=None, client_context=None, transcript=None):
|
||||
target_memory_ref = normalize_string(draft.get("target_memory_ref"), 80)
|
||||
explicit_target_task_id = normalize_uuid_string(draft.get("target_task_id"))
|
||||
memory_sessions = get_committed_voice_task_memory_sessions(workspace, user, current_session=current_session)
|
||||
generic_memory_reference = transcript_has_generic_memory_reference(transcript)
|
||||
|
||||
if explicit_target_task_id:
|
||||
target_issue = (
|
||||
Issue.issue_objects.filter(workspace=workspace, id=explicit_target_task_id)
|
||||
.select_related("project")
|
||||
.first()
|
||||
)
|
||||
if (
|
||||
is_voice_task_issue_available(target_issue)
|
||||
and get_accessible_projects(workspace, user).filter(id=target_issue.project_id).exists()
|
||||
):
|
||||
return target_issue, "explicit_target_task_id", None
|
||||
|
||||
if target_memory_ref:
|
||||
target_uuid = None
|
||||
try:
|
||||
|
|
@ -1342,6 +1571,8 @@ def voice_task_has_update_fields(draft, resolution):
|
|||
or draft.get("due_time")
|
||||
or (draft.get("priority") and draft.get("priority") != "none")
|
||||
or draft.get("checklist")
|
||||
or "assignee_ids" in draft
|
||||
or draft.get("state_id")
|
||||
or (resolution.get("assignee") and resolution["assignee"]["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD)
|
||||
or (draft.get("state_hint") and resolution.get("state") and resolution["state"]["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD)
|
||||
or resolution.get("labels")
|
||||
|
|
@ -1444,10 +1675,12 @@ def build_voice_task_resolution(workspace, user, ai_settings, draft, client_cont
|
|||
warnings.append("project_hint_not_in_transcript")
|
||||
|
||||
resolved_assignee = None
|
||||
resolved_assignees = []
|
||||
resolved_labels = []
|
||||
resolved_state = None
|
||||
if project:
|
||||
resolved_assignee = resolve_voice_task_assignee(project, draft)
|
||||
resolved_assignees = resolve_voice_task_assignees(project, draft, transcript)
|
||||
resolved_assignee = resolved_assignees[0] if len(resolved_assignees) == 1 else None
|
||||
if resolved_assignee and resolved_assignee["confidence"] < VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
|
||||
warnings.append("low_assignee_confidence")
|
||||
resolved_labels = resolve_voice_task_labels(project, draft)
|
||||
|
|
@ -1490,6 +1723,7 @@ def build_voice_task_resolution(workspace, user, ai_settings, draft, client_cont
|
|||
resolution = {
|
||||
"project": resolved_project,
|
||||
"assignee": resolved_assignee,
|
||||
"assignees": resolved_assignees,
|
||||
"labels": resolved_labels,
|
||||
"state": resolved_state,
|
||||
"target_task": serialize_voice_task_target(target_issue, target_source, target_session),
|
||||
|
|
@ -1616,10 +1850,17 @@ def append_voice_task_description(existing_html, update_html):
|
|||
def build_voice_task_issue_payload(draft, resolution, transcript=None):
|
||||
project = resolution.get("project")
|
||||
assignee = resolution.get("assignee")
|
||||
assignees = resolution.get("assignees") or []
|
||||
state = resolution.get("state")
|
||||
labels = resolution.get("labels") or []
|
||||
assignee_ids = []
|
||||
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
|
||||
if assignees:
|
||||
assignee_ids = [
|
||||
assignee["id"]
|
||||
for assignee in assignees
|
||||
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD
|
||||
]
|
||||
elif assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
|
||||
assignee_ids = [assignee["id"]]
|
||||
|
||||
return {
|
||||
|
|
@ -1636,6 +1877,7 @@ def build_voice_task_issue_payload(draft, resolution, transcript=None):
|
|||
|
||||
def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=None):
|
||||
assignee = resolution.get("assignee")
|
||||
assignees = resolution.get("assignees") or []
|
||||
state = resolution.get("state")
|
||||
labels = resolution.get("labels") or []
|
||||
project_change = resolution.get("project_change")
|
||||
|
|
@ -1654,10 +1896,22 @@ def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=N
|
|||
if draft.get("priority") and draft["priority"] != "none":
|
||||
payload["priority"] = draft["priority"]
|
||||
|
||||
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
|
||||
if "assignee_ids" in draft:
|
||||
payload["assignee_ids"] = [
|
||||
assignee["id"]
|
||||
for assignee in assignees
|
||||
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD
|
||||
]
|
||||
elif assignees:
|
||||
payload["assignee_ids"] = [
|
||||
assignee["id"]
|
||||
for assignee in assignees
|
||||
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD
|
||||
]
|
||||
elif assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
|
||||
payload["assignee_ids"] = [assignee["id"]]
|
||||
|
||||
if (draft.get("state_hint") or project_change) and state and state["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD:
|
||||
if (draft.get("state_hint") or draft.get("state_id") or project_change) and state and state["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD:
|
||||
payload["state_id"] = state["id"]
|
||||
|
||||
if labels:
|
||||
|
|
@ -1845,6 +2099,29 @@ def normalize_string(value, max_length=None):
|
|||
return normalized[:max_length] if max_length else normalized
|
||||
|
||||
|
||||
def normalize_uuid_string(value):
|
||||
normalized = normalize_string(value, 80)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
try:
|
||||
return str(uuid.UUID(normalized))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def normalize_uuid_list(value, limit=50):
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
result = []
|
||||
for item in value[:limit]:
|
||||
normalized = normalize_uuid_string(item)
|
||||
if normalized and normalized not in result:
|
||||
result.append(normalized)
|
||||
return result
|
||||
|
||||
|
||||
def normalize_string_list(value, limit=20, item_max_length=120):
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
|
@ -1899,6 +2176,9 @@ def normalize_voice_task_parse(parsed):
|
|||
normalized = {
|
||||
"intent": intent,
|
||||
"target_memory_ref": normalize_string(parsed.get("target_memory_ref"), 80),
|
||||
"project_id": normalize_uuid_string(parsed.get("project_id")),
|
||||
"state_id": normalize_uuid_string(parsed.get("state_id")),
|
||||
"target_task_id": normalize_uuid_string(parsed.get("target_task_id") or parsed.get("target_issue_id")),
|
||||
"project_hint": normalize_string(parsed.get("project_hint"), 255),
|
||||
"state_hint": normalize_string(parsed.get("state_hint"), 120),
|
||||
"assignee_hint": normalize_string(parsed.get("assignee_hint"), 255),
|
||||
|
|
@ -1917,6 +2197,8 @@ def normalize_voice_task_parse(parsed):
|
|||
},
|
||||
"questions": normalize_string_list(parsed.get("questions"), limit=10, item_max_length=255),
|
||||
}
|
||||
if "assignee_ids" in parsed:
|
||||
normalized["assignee_ids"] = normalize_uuid_list(parsed.get("assignee_ids"))
|
||||
|
||||
return normalized
|
||||
|
||||
|
|
@ -2307,66 +2589,145 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
if action == "create_task":
|
||||
project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace)
|
||||
payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript)
|
||||
payload_without_project = {key: value for key, value in payload.items() if key != "project_id"}
|
||||
payload_without_project["external_source"] = VOICE_TASK_EXTERNAL_SOURCE
|
||||
payload_without_project["external_id"] = str(voice_session.id)
|
||||
existing_issue = voice_session.created_task
|
||||
if is_voice_task_issue_available(existing_issue):
|
||||
issue = existing_issue
|
||||
if not can_user_update_voice_task_issue(request.user, workspace, issue):
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"code": "issue_permission_denied",
|
||||
"error": "Voice Task draft could not update the created work item.",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = IssueCreateSerializer(
|
||||
data=payload_without_project,
|
||||
context={
|
||||
"project_id": project.id,
|
||||
"workspace_id": workspace.id,
|
||||
"default_assignee_id": project.default_assignee_id,
|
||||
},
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"code": "issue_validation_failed",
|
||||
"error": "Voice Task draft could not be converted to a work item.",
|
||||
"details": serializer.errors,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace)
|
||||
payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript)
|
||||
payload_without_project = {key: value for key, value in payload.items() if key != "project_id"}
|
||||
payload_without_project["external_source"] = VOICE_TASK_EXTERNAL_SOURCE
|
||||
payload_without_project["external_id"] = str(voice_session.id)
|
||||
current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
|
||||
requested_data = json.dumps(
|
||||
{**payload_without_project, "project_id": str(project.id)},
|
||||
cls=DjangoJSONEncoder,
|
||||
)
|
||||
|
||||
issue = serializer.save(created_by_id=request.user.id)
|
||||
voice_session.created_task = issue
|
||||
voice_session.parsed_json = draft
|
||||
voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"])
|
||||
if issue.project_id != project.id:
|
||||
issue = move_voice_task_issue_to_project(issue, project, resolution["state"], request.user)
|
||||
|
||||
requested_data = json.dumps(payload_without_project, cls=DjangoJSONEncoder)
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project.id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(issue.id),
|
||||
requested_data=payload_without_project,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
issue_description_version_task.delay(
|
||||
updated_issue=requested_data,
|
||||
issue_id=str(issue.id),
|
||||
user_id=request.user.id,
|
||||
is_creating=True,
|
||||
)
|
||||
serializer = IssueCreateSerializer(
|
||||
issue,
|
||||
data=payload_without_project,
|
||||
partial=True,
|
||||
context={"project_id": project.id},
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"code": "issue_validation_failed",
|
||||
"error": "Voice Task draft could not update the created work item.",
|
||||
"details": serializer.errors,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
response_status = status.HTTP_201_CREATED
|
||||
commit_status = "created"
|
||||
serializer.save()
|
||||
issue.refresh_from_db()
|
||||
voice_session.created_task = issue
|
||||
voice_session.parsed_json = draft
|
||||
voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"])
|
||||
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project.id),
|
||||
current_instance=current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(issue.id),
|
||||
requested_data=json.loads(requested_data),
|
||||
current_instance=current_instance,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
issue_description_version_task.delay(
|
||||
updated_issue=current_instance,
|
||||
issue_id=str(issue.id),
|
||||
user_id=request.user.id,
|
||||
)
|
||||
|
||||
response_status = status.HTTP_200_OK
|
||||
commit_status = "updated"
|
||||
else:
|
||||
project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace)
|
||||
payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript)
|
||||
payload_without_project = {key: value for key, value in payload.items() if key != "project_id"}
|
||||
payload_without_project["external_source"] = VOICE_TASK_EXTERNAL_SOURCE
|
||||
payload_without_project["external_id"] = str(voice_session.id)
|
||||
|
||||
serializer = IssueCreateSerializer(
|
||||
data=payload_without_project,
|
||||
context={
|
||||
"project_id": project.id,
|
||||
"workspace_id": workspace.id,
|
||||
"default_assignee_id": project.default_assignee_id,
|
||||
},
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"code": "issue_validation_failed",
|
||||
"error": "Voice Task draft could not be converted to a work item.",
|
||||
"details": serializer.errors,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
issue = serializer.save(created_by_id=request.user.id)
|
||||
voice_session.created_task = issue
|
||||
voice_session.parsed_json = draft
|
||||
voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"])
|
||||
|
||||
requested_data = json.dumps(payload_without_project, cls=DjangoJSONEncoder)
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project.id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(issue.id),
|
||||
requested_data=payload_without_project,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
issue_description_version_task.delay(
|
||||
updated_issue=requested_data,
|
||||
issue_id=str(issue.id),
|
||||
user_id=request.user.id,
|
||||
is_creating=True,
|
||||
)
|
||||
|
||||
response_status = status.HTTP_201_CREATED
|
||||
commit_status = "created"
|
||||
|
||||
elif action == "update_task":
|
||||
target_task = resolution.get("target_task") or {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import FileAsset, Project, StoredBlob, Workspace
|
||||
|
||||
|
||||
def _int_size(value):
|
||||
return int(value or 0)
|
||||
|
||||
|
||||
def _sum_asset_size(queryset):
|
||||
return _int_size(queryset.aggregate(total=Sum("size")).get("total"))
|
||||
|
||||
|
||||
def _sum_blob_size(blob_ids):
|
||||
if not blob_ids:
|
||||
return 0
|
||||
|
||||
return _int_size(StoredBlob.objects.filter(id__in=blob_ids).aggregate(total=Sum("size")).get("total"))
|
||||
|
||||
|
||||
def _dedup_savings(logical_size, physical_size):
|
||||
return max(_int_size(logical_size) - _int_size(physical_size), 0)
|
||||
|
||||
|
||||
class WorkspaceStorageSummaryEndpoint(BaseAPIView):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def get(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
stale_cutoff = timezone.now() - timedelta(days=1)
|
||||
|
||||
active_assets = FileAsset.objects.filter(workspace=workspace, is_uploaded=True)
|
||||
active_blob_ids = list(active_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct())
|
||||
|
||||
workspace_logical_size = _sum_asset_size(active_assets)
|
||||
workspace_physical_size = _sum_blob_size(active_blob_ids)
|
||||
|
||||
failed_uploads = FileAsset.all_objects.filter(
|
||||
workspace=workspace,
|
||||
is_uploaded=False,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
stale_unuploaded = failed_uploads.filter(created_at__lt=stale_cutoff)
|
||||
soft_deleted_assets = FileAsset.all_objects.filter(workspace=workspace, deleted_at__isnull=False)
|
||||
orphaned_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.ORPHANED)
|
||||
missing_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.MISSING)
|
||||
uploaded_without_blob = active_assets.filter(blob__isnull=True)
|
||||
|
||||
project_rows = []
|
||||
projects = Project.objects.filter(workspace=workspace).order_by("name")
|
||||
for project in projects:
|
||||
project_assets = active_assets.filter(project=project)
|
||||
project_blob_ids = list(
|
||||
project_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct()
|
||||
)
|
||||
project_logical_size = _sum_asset_size(project_assets)
|
||||
project_physical_size = _sum_blob_size(project_blob_ids)
|
||||
project_failed_uploads = failed_uploads.filter(project=project)
|
||||
project_soft_deleted = soft_deleted_assets.filter(project=project)
|
||||
project_uploaded_without_blob = uploaded_without_blob.filter(project=project)
|
||||
|
||||
project_rows.append(
|
||||
{
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"identifier": project.identifier,
|
||||
"file_count": project_assets.count(),
|
||||
"blob_count": len(project_blob_ids),
|
||||
"logical_size": project_logical_size,
|
||||
"physical_size": project_physical_size,
|
||||
"dedup_savings": _dedup_savings(project_logical_size, project_physical_size),
|
||||
"failed_upload_count": project_failed_uploads.count(),
|
||||
"failed_upload_size": _sum_asset_size(project_failed_uploads),
|
||||
"soft_deleted_count": project_soft_deleted.count(),
|
||||
"soft_deleted_size": _sum_asset_size(project_soft_deleted),
|
||||
"uploaded_without_blob_count": project_uploaded_without_blob.count(),
|
||||
}
|
||||
)
|
||||
|
||||
data = {
|
||||
"workspace": {
|
||||
"id": str(workspace.id),
|
||||
"name": workspace.name,
|
||||
"slug": workspace.slug,
|
||||
"upload_file_size_limit_enabled": workspace.storage_file_size_limit_enabled,
|
||||
"upload_file_size_limit": workspace.storage_file_size_limit,
|
||||
},
|
||||
"summary": {
|
||||
"file_count": active_assets.count(),
|
||||
"blob_count": len(active_blob_ids),
|
||||
"logical_size": workspace_logical_size,
|
||||
"physical_size": workspace_physical_size,
|
||||
"dedup_savings": _dedup_savings(workspace_logical_size, workspace_physical_size),
|
||||
"uploaded_without_blob_count": uploaded_without_blob.count(),
|
||||
},
|
||||
"diagnostics": {
|
||||
"failed_upload_count": failed_uploads.count(),
|
||||
"failed_upload_size": _sum_asset_size(failed_uploads),
|
||||
"stale_unuploaded_count": stale_unuploaded.count(),
|
||||
"stale_unuploaded_size": _sum_asset_size(stale_unuploaded),
|
||||
"soft_deleted_count": soft_deleted_assets.count(),
|
||||
"soft_deleted_size": _sum_asset_size(soft_deleted_assets),
|
||||
"orphaned_blob_count": orphaned_blobs.count(),
|
||||
"orphaned_blob_size": _sum_blob_size(list(orphaned_blobs.values_list("id", flat=True))),
|
||||
"missing_blob_count": missing_blobs.count(),
|
||||
"missing_blob_size": _sum_blob_size(list(missing_blobs.values_list("id", flat=True))),
|
||||
},
|
||||
"projects": project_rows,
|
||||
}
|
||||
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
|
@ -406,6 +406,35 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
|
||||
class WorkspaceActivityEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def get(self, request, slug):
|
||||
projects = request.query_params.getlist("project", [])
|
||||
|
||||
queryset = (
|
||||
IssueActivity.objects.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
)
|
||||
.select_related("actor", "workspace", "issue", "project")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
if projects:
|
||||
queryset = queryset.filter(project__in=projects)
|
||||
|
||||
return self.paginate(
|
||||
order_by=request.GET.get("order_by", "-created_at"),
|
||||
request=request,
|
||||
queryset=queryset,
|
||||
on_results=lambda issue_activities: IssueActivitySerializer(issue_activities, many=True).data,
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, user_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from plane.utils.exception_logger import log_exception
|
|||
from plane.settings.storage import S3Storage
|
||||
from celery import shared_task
|
||||
from plane.utils.url import normalize_url_path
|
||||
from plane.utils.file_dedup import attach_existing_blob_to_file_asset, confirm_uploaded_file_asset
|
||||
|
||||
|
||||
def get_entity_id_field(entity_type, entity_id):
|
||||
|
|
@ -108,15 +109,16 @@ def copy_assets(entity, entity_identifier, project_id, asset_ids, user_id):
|
|||
storage_metadata=original_asset.storage_metadata,
|
||||
**get_entity_id_field(original_asset.entity_type, entity_identifier),
|
||||
)
|
||||
storage.copy_object(original_asset.asset, destination_key)
|
||||
if not attach_existing_blob_to_file_asset(original_asset, duplicated_asset):
|
||||
storage.copy_object(original_asset.asset, destination_key)
|
||||
confirm_uploaded_file_asset(duplicated_asset)
|
||||
|
||||
duplicated_assets.append(
|
||||
{
|
||||
"new_asset_id": str(duplicated_asset.id),
|
||||
"old_asset_id": str(original_asset.id),
|
||||
}
|
||||
)
|
||||
if duplicated_assets:
|
||||
FileAsset.objects.filter(pk__in=[item["new_asset_id"] for item in duplicated_assets]).update(is_uploaded=True)
|
||||
|
||||
return duplicated_assets
|
||||
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ def restore_related_objects(app_label, model_name, instance_pk, using=None):
|
|||
|
||||
@shared_task
|
||||
def hard_delete():
|
||||
from plane.bgtasks.file_asset_task import delete_expired_file_asset
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
|
|
@ -134,6 +135,9 @@ def hard_delete():
|
|||
)
|
||||
|
||||
days = settings.HARD_DELETE_AFTER_DAYS
|
||||
|
||||
delete_expired_file_asset()
|
||||
|
||||
# check delete workspace
|
||||
_ = Workspace.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
|
|
@ -185,6 +189,9 @@ def hard_delete():
|
|||
|
||||
# Iterate through all models
|
||||
for model in all_models:
|
||||
if model._meta.label_lower in {"db.fileasset", "db.storedblob"}:
|
||||
continue
|
||||
|
||||
# Check if the model has a 'deleted_at' field
|
||||
if hasattr(model, "deleted_at"):
|
||||
# Get all instances where 'deleted_at' is greater than 30 days ago
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import os
|
|||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
|
||||
|
|
@ -14,13 +15,63 @@ from django.db.models import Q
|
|||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset
|
||||
from plane.db.models import FileAsset, StoredBlob
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.file_dedup import release_file_asset_blob
|
||||
|
||||
|
||||
def _asset_object_key(asset):
|
||||
return str(asset.asset.name or asset.asset)
|
||||
|
||||
|
||||
def _delete_legacy_asset_object(asset, storage):
|
||||
object_key = _asset_object_key(asset)
|
||||
if not object_key:
|
||||
return False
|
||||
|
||||
has_other_reference = FileAsset.all_objects.filter(asset=object_key).exclude(pk=asset.pk).exists()
|
||||
if has_other_reference:
|
||||
return False
|
||||
|
||||
return storage.delete_files(object_names=[object_key])
|
||||
|
||||
|
||||
def _hard_delete_file_asset(asset, storage=None):
|
||||
blob_id = asset.blob_id
|
||||
|
||||
if blob_id:
|
||||
release_file_asset_blob(asset)
|
||||
StoredBlob.all_objects.filter(
|
||||
pk=blob_id,
|
||||
ref_count=0,
|
||||
status=StoredBlob.Status.ORPHANED,
|
||||
).delete()
|
||||
else:
|
||||
storage = storage or S3Storage()
|
||||
_delete_legacy_asset_object(asset, storage)
|
||||
|
||||
asset.delete(soft=False)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_unuploaded_file_asset():
|
||||
"""This task deletes unuploaded file assets older than a certain number of days."""
|
||||
FileAsset.objects.filter(
|
||||
stale_assets = FileAsset.objects.filter(
|
||||
Q(created_at__lt=timezone.now() - timedelta(days=int(os.environ.get("UNUPLOADED_ASSET_DELETE_DAYS", "7"))))
|
||||
& Q(is_uploaded=False)
|
||||
).delete()
|
||||
)
|
||||
for asset in stale_assets.iterator():
|
||||
release_file_asset_blob(asset, delete_untracked_object=True)
|
||||
asset.delete(soft=False)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_expired_file_asset():
|
||||
"""Hard delete soft-deleted file assets after the restore retention window."""
|
||||
days = int(os.environ.get("FILE_ASSET_HARD_DELETE_AFTER_DAYS", settings.HARD_DELETE_AFTER_DAYS))
|
||||
cutoff = timezone.now() - timedelta(days=days)
|
||||
storage = S3Storage()
|
||||
expired_assets = FileAsset.all_objects.filter(deleted_at__lt=cutoff)
|
||||
|
||||
for asset in expired_assets.iterator():
|
||||
_hard_delete_file_asset(asset, storage=storage)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
# Generated by Codex on 2026-04-26
|
||||
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0127_issue_detail_layout"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="StoredBlob",
|
||||
fields=[
|
||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")),
|
||||
("updated_at", models.DateTimeField(auto_now=True, verbose_name="Last Modified At")),
|
||||
("deleted_at", models.DateTimeField(blank=True, null=True, verbose_name="Deleted At")),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("sha256", models.CharField(db_index=True, max_length=64)),
|
||||
("size", models.PositiveBigIntegerField(default=0)),
|
||||
("mime_type", models.CharField(blank=True, default="", max_length=255)),
|
||||
("canonical_object_key", models.CharField(max_length=800)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[("active", "Active"), ("orphaned", "Orphaned"), ("missing", "Missing")],
|
||||
default="active",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("ref_count", models.PositiveIntegerField(default=0)),
|
||||
("storage_metadata", models.JSONField(blank=True, default=dict, null=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="stored_blobs",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Stored Blob",
|
||||
"verbose_name_plural": "Stored Blobs",
|
||||
"db_table": "stored_blobs",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="fileasset",
|
||||
name="blob",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="assets",
|
||||
to="db.storedblob",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="storedblob",
|
||||
index=models.Index(fields=["workspace", "sha256"], name="stored_blob_workspace_sha_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="storedblob",
|
||||
index=models.Index(fields=["status"], name="stored_blob_status_idx"),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="storedblob",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True), ("status", "active")),
|
||||
fields=("workspace", "sha256", "size"),
|
||||
name="stored_blob_unique_active_hash_size",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
from .analytic import AnalyticView
|
||||
from .api import APIActivityLog, APIToken
|
||||
from .asset import FileAsset
|
||||
from .asset import FileAsset, StoredBlob
|
||||
from .base import BaseModel
|
||||
from .cycle import Cycle, CycleIssue, CycleUserProperties
|
||||
from .deploy_board import DeployBoard
|
||||
|
|
|
|||
|
|
@ -60,6 +60,13 @@ class FileAsset(BaseModel):
|
|||
size = models.FloatField(default=0)
|
||||
is_uploaded = models.BooleanField(default=False)
|
||||
storage_metadata = models.JSONField(default=dict, null=True, blank=True)
|
||||
blob = models.ForeignKey(
|
||||
"db.StoredBlob",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="assets",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "File Asset"
|
||||
|
|
@ -98,3 +105,43 @@ class FileAsset(BaseModel):
|
|||
return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/{self.id}/"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class StoredBlob(BaseModel):
|
||||
"""
|
||||
Canonical stored object shared by one or more FileAsset records.
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
ACTIVE = "active", "Active"
|
||||
ORPHANED = "orphaned", "Orphaned"
|
||||
MISSING = "missing", "Missing"
|
||||
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="stored_blobs")
|
||||
sha256 = models.CharField(max_length=64, db_index=True)
|
||||
size = models.PositiveBigIntegerField(default=0)
|
||||
mime_type = models.CharField(max_length=255, blank=True, default="")
|
||||
canonical_object_key = models.CharField(max_length=800)
|
||||
status = models.CharField(max_length=32, choices=Status.choices, default=Status.ACTIVE)
|
||||
ref_count = models.PositiveIntegerField(default=0)
|
||||
storage_metadata = models.JSONField(default=dict, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Stored Blob"
|
||||
verbose_name_plural = "Stored Blobs"
|
||||
db_table = "stored_blobs"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["workspace", "sha256"], name="stored_blob_workspace_sha_idx"),
|
||||
models.Index(fields=["status"], name="stored_blob_status_idx"),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "sha256", "size"],
|
||||
condition=models.Q(deleted_at__isnull=True, status="active"),
|
||||
name="stored_blob_unique_active_hash_size",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.canonical_object_key
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class S3Storage(S3Boto3Storage):
|
|||
|
||||
"""S3 storage class to generate presigned URLs for S3 objects"""
|
||||
|
||||
def __init__(self, request=None):
|
||||
def __init__(self, request=None, is_server=False, use_internal_endpoint=False, **kwargs):
|
||||
# Get the AWS credentials and bucket name from the environment
|
||||
self.aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID")
|
||||
# Use the AWS_SECRET_ACCESS_KEY environment variable for the secret key
|
||||
|
|
@ -36,19 +36,26 @@ class S3Storage(S3Boto3Storage):
|
|||
# Use the SIGNED_URL_EXPIRATION environment variable for the expiration time (default: 3600 seconds)
|
||||
self.signed_url_expiration = int(os.environ.get("SIGNED_URL_EXPIRATION", "3600"))
|
||||
|
||||
self.use_internal_endpoint = bool(is_server or use_internal_endpoint)
|
||||
|
||||
if os.environ.get("USE_MINIO") == "1":
|
||||
# Determine protocol based on environment variable
|
||||
if os.environ.get("MINIO_ENDPOINT_SSL") == "1":
|
||||
endpoint_protocol = "https"
|
||||
else:
|
||||
endpoint_protocol = request.scheme if request else "http"
|
||||
endpoint_protocol = request.scheme if request and not self.use_internal_endpoint else "http"
|
||||
|
||||
endpoint_url = self.aws_s3_endpoint_url
|
||||
if request and not self.use_internal_endpoint:
|
||||
endpoint_url = f"{endpoint_protocol}://{request.get_host()}"
|
||||
|
||||
# Create an S3 client for MinIO
|
||||
self.s3_client = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=self.aws_access_key_id,
|
||||
aws_secret_access_key=self.aws_secret_access_key,
|
||||
region_name=self.aws_region,
|
||||
endpoint_url=(f"{endpoint_protocol}://{request.get_host()}" if request else self.aws_s3_endpoint_url),
|
||||
endpoint_url=endpoint_url,
|
||||
config=boto3.session.Config(signature_version="s3v4"),
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ from rest_framework import status
|
|||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.db.models import DeployBoard, FileAsset
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.file_dedup import UploadedObjectMissing, confirm_uploaded_file_asset, release_file_asset_blob
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
|
|
@ -141,16 +141,17 @@ class EntityAssetEndpoint(BaseAPIView):
|
|||
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
if not asset.storage_metadata:
|
||||
get_asset_object_metadata.delay(str(asset.id))
|
||||
|
||||
# update the attributes
|
||||
asset.attributes = request.data.get("attributes", asset.attributes)
|
||||
# save the asset
|
||||
asset.save(update_fields=["attributes", "is_uploaded"])
|
||||
try:
|
||||
confirm_uploaded_file_asset(
|
||||
asset,
|
||||
request=request,
|
||||
attributes=request.data.get("attributes", asset.attributes),
|
||||
)
|
||||
except UploadedObjectMissing:
|
||||
return Response(
|
||||
{"error": "The uploaded asset object was not found.", "status": False},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def delete(self, request, anchor, pk):
|
||||
|
|
@ -161,11 +162,13 @@ class EntityAssetEndpoint(BaseAPIView):
|
|||
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace, project_id=deploy_board.project_id)
|
||||
if not asset.is_uploaded:
|
||||
release_file_asset_blob(asset, request=request, delete_untracked_object=True)
|
||||
# Check deleted assets
|
||||
asset.is_deleted = True
|
||||
asset.deleted_at = timezone.now()
|
||||
# Save the asset
|
||||
asset.save(update_fields=["is_deleted", "deleted_at"])
|
||||
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -204,3 +204,49 @@ class TestS3StorageSignedURLExpiration:
|
|||
mock_s3_client.generate_presigned_url.assert_called_once()
|
||||
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
|
||||
assert call_kwargs["ExpiresIn"] == 120
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_S3_BUCKET_NAME": "test-bucket",
|
||||
"AWS_REGION": "us-east-1",
|
||||
"AWS_S3_ENDPOINT_URL": "http://plane-minio:9000",
|
||||
"USE_MINIO": "1",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_minio_request_uses_request_host_for_browser_urls(self, mock_boto3):
|
||||
"""Test that browser-facing MinIO URLs keep using the request host"""
|
||||
request = Mock(scheme="http")
|
||||
request.get_host.return_value = "localhost:8090"
|
||||
|
||||
S3Storage(request=request)
|
||||
|
||||
call_kwargs = mock_boto3.client.call_args[1]
|
||||
assert call_kwargs["endpoint_url"] == "http://localhost:8090"
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_S3_BUCKET_NAME": "test-bucket",
|
||||
"AWS_REGION": "us-east-1",
|
||||
"AWS_S3_ENDPOINT_URL": "http://plane-minio:9000",
|
||||
"USE_MINIO": "1",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch("plane.settings.storage.boto3")
|
||||
def test_minio_server_mode_uses_internal_endpoint_with_request(self, mock_boto3):
|
||||
"""Test that server-side MinIO operations use the internal endpoint"""
|
||||
request = Mock(scheme="http")
|
||||
request.get_host.return_value = "localhost:8090"
|
||||
|
||||
S3Storage(request=request, is_server=True)
|
||||
|
||||
call_kwargs = mock_boto3.client.call_args[1]
|
||||
assert call_kwargs["endpoint_url"] == "http://plane-minio:9000"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from plane.db.models import FileAsset, StoredBlob
|
||||
from plane.bgtasks.file_asset_task import delete_expired_file_asset
|
||||
from plane.utils.file_dedup import attach_existing_blob_to_file_asset, finalize_uploaded_file_asset, release_file_asset_blob
|
||||
|
||||
|
||||
class FakeStreamingBody:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
self.closed = False
|
||||
|
||||
def iter_chunks(self, chunk_size):
|
||||
for index in range(0, len(self.payload), chunk_size):
|
||||
yield self.payload[index : index + chunk_size]
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FakeS3Client:
|
||||
def __init__(self, objects):
|
||||
self.objects = objects
|
||||
|
||||
def get_object(self, Bucket, Key):
|
||||
return {"Body": FakeStreamingBody(self.objects[Key]["body"])}
|
||||
|
||||
|
||||
class FakeStorage:
|
||||
def __init__(self, objects):
|
||||
self.aws_storage_bucket_name = "uploads"
|
||||
self.s3_client = FakeS3Client(objects)
|
||||
self.deleted = []
|
||||
self.objects = objects
|
||||
|
||||
def get_object_metadata(self, object_name):
|
||||
item = self.objects.get(object_name)
|
||||
if item is None:
|
||||
return None
|
||||
return {
|
||||
"ContentType": item["type"],
|
||||
"ContentLength": len(item["body"]),
|
||||
"ETag": item.get("etag", "etag"),
|
||||
"Metadata": {},
|
||||
}
|
||||
|
||||
def delete_files(self, object_names):
|
||||
self.deleted.extend(object_names)
|
||||
for object_name in object_names:
|
||||
self.objects.pop(object_name, None)
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(workspace):
|
||||
from plane.db.models import Project, ProjectMember
|
||||
|
||||
project = Project.objects.create(name="Dedup Project", identifier="DEDUP", workspace=workspace)
|
||||
ProjectMember.objects.create(project=project, member=workspace.owner)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_storage():
|
||||
objects = {
|
||||
"workspace/a.txt": {"body": b"same payload", "type": "text/plain"},
|
||||
"workspace/b.txt": {"body": b"same payload", "type": "text/plain"},
|
||||
}
|
||||
return FakeStorage(objects)
|
||||
|
||||
|
||||
def create_asset(workspace, project, object_key):
|
||||
return FileAsset.objects.create(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
asset=object_key,
|
||||
size=12,
|
||||
attributes={"name": object_key.rsplit("/", 1)[-1], "type": "text/plain", "size": 12},
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_finalize_uploaded_file_asset_reuses_existing_blob(workspace, project, fake_storage):
|
||||
first_asset = create_asset(workspace, project, "workspace/a.txt")
|
||||
duplicate_asset = create_asset(workspace, project, "workspace/b.txt")
|
||||
|
||||
with patch("plane.utils.file_dedup.S3Storage", return_value=fake_storage):
|
||||
first_result = finalize_uploaded_file_asset(first_asset)
|
||||
duplicate_result = finalize_uploaded_file_asset(duplicate_asset)
|
||||
|
||||
first_asset.refresh_from_db()
|
||||
duplicate_asset.refresh_from_db()
|
||||
blob = StoredBlob.objects.get()
|
||||
|
||||
assert first_result.deduplicated is False
|
||||
assert duplicate_result.deduplicated is True
|
||||
assert duplicate_result.deleted_duplicate_object is True
|
||||
assert first_asset.blob_id == blob.id
|
||||
assert duplicate_asset.blob_id == blob.id
|
||||
assert first_asset.asset.name == "workspace/a.txt"
|
||||
assert duplicate_asset.asset.name == "workspace/a.txt"
|
||||
assert blob.ref_count == 2
|
||||
assert fake_storage.deleted == ["workspace/b.txt"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_release_file_asset_blob_deletes_canonical_object_after_last_reference(workspace, project, fake_storage):
|
||||
first_asset = create_asset(workspace, project, "workspace/a.txt")
|
||||
duplicate_asset = create_asset(workspace, project, "workspace/b.txt")
|
||||
|
||||
with patch("plane.utils.file_dedup.S3Storage", return_value=fake_storage):
|
||||
finalize_uploaded_file_asset(first_asset)
|
||||
finalize_uploaded_file_asset(duplicate_asset)
|
||||
first_release = release_file_asset_blob(first_asset)
|
||||
second_release = release_file_asset_blob(duplicate_asset)
|
||||
|
||||
blob = StoredBlob.objects.get()
|
||||
|
||||
assert first_release.had_blob is True
|
||||
assert first_release.deleted_object is False
|
||||
assert second_release.had_blob is True
|
||||
assert second_release.deleted_object is True
|
||||
assert second_release.object_key == "workspace/a.txt"
|
||||
assert blob.ref_count == 0
|
||||
assert blob.status == StoredBlob.Status.ORPHANED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_attach_existing_blob_reuses_canonical_object_without_copy(workspace, project, fake_storage):
|
||||
source_asset = create_asset(workspace, project, "workspace/a.txt")
|
||||
target_asset = create_asset(workspace, project, "workspace/c.txt")
|
||||
|
||||
with patch("plane.utils.file_dedup.S3Storage", return_value=fake_storage):
|
||||
finalize_uploaded_file_asset(source_asset)
|
||||
attached = attach_existing_blob_to_file_asset(source_asset, target_asset)
|
||||
|
||||
source_asset.refresh_from_db()
|
||||
target_asset.refresh_from_db()
|
||||
blob = StoredBlob.objects.get()
|
||||
|
||||
assert attached is True
|
||||
assert source_asset.blob_id == blob.id
|
||||
assert target_asset.blob_id == blob.id
|
||||
assert target_asset.asset.name == "workspace/a.txt"
|
||||
assert blob.ref_count == 2
|
||||
assert fake_storage.deleted == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_expired_file_asset_releases_blob_and_hard_deletes_rows(workspace, project, fake_storage):
|
||||
first_asset = create_asset(workspace, project, "workspace/a.txt")
|
||||
duplicate_asset = create_asset(workspace, project, "workspace/b.txt")
|
||||
|
||||
with patch("plane.utils.file_dedup.S3Storage", return_value=fake_storage):
|
||||
finalize_uploaded_file_asset(first_asset)
|
||||
finalize_uploaded_file_asset(duplicate_asset)
|
||||
|
||||
deleted_at = timezone.now() - timezone.timedelta(days=90)
|
||||
FileAsset.all_objects.filter(pk__in=[first_asset.pk, duplicate_asset.pk]).update(deleted_at=deleted_at)
|
||||
|
||||
with (
|
||||
patch("plane.utils.file_dedup.S3Storage", return_value=fake_storage),
|
||||
patch("plane.bgtasks.file_asset_task.S3Storage", return_value=fake_storage),
|
||||
):
|
||||
delete_expired_file_asset()
|
||||
|
||||
assert FileAsset.all_objects.filter(pk__in=[first_asset.pk, duplicate_asset.pk]).count() == 0
|
||||
assert StoredBlob.all_objects.count() == 0
|
||||
assert fake_storage.deleted == ["workspace/b.txt", "workspace/a.txt"]
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
from plane.db.models import FileAsset, StoredBlob
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
class UploadedObjectMissing(Exception):
|
||||
"""Raised when a FileAsset points to a storage object that is not available."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileBlobFinalizeResult:
|
||||
asset: FileAsset
|
||||
blob: StoredBlob
|
||||
sha256: str
|
||||
deduplicated: bool
|
||||
deleted_duplicate_object: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileBlobReleaseResult:
|
||||
had_blob: bool
|
||||
deleted_object: bool
|
||||
object_key: str | None = None
|
||||
|
||||
|
||||
def supports_blob_dedup(asset: FileAsset) -> bool:
|
||||
return bool(asset.workspace_id)
|
||||
|
||||
|
||||
def _object_key(asset: FileAsset) -> str:
|
||||
return str(asset.asset.name or asset.asset)
|
||||
|
||||
|
||||
def _server_storage(request=None) -> S3Storage:
|
||||
return S3Storage(request=request, is_server=True)
|
||||
|
||||
|
||||
def _get_object_metadata(storage: S3Storage, object_key: str) -> dict:
|
||||
metadata = storage.get_object_metadata(object_name=object_key)
|
||||
if not metadata:
|
||||
raise UploadedObjectMissing(f"Storage object not found: {object_key}")
|
||||
return metadata
|
||||
|
||||
|
||||
def _calculate_object_sha256(storage: S3Storage, object_key: str) -> str:
|
||||
try:
|
||||
response = storage.s3_client.get_object(Bucket=storage.aws_storage_bucket_name, Key=object_key)
|
||||
except ClientError as exc:
|
||||
raise UploadedObjectMissing(f"Storage object not found: {object_key}") from exc
|
||||
|
||||
digest = hashlib.sha256()
|
||||
body = response["Body"]
|
||||
try:
|
||||
for chunk in body.iter_chunks(chunk_size=1024 * 1024):
|
||||
if chunk:
|
||||
digest.update(chunk)
|
||||
finally:
|
||||
body.close()
|
||||
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _resolve_size(asset: FileAsset, metadata: dict) -> int:
|
||||
metadata_size = metadata.get("ContentLength")
|
||||
if metadata_size is not None:
|
||||
return int(metadata_size)
|
||||
|
||||
attr_size = (asset.attributes or {}).get("size")
|
||||
if attr_size is not None:
|
||||
return int(float(attr_size))
|
||||
|
||||
return int(asset.size or 0)
|
||||
|
||||
|
||||
def _resolve_mime_type(asset: FileAsset, metadata: dict) -> str:
|
||||
return str((asset.attributes or {}).get("type") or metadata.get("ContentType") or "")
|
||||
|
||||
|
||||
def finalize_uploaded_file_asset(asset: FileAsset, request=None) -> FileBlobFinalizeResult:
|
||||
"""
|
||||
Attach a confirmed upload to a canonical StoredBlob.
|
||||
|
||||
The uploaded object is treated as a temporary candidate until its SHA-256 is
|
||||
known. If the same blob already exists in the workspace, the FileAsset is
|
||||
repointed to the canonical object and the duplicate candidate object is
|
||||
removed from storage.
|
||||
"""
|
||||
|
||||
if not asset.workspace_id:
|
||||
raise ValueError("FileAsset workspace is required for blob deduplication.")
|
||||
|
||||
storage = _server_storage(request=request)
|
||||
candidate_key = _object_key(asset)
|
||||
candidate_metadata = _get_object_metadata(storage, candidate_key)
|
||||
candidate_sha256 = _calculate_object_sha256(storage, candidate_key)
|
||||
candidate_size = _resolve_size(asset, candidate_metadata)
|
||||
candidate_mime_type = _resolve_mime_type(asset, candidate_metadata)
|
||||
|
||||
duplicate_object_key = None
|
||||
released_object_key = None
|
||||
|
||||
with transaction.atomic():
|
||||
locked_asset = FileAsset.objects.select_for_update().get(pk=asset.pk)
|
||||
previous_blob_id = locked_asset.blob_id
|
||||
|
||||
blob = (
|
||||
StoredBlob.objects.select_for_update()
|
||||
.filter(
|
||||
workspace_id=locked_asset.workspace_id,
|
||||
sha256=candidate_sha256,
|
||||
size=candidate_size,
|
||||
status=StoredBlob.Status.ACTIVE,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if blob is None:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
blob = StoredBlob.objects.create(
|
||||
workspace_id=locked_asset.workspace_id,
|
||||
sha256=candidate_sha256,
|
||||
size=candidate_size,
|
||||
mime_type=candidate_mime_type,
|
||||
canonical_object_key=candidate_key,
|
||||
status=StoredBlob.Status.ACTIVE,
|
||||
ref_count=0,
|
||||
storage_metadata=candidate_metadata,
|
||||
)
|
||||
except IntegrityError:
|
||||
blob = (
|
||||
StoredBlob.objects.select_for_update()
|
||||
.filter(
|
||||
workspace_id=locked_asset.workspace_id,
|
||||
sha256=candidate_sha256,
|
||||
size=candidate_size,
|
||||
status=StoredBlob.Status.ACTIVE,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.get()
|
||||
)
|
||||
|
||||
deduplicated = blob.canonical_object_key != candidate_key
|
||||
if deduplicated:
|
||||
duplicate_object_key = candidate_key
|
||||
|
||||
if previous_blob_id and previous_blob_id != blob.id:
|
||||
previous_blob = StoredBlob.objects.select_for_update().filter(pk=previous_blob_id).first()
|
||||
if previous_blob:
|
||||
previous_blob.ref_count = max(previous_blob.ref_count - 1, 0)
|
||||
if previous_blob.ref_count == 0 and previous_blob.status == StoredBlob.Status.ACTIVE:
|
||||
previous_blob.status = StoredBlob.Status.ORPHANED
|
||||
released_object_key = previous_blob.canonical_object_key
|
||||
previous_blob.save(update_fields=["ref_count", "status", "updated_at"])
|
||||
|
||||
if previous_blob_id != blob.id:
|
||||
blob.ref_count += 1
|
||||
blob.save(update_fields=["ref_count", "updated_at"])
|
||||
|
||||
attributes = dict(locked_asset.attributes or {})
|
||||
attributes["size"] = candidate_size
|
||||
attributes["type"] = candidate_mime_type
|
||||
attributes["sha256"] = candidate_sha256
|
||||
attributes["blob_id"] = str(blob.id)
|
||||
attributes["deduplicated"] = deduplicated
|
||||
|
||||
locked_asset.blob = blob
|
||||
locked_asset.asset = blob.canonical_object_key
|
||||
locked_asset.size = candidate_size
|
||||
locked_asset.attributes = attributes
|
||||
locked_asset.storage_metadata = blob.storage_metadata or candidate_metadata
|
||||
locked_asset.is_uploaded = True
|
||||
locked_asset.save(
|
||||
update_fields=[
|
||||
"blob",
|
||||
"asset",
|
||||
"size",
|
||||
"attributes",
|
||||
"storage_metadata",
|
||||
"is_uploaded",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
deleted_duplicate_object = False
|
||||
object_keys_to_delete = [key for key in {duplicate_object_key, released_object_key} if key]
|
||||
if object_keys_to_delete:
|
||||
deleted_duplicate_object = storage.delete_files(object_names=object_keys_to_delete)
|
||||
|
||||
locked_asset.refresh_from_db()
|
||||
asset.blob_id = locked_asset.blob_id
|
||||
asset.asset = locked_asset.asset
|
||||
asset.size = locked_asset.size
|
||||
asset.attributes = locked_asset.attributes
|
||||
asset.storage_metadata = locked_asset.storage_metadata
|
||||
asset.is_uploaded = locked_asset.is_uploaded
|
||||
return FileBlobFinalizeResult(
|
||||
asset=locked_asset,
|
||||
blob=blob,
|
||||
sha256=candidate_sha256,
|
||||
deduplicated=deduplicated,
|
||||
deleted_duplicate_object=deleted_duplicate_object,
|
||||
)
|
||||
|
||||
|
||||
def confirm_uploaded_file_asset(asset: FileAsset, request=None, attributes: dict | None = None) -> FileAsset:
|
||||
if attributes is not None:
|
||||
asset.attributes = attributes
|
||||
asset.save(update_fields=["attributes", "updated_at"])
|
||||
|
||||
if supports_blob_dedup(asset):
|
||||
return finalize_uploaded_file_asset(asset, request=request).asset
|
||||
|
||||
storage = _server_storage(request=request)
|
||||
asset.storage_metadata = storage.get_object_metadata(object_name=_object_key(asset)) or asset.storage_metadata
|
||||
asset.is_uploaded = True
|
||||
asset.save(update_fields=["storage_metadata", "is_uploaded", "updated_at"])
|
||||
return asset
|
||||
|
||||
|
||||
def release_file_asset_blob(
|
||||
asset: FileAsset,
|
||||
request=None,
|
||||
delete_untracked_object: bool = False,
|
||||
) -> FileBlobReleaseResult:
|
||||
"""
|
||||
Release the blob reference held by a FileAsset.
|
||||
|
||||
The canonical object is deleted only when the last blob-backed FileAsset
|
||||
releases it. Legacy non-blob assets are left intact unless the caller marks
|
||||
them as temporary/untracked.
|
||||
"""
|
||||
|
||||
storage = _server_storage(request=request)
|
||||
delete_object_key = None
|
||||
had_blob = False
|
||||
|
||||
with transaction.atomic():
|
||||
locked_asset = FileAsset.all_objects.select_for_update().get(pk=asset.pk)
|
||||
current_key = _object_key(locked_asset)
|
||||
|
||||
if locked_asset.blob_id:
|
||||
had_blob = True
|
||||
blob = StoredBlob.objects.select_for_update().get(pk=locked_asset.blob_id)
|
||||
blob.ref_count = max(blob.ref_count - 1, 0)
|
||||
if blob.ref_count == 0 and blob.status == StoredBlob.Status.ACTIVE:
|
||||
blob.status = StoredBlob.Status.ORPHANED
|
||||
delete_object_key = blob.canonical_object_key
|
||||
blob.save(update_fields=["ref_count", "status", "updated_at"])
|
||||
|
||||
locked_asset.blob = None
|
||||
locked_asset.save(update_fields=["blob", "updated_at"])
|
||||
elif delete_untracked_object and current_key:
|
||||
delete_object_key = current_key
|
||||
|
||||
if had_blob:
|
||||
asset.blob_id = None
|
||||
|
||||
deleted_object = False
|
||||
if delete_object_key:
|
||||
try:
|
||||
deleted_object = storage.delete_files(object_names=[delete_object_key])
|
||||
except Exception as exc:
|
||||
log_exception(exc)
|
||||
deleted_object = False
|
||||
|
||||
return FileBlobReleaseResult(had_blob=had_blob, deleted_object=deleted_object, object_key=delete_object_key)
|
||||
|
||||
|
||||
def attach_existing_blob_to_file_asset(source: FileAsset, target: FileAsset) -> bool:
|
||||
if not source.blob_id:
|
||||
source.refresh_from_db(fields=["blob"])
|
||||
|
||||
if not source.blob_id:
|
||||
return False
|
||||
|
||||
with transaction.atomic():
|
||||
source_blob = StoredBlob.objects.select_for_update().get(pk=source.blob_id)
|
||||
locked_target = FileAsset.objects.select_for_update().get(pk=target.pk)
|
||||
if locked_target.blob_id == source_blob.id:
|
||||
return True
|
||||
|
||||
source_blob.ref_count += 1
|
||||
source_blob.save(update_fields=["ref_count", "updated_at"])
|
||||
|
||||
attributes = dict(locked_target.attributes or {})
|
||||
attributes["sha256"] = source_blob.sha256
|
||||
attributes["blob_id"] = str(source_blob.id)
|
||||
attributes["deduplicated"] = True
|
||||
|
||||
locked_target.blob = source_blob
|
||||
locked_target.asset = source_blob.canonical_object_key
|
||||
locked_target.size = source_blob.size
|
||||
locked_target.attributes = attributes
|
||||
locked_target.storage_metadata = source_blob.storage_metadata
|
||||
locked_target.is_uploaded = True
|
||||
locked_target.save(
|
||||
update_fields=[
|
||||
"blob",
|
||||
"asset",
|
||||
"size",
|
||||
"attributes",
|
||||
"storage_metadata",
|
||||
"is_uploaded",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
target.blob_id = source.blob_id
|
||||
target.asset = source_blob.canonical_object_key
|
||||
return True
|
||||
|
|
@ -117,7 +117,9 @@ const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu() {
|
|||
>
|
||||
<span
|
||||
className={`nodedc-toolbar-icon-active-dot ${
|
||||
pathname.includes("/projects/") ? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]" : ""
|
||||
pathname.includes("/projects/")
|
||||
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<ProjectIcon className="size-4" />
|
||||
|
|
@ -126,7 +128,7 @@ const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu() {
|
|||
|
||||
<Menu.Items className="absolute top-full -right-2 z-[170] mt-2 origin-top-right">
|
||||
<div className="nodedc-glass-modal nodedc-glass-popup-surface flex max-h-[70vh] min-w-[26rem] flex-col overflow-hidden rounded-[1.5rem] border-0 p-2 shadow-none outline-none">
|
||||
<div className="vertical-scrollbar scrollbar-sm flex max-h-[70vh] flex-col gap-0.5 overflow-y-auto pr-1">
|
||||
<div className="vertical-scrollbar flex scrollbar-sm max-h-[70vh] flex-col gap-0.5 overflow-y-auto pr-1">
|
||||
{joinedProjectIds.map((projectId, index) => (
|
||||
<SidebarProjectsListItem
|
||||
key={projectId}
|
||||
|
|
@ -258,14 +260,9 @@ export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar()
|
|||
<div className="nodedc-glass-modal flex w-full flex-wrap items-center justify-between gap-4 rounded-[1.6rem] px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
|
||||
<ToolbarIconButton
|
||||
label={t("app_header.add_task")}
|
||||
onClick={() => toggleCreateIssueModal(true)}
|
||||
disabled={!canCreateIssue || joinedProjectIds.length === 0}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</ToolbarIconButton>
|
||||
<WorkspaceMenuRoot variant="toolbar" />
|
||||
<TopNavPowerK variant="sidebar" />
|
||||
<UserMenuRoot variant="toolbar" />
|
||||
<Tooltip tooltipContent={t("notification.label")} position="bottom">
|
||||
<Link
|
||||
href={`/${workspaceSlug?.toString()}/notifications/`}
|
||||
|
|
@ -281,8 +278,13 @@ export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar()
|
|||
)}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<UserMenuRoot variant="toolbar" />
|
||||
<WorkspaceMenuRoot variant="toolbar" />
|
||||
<ToolbarIconButton
|
||||
label={t("app_header.add_task")}
|
||||
onClick={() => toggleCreateIssueModal(true)}
|
||||
disabled={!canCreateIssue || joinedProjectIds.length === 0}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</ToolbarIconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { redirect } from "react-router";
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
export function clientLoader({ params }: Route.ClientLoaderArgs) {
|
||||
const { workspaceSlug } = params;
|
||||
throw redirect(`/${workspaceSlug}/?workspaceSettings=storage`);
|
||||
}
|
||||
|
||||
function StorageWorkspaceSettingsPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default StorageWorkspaceSettingsPage;
|
||||
|
|
@ -281,6 +281,10 @@ export const coreRoutes: RouteConfigEntry[] = [
|
|||
":workspaceSlug/settings/exports",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/storage",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/storage/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/webhooks",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx"
|
||||
|
|
|
|||
|
|
@ -7,16 +7,13 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { MoveDiagonal, MoveRight } from "lucide-react";
|
||||
import { Check, MoveDiagonal, MoveRight, X } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import {
|
||||
CenterPanelIcon,
|
||||
CheckCircleFilledIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CloseCircleFilledIcon,
|
||||
CopyLinkIcon,
|
||||
FullScreenPanelIcon,
|
||||
SidePanelIcon,
|
||||
|
|
@ -100,6 +97,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
contourRequest.capabilities?.can_source_decide ??
|
||||
(contourRequest.status === "closed" && contourRequest.source_decision !== "accepted");
|
||||
const isSourceAccepted = contourRequest.source_decision === "accepted";
|
||||
const isDecisionSubmitting = loader === "mutation-loading";
|
||||
|
||||
const redirectToRelativeIssue = useCallback(
|
||||
(direction: "next" | "prev") => {
|
||||
|
|
@ -291,16 +289,30 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canReviewClosedRequest && (
|
||||
<>
|
||||
<Button variant="primary" size="lg" onClick={() => handleDecision("accept")} className="nodedc-external-primary-button">
|
||||
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
|
||||
{t("external_contours_page.actions.accept")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" onClick={() => setIsDeclineModalOpen(true)} className="nodedc-external-action-button">
|
||||
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
|
||||
{t("external_contours_page.actions.decline")}
|
||||
</Button>
|
||||
</>
|
||||
<div className="nodedc-external-decision-cluster">
|
||||
<Tooltip tooltipContent={t("external_contours_page.actions.accept")}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("external_contours_page.actions.accept")}
|
||||
onClick={() => handleDecision("accept")}
|
||||
disabled={isDecisionSubmitting}
|
||||
className="nodedc-external-decision-button nodedc-external-decision-button-accept"
|
||||
>
|
||||
<Check className="size-4" strokeWidth={2.6} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent={t("external_contours_page.actions.decline")}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("external_contours_page.actions.decline")}
|
||||
onClick={() => setIsDeclineModalOpen(true)}
|
||||
disabled={isDecisionSubmitting}
|
||||
className="nodedc-external-decision-button nodedc-external-decision-button-decline"
|
||||
>
|
||||
<X className="size-4" strokeWidth={2.5} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSourceAccepted && (
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ import { useTranslation } from "@plane/i18n";
|
|||
import { LabelPropertyIcon, PriorityIcon, PriorityPropertyIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TExternalContourRequest, TIssue, TNameDescriptionLoader } from "@plane/types";
|
||||
import { EFileAssetType } from "@plane/types";
|
||||
import { EFileAssetType, EIssueServiceType } from "@plane/types";
|
||||
import { getTextContent } from "@plane/utils";
|
||||
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
|
||||
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
|
||||
import { DescriptionInputLoader } from "@/components/editor/rich-text/description-input/loader";
|
||||
import { IssueAttachmentRoot } from "@/components/issues/attachment";
|
||||
import type { TIssueOperations } from "@/components/issues/issue-detail";
|
||||
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
|
||||
import { IssueDetailWidgets } from "@/components/issues/issue-detail-widgets";
|
||||
import { IssueReaction } from "@/components/issues/issue-detail/reactions";
|
||||
import { IssueTitleInput } from "@/components/issues/title-input";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
|
|
@ -329,8 +329,16 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
|
|||
|
||||
<ExternalContoursRequestTraceability contourRequest={contourRequest} />
|
||||
|
||||
<div className="nodedc-external-section overflow-visible px-4 py-4">
|
||||
<IssueAttachmentRoot workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} disabled={!isEditable} />
|
||||
<div className="nodedc-external-section nodedc-external-detail-widgets overflow-visible px-4 py-4">
|
||||
<IssueDetailWidgets
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={targetProjectId}
|
||||
issueId={issue.id}
|
||||
disabled={!isEditable}
|
||||
issueOperations={issueOperations}
|
||||
issueServiceType={EIssueServiceType.ISSUES}
|
||||
compactView
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="nodedc-external-section overflow-visible px-4 py-4">
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outsi
|
|||
import { ExternalContoursIssueActionsHeader, type TExternalContourPeekMode } from "./issue-header";
|
||||
|
||||
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:external-contour-peek-width";
|
||||
const SIDE_PEEK_MIN_WIDTH = 780;
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
|
|
@ -41,9 +42,9 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
|
|||
} = props;
|
||||
const [peekMode, setPeekMode] = useState<TExternalContourPeekMode>("side-peek");
|
||||
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
|
||||
if (typeof window === "undefined") return 720;
|
||||
if (typeof window === "undefined") return SIDE_PEEK_MIN_WIDTH;
|
||||
|
||||
const fallbackWidth = Math.max(640, Math.floor(window.innerWidth * 0.5));
|
||||
const fallbackWidth = Math.max(SIDE_PEEK_MIN_WIDTH, Math.floor(window.innerWidth * 0.54));
|
||||
const storedWidth = window.localStorage.getItem(SIDE_PEEK_WIDTH_STORAGE_KEY);
|
||||
const parsedWidth = storedWidth ? parseInt(storedWidth, 10) : NaN;
|
||||
|
||||
|
|
@ -72,10 +73,9 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
|
|||
(event: MouseEvent) => {
|
||||
if (!isResizingPeek) return;
|
||||
|
||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
||||
const minWidth = 640;
|
||||
const maxWidth = Math.max(SIDE_PEEK_MIN_WIDTH, window.innerWidth - 48);
|
||||
const deltaX = event.clientX - initialMouseXRef.current;
|
||||
const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, minWidth), maxWidth);
|
||||
const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, SIDE_PEEK_MIN_WIDTH), maxWidth);
|
||||
setSidePeekWidth(nextWidth);
|
||||
},
|
||||
[isResizingPeek]
|
||||
|
|
@ -94,7 +94,7 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
|
|||
|
||||
useEffect(() => {
|
||||
const handleWindowResize = () => {
|
||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
||||
const maxWidth = Math.max(SIDE_PEEK_MIN_WIDTH, window.innerWidth - 48);
|
||||
setSidePeekWidth((currentWidth) => Math.min(currentWidth, maxWidth));
|
||||
};
|
||||
|
||||
|
|
@ -105,8 +105,8 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
|
|||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
||||
const clampedWidth = Math.min(Math.max(sidePeekWidth, 640), maxWidth);
|
||||
const maxWidth = Math.max(SIDE_PEEK_MIN_WIDTH, window.innerWidth - 48);
|
||||
const clampedWidth = Math.min(Math.max(sidePeekWidth, SIDE_PEEK_MIN_WIDTH), maxWidth);
|
||||
|
||||
window.localStorage.setItem(SIDE_PEEK_WIDTH_STORAGE_KEY, String(clampedWidth));
|
||||
|
||||
|
|
@ -151,7 +151,7 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
|
|||
? "absolute z-[25] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
|
||||
: "h-full w-full",
|
||||
!embedIssue && {
|
||||
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
|
||||
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[780px] md:max-w-[calc(100vw-1.5rem)]":
|
||||
peekMode === "side-peek",
|
||||
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
|
||||
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ export function LogoSpinner() {
|
|||
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<img src={logoSrc} alt="logo" className="h-6 w-auto object-contain sm:h-11" />
|
||||
<div className="pointer-events-none flex items-center justify-center opacity-0" aria-hidden="true">
|
||||
<img src={logoSrc} alt="" className="h-6 w-auto object-contain sm:h-11" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { cn } from "@plane/utils";
|
||||
|
||||
type TNodedcProcessingLoaderProps = {
|
||||
className?: string;
|
||||
label?: string;
|
||||
tone?: "accent" | "white";
|
||||
};
|
||||
|
||||
export function NodedcProcessingLoader({ className, label, tone = "accent" }: TNodedcProcessingLoaderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center gap-5 text-center", className)}>
|
||||
<span
|
||||
className={cn("nodedc-processing-loader", tone === "white" && "nodedc-processing-loader-white")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label && <div className="text-18 font-semibold text-primary">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -238,19 +238,19 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
|
|||
multiple={multiple}
|
||||
>
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<Combobox.Options className="fixed z-[1000]" static>
|
||||
<div
|
||||
className="my-1 w-48 rounded-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 text-11 shadow-raised-200 focus:outline-none"
|
||||
className="nodedc-dropdown-surface my-1 w-56 focus:outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded-sm border border-subtle bg-surface-2 px-2">
|
||||
<div className="nodedc-dropdown-search">
|
||||
<SearchIcon className="h-3.5 w-3.5 text-placeholder" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none"
|
||||
className="w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("search")}
|
||||
|
|
@ -268,9 +268,9 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
|
|||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none ${
|
||||
active ? "bg-layer-transparent-hover" : ""
|
||||
} ${selected ? "text-primary" : "text-secondary"}`
|
||||
`nodedc-dropdown-option cursor-pointer ${active ? "bg-white/[0.06]" : ""} ${
|
||||
selected ? "text-primary" : "text-secondary"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
|
|
@ -283,10 +283,10 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
|
|||
);
|
||||
})
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-placeholder italic">{t("no_matching_results")}</p>
|
||||
<p className="px-3 py-2 text-12 text-placeholder italic">{t("no_matching_results")}</p>
|
||||
)
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-placeholder italic">{t("loading")}</p>
|
||||
<p className="px-3 py-2 text-12 text-placeholder italic">{t("loading")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,13 +6,18 @@
|
|||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArrowDownUp, Check, Filter } from "lucide-react";
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// plane types
|
||||
import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons";
|
||||
import type { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
|
||||
import { Avatar } from "@plane/propel/avatar";
|
||||
import type { IIssueActivity, TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
|
||||
import { calculateTimeAgo, cn, generateWorkItemLink, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||
import { ActivityIcon } from "@/components/core/activity";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/services/workspace.service";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { getActivityProjectId } from "../../home.utils";
|
||||
import { RecentsEmptyState } from "../empty-states";
|
||||
import { EWidgetKeys, WidgetLoader } from "../loaders";
|
||||
|
|
@ -30,6 +35,117 @@ const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode; i18n_k
|
|||
{ name: "project", icon: <ProjectIcon height={16} width={16} />, i18n_key: "home.recents.filters.projects" },
|
||||
];
|
||||
|
||||
type TActivityTraceFilterKey =
|
||||
| "archive"
|
||||
| "assignees"
|
||||
| "content"
|
||||
| "created"
|
||||
| "dates"
|
||||
| "labels"
|
||||
| "mine"
|
||||
| "priority"
|
||||
| "state";
|
||||
type TActivityTraceSortMode = "newest" | "oldest";
|
||||
|
||||
const ACTIVITY_TRACE_FILTER_OPTIONS: { key: TActivityTraceFilterKey; label: string }[] = [
|
||||
{ key: "created", label: "Новые задачи" },
|
||||
{ key: "assignees", label: "Исполнители" },
|
||||
{ key: "state", label: "Статусы" },
|
||||
{ key: "priority", label: "Приоритеты" },
|
||||
{ key: "dates", label: "Сроки и даты" },
|
||||
{ key: "content", label: "Название и описание" },
|
||||
{ key: "labels", label: "Метки" },
|
||||
{ key: "archive", label: "Архив и восстановление" },
|
||||
{ key: "mine", label: "Только мои действия" },
|
||||
];
|
||||
|
||||
const ACTIVITY_TRACE_SORT_OPTIONS: { key: TActivityTraceSortMode; label: string }[] = [
|
||||
{ key: "newest", label: "Новые сверху" },
|
||||
{ key: "oldest", label: "Старые сверху" },
|
||||
];
|
||||
|
||||
const PRIORITY_LABELS: Record<string, string> = {
|
||||
urgent: "Срочный",
|
||||
high: "Высокий",
|
||||
medium: "Средний",
|
||||
low: "Низкий",
|
||||
none: "Без приоритета",
|
||||
};
|
||||
|
||||
const activityValue = (value: string | null | undefined) => {
|
||||
if (!value) return null;
|
||||
return PRIORITY_LABELS[value] ?? value;
|
||||
};
|
||||
|
||||
const activityDate = (value: string | null | undefined) => {
|
||||
if (!value) return null;
|
||||
return renderFormattedDate(value);
|
||||
};
|
||||
|
||||
const activityMessage = (activity: IIssueActivity) => {
|
||||
if (!activity.field && activity.verb === "created") return "создал рабочий элемент";
|
||||
|
||||
switch (activity.field) {
|
||||
case "assignees":
|
||||
return activity.old_value === ""
|
||||
? `добавил исполнителя ${activityValue(activity.new_value) ?? ""}`.trim()
|
||||
: `убрал исполнителя ${activityValue(activity.old_value) ?? ""}`.trim();
|
||||
case "state":
|
||||
return `изменил статус на ${activityValue(activity.new_value) ?? "новое значение"}`;
|
||||
case "priority":
|
||||
return `изменил приоритет на ${activityValue(activity.new_value) ?? "новое значение"}`;
|
||||
case "target_date":
|
||||
return `изменил срок на ${activityDate(activity.new_value) ?? "новую дату"}`;
|
||||
case "start_date":
|
||||
return `изменил дату начала на ${activityDate(activity.new_value) ?? "новую дату"}`;
|
||||
case "name":
|
||||
return "изменил название";
|
||||
case "description":
|
||||
return "обновил описание";
|
||||
case "labels":
|
||||
return "изменил метки";
|
||||
case "estimate_point":
|
||||
return "изменил оценку";
|
||||
case "archived_at":
|
||||
return activity.new_value === "restore" ? "восстановил рабочий элемент" : "архивировал рабочий элемент";
|
||||
default:
|
||||
return "обновил рабочий элемент";
|
||||
}
|
||||
};
|
||||
|
||||
const getActivityTraceFilterKeys = (activity: IIssueActivity): TActivityTraceFilterKey[] => {
|
||||
if (!activity.field && activity.verb === "created") return ["created"];
|
||||
|
||||
switch (activity.field) {
|
||||
case "assignees":
|
||||
return ["assignees"];
|
||||
case "state":
|
||||
return ["state"];
|
||||
case "priority":
|
||||
return ["priority"];
|
||||
case "start_date":
|
||||
case "target_date":
|
||||
return ["dates"];
|
||||
case "name":
|
||||
case "description":
|
||||
return ["content"];
|
||||
case "labels":
|
||||
return ["labels"];
|
||||
case "archived_at":
|
||||
return ["archive"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const sortActivityTrace = (activities: IIssueActivity[], sortMode: TActivityTraceSortMode) =>
|
||||
[...activities].sort((firstActivity, secondActivity) => {
|
||||
const firstTime = new Date(firstActivity.created_at).getTime();
|
||||
const secondTime = new Date(secondActivity.created_at).getTime();
|
||||
|
||||
return sortMode === "oldest" ? firstTime - secondTime : secondTime - firstTime;
|
||||
});
|
||||
|
||||
type TRecentWidgetProps = THomeWidgetProps & {
|
||||
presetFilter?: TRecentActivityFilterKeys;
|
||||
showFilterSelect?: boolean;
|
||||
|
|
@ -37,16 +153,105 @@ type TRecentWidgetProps = THomeWidgetProps & {
|
|||
recents?: TActivityEntityData[];
|
||||
};
|
||||
|
||||
function RecentActivityTraceItem({
|
||||
activity,
|
||||
currentUserId,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
activity: IIssueActivity;
|
||||
currentUserId?: string;
|
||||
workspaceSlug: string;
|
||||
}) {
|
||||
const actorName =
|
||||
currentUserId === activity.actor_detail?.id ? "Вы" : activity.actor_detail?.display_name || "Пользователь";
|
||||
const issueIdentifier =
|
||||
activity.project_detail?.identifier && activity.issue_detail?.sequence_id
|
||||
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
|
||||
: null;
|
||||
const issueTitle = activity.issue_detail?.name ?? "рабочем элементе";
|
||||
const issueLink =
|
||||
activity.issue && activity.issue_detail
|
||||
? generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: activity.project,
|
||||
issueId: activity.issue,
|
||||
projectIdentifier: activity.project_detail?.identifier,
|
||||
sequenceId: activity.issue_detail?.sequence_id,
|
||||
})
|
||||
: null;
|
||||
|
||||
const content = (
|
||||
<div className="group flex min-w-0 items-start gap-3 rounded-[18px] px-3 py-2.5 transition-colors hover:bg-white/[0.03]">
|
||||
<Avatar
|
||||
name={activity.actor_detail?.display_name}
|
||||
src={getFileURL(activity.actor_detail?.avatar_url)}
|
||||
size="sm"
|
||||
shape="circle"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-13 leading-5 text-secondary">
|
||||
<span className="font-medium text-primary">{actorName}</span>{" "}
|
||||
<span>{activityMessage(activity)}</span>{" "}
|
||||
<span className="font-medium text-primary">
|
||||
{issueIdentifier ? `${issueIdentifier}: ${issueTitle}` : issueTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 pt-0.5">
|
||||
<span className="text-11 whitespace-nowrap text-placeholder">{calculateTimeAgo(activity.created_at)}</span>
|
||||
<span className="grid size-6 place-items-center rounded-full bg-white/[0.04] text-tertiary">
|
||||
{activity.field ? <ActivityIcon activity={activity} /> : <WorkItemsIcon className="size-3" />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!issueLink) return content;
|
||||
|
||||
return (
|
||||
<a href={issueLink} className="block">
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export const RecentActivityWidget = observer(function RecentActivityWidget(props: TRecentWidgetProps) {
|
||||
const { presetFilter, showFilterSelect = true, workspaceSlug, projectId, recents: preloadedRecents } = props;
|
||||
// states
|
||||
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name);
|
||||
const [activeTraceFilters, setActiveTraceFilters] = useState<TActivityTraceFilterKey[]>([]);
|
||||
const [isTraceFilterOpen, setIsTraceFilterOpen] = useState(false);
|
||||
const [traceSortMode, setTraceSortMode] = useState<TActivityTraceSortMode>("newest");
|
||||
const { t } = useTranslation();
|
||||
const { data: currentUser } = useUser();
|
||||
// ref
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const shouldUseActivityTrace = filter === filters[0].name || filter === "issue";
|
||||
|
||||
const { data: fetchedActivity, isLoading: isActivityLoading } = useSWR(
|
||||
workspaceSlug && shouldUseActivityTrace
|
||||
? `WORKSPACE_ACTIVITY_TRACE_${workspaceSlug}_${projectId ?? "all"}`
|
||||
: null,
|
||||
workspaceSlug
|
||||
? () =>
|
||||
workspaceService.fetchWorkspaceActivity(workspaceSlug.toString(), {
|
||||
per_page: 60,
|
||||
...(projectId ? { project: projectId } : {}),
|
||||
})
|
||||
: null,
|
||||
{
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: fetchedRecents, isLoading } = useSWR(
|
||||
workspaceSlug && !preloadedRecents ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null,
|
||||
workspaceSlug && !preloadedRecents && !shouldUseActivityTrace
|
||||
? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}`
|
||||
: null,
|
||||
workspaceSlug
|
||||
? () =>
|
||||
workspaceService.fetchWorkspaceRecents(
|
||||
|
|
@ -61,6 +266,21 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
|||
}
|
||||
);
|
||||
|
||||
const activityTrace = useMemo(() => {
|
||||
const source = fetchedActivity?.results ?? [];
|
||||
const onlyMine = activeTraceFilters.includes("mine");
|
||||
const fieldFilters = activeTraceFilters.filter((filterKey) => filterKey !== "mine");
|
||||
const filteredTrace = source.filter((activity) => {
|
||||
if (onlyMine && currentUser?.id !== activity.actor_detail?.id) return false;
|
||||
if (fieldFilters.length === 0) return true;
|
||||
|
||||
const activityFilterKeys = getActivityTraceFilterKeys(activity);
|
||||
return fieldFilters.some((filterKey) => activityFilterKeys.includes(filterKey));
|
||||
});
|
||||
|
||||
return sortActivityTrace(filteredTrace, traceSortMode);
|
||||
}, [activeTraceFilters, currentUser?.id, fetchedActivity?.results, traceSortMode]);
|
||||
|
||||
const recents = useMemo(() => {
|
||||
const source = preloadedRecents ?? fetchedRecents ?? [];
|
||||
const filteredByType = source.filter((activity) =>
|
||||
|
|
@ -74,6 +294,109 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
|||
});
|
||||
}, [fetchedRecents, filter, preloadedRecents, projectId]);
|
||||
|
||||
const isWidgetLoading = shouldUseActivityTrace ? isActivityLoading : isLoading;
|
||||
const isWidgetEmpty = shouldUseActivityTrace ? activityTrace.length === 0 : recents.length === 0;
|
||||
const activeTraceFilterCount = activeTraceFilters.length + (traceSortMode === "newest" ? 0 : 1);
|
||||
|
||||
const toggleTraceFilter = (filterKey: TActivityTraceFilterKey) =>
|
||||
setActiveTraceFilters((currentFilters) =>
|
||||
currentFilters.includes(filterKey)
|
||||
? currentFilters.filter((currentFilter) => currentFilter !== filterKey)
|
||||
: [...currentFilters, filterKey]
|
||||
);
|
||||
|
||||
const resetTraceFilters = () => {
|
||||
setActiveTraceFilters([]);
|
||||
setTraceSortMode("newest");
|
||||
};
|
||||
|
||||
const headerActions = (
|
||||
<div className="flex items-center gap-2">
|
||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||
{shouldUseActivityTrace && (
|
||||
<div className="nodedc-home-gantt-action-group">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("nodedc-home-gantt-round-button", {
|
||||
"nodedc-home-gantt-round-button-active": activeTraceFilterCount > 0 || isTraceFilterOpen,
|
||||
"nodedc-home-gantt-filter-button-has-count": activeTraceFilterCount > 0,
|
||||
})}
|
||||
aria-expanded={isTraceFilterOpen}
|
||||
aria-label="Фильтры недавних действий"
|
||||
aria-pressed={activeTraceFilterCount > 0}
|
||||
onClick={() => setIsTraceFilterOpen((isOpen) => !isOpen)}
|
||||
>
|
||||
<Filter className="size-4" />
|
||||
{activeTraceFilterCount > 0 && (
|
||||
<span className="nodedc-home-gantt-filter-count">{activeTraceFilterCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isTraceFilterOpen && (
|
||||
<div className="nodedc-home-gantt-popover nodedc-home-gantt-popover-wide">
|
||||
<div className="nodedc-home-gantt-popover-section">
|
||||
<div className="nodedc-home-gantt-popover-title">Тип действия</div>
|
||||
{ACTIVITY_TRACE_FILTER_OPTIONS.map((option) => {
|
||||
const isActive = activeTraceFilters.includes(option.key);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={cn("nodedc-home-gantt-popover-option", {
|
||||
"nodedc-home-gantt-popover-option-active": isActive,
|
||||
})}
|
||||
onClick={() => toggleTraceFilter(option.key)}
|
||||
>
|
||||
<span className="nodedc-home-gantt-popover-option-left">
|
||||
<span className="nodedc-home-gantt-popover-check">
|
||||
{isActive && <Check className="size-3" />}
|
||||
</span>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="nodedc-home-gantt-popover-section">
|
||||
<div className="nodedc-home-gantt-popover-title">Сортировка</div>
|
||||
{ACTIVITY_TRACE_SORT_OPTIONS.map((option) => {
|
||||
const isActive = traceSortMode === option.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={cn("nodedc-home-gantt-popover-option", {
|
||||
"nodedc-home-gantt-popover-option-active": isActive,
|
||||
})}
|
||||
onClick={() => setTraceSortMode(option.key)}
|
||||
>
|
||||
<span className="nodedc-home-gantt-popover-option-left">
|
||||
<span className="nodedc-home-gantt-popover-check">
|
||||
{isActive && <Check className="size-3" />}
|
||||
</span>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
<ArrowDownUp className="size-3 text-tertiary" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeTraceFilterCount > 0 && (
|
||||
<button type="button" className="nodedc-home-gantt-popover-reset" onClick={resetTraceFilters}>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const resolveRecent = (activity: TActivityEntityData) => {
|
||||
switch (activity.entity_name) {
|
||||
case "page":
|
||||
|
|
@ -88,12 +411,12 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
|||
}
|
||||
};
|
||||
|
||||
if (!isLoading && recents.length === 0)
|
||||
if (!isWidgetLoading && isWidgetEmpty)
|
||||
return (
|
||||
<div ref={ref} className="max-h-[500px] overflow-y-scroll">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
|
||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||
{headerActions}
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<RecentsEmptyState type={filter} />
|
||||
|
|
@ -105,11 +428,23 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
|
|||
<div className="box-border min-h-[250px]">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
|
||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||
{headerActions}
|
||||
</div>
|
||||
<div className="flex max-h-[415px] min-h-[250px] flex-col overflow-y-auto">
|
||||
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
|
||||
{!isLoading && recents.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
|
||||
{isWidgetLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
|
||||
{!isWidgetLoading &&
|
||||
shouldUseActivityTrace &&
|
||||
activityTrace.map((activity) => (
|
||||
<RecentActivityTraceItem
|
||||
key={activity.id}
|
||||
activity={activity}
|
||||
currentUserId={currentUser?.id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
))}
|
||||
{!isWidgetLoading &&
|
||||
!shouldUseActivityTrace &&
|
||||
recents.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -49,8 +49,6 @@ export const SubIssuesActionButton = observer(function SubIssuesActionButton(pro
|
|||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
// handlers
|
||||
const handleIssueCrudState = (
|
||||
key: "create" | "existing",
|
||||
|
|
@ -74,7 +72,7 @@ export const SubIssuesActionButton = observer(function SubIssuesActionButton(pro
|
|||
|
||||
const handleAddExisting = () => {
|
||||
handleIssueCrudState("existing", issueId, null);
|
||||
toggleSubIssuesModal(issue.id);
|
||||
toggleSubIssuesModal(issue?.id ?? issueId);
|
||||
};
|
||||
|
||||
// options
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { cn } from "@plane/utils";
|
|||
// components
|
||||
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
|
||||
import {
|
||||
getWorkspaceSettingsModalTabFromSearch,
|
||||
openWorkspaceSettingsModal,
|
||||
WORKSPACE_SETTINGS_MODAL_EVENT,
|
||||
} from "@/components/workspace/settings/workspace-settings-modal.utils";
|
||||
|
|
@ -35,13 +36,13 @@ export const AppRailRoot = observer(() => {
|
|||
const { preferences, updateDisplayMode } = useAppRailPreferences();
|
||||
const { isCollapsed, toggleAppRail } = useAppRailVisibility();
|
||||
const [isWorkspaceSettingsModalOpen, setIsWorkspaceSettingsModalOpen] = useState(
|
||||
searchParams?.get("workspaceSettings") === "general" || searchParams?.get("workspaceSettings") === "ai-voice-tasker"
|
||||
Boolean(getWorkspaceSettingsModalTabFromSearch(searchParams?.toString() ?? ""))
|
||||
);
|
||||
// derived values
|
||||
const workspaceSettingsModalTab = getWorkspaceSettingsModalTabFromSearch(searchParams?.toString() ?? "");
|
||||
const isWorkspaceSettingsPath =
|
||||
(pathname.includes(`/${workspaceSlug}/settings`) && !projectId) ||
|
||||
searchParams?.get("workspaceSettings") === "general" ||
|
||||
searchParams?.get("workspaceSettings") === "ai-voice-tasker" ||
|
||||
Boolean(workspaceSettingsModalTab) ||
|
||||
isWorkspaceSettingsModalOpen;
|
||||
const showLabel = preferences.displayMode === "icon_with_label";
|
||||
const railWidth = showLabel ? "3.75rem" : "3rem";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { ArrowUpToLine, Building, CreditCard, Mic, Users, Webhook } from "lucide-react";
|
||||
import { ArrowUpToLine, Building, CreditCard, Database, Mic, Users, Webhook } from "lucide-react";
|
||||
// plane imports
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
import type { TWorkspaceSettingsTabs } from "@plane/types";
|
||||
|
|
@ -15,6 +15,7 @@ export const WORKSPACE_SETTINGS_ICONS: Record<TWorkspaceSettingsTabs, LucideIcon
|
|||
members: Users,
|
||||
export: ArrowUpToLine,
|
||||
"billing-and-plans": CreditCard,
|
||||
storage: Database,
|
||||
webhooks: Webhook,
|
||||
"ai-voice-tasker": Mic,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,22 +12,15 @@ import type {
|
|||
import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import Masonry from "react-masonry-component";
|
||||
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PlusIcon } from "@plane/propel/icons";
|
||||
import { EmptyStateDetailed } from "@plane/propel/empty-state";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
// assets
|
||||
import darkStickiesAsset from "@/app/assets/empty-state/stickies/stickies-dark.webp?url";
|
||||
import lightStickiesAsset from "@/app/assets/empty-state/stickies/stickies-light.webp?url";
|
||||
import darkStickiesSearchAsset from "@/app/assets/empty-state/stickies/stickies-search-dark.webp?url";
|
||||
import lightStickiesSearchAsset from "@/app/assets/empty-state/stickies/stickies-search-light.webp?url";
|
||||
// components
|
||||
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
|
||||
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
|
||||
import { StickiesEmptyState } from "@/components/home/widgets/empty-states/stickies";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
|
@ -51,8 +44,6 @@ export const StickiesList = observer(function StickiesList(props: TProps) {
|
|||
const { workspaceSlug, intersectionElement, columnCount } = props;
|
||||
// navigation
|
||||
const pathname = usePathname();
|
||||
// theme hook
|
||||
const { resolvedTheme } = useTheme();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
|
|
@ -69,8 +60,6 @@ export const StickiesList = observer(function StickiesList(props: TProps) {
|
|||
[EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
const stickiesResolvedPath = resolvedTheme === "light" ? lightStickiesAsset : darkStickiesAsset;
|
||||
const stickiesSearchResolvedPath = resolvedTheme === "light" ? lightStickiesSearchAsset : darkStickiesSearchAsset;
|
||||
const masonryRef = useRef<any>(null);
|
||||
|
||||
const handleLayout = () => {
|
||||
|
|
@ -118,25 +107,36 @@ export const StickiesList = observer(function StickiesList(props: TProps) {
|
|||
{isStickiesPage ? (
|
||||
<>
|
||||
{searchQuery ? (
|
||||
<SimpleEmptyState
|
||||
<EmptyStateDetailed
|
||||
assetKey="search"
|
||||
assetClassName="nodedc-stickies-empty-asset size-40"
|
||||
rootClassName="nodedc-stickies-empty-root"
|
||||
align="center"
|
||||
title={t("stickies.empty_state.search.title")}
|
||||
description={t("stickies.empty_state.search.description")}
|
||||
assetPath={stickiesSearchResolvedPath}
|
||||
/>
|
||||
) : (
|
||||
<DetailedEmptyState
|
||||
<EmptyStateDetailed
|
||||
assetKey="dashboard"
|
||||
assetClassName="nodedc-stickies-empty-asset size-40"
|
||||
rootClassName="nodedc-stickies-empty-root"
|
||||
align="center"
|
||||
title={t("stickies.empty_state.general.title")}
|
||||
description={t("stickies.empty_state.general.description")}
|
||||
assetPath={stickiesResolvedPath}
|
||||
primaryButton={{
|
||||
prependIcon: <PlusIcon className="size-4" />,
|
||||
text: t("stickies.empty_state.general.primary_button.text"),
|
||||
onClick: () => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create();
|
||||
},
|
||||
disabled: !hasGuestLevelPermissions,
|
||||
}}
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-stickies-empty-primary-button"
|
||||
onClick={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create();
|
||||
}}
|
||||
disabled={!hasGuestLevelPermissions}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
<span>{t("stickies.empty_state.general.primary_button.text")}</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { AlertTriangle, Database, Files, HardDrive, Layers3, Recycle, UploadCloud } from "lucide-react";
|
||||
import type { ElementType } from "react";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import type { IWorkspaceStorageProjectSummary } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// services
|
||||
import { WorkspaceService } from "@/services/workspace.service";
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
const formatBytes = (value: number) => {
|
||||
const bytes = Number(value || 0);
|
||||
if (bytes <= 0) return "0 Б";
|
||||
|
||||
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const size = bytes / 1024 ** index;
|
||||
|
||||
return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`;
|
||||
};
|
||||
|
||||
const formatCount = (value: number) => new Intl.NumberFormat("ru-RU").format(Number(value || 0));
|
||||
|
||||
const StatCard = (props: {
|
||||
title: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
icon: ElementType;
|
||||
tone?: "default" | "accent" | "warning";
|
||||
}) => {
|
||||
const { title, value, caption, icon: Icon, tone = "default" } = props;
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/5 bg-custom-background-80/85 p-5 shadow-[0_22px_80px_rgba(0,0,0,0.28)]">
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-full bg-custom-background-90 text-secondary",
|
||||
tone === "accent" && "bg-accent-primary/20 text-accent-primary",
|
||||
tone === "warning" && "bg-red-500/15 text-red-300"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-semibold tracking-normal text-primary">{value}</div>
|
||||
<div className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectStorageRow = (props: { project: IWorkspaceStorageProjectSummary; maxSize: number }) => {
|
||||
const { project, maxSize } = props;
|
||||
const ratio = maxSize > 0 ? Math.max((project.logical_size / maxSize) * 100, project.logical_size > 0 ? 3 : 0) : 0;
|
||||
|
||||
return (
|
||||
<tr className="border-b border-white/6 last:border-0">
|
||||
<td className="py-4 pr-4 align-middle">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-14 font-semibold text-primary">{project.name}</span>
|
||||
<span className="mt-1 text-11 uppercase tracking-[0.16em] text-tertiary">{project.identifier}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 align-middle text-13 text-secondary">{formatCount(project.file_count)}</td>
|
||||
<td className="px-4 py-4 align-middle text-13 text-secondary">{formatCount(project.blob_count)}</td>
|
||||
<td className="px-4 py-4 align-middle">
|
||||
<div className="flex min-w-[12rem] items-center gap-3">
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-custom-background-90">
|
||||
<div className="h-full rounded-full bg-accent-primary" style={{ width: `${ratio}%` }} />
|
||||
</div>
|
||||
<span className="w-20 text-right text-13 font-medium text-primary">{formatBytes(project.logical_size)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 align-middle text-13 text-secondary">{formatBytes(project.physical_size)}</td>
|
||||
<td className="px-4 py-4 align-middle text-13 text-accent-primary">{formatBytes(project.dedup_savings)}</td>
|
||||
<td className="px-4 py-4 align-middle text-13 text-secondary">{formatCount(project.failed_upload_count)}</td>
|
||||
<td className="pl-4 py-4 align-middle text-13 text-secondary">{formatCount(project.soft_deleted_count)}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
type TStorageSettingsContentProps = {
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsContentProps) {
|
||||
const { data, error, isLoading } = useSWR(
|
||||
workspaceSlug ? ["workspace-storage-summary", workspaceSlug] : null,
|
||||
([, slug]) => workspaceService.fetchWorkspaceStorageSummary(slug)
|
||||
);
|
||||
|
||||
const projects = [...(data?.projects ?? [])].sort((a, b) => b.logical_size - a.logical_size);
|
||||
const maxProjectSize = Math.max(...projects.map((project) => project.logical_size), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal text-primary">Хранилище</h1>
|
||||
<p className="mt-2 max-w-3xl text-14 leading-6 text-secondary">
|
||||
Контроль объема файлов, дедупликации и кандидатов на очистку по workspace и проектам.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="h-36 animate-pulse rounded-[28px] bg-custom-background-80/80" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-[28px] border border-red-500/20 bg-red-500/10 px-5 py-4 text-14 text-red-200">
|
||||
Не удалось загрузить данные хранилища.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
title="Логический объем"
|
||||
value={formatBytes(data.summary.logical_size)}
|
||||
caption={`${formatCount(data.summary.file_count)} файлов во всех проектах`}
|
||||
icon={Files}
|
||||
/>
|
||||
<StatCard
|
||||
title="Физический объем"
|
||||
value={formatBytes(data.summary.physical_size)}
|
||||
caption={`${formatCount(data.summary.blob_count)} уникальных blob`}
|
||||
icon={HardDrive}
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
title="Экономия дедупа"
|
||||
value={formatBytes(data.summary.dedup_savings)}
|
||||
caption={`${formatCount(data.summary.uploaded_without_blob_count)} загруженных файлов без blob`}
|
||||
icon={Layers3}
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
title="Проблемы загрузки"
|
||||
value={formatCount(data.diagnostics.failed_upload_count)}
|
||||
caption={`${formatBytes(data.diagnostics.failed_upload_size)} неподтвержденных файлов`}
|
||||
icon={AlertTriangle}
|
||||
tone={data.diagnostics.failed_upload_count > 0 ? "warning" : "default"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<StatCard
|
||||
title="Зависшие загрузки"
|
||||
value={formatCount(data.diagnostics.stale_unuploaded_count)}
|
||||
caption={`${formatBytes(data.diagnostics.stale_unuploaded_size)} старше суток`}
|
||||
icon={UploadCloud}
|
||||
/>
|
||||
<StatCard
|
||||
title="Удаленные файлы"
|
||||
value={formatCount(data.diagnostics.soft_deleted_count)}
|
||||
caption={`${formatBytes(data.diagnostics.soft_deleted_size)} ожидают retention cleanup`}
|
||||
icon={Recycle}
|
||||
/>
|
||||
<StatCard
|
||||
title="Потерянные blob"
|
||||
value={`${formatCount(data.diagnostics.orphaned_blob_count)} / ${formatCount(data.diagnostics.missing_blob_count)}`}
|
||||
caption={`${formatBytes(data.diagnostics.orphaned_blob_size + data.diagnostics.missing_blob_size)} вне активных ссылок`}
|
||||
icon={Database}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[28px] border border-white/5 bg-custom-background-80/85 p-5 shadow-[0_22px_80px_rgba(0,0,0,0.28)]">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-normal text-primary">Проекты</h2>
|
||||
<p className="mt-1 text-13 text-secondary">Сортировка по логическому объему файлов.</p>
|
||||
</div>
|
||||
<div className="rounded-full bg-custom-background-90 px-4 py-2 text-12 font-medium text-secondary">
|
||||
{formatCount(projects.length)} проектов
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[58rem] border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/8 text-left text-[11px] font-semibold uppercase tracking-[0.16em] text-tertiary">
|
||||
<th className="pb-3 pr-4">Проект</th>
|
||||
<th className="px-4 pb-3">Файлы</th>
|
||||
<th className="px-4 pb-3">Blob</th>
|
||||
<th className="px-4 pb-3">Логический объем</th>
|
||||
<th className="px-4 pb-3">Физический</th>
|
||||
<th className="px-4 pb-3">Дедуп</th>
|
||||
<th className="px-4 pb-3">Ошибки</th>
|
||||
<th className="pb-3 pl-4">Удалено</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map((project) => (
|
||||
<ProjectStorageRow key={project.id} project={project} maxSize={maxProjectSize} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
|
|||
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
|
||||
import { WorkspaceSettingsSidebarHeader } from "@/components/settings/workspace/sidebar/header";
|
||||
import { AIVoiceTaskerSettingsContent } from "@/components/workspace/settings/ai-voice-tasker-settings";
|
||||
import { StorageSettingsContent } from "@/components/workspace/settings/storage-settings";
|
||||
import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
|
@ -37,7 +38,7 @@ import {
|
|||
} from "./workspace-settings-modal.utils";
|
||||
|
||||
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
|
||||
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "ai-voice-tasker"]);
|
||||
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "storage", "ai-voice-tasker"]);
|
||||
|
||||
const getInitialTab = (): TWorkspaceSettingsModalTab => {
|
||||
if (typeof window === "undefined") return "general";
|
||||
|
|
@ -103,9 +104,16 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
|||
return <AIVoiceTaskerSettingsContent workspaceSlug={currentWorkspace.slug} />;
|
||||
}
|
||||
|
||||
if (activeTab === "storage" && currentWorkspace?.slug) {
|
||||
return <StorageSettingsContent workspaceSlug={currentWorkspace.slug} />;
|
||||
}
|
||||
|
||||
return <WorkspaceDetails />;
|
||||
};
|
||||
|
||||
const activeTabLabel =
|
||||
activeTab === "ai-voice-tasker" ? "AI / Voice Tasker" : activeTab === "storage" ? "хранилище" : "основные параметры";
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
|
|
@ -130,7 +138,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
|
|||
<div className="min-w-0">
|
||||
<div className="text-18 font-semibold text-primary">Настройки workspace</div>
|
||||
<div className="mt-1 truncate text-12 text-tertiary">
|
||||
{currentWorkspace?.name ?? "Workspace"} / {activeTab === "ai-voice-tasker" ? "AI / Voice Tasker" : "основные параметры"}
|
||||
{currentWorkspace?.name ?? "Workspace"} / {activeTabLabel}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -188,7 +196,6 @@ function WorkspaceModalSidebar({ activeTab, allowPermissions, onSelectItem, work
|
|||
{accessibleItems.map((item) => {
|
||||
const Icon = WORKSPACE_SETTINGS_ICONS[item.key];
|
||||
const isActive = item.key === activeTab;
|
||||
const isModalTab = MODAL_TABS.has(item.key);
|
||||
|
||||
return (
|
||||
<SettingsSidebarItem
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export const WORKSPACE_SETTINGS_MODAL_QUERY_KEY = "workspaceSettings";
|
||||
export const WORKSPACE_SETTINGS_MODAL_EVENT = "nodedc:workspace-settings-modal";
|
||||
|
||||
export type TWorkspaceSettingsModalTab = "general" | "ai-voice-tasker";
|
||||
export type TWorkspaceSettingsModalTab = "general" | "storage" | "ai-voice-tasker";
|
||||
|
||||
type TWorkspaceSettingsModalEventDetail = {
|
||||
isOpen: boolean;
|
||||
|
|
@ -15,7 +15,7 @@ const dispatchWorkspaceSettingsModalEvent = (detail: TWorkspaceSettingsModalEven
|
|||
export const getWorkspaceSettingsModalTabFromSearch = (search: string): TWorkspaceSettingsModalTab | undefined => {
|
||||
const value = new URLSearchParams(search).get(WORKSPACE_SETTINGS_MODAL_QUERY_KEY);
|
||||
|
||||
if (value === "general" || value === "ai-voice-tasker") return value;
|
||||
if (value === "general" || value === "storage" || value === "ai-voice-tasker") return value;
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,140 +4,6 @@
|
|||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { BProgress } from "@bprogress/core";
|
||||
import { useNavigation } from "react-router";
|
||||
import "@bprogress/core/css";
|
||||
|
||||
/**
|
||||
* Progress bar configuration options
|
||||
*/
|
||||
interface ProgressConfig {
|
||||
/** Whether to show the loading spinner */
|
||||
showSpinner: boolean;
|
||||
/** Minimum progress percentage (0-1) */
|
||||
minimum: number;
|
||||
/** Animation speed in milliseconds */
|
||||
speed: number;
|
||||
/** Auto-increment speed in milliseconds */
|
||||
trickleSpeed: number;
|
||||
/** CSS easing function */
|
||||
easing: string;
|
||||
/** Enable auto-increment */
|
||||
trickle: boolean;
|
||||
/** Delay before showing progress bar in milliseconds */
|
||||
delay: number;
|
||||
/** Whether to disable the progress bar */
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the progress bar
|
||||
*/
|
||||
const PROGRESS_CONFIG: Readonly<ProgressConfig> = {
|
||||
showSpinner: false,
|
||||
minimum: 0.1,
|
||||
speed: 400,
|
||||
trickleSpeed: 800,
|
||||
easing: "ease",
|
||||
trickle: true,
|
||||
delay: 0,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Navigation Progress Bar Component
|
||||
*
|
||||
* Automatically displays a progress bar at the top of the page during React Router navigation.
|
||||
* Integrates with React Router's useNavigation hook to monitor route changes.
|
||||
*
|
||||
* Note: Progress bar is disabled in production builds.
|
||||
*
|
||||
* @returns null - This component doesn't render any visible elements
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function App() {
|
||||
* return (
|
||||
* <>
|
||||
* <AppProgressBar />
|
||||
* <Outlet />
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export default function AppProgressBar(): null {
|
||||
const navigation = useNavigation();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const startedRef = useRef<boolean>(false);
|
||||
|
||||
// Initialize BProgress once on mount
|
||||
useEffect(() => {
|
||||
// Skip initialization in production builds
|
||||
if (PROGRESS_CONFIG.isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure BProgress with our settings
|
||||
BProgress.configure({
|
||||
showSpinner: PROGRESS_CONFIG.showSpinner,
|
||||
minimum: PROGRESS_CONFIG.minimum,
|
||||
speed: PROGRESS_CONFIG.speed,
|
||||
trickleSpeed: PROGRESS_CONFIG.trickleSpeed,
|
||||
easing: PROGRESS_CONFIG.easing,
|
||||
trickle: PROGRESS_CONFIG.trickle,
|
||||
});
|
||||
|
||||
// Render the progress bar element in the DOM
|
||||
BProgress.render(true);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (BProgress.isStarted()) {
|
||||
BProgress.done();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle navigation state changes
|
||||
useEffect(() => {
|
||||
// Skip navigation tracking in production builds
|
||||
if (PROGRESS_CONFIG.isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigation.state === "idle") {
|
||||
// Navigation complete - clear any pending timer
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// Complete progress if it was started
|
||||
if (startedRef.current) {
|
||||
BProgress.done();
|
||||
startedRef.current = false;
|
||||
}
|
||||
} else {
|
||||
// Navigation in progress (loading or submitting)
|
||||
// Only start if not already started and no timer pending
|
||||
if (timerRef.current === null && !startedRef.current) {
|
||||
timerRef.current = setTimeout((): void => {
|
||||
if (!BProgress.isStarted()) {
|
||||
BProgress.start();
|
||||
startedRef.current = true;
|
||||
}
|
||||
timerRef.current = null;
|
||||
}, PROGRESS_CONFIG.delay);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [navigation.state]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { LiveAudioVisualizer } from "./live-audio-visualizer";
|
||||
172
plane-src/apps/web/core/lib/vendor/react-audio-visualize/live-audio-visualizer.tsx
vendored
Normal file
172
plane-src/apps/web/core/lib/vendor/react-audio-visualize/live-audio-visualizer.tsx
vendored
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Localized audio visualizer based on the public API shape of `react-audio-visualize`.
|
||||
* It stays in-repo so the Voice Tasker recording UI can keep the package behavior
|
||||
* while preserving NODEDC styling and silent-state dots.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
type LiveAudioVisualizerProps = {
|
||||
backgroundColor?: string;
|
||||
barColor?: string;
|
||||
barWidth?: number;
|
||||
fftSize?: 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192 | 16384 | 32768;
|
||||
gap?: number;
|
||||
height?: number;
|
||||
maxDecibels?: number;
|
||||
mediaRecorder: MediaRecorder;
|
||||
minDecibels?: number;
|
||||
smoothingTimeConstant?: number;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
const averageVoiceFrequencyBins = (data: Uint8Array, width: number, barWidth: number, gap: number) => {
|
||||
let barsCount = Math.max(1, Math.floor(width / (barWidth + gap)));
|
||||
const voiceBandBinCount = Math.max(barsCount, Math.floor(data.length * 0.32));
|
||||
const voiceBand = data.slice(1, voiceBandBinCount);
|
||||
let binWindow = Math.floor(voiceBand.length / barsCount);
|
||||
|
||||
if (barsCount > voiceBand.length) {
|
||||
barsCount = voiceBand.length;
|
||||
binWindow = 1;
|
||||
}
|
||||
|
||||
return Array.from({ length: barsCount }, (_, index) => {
|
||||
let peak = 0;
|
||||
let sumSquares = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let offset = 0; offset < binWindow && index * binWindow + offset < voiceBand.length; offset++) {
|
||||
const value = voiceBand[index * binWindow + offset] ?? 0;
|
||||
peak = Math.max(peak, value);
|
||||
sumSquares += value * value;
|
||||
count++;
|
||||
}
|
||||
|
||||
const rms = Math.sqrt(sumSquares / Math.max(1, count));
|
||||
return Math.max(rms, peak * 0.72);
|
||||
});
|
||||
};
|
||||
|
||||
export function LiveAudioVisualizer(props: LiveAudioVisualizerProps) {
|
||||
const {
|
||||
backgroundColor = "transparent",
|
||||
barColor = "rgb(160, 198, 255)",
|
||||
barWidth = 2,
|
||||
fftSize = 1024,
|
||||
gap = 1,
|
||||
height = 100,
|
||||
maxDecibels = -10,
|
||||
mediaRecorder,
|
||||
minDecibels = -90,
|
||||
smoothingTimeConstant = 0.4,
|
||||
width = 300,
|
||||
} = props;
|
||||
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const latestHeightsRef = useRef<number[]>([]);
|
||||
|
||||
const draw = useCallback(
|
||||
(rawValues: number[]) => {
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !context) return;
|
||||
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
const canvasWidth = Math.max(1, Math.floor(width));
|
||||
const canvasHeight = Math.max(1, Math.floor(height));
|
||||
|
||||
if (canvas.width !== canvasWidth * pixelRatio || canvas.height !== canvasHeight * pixelRatio) {
|
||||
canvas.width = canvasWidth * pixelRatio;
|
||||
canvas.height = canvasHeight * pixelRatio;
|
||||
}
|
||||
|
||||
canvas.style.width = `${canvasWidth}px`;
|
||||
canvas.style.height = `${canvasHeight}px`;
|
||||
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
if (backgroundColor !== "transparent") {
|
||||
context.fillStyle = backgroundColor;
|
||||
context.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
const previousHeights = latestHeightsRef.current;
|
||||
const centerY = canvasHeight / 2;
|
||||
const maxBarHeight = canvasHeight * 0.94;
|
||||
const nextHeights = rawValues.map((value, index) => {
|
||||
const normalized = Math.max(0, Math.min(1, (value - 7) / 118));
|
||||
const shapedValue = Math.min(1, Math.pow(normalized, 0.54) * 1.18);
|
||||
const targetHeight = barWidth + shapedValue * (maxBarHeight - barWidth);
|
||||
const previousHeight = previousHeights[index] ?? barWidth;
|
||||
const smoothing = targetHeight > previousHeight ? 0.88 : 0.46;
|
||||
|
||||
return previousHeight + (targetHeight - previousHeight) * smoothing;
|
||||
});
|
||||
|
||||
latestHeightsRef.current = nextHeights;
|
||||
|
||||
context.fillStyle = barColor;
|
||||
nextHeights.forEach((barHeight, index) => {
|
||||
const x = index * (barWidth + gap);
|
||||
const y = centerY - barHeight / 2;
|
||||
const radius = barWidth / 2;
|
||||
|
||||
context.beginPath();
|
||||
if (context.roundRect) context.roundRect(x, y, barWidth, barHeight, radius);
|
||||
else context.rect(x, y, barWidth, barHeight);
|
||||
context.fill();
|
||||
});
|
||||
},
|
||||
[backgroundColor, barColor, barWidth, gap, height, width]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const stream = mediaRecorder.stream;
|
||||
if (!stream) return;
|
||||
|
||||
const AudioContextClass =
|
||||
window.AudioContext ||
|
||||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
|
||||
const audioContext = new AudioContextClass();
|
||||
const analyser = audioContext.createAnalyser();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
|
||||
analyser.fftSize = fftSize;
|
||||
analyser.minDecibels = minDecibels;
|
||||
analyser.maxDecibels = maxDecibels;
|
||||
analyser.smoothingTimeConstant = smoothingTimeConstant;
|
||||
const frequencyData = new Uint8Array(analyser.frequencyBinCount);
|
||||
source.connect(analyser);
|
||||
void audioContext.resume();
|
||||
|
||||
const renderFrame = () => {
|
||||
if (mediaRecorder.state === "recording") {
|
||||
analyser.getByteFrequencyData(frequencyData);
|
||||
draw(averageVoiceFrequencyBins(frequencyData, width, barWidth, gap));
|
||||
animationFrameRef.current = window.requestAnimationFrame(renderFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
draw(averageVoiceFrequencyBins(new Uint8Array(analyser.frequencyBinCount), width, barWidth, gap));
|
||||
};
|
||||
|
||||
renderFrame();
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
window.cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
source.disconnect();
|
||||
analyser.disconnect();
|
||||
if (audioContext.state !== "closed") void audioContext.close();
|
||||
};
|
||||
}, [barWidth, draw, fftSize, gap, maxDecibels, mediaRecorder, minDecibels, smoothingTimeConstant, width]);
|
||||
|
||||
return <canvas ref={canvasRef} aria-hidden="true" />;
|
||||
}
|
||||
|
|
@ -23,9 +23,11 @@ import type {
|
|||
TSearchEntityRequestPayload,
|
||||
TWidgetEntityData,
|
||||
TActivityEntityData,
|
||||
IUserActivityResponse,
|
||||
IWorkspaceSidebarNavigationItem,
|
||||
IWorkspaceSidebarNavigation,
|
||||
IWorkspaceUserPropertiesResponse,
|
||||
IWorkspaceStorageSummaryResponse,
|
||||
} from "@plane/types";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
|
@ -51,6 +53,14 @@ export class WorkspaceService extends APIService {
|
|||
});
|
||||
}
|
||||
|
||||
async fetchWorkspaceStorageSummary(workspaceSlug: string): Promise<IWorkspaceStorageSummaryResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/storage/summary/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async createWorkspace(data: Partial<IWorkspace>): Promise<IWorkspace> {
|
||||
return this.post("/api/workspaces/", data)
|
||||
.then((response) => response?.data)
|
||||
|
|
@ -353,6 +363,23 @@ export class WorkspaceService extends APIService {
|
|||
});
|
||||
}
|
||||
|
||||
async fetchWorkspaceActivity(
|
||||
workspaceSlug: string,
|
||||
params: {
|
||||
per_page: number;
|
||||
cursor?: string;
|
||||
project?: string;
|
||||
}
|
||||
): Promise<IUserActivityResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/activity/`, {
|
||||
params,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
// widgets
|
||||
async fetchWorkspaceWidgets(workspaceSlug: string): Promise<TWidgetEntityData[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/home-preferences/`)
|
||||
|
|
|
|||
|
|
@ -1525,6 +1525,56 @@
|
|||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
.nodedc-stickies-empty-root {
|
||||
padding-bottom: calc(var(--nodedc-bottom-dock-height) + 2rem) !important;
|
||||
}
|
||||
|
||||
.nodedc-stickies-empty-asset {
|
||||
--illustration-fill-primary: rgba(255, 255, 255, 0.1);
|
||||
--illustration-fill-secondary: rgba(255, 255, 255, 0.075);
|
||||
--illustration-fill-tertiary: rgba(var(--nodedc-card-active-rgb), 0.16);
|
||||
--illustration-fill-quaternary: rgba(var(--nodedc-card-active-rgb), 0.24);
|
||||
--illustration-stroke-primary: rgba(var(--nodedc-card-active-rgb), 0.46);
|
||||
--illustration-stroke-secondary: rgba(var(--nodedc-card-active-rgb), 0.62);
|
||||
--illustration-stroke-tertiary: rgb(var(--nodedc-card-active-rgb));
|
||||
color: rgb(var(--nodedc-card-active-rgb));
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.nodedc-stickies-empty-primary-button {
|
||||
display: inline-flex;
|
||||
min-height: 3.05rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
border-radius: 1.2rem !important;
|
||||
background: rgb(var(--nodedc-card-active-rgb)) !important;
|
||||
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
|
||||
padding: 0 1.45rem !important;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
0 16px 32px rgba(var(--nodedc-card-active-rgb), 0.14) !important;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
transform 160ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.nodedc-stickies-empty-primary-button:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 84%, white) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nodedc-stickies-empty-primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.nodedc-external-sidebar-shell {
|
||||
border: 0 !important;
|
||||
background:
|
||||
|
|
@ -3603,11 +3653,16 @@
|
|||
|
||||
.nodedc-external-detail-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nodedc-external-detail-toolbar > .flex {
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.nodedc-external-toolbar-cluster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -3618,6 +3673,85 @@
|
|||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.018) !important;
|
||||
}
|
||||
|
||||
.nodedc-external-decision-cluster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-height: 3.05rem;
|
||||
padding: 0.28rem !important;
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.055) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.018),
|
||||
0 10px 28px rgba(0, 0, 0, 0.1) !important;
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.nodedc-external-decision-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
border-radius: 999px;
|
||||
padding: 0 !important;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
background 160ms ease,
|
||||
color 160ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.nodedc-external-decision-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nodedc-external-decision-button:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.nodedc-external-decision-button-accept {
|
||||
background: rgb(var(--nodedc-card-active-rgb)) !important;
|
||||
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
0 8px 18px rgba(var(--nodedc-card-active-rgb), 0.2) !important;
|
||||
}
|
||||
|
||||
.nodedc-external-decision-button-accept:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 86%, white) !important;
|
||||
}
|
||||
|
||||
.nodedc-external-decision-button-decline {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
color: rgba(255, 255, 255, 0.72) !important;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04) !important;
|
||||
}
|
||||
|
||||
.nodedc-external-decision-button-decline:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.18) !important;
|
||||
color: rgba(255, 255, 255, 0.86) !important;
|
||||
}
|
||||
|
||||
.nodedc-external-detail-widgets {
|
||||
--nodedc-widget-surface-rgb: 255 255 255;
|
||||
}
|
||||
|
||||
.nodedc-external-detail-widgets [data-slot="button"],
|
||||
.nodedc-external-detail-widgets button {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.nodedc-external-detail-widgets .nodedc-attachment-upload {
|
||||
min-height: 4.5rem;
|
||||
}
|
||||
|
||||
.nodedc-external-priority-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -3936,4 +4070,74 @@
|
|||
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
.nodedc-processing-loader,
|
||||
.nodedc-voice-task-processing-loader {
|
||||
--nodedc-processing-loader-rgb: var(--nodedc-accent-rgb);
|
||||
width: 5rem;
|
||||
aspect-ratio: 1;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: block;
|
||||
color: rgb(var(--nodedc-processing-loader-rgb));
|
||||
}
|
||||
|
||||
.nodedc-processing-loader-white {
|
||||
--nodedc-processing-loader-rgb: 255 255 255;
|
||||
}
|
||||
|
||||
.nodedc-processing-loader::before,
|
||||
.nodedc-processing-loader::after,
|
||||
.nodedc-voice-task-processing-loader::before,
|
||||
.nodedc-voice-task-processing-loader::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nodedc-processing-loader::before,
|
||||
.nodedc-voice-task-processing-loader::before {
|
||||
inset: 1.125rem;
|
||||
border: 0.5rem solid currentColor;
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: 0 0 1.25rem rgba(var(--nodedc-processing-loader-rgb), 0.18);
|
||||
}
|
||||
|
||||
.nodedc-processing-loader::after,
|
||||
.nodedc-voice-task-processing-loader::after {
|
||||
width: 1rem;
|
||||
aspect-ratio: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 1.125rem rgba(var(--nodedc-processing-loader-rgb), 0.28);
|
||||
offset-anchor: center;
|
||||
offset-path: path("M 22 22 H 58 V 58 H 22 V 22");
|
||||
animation: nodedc-voice-task-processing-loader 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes nodedc-voice-task-processing-loader {
|
||||
0% {
|
||||
offset-distance: 0%;
|
||||
}
|
||||
|
||||
25% {
|
||||
offset-distance: 25%;
|
||||
}
|
||||
|
||||
50% {
|
||||
offset-distance: 50%;
|
||||
}
|
||||
|
||||
75% {
|
||||
offset-distance: 75%;
|
||||
}
|
||||
|
||||
100% {
|
||||
offset-distance: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,13 @@ export const WORKSPACE_SETTINGS: Record<TWorkspaceSettingsTabs, TWorkspaceSettin
|
|||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`,
|
||||
},
|
||||
storage: {
|
||||
key: "storage",
|
||||
i18n_label: "workspace_settings.settings.storage.title",
|
||||
href: `/settings/storage`,
|
||||
access: [EUserWorkspaceRoles.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/storage/`,
|
||||
},
|
||||
webhooks: {
|
||||
key: "webhooks",
|
||||
i18n_label: "workspace_settings.settings.webhooks.title",
|
||||
|
|
@ -75,6 +82,7 @@ export const GROUPED_WORKSPACE_SETTINGS: Record<WORKSPACE_SETTINGS_CATEGORY, TWo
|
|||
WORKSPACE_SETTINGS["members"],
|
||||
WORKSPACE_SETTINGS["billing-and-plans"],
|
||||
WORKSPACE_SETTINGS["export"],
|
||||
WORKSPACE_SETTINGS["storage"],
|
||||
],
|
||||
[WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [WORKSPACE_SETTINGS["ai-voice-tasker"]],
|
||||
[WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]],
|
||||
|
|
|
|||
|
|
@ -1730,6 +1730,9 @@ export default {
|
|||
},
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
title: "Storage",
|
||||
},
|
||||
webhooks: {
|
||||
heading: "Webhooks",
|
||||
description: "Automate notifications to external services when project events occur.",
|
||||
|
|
|
|||
|
|
@ -1892,6 +1892,9 @@ export default {
|
|||
},
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
title: "Хранилище",
|
||||
},
|
||||
webhooks: {
|
||||
heading: "Вебхуки",
|
||||
description: "Автоматизируйте уведомления во внешние сервисы при событиях проекта.",
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ export type TVoiceTaskDraft = {
|
|||
intent: TVoiceTaskIntent;
|
||||
target_memory_ref: string | null;
|
||||
project_id?: string | null;
|
||||
state_id?: string | null;
|
||||
assignee_ids?: string[];
|
||||
target_task_id?: string | null;
|
||||
project_hint: string | null;
|
||||
state_hint: string | null;
|
||||
assignee_hint: string | null;
|
||||
|
|
@ -104,13 +107,8 @@ export type TVoiceTaskDraft = {
|
|||
|
||||
export type TVoiceTaskResolution = {
|
||||
project: TVoiceTaskResolvedProject | null;
|
||||
assignee: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
confidence: number;
|
||||
source: string | null;
|
||||
} | null;
|
||||
assignee: TVoiceTaskResolvedAssignee | null;
|
||||
assignees?: TVoiceTaskResolvedAssignee[];
|
||||
labels: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -149,6 +147,14 @@ export type TVoiceTaskResolvedProject = {
|
|||
source: string | null;
|
||||
};
|
||||
|
||||
export type TVoiceTaskResolvedAssignee = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
confidence: number;
|
||||
source: string | null;
|
||||
};
|
||||
|
||||
export type TVoiceTaskUploadResult = {
|
||||
ok: boolean;
|
||||
status?: "uploaded" | "parsed";
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export type TWorkspaceSettingsTabs =
|
|||
| "members"
|
||||
| "billing-and-plans"
|
||||
| "export"
|
||||
| "storage"
|
||||
| "webhooks"
|
||||
| "ai-voice-tasker";
|
||||
export type TWorkspaceSettingsItem = {
|
||||
|
|
|
|||
|
|
@ -240,6 +240,53 @@ export interface IWorkspaceAnalyticsResponse {
|
|||
completion_chart: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IWorkspaceStorageProjectSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
identifier: string;
|
||||
file_count: number;
|
||||
blob_count: number;
|
||||
logical_size: number;
|
||||
physical_size: number;
|
||||
dedup_savings: number;
|
||||
failed_upload_count: number;
|
||||
failed_upload_size: number;
|
||||
soft_deleted_count: number;
|
||||
soft_deleted_size: number;
|
||||
uploaded_without_blob_count: number;
|
||||
}
|
||||
|
||||
export interface IWorkspaceStorageSummaryResponse {
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
upload_file_size_limit_enabled: boolean;
|
||||
upload_file_size_limit: number;
|
||||
};
|
||||
summary: {
|
||||
file_count: number;
|
||||
blob_count: number;
|
||||
logical_size: number;
|
||||
physical_size: number;
|
||||
dedup_savings: number;
|
||||
uploaded_without_blob_count: number;
|
||||
};
|
||||
diagnostics: {
|
||||
failed_upload_count: number;
|
||||
failed_upload_size: number;
|
||||
stale_unuploaded_count: number;
|
||||
stale_unuploaded_size: number;
|
||||
soft_deleted_count: number;
|
||||
soft_deleted_size: number;
|
||||
orphaned_blob_count: number;
|
||||
orphaned_blob_size: number;
|
||||
missing_blob_count: number;
|
||||
missing_blob_size: number;
|
||||
};
|
||||
projects: IWorkspaceStorageProjectSummary[];
|
||||
}
|
||||
|
||||
export type TWorkspacePaginationInfo = TPaginationInfo & {
|
||||
results: IWorkspace[];
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue