mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
3 Commits
fix/active
...
issue-link
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6592223ad2 | ||
|
|
f7c67e19e0 | ||
|
|
675a6d6eea |
@@ -21,6 +21,7 @@ from plane.db.models import (
|
|||||||
ProjectMember,
|
ProjectMember,
|
||||||
State,
|
State,
|
||||||
User,
|
User,
|
||||||
|
Project,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
@@ -28,6 +29,8 @@ from .cycle import CycleLiteSerializer, CycleSerializer
|
|||||||
from .module import ModuleLiteSerializer, ModuleSerializer
|
from .module import ModuleLiteSerializer, ModuleSerializer
|
||||||
from .state import StateLiteSerializer
|
from .state import StateLiteSerializer
|
||||||
from .user import UserLiteSerializer
|
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):
|
class IssueSerializer(BaseSerializer):
|
||||||
@@ -272,6 +275,8 @@ class LabelSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class IssueLinkSerializer(BaseSerializer):
|
class IssueLinkSerializer(BaseSerializer):
|
||||||
|
metadata = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IssueLink
|
model = IssueLink
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
@@ -286,6 +291,12 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
"updated_at",
|
"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):
|
def validate_url(self, value):
|
||||||
# Check URL format
|
# Check URL format
|
||||||
validate_url = URLValidator()
|
validate_url = URLValidator()
|
||||||
@@ -309,17 +320,33 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{"error": "URL already exists for this Issue"}
|
{"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)
|
return IssueLink.objects.create(**validated_data)
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
if IssueLink.objects.filter(
|
if (
|
||||||
url=validated_data.get("url"),
|
IssueLink.objects.filter(
|
||||||
issue_id=instance.issue_id,
|
url=validated_data.get("url"),
|
||||||
).exclude(pk=instance.id).exists():
|
issue_id=instance.issue_id,
|
||||||
|
)
|
||||||
|
.exclude(pk=instance.id)
|
||||||
|
.exists()
|
||||||
|
):
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{"error": "URL already exists for this Issue"}
|
{"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)
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ from plane.db.models import (
|
|||||||
IssueVote,
|
IssueVote,
|
||||||
IssueRelation,
|
IssueRelation,
|
||||||
State,
|
State,
|
||||||
|
Project,
|
||||||
)
|
)
|
||||||
|
from plane.utils.metadata import get_metadata
|
||||||
|
from plane.utils.presigned_url_generator import generate_download_presigned_url
|
||||||
|
|
||||||
|
|
||||||
class IssueFlatSerializer(BaseSerializer):
|
class IssueFlatSerializer(BaseSerializer):
|
||||||
@@ -419,6 +422,7 @@ class IssueModuleDetailSerializer(BaseSerializer):
|
|||||||
|
|
||||||
class IssueLinkSerializer(BaseSerializer):
|
class IssueLinkSerializer(BaseSerializer):
|
||||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
||||||
|
metadata = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IssueLink
|
model = IssueLink
|
||||||
@@ -433,6 +437,12 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
"issue",
|
"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):
|
def validate_url(self, value):
|
||||||
# Check URL format
|
# Check URL format
|
||||||
validate_url = URLValidator()
|
validate_url = URLValidator()
|
||||||
@@ -449,6 +459,7 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
|
|
||||||
# Validation if url already exists
|
# Validation if url already exists
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
|
# Check if URL already exists for this Issue
|
||||||
if IssueLink.objects.filter(
|
if IssueLink.objects.filter(
|
||||||
url=validated_data.get("url"),
|
url=validated_data.get("url"),
|
||||||
issue_id=validated_data.get("issue_id"),
|
issue_id=validated_data.get("issue_id"),
|
||||||
@@ -456,17 +467,32 @@ class IssueLinkSerializer(BaseSerializer):
|
|||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{"error": "URL already exists for this Issue"}
|
{"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)
|
return IssueLink.objects.create(**validated_data)
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
if IssueLink.objects.filter(
|
if (
|
||||||
url=validated_data.get("url"),
|
IssueLink.objects.filter(
|
||||||
issue_id=instance.issue_id,
|
url=validated_data.get("url"),
|
||||||
).exclude(pk=instance.id).exists():
|
issue_id=instance.issue_id,
|
||||||
|
)
|
||||||
|
.exclude(pk=instance.id)
|
||||||
|
.exists()
|
||||||
|
):
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{"error": "URL already exists for this Issue"}
|
{"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)
|
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
|
# Generate presigned url for the uploaded file with different base
|
||||||
presign_s3 = boto3.client(
|
presign_s3 = boto3.client(
|
||||||
"s3",
|
"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_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||||
config=Config(signature_version="s3v4"),
|
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
|
pytz==2024.1
|
||||||
# jwt
|
# jwt
|
||||||
PyJWT==2.8.0
|
PyJWT==2.8.0
|
||||||
|
# favicon
|
||||||
|
favicon==0.7.0
|
||||||
|
|||||||
Reference in New Issue
Block a user