dev: update user assets with backend streaming

This commit is contained in:
pablohashescobar 2024-02-01 15:55:43 +05:30
parent 42f307421a
commit 94f445cc08
17 changed files with 341 additions and 81 deletions

View File

@ -7,6 +7,7 @@ from .user import (
UserAdminLiteSerializer, UserAdminLiteSerializer,
UserMeSerializer, UserMeSerializer,
UserMeSettingsSerializer, UserMeSettingsSerializer,
UserAssetSerializer,
) )
from .workspace import ( from .workspace import (
WorkSpaceSerializer, WorkSpaceSerializer,

View File

@ -162,17 +162,10 @@ class DynamicBaseSerializer(BaseSerializer):
class BaseFileSerializer(DynamicBaseSerializer): class BaseFileSerializer(DynamicBaseSerializer):
download_url = serializers.SerializerMethodField()
class Meta: class Meta:
abstract = True # Make this serializer abstract abstract = True # Make this serializer abstract
def get_download_url(self, obj):
if hasattr(obj, "asset") and obj.asset:
storage = S3PrivateBucketStorage()
return storage.download_url(obj.asset.name)
return None
def to_representation(self, instance): def to_representation(self, instance):
""" """
Object instance -> Dict of primitive datatypes. Object instance -> Dict of primitive datatypes.

View File

@ -3,8 +3,7 @@ from rest_framework import serializers
# Module import # Module import
from .base import BaseSerializer from .base import BaseSerializer
from plane.db.models import User, Workspace, WorkspaceMemberInvite from plane.db.models import User, Workspace, WorkspaceMemberInvite, UserAsset
from plane.license.models import InstanceAdmin, Instance
class UserSerializer(BaseSerializer): class UserSerializer(BaseSerializer):
@ -197,3 +196,12 @@ class ResetPasswordSerializer(serializers.Serializer):
""" """
new_password = serializers.CharField(required=True, min_length=8) new_password = serializers.CharField(required=True, min_length=8)
class UserAssetSerializer(BaseSerializer):
class Meta:
model = UserAsset
fields = "__all__"
read_only_fields = [
"user",
]

View File

@ -19,16 +19,6 @@ urlpatterns = [
FileAssetEndpoint.as_view(), FileAssetEndpoint.as_view(),
name="file-assets", name="file-assets",
), ),
path(
"users/file-assets/",
UserAssetsEndpoint.as_view(),
name="user-file-assets",
),
path(
"users/file-assets/<str:asset_key>/",
UserAssetsEndpoint.as_view(),
name="user-file-assets",
),
path( path(
"workspaces/file-assets/<uuid:workspace_id>/<str:asset_key>/restore/", "workspaces/file-assets/<uuid:workspace_id>/<str:asset_key>/restore/",
FileAssetViewSet.as_view( FileAssetViewSet.as_view(

View File

@ -8,6 +8,7 @@ from plane.app.views import (
UserActivityEndpoint, UserActivityEndpoint,
ChangePasswordEndpoint, ChangePasswordEndpoint,
SetUserPasswordEndpoint, SetUserPasswordEndpoint,
UserAssetsEndpoint,
## End User ## End User
## Workspaces ## Workspaces
UserWorkSpacesEndpoint, UserWorkSpacesEndpoint,
@ -95,5 +96,15 @@ urlpatterns = [
SetUserPasswordEndpoint.as_view(), SetUserPasswordEndpoint.as_view(),
name="set-password", name="set-password",
), ),
path(
"users/assets/",
UserAssetsEndpoint.as_view(),
name="user-assets",
),
path(
"users/assets/<str:key>/",
UserAssetsEndpoint.as_view(),
name="user-assets",
),
## End User Graph ## End User Graph
] ]

View File

@ -18,6 +18,7 @@ from .user import (
UpdateUserOnBoardedEndpoint, UpdateUserOnBoardedEndpoint,
UpdateUserTourCompletedEndpoint, UpdateUserTourCompletedEndpoint,
UserActivityEndpoint, UserActivityEndpoint,
UserAssetsEndpoint,
) )
from .oauth import OauthEndpoint from .oauth import OauthEndpoint
@ -65,7 +66,7 @@ from .cycle import (
TransferCycleIssueEndpoint, TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint, CycleUserPropertiesEndpoint,
) )
from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet from .asset import FileAssetEndpoint, FileAssetViewSet
from .issue import ( from .issue import (
IssueViewSet, IssueViewSet,
WorkSpaceIssuesEndpoint, WorkSpaceIssuesEndpoint,
@ -180,7 +181,4 @@ from .webhook import (
WebhookSecretRegenerateEndpoint, WebhookSecretRegenerateEndpoint,
) )
from .dashboard import ( from .dashboard import DashboardEndpoint, WidgetsEndpoint
DashboardEndpoint,
WidgetsEndpoint
)

View File

@ -61,40 +61,3 @@ class FileAssetViewSet(BaseViewSet):
file_asset.is_deleted = False file_asset.is_deleted = False
file_asset.save() file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class UserAssetsEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser)
def get(self, request, asset_key):
files = FileAsset.objects.filter(
asset=asset_key, created_by=request.user
)
if files.exists():
serializer = FileAssetSerializer(
files, context={"request": request}
)
return Response(
{"data": serializer.data, "status": True},
status=status.HTTP_200_OK,
)
else:
return Response(
{"error": "Asset key does not exist", "status": False},
status=status.HTTP_200_OK,
)
def post(self, request):
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
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
)
file_asset.is_deleted = True
file_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -1,7 +1,15 @@
# Python imports
import requests
# Django imports
from django.http import StreamingHttpResponse
from django.conf import settings
# Third party imports # Third party imports
import boto3
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
# Module imports # Module imports
from plane.app.serializers import ( from plane.app.serializers import (
@ -9,13 +17,14 @@ from plane.app.serializers import (
IssueActivitySerializer, IssueActivitySerializer,
UserMeSerializer, UserMeSerializer,
UserMeSettingsSerializer, UserMeSettingsSerializer,
UserAssetSerializer,
) )
from plane.app.views.base import BaseViewSet, BaseAPIView from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember from plane.db.models import User, IssueActivity, WorkspaceMember, ProjectMember, UserAsset
from plane.license.models import Instance, InstanceAdmin from plane.license.models import Instance, InstanceAdmin
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
from plane.utils.file_stream import get_file_streams
from django.db.models import Q, F, Count, Case, When, IntegerField from django.db.models import Q, F, Count, Case, When, IntegerField
@ -177,3 +186,27 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator):
issue_activities, many=True issue_activities, many=True
).data, ).data,
) )
class UserAssetsEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser)
def post(self, request):
serializer = UserAssetSerializer(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user)
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):
user_asset = UserAsset.objects.get(
asset=asset_key, created_by=request.user
)
user_asset.is_deleted = True
user_asset.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def get(self, request, key):
response = get_file_streams(key, key)
return response

View File

@ -0,0 +1,175 @@
# Generated by Django 4.2.7 on 2024-01-31 13:34
import uuid
from django.db import migrations, models
from django.conf import settings
import django.db.models
import plane.db.models.asset
def update_urls(apps, schema_editor):
# Check if the app is using minio or s3
if settings.USE_MINIO:
prefix = (
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/"
)
prefix2 = prefix
else:
prefix = f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/"
prefix2 = (
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/"
)
User = apps.get_model("db", "User")
UserAsset = apps.get_model("db", "UserAsset")
bulk_users = []
bulk_user_assets = []
for user in User.objects.all():
if user.avatar and (
user.avatar.startswith(prefix) or user.avatar.startswith(prefix2)
):
avatar = user.avatar[len(prefix) :]
user.avatar = avatar
bulk_user_assets.append(
UserAsset(
user=user,
asset=avatar,
)
)
if user.cover_image and (
user.cover_image.startswith(prefix)
or user.cover_image.startswith(prefix2)
):
cover_image = user.cover_image[len(prefix) :]
user.cover_image = cover_image
bulk_user_assets.append(
UserAsset(
user=user,
asset=cover_image,
)
)
User.objects.bulk_update(
bulk_users, ["avatar", "cover_image"], batch_size=100
)
UserAsset.objects.bulk_create(bulk_user_assets, batch_size=100)
class Migration(migrations.Migration):
dependencies = [
("db", "0058_alter_moduleissue_issue_and_more"),
]
operations = [
migrations.CreateModel(
name="UserAsset",
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"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"asset",
models.FileField(
max_length=500,
storage=plane.settings.storage.S3PrivateBucketStorage(),
upload_to=plane.db.models.user.get_upload_path,
validators=[plane.db.models.user.file_size],
),
),
("is_deleted", models.BooleanField(default=False)),
("size", models.PositiveBigIntegerField(null=True)),
("attributes", models.JSONField(default=dict)),
(
"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",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="assets",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "User Asset",
"verbose_name_plural": "User Assets",
"db_table": "user_assets",
"ordering": ("-created_at",),
},
),
migrations.AlterField(
model_name="fileasset",
name="asset",
field=models.FileField(
storage=plane.settings.storage.S3PrivateBucketStorage(),
upload_to=plane.db.models.asset.get_upload_path,
validators=[plane.db.models.asset.file_size],
),
),
migrations.RemoveField(
model_name="issueactivity",
name="attachments",
),
migrations.RemoveField(
model_name="issuecomment",
name="attachments",
),
migrations.AlterField(
model_name="integration",
name="avatar_url",
field=models.CharField(blank=True, null=True),
),
migrations.AlterField(
model_name="project",
name="cover_image",
field=models.CharField(blank=True, max_length=800, null=True),
),
migrations.AlterField(
model_name="user",
name="cover_image",
field=models.CharField(blank=True, max_length=800, null=True),
),
migrations.AlterField(
model_name="workspace",
name="logo",
field=models.CharField(blank=True, null=True, verbose_name="Logo"),
),
migrations.RunPython(update_urls),
]

View File

@ -1,6 +1,6 @@
from .base import BaseModel from .base import BaseModel
from .user import User from .user import User, UserAsset
from .workspace import ( from .workspace import (
Workspace, Workspace,

View File

@ -29,7 +29,7 @@ class Integration(AuditModel):
redirect_url = models.TextField(blank=True) redirect_url = models.TextField(blank=True)
metadata = models.JSONField(default=dict) metadata = models.JSONField(default=dict)
verified = models.BooleanField(default=False) verified = models.BooleanField(default=False)
avatar_url = models.URLField(blank=True, null=True) avatar_url = models.CharField(blank=True, null=True)
def __str__(self): def __str__(self):
"""Return provider of the integration""" """Return provider of the integration"""

View File

@ -389,9 +389,6 @@ class IssueActivity(ProjectBaseModel):
) )
comment = models.TextField(verbose_name="Comment", blank=True) comment = models.TextField(verbose_name="Comment", blank=True)
attachments = ArrayField(
models.URLField(), size=10, blank=True, default=list
)
issue_comment = models.ForeignKey( issue_comment = models.ForeignKey(
"db.IssueComment", "db.IssueComment",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -423,9 +420,6 @@ class IssueComment(ProjectBaseModel):
comment_stripped = models.TextField(verbose_name="Comment", blank=True) comment_stripped = models.TextField(verbose_name="Comment", blank=True)
comment_json = models.JSONField(blank=True, default=dict) comment_json = models.JSONField(blank=True, default=dict)
comment_html = models.TextField(blank=True, default="<p></p>") comment_html = models.TextField(blank=True, default="<p></p>")
attachments = ArrayField(
models.URLField(), size=10, blank=True, default=list
)
issue = models.ForeignKey( issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_comments" Issue, on_delete=models.CASCADE, related_name="issue_comments"
) )

View File

@ -94,7 +94,7 @@ class Project(BaseModel):
issue_views_view = models.BooleanField(default=True) issue_views_view = models.BooleanField(default=True)
page_view = models.BooleanField(default=True) page_view = models.BooleanField(default=True)
inbox_view = models.BooleanField(default=False) inbox_view = models.BooleanField(default=False)
cover_image = models.URLField(blank=True, null=True, max_length=800) cover_image = models.CharField(blank=True, null=True, max_length=800)
estimate = models.ForeignKey( estimate = models.ForeignKey(
"db.Estimate", "db.Estimate",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,

View File

@ -15,12 +15,17 @@ from django.db.models.signals import post_save
from django.conf import settings from django.conf import settings
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from django.core.exceptions import ValidationError
# Third party imports # Third party imports
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
from slack_sdk import WebClient from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError from slack_sdk.errors import SlackApiError
# Module imports
from . import BaseModel
from plane.settings.storage import S3PrivateBucketStorage
def get_default_onboarding(): def get_default_onboarding():
return { return {
@ -49,7 +54,7 @@ class User(AbstractBaseUser, PermissionsMixin):
first_name = models.CharField(max_length=255, blank=True) first_name = models.CharField(max_length=255, blank=True)
last_name = models.CharField(max_length=255, blank=True) last_name = models.CharField(max_length=255, blank=True)
avatar = models.CharField(max_length=255, blank=True) avatar = models.CharField(max_length=255, blank=True)
cover_image = models.URLField(blank=True, null=True, max_length=800) cover_image = models.CharField(blank=True, null=True, max_length=800)
# tracking metrics # tracking metrics
date_joined = models.DateTimeField( date_joined = models.DateTimeField(
@ -144,6 +149,42 @@ class User(AbstractBaseUser, PermissionsMixin):
super(User, self).save(*args, **kwargs) super(User, self).save(*args, **kwargs)
def get_upload_path(instance, filename):
return f"user-{uuid.uuid4().hex}"
def file_size(value):
if value.size > settings.FILE_SIZE_LIMIT:
raise ValidationError("File too large. Size should not exceed 5 MB.")
class UserAsset(BaseModel):
user = models.ForeignKey("db.User", on_delete=models.CASCADE, related_name="assets")
asset = models.FileField(
upload_to=get_upload_path,
validators=[
file_size,
],
storage=S3PrivateBucketStorage(),
max_length=500,
)
is_deleted = models.BooleanField(default=False)
size = models.PositiveBigIntegerField(null=True)
attributes = models.JSONField(default=dict)
def save(self, *args, **kwargs):
self.size = self.asset.size
super(UserAsset, self).save(*args, **kwargs)
class Meta:
verbose_name = "User Asset"
verbose_name_plural = "User Assets"
db_table = "user_assets"
ordering = ("-created_at",)
def __str__(self):
return str(self.asset)
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def send_welcome_slack(sender, instance, created, **kwargs): def send_welcome_slack(sender, instance, created, **kwargs):
try: try:

View File

@ -131,7 +131,7 @@ def slug_validator(value):
class Workspace(BaseModel): class Workspace(BaseModel):
name = models.CharField(max_length=80, verbose_name="Workspace Name") name = models.CharField(max_length=80, verbose_name="Workspace Name")
logo = models.URLField(verbose_name="Logo", blank=True, null=True) logo = models.CharField(verbose_name="Logo", blank=True, null=True)
owner = models.ForeignKey( owner = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.CASCADE,

View File

@ -1,15 +1,9 @@
# Third party imports # Third party imports
from storages.backends.s3boto3 import S3Boto3Storage from storages.backends.s3boto3 import S3Boto3Storage
# Module imports
from plane.utils.presigned_url_generator import generate_download_presigned_url
class S3PrivateBucketStorage(S3Boto3Storage): class S3PrivateBucketStorage(S3Boto3Storage):
def url(self, name): def url(self, name):
# Return an empty string or None, or implement custom logic here # Return an empty string or None, or implement custom logic here
return name return name
def download_url(self, name):
return generate_download_presigned_url(name)

View File

@ -0,0 +1,59 @@
# Python imports
import uuid
import requests
# Django imports
from django.conf import settings
from django.http import StreamingHttpResponse
# Third party imports
import boto3
def get_file_streams(key, filename=uuid.uuid4().hex):
if settings.USE_MINIO:
s3 = boto3.client(
's3',
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=boto3.session.Config(signature_version='s3v4'),
)
else:
s3 = boto3.client(
's3',
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
config=boto3.session.Config(signature_version='s3v4'),
)
presigned_url = s3.generate_presigned_url(
'get_object',
Params={
'Bucket': settings.AWS_STORAGE_BUCKET_NAME,
'Key': key,
},
ExpiresIn=3600,
)
# Fetch the object metadata to get the content type
metadata = s3.head_object(
Bucket=settings.AWS_STORAGE_BUCKET_NAME,
Key=key,
)
# Stream the file from the custom endpoint URL
def stream_file_from_url(url):
with requests.get(url, stream=True) as r:
r.raise_for_status()
for chunk in r.iter_content(chunk_size=8192):
if chunk: # filter out keep-alive new chunks
yield chunk
content_type = metadata['ContentType']
response = StreamingHttpResponse(stream_file_from_url(presigned_url), content_type=content_type)
response['Content-Disposition'] = f'inline; filename={filename}' # Adjust filename as needed
return response