mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
3 Commits
fix/toast-
...
issue-link
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6592223ad2 | ||
|
|
f7c67e19e0 | ||
|
|
675a6d6eea |
@@ -21,6 +21,7 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
State,
|
||||
User,
|
||||
Project,
|
||||
)
|
||||
|
||||
from .base import BaseSerializer
|
||||
@@ -28,6 +29,8 @@ from .cycle import CycleLiteSerializer, CycleSerializer
|
||||
from .module import ModuleLiteSerializer, ModuleSerializer
|
||||
from .state import StateLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from plane.utils.metadata import get_metadata
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
|
||||
|
||||
class IssueSerializer(BaseSerializer):
|
||||
@@ -272,6 +275,8 @@ class LabelSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueLinkSerializer(BaseSerializer):
|
||||
metadata = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = IssueLink
|
||||
fields = "__all__"
|
||||
@@ -286,6 +291,12 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_metadata(self, obj):
|
||||
logo = obj.metadata.get("logo", None)
|
||||
if logo:
|
||||
obj.metadata["logo"] = generate_download_presigned_url(logo)
|
||||
return obj.metadata
|
||||
|
||||
def validate_url(self, value):
|
||||
# Check URL format
|
||||
validate_url = URLValidator()
|
||||
@@ -309,17 +320,33 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
|
||||
# Workspace
|
||||
project = Project.objects.get(pk=validated_data.get("project_id"))
|
||||
# Fetch metadata from URL
|
||||
validated_data["metadata"] = get_metadata(
|
||||
validated_data.get("url"), project.workspace_id
|
||||
)
|
||||
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if IssueLink.objects.filter(
|
||||
url=validated_data.get("url"),
|
||||
issue_id=instance.issue_id,
|
||||
).exclude(pk=instance.id).exists():
|
||||
if (
|
||||
IssueLink.objects.filter(
|
||||
url=validated_data.get("url"),
|
||||
issue_id=instance.issue_id,
|
||||
)
|
||||
.exclude(pk=instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
|
||||
validated_data["metadata"] = get_metadata(
|
||||
validated_data.get("url"), instance.workspace_id
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ from plane.db.models import (
|
||||
IssueVote,
|
||||
IssueRelation,
|
||||
State,
|
||||
Project,
|
||||
)
|
||||
from plane.utils.metadata import get_metadata
|
||||
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||
|
||||
|
||||
class IssueFlatSerializer(BaseSerializer):
|
||||
@@ -419,6 +422,7 @@ class IssueModuleDetailSerializer(BaseSerializer):
|
||||
|
||||
class IssueLinkSerializer(BaseSerializer):
|
||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
||||
metadata = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = IssueLink
|
||||
@@ -433,6 +437,12 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
"issue",
|
||||
]
|
||||
|
||||
def get_metadata(self, obj):
|
||||
logo = obj.metadata.get("logo", None)
|
||||
if logo:
|
||||
obj.metadata["logo"] = generate_download_presigned_url(logo)
|
||||
return obj.metadata
|
||||
|
||||
def validate_url(self, value):
|
||||
# Check URL format
|
||||
validate_url = URLValidator()
|
||||
@@ -449,6 +459,7 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
|
||||
# Validation if url already exists
|
||||
def create(self, validated_data):
|
||||
# Check if URL already exists for this Issue
|
||||
if IssueLink.objects.filter(
|
||||
url=validated_data.get("url"),
|
||||
issue_id=validated_data.get("issue_id"),
|
||||
@@ -456,17 +467,32 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
|
||||
# Workspace
|
||||
project = Project.objects.get(pk=validated_data.get("project_id"))
|
||||
# Fetch metadata from URL
|
||||
validated_data["metadata"] = get_metadata(
|
||||
validated_data.get("url"), project.workspace_id
|
||||
)
|
||||
|
||||
return IssueLink.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if IssueLink.objects.filter(
|
||||
url=validated_data.get("url"),
|
||||
issue_id=instance.issue_id,
|
||||
).exclude(pk=instance.id).exists():
|
||||
if (
|
||||
IssueLink.objects.filter(
|
||||
url=validated_data.get("url"),
|
||||
issue_id=instance.issue_id,
|
||||
)
|
||||
.exclude(pk=instance.id)
|
||||
.exists()
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this Issue"}
|
||||
)
|
||||
|
||||
validated_data["metadata"] = get_metadata(
|
||||
validated_data.get("url"), instance.workspace_id
|
||||
)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
||||
# Generate presigned url for the uploaded file with different base
|
||||
presign_s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/",
|
||||
endpoint_url=f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace(settings.AWS_STORAGE_BUCKET_NAME, '')}/",
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
|
||||
67
apiserver/plane/utils/metadata.py
Normal file
67
apiserver/plane/utils/metadata.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# Python imports
|
||||
import requests
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
# Third party imports
|
||||
from bs4 import BeautifulSoup
|
||||
import favicon
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset
|
||||
|
||||
|
||||
def get_metadata(url, workspace_id):
|
||||
try:
|
||||
# Send a GET request to the URL
|
||||
response = requests.get(url)
|
||||
response.raise_for_status() # Raise an HTTPError for bad responses
|
||||
|
||||
# Parse the HTML content
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
|
||||
# Extract metadata
|
||||
metadata = {
|
||||
"title": soup.title.string if soup.title else "N/A",
|
||||
"description": "",
|
||||
"logo": "",
|
||||
}
|
||||
|
||||
# Extract meta tags
|
||||
meta_tags = soup.find_all("meta")
|
||||
for tag in meta_tags:
|
||||
if "name" in tag.attrs:
|
||||
if tag.attrs["name"].lower() == "description":
|
||||
metadata["description"] = tag.attrs["content"]
|
||||
elif tag.attrs["name"].lower() == "keywords":
|
||||
metadata["keywords"] = tag.attrs["content"]
|
||||
elif (
|
||||
"property" in tag.attrs
|
||||
and tag.attrs["property"].lower() == "og:description"
|
||||
):
|
||||
metadata["description"] = tag.attrs["content"]
|
||||
|
||||
# Extract favicon
|
||||
icons = favicon.get(url, timeout=3)
|
||||
|
||||
# Download the favicon
|
||||
if icons:
|
||||
favicon_response = requests.get(icons[0].url)
|
||||
content = ContentFile(
|
||||
favicon_response.content,
|
||||
name=uuid.uuid4().hex,
|
||||
)
|
||||
|
||||
# Save the favicon as an asset
|
||||
asset = FileAsset.objects.create(
|
||||
asset=content,
|
||||
attributes={"type": "favicon"},
|
||||
workspace_id=workspace_id,
|
||||
)
|
||||
metadata["logo"] = str(asset.asset)
|
||||
|
||||
return metadata
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {}
|
||||
47
apiserver/plane/utils/presigned_url_generator.py
Normal file
47
apiserver/plane/utils/presigned_url_generator.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import boto3
|
||||
from django.conf import settings
|
||||
from botocore.client import Config
|
||||
|
||||
|
||||
def generate_download_presigned_url(
|
||||
key,
|
||||
expiration=3600,
|
||||
content_type="image/jpeg",
|
||||
):
|
||||
"""
|
||||
Generate a presigned URL to download an object from S3, dynamically setting
|
||||
the Content-Disposition based on the file metadata.
|
||||
"""
|
||||
# Create a new S3 client
|
||||
if settings.USE_MINIO:
|
||||
s3_client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace(settings.AWS_STORAGE_BUCKET_NAME, '')}/",
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
else:
|
||||
s3_client = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
region_name=settings.AWS_REGION,
|
||||
)
|
||||
|
||||
try:
|
||||
# Generate a presigned URL for the object
|
||||
url = s3_client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={
|
||||
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
||||
"Key": key,
|
||||
"ResponseContentType": content_type,
|
||||
},
|
||||
ExpiresIn=expiration,
|
||||
)
|
||||
|
||||
# Return the presigned URL
|
||||
return url
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -61,4 +61,5 @@ zxcvbn==4.4.28
|
||||
pytz==2024.1
|
||||
# jwt
|
||||
PyJWT==2.8.0
|
||||
|
||||
# favicon
|
||||
favicon==0.7.0
|
||||
|
||||
Reference in New Issue
Block a user