base
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.db.models import Q, Case, Value, When
|
||||
from django.db import models
|
||||
from django.db.models.functions import Concat
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.csv_utils import sanitize_csv_row
|
||||
|
||||
row_mapping = {
|
||||
"state__name": "State",
|
||||
"state__group": "State Group",
|
||||
"labels__id": "Label",
|
||||
"assignees__id": "Assignee Name",
|
||||
"start_date": "Start Date",
|
||||
"target_date": "Due Date",
|
||||
"completed_at": "Completed At",
|
||||
"created_at": "Created At",
|
||||
"issue_count": "Issue Count",
|
||||
"priority": "Priority",
|
||||
"estimate": "Estimate",
|
||||
"issue_cycle__cycle_id": "Cycle",
|
||||
"issue_module__module_id": "Module",
|
||||
}
|
||||
|
||||
ASSIGNEE_ID = "assignees__id"
|
||||
LABEL_ID = "labels__id"
|
||||
STATE_ID = "state_id"
|
||||
CYCLE_ID = "issue_cycle__cycle_id"
|
||||
MODULE_ID = "issue_module__module_id"
|
||||
|
||||
|
||||
def send_export_email(email, slug, csv_buffer, rows):
|
||||
"""Helper function to send export email."""
|
||||
subject = "Your Export is ready"
|
||||
html_content = render_to_string("emails/exports/analytics.html", {})
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue())
|
||||
msg.send(fail_silently=False)
|
||||
return
|
||||
|
||||
|
||||
def get_assignee_details(slug, filters):
|
||||
"""Fetch assignee details if required."""
|
||||
return (
|
||||
Issue.issue_objects.filter(
|
||||
Q(Q(assignees__avatar__isnull=False) | Q(assignees__avatar_asset__isnull=False)),
|
||||
workspace__slug=slug,
|
||||
**filters,
|
||||
)
|
||||
.annotate(
|
||||
assignees__avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.distinct("assignees__id")
|
||||
.order_by("assignees__id")
|
||||
.values(
|
||||
"assignees__avatar_url",
|
||||
"assignees__display_name",
|
||||
"assignees__first_name",
|
||||
"assignees__last_name",
|
||||
"assignees__id",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_label_details(slug, filters):
|
||||
"""Fetch label details if required"""
|
||||
return (
|
||||
Issue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
**filters,
|
||||
labels__id__isnull=False,
|
||||
label_issue__deleted_at__isnull=True,
|
||||
)
|
||||
.distinct("labels__id")
|
||||
.order_by("labels__id")
|
||||
.values("labels__id", "labels__color", "labels__name")
|
||||
)
|
||||
|
||||
|
||||
def get_state_details(slug, filters):
|
||||
return (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, **filters)
|
||||
.distinct("state_id")
|
||||
.order_by("state_id")
|
||||
.values("state_id", "state__name", "state__color")
|
||||
)
|
||||
|
||||
|
||||
def get_module_details(slug, filters):
|
||||
return (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
**filters,
|
||||
issue_module__module_id__isnull=False,
|
||||
issue_module__deleted_at__isnull=True,
|
||||
)
|
||||
.distinct("issue_module__module_id")
|
||||
.order_by("issue_module__module_id")
|
||||
.values("issue_module__module_id", "issue_module__module__name")
|
||||
)
|
||||
|
||||
|
||||
def get_cycle_details(slug, filters):
|
||||
return (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug,
|
||||
**filters,
|
||||
issue_cycle__cycle_id__isnull=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
)
|
||||
.distinct("issue_cycle__cycle_id")
|
||||
.order_by("issue_cycle__cycle_id")
|
||||
.values("issue_cycle__cycle_id", "issue_cycle__cycle__name")
|
||||
)
|
||||
|
||||
|
||||
def generate_csv_from_rows(rows):
|
||||
"""Generate CSV buffer from rows."""
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
[writer.writerow(sanitize_csv_row(row)) for row in rows]
|
||||
return csv_buffer
|
||||
|
||||
|
||||
def generate_segmented_rows(
|
||||
distribution,
|
||||
x_axis,
|
||||
y_axis,
|
||||
segment,
|
||||
key,
|
||||
assignee_details,
|
||||
label_details,
|
||||
state_details,
|
||||
cycle_details,
|
||||
module_details,
|
||||
):
|
||||
segment_zero = list(set(item.get("segment") for sublist in distribution.values() for item in sublist))
|
||||
|
||||
segmented = segment
|
||||
|
||||
row_zero = [
|
||||
row_mapping.get(x_axis, "X-Axis"),
|
||||
row_mapping.get(y_axis, "Y-Axis"),
|
||||
] + segment_zero
|
||||
|
||||
rows = []
|
||||
for item, data in distribution.items():
|
||||
generated_row = [
|
||||
item,
|
||||
sum(obj.get(key) for obj in data if obj.get(key) is not None),
|
||||
]
|
||||
|
||||
for segment in segment_zero:
|
||||
value = next((x.get(key) for x in data if x.get("segment") == segment), "0")
|
||||
generated_row.append(value)
|
||||
|
||||
if x_axis == ASSIGNEE_ID:
|
||||
assignee = next(
|
||||
(user for user in assignee_details if str(user[ASSIGNEE_ID]) == str(item)),
|
||||
None,
|
||||
)
|
||||
if assignee:
|
||||
generated_row[0] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
|
||||
if x_axis == LABEL_ID:
|
||||
label = next((lab for lab in label_details if str(lab[LABEL_ID]) == str(item)), None)
|
||||
|
||||
if label:
|
||||
generated_row[0] = f"{label['labels__name']}"
|
||||
|
||||
if x_axis == STATE_ID:
|
||||
state = next((sta for sta in state_details if str(sta[STATE_ID]) == str(item)), None)
|
||||
|
||||
if state:
|
||||
generated_row[0] = f"{state['state__name']}"
|
||||
|
||||
if x_axis == CYCLE_ID:
|
||||
cycle = next((cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), None)
|
||||
|
||||
if cycle:
|
||||
generated_row[0] = f"{cycle['issue_cycle__cycle__name']}"
|
||||
|
||||
if x_axis == MODULE_ID:
|
||||
module = next(
|
||||
(mod for mod in module_details if str(mod[MODULE_ID]) == str(item)),
|
||||
None,
|
||||
)
|
||||
|
||||
if module:
|
||||
generated_row[0] = f"{module['issue_module__module__name']}"
|
||||
|
||||
rows.append(tuple(generated_row))
|
||||
|
||||
if segmented == ASSIGNEE_ID:
|
||||
for index, segm in enumerate(row_zero[2:]):
|
||||
assignee = next(
|
||||
(user for user in assignee_details if str(user[ASSIGNEE_ID]) == str(segm)),
|
||||
None,
|
||||
)
|
||||
if assignee:
|
||||
row_zero[index + 2] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
|
||||
if segmented == LABEL_ID:
|
||||
for index, segm in enumerate(row_zero[2:]):
|
||||
label = next((lab for lab in label_details if str(lab[LABEL_ID]) == str(segm)), None)
|
||||
if label:
|
||||
row_zero[index + 2] = label["labels__name"]
|
||||
|
||||
if segmented == STATE_ID:
|
||||
for index, segm in enumerate(row_zero[2:]):
|
||||
state = next((sta for sta in state_details if str(sta[STATE_ID]) == str(segm)), None)
|
||||
if state:
|
||||
row_zero[index + 2] = state["state__name"]
|
||||
|
||||
if segmented == MODULE_ID:
|
||||
for index, segm in enumerate(row_zero[2:]):
|
||||
module = next((mod for mod in label_details if str(mod[MODULE_ID]) == str(segm)), None)
|
||||
if module:
|
||||
row_zero[index + 2] = module["issue_module__module__name"]
|
||||
|
||||
if segmented == CYCLE_ID:
|
||||
for index, segm in enumerate(row_zero[2:]):
|
||||
cycle = next((cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(segm)), None)
|
||||
if cycle:
|
||||
row_zero[index + 2] = cycle["issue_cycle__cycle__name"]
|
||||
|
||||
return [tuple(row_zero)] + rows
|
||||
|
||||
|
||||
def generate_non_segmented_rows(
|
||||
distribution,
|
||||
x_axis,
|
||||
y_axis,
|
||||
key,
|
||||
assignee_details,
|
||||
label_details,
|
||||
state_details,
|
||||
cycle_details,
|
||||
module_details,
|
||||
):
|
||||
rows = []
|
||||
for item, data in distribution.items():
|
||||
row = [item, data[0].get("count" if y_axis == "issue_count" else "estimate")]
|
||||
|
||||
if x_axis == ASSIGNEE_ID:
|
||||
assignee = next(
|
||||
(user for user in assignee_details if str(user[ASSIGNEE_ID]) == str(item)),
|
||||
None,
|
||||
)
|
||||
if assignee:
|
||||
row[0] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}"
|
||||
|
||||
if x_axis == LABEL_ID:
|
||||
label = next((lab for lab in label_details if str(lab[LABEL_ID]) == str(item)), None)
|
||||
|
||||
if label:
|
||||
row[0] = f"{label['labels__name']}"
|
||||
|
||||
if x_axis == STATE_ID:
|
||||
state = next((sta for sta in state_details if str(sta[STATE_ID]) == str(item)), None)
|
||||
|
||||
if state:
|
||||
row[0] = f"{state['state__name']}"
|
||||
|
||||
if x_axis == CYCLE_ID:
|
||||
cycle = next((cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), None)
|
||||
|
||||
if cycle:
|
||||
row[0] = f"{cycle['issue_cycle__cycle__name']}"
|
||||
|
||||
if x_axis == MODULE_ID:
|
||||
module = next(
|
||||
(mod for mod in module_details if str(mod[MODULE_ID]) == str(item)),
|
||||
None,
|
||||
)
|
||||
|
||||
if module:
|
||||
row[0] = f"{module['issue_module__module__name']}"
|
||||
|
||||
rows.append(tuple(row))
|
||||
|
||||
row_zero = [row_mapping.get(x_axis, "X-Axis"), row_mapping.get(y_axis, "Y-Axis")]
|
||||
return [tuple(row_zero)] + rows
|
||||
|
||||
|
||||
@shared_task
|
||||
def analytic_export_task(email, data, slug):
|
||||
try:
|
||||
filters = issue_filters(data, "POST")
|
||||
queryset = Issue.issue_objects.filter(**filters, workspace__slug=slug)
|
||||
|
||||
x_axis = data.get("x_axis", False)
|
||||
y_axis = data.get("y_axis", False)
|
||||
segment = data.get("segment", False)
|
||||
|
||||
distribution = build_graph_plot(queryset, x_axis=x_axis, y_axis=y_axis, segment=segment)
|
||||
key = "count" if y_axis == "issue_count" else "estimate"
|
||||
|
||||
assignee_details = (
|
||||
get_assignee_details(slug, filters) if x_axis == ASSIGNEE_ID or segment == ASSIGNEE_ID else {}
|
||||
)
|
||||
|
||||
label_details = get_label_details(slug, filters) if x_axis == LABEL_ID or segment == LABEL_ID else {}
|
||||
|
||||
state_details = get_state_details(slug, filters) if x_axis == STATE_ID or segment == STATE_ID else {}
|
||||
|
||||
cycle_details = get_cycle_details(slug, filters) if x_axis == CYCLE_ID or segment == CYCLE_ID else {}
|
||||
|
||||
module_details = get_module_details(slug, filters) if x_axis == MODULE_ID or segment == MODULE_ID else {}
|
||||
|
||||
if segment:
|
||||
rows = generate_segmented_rows(
|
||||
distribution,
|
||||
x_axis,
|
||||
y_axis,
|
||||
segment,
|
||||
key,
|
||||
assignee_details,
|
||||
label_details,
|
||||
state_details,
|
||||
cycle_details,
|
||||
module_details,
|
||||
)
|
||||
else:
|
||||
rows = generate_non_segmented_rows(
|
||||
distribution,
|
||||
x_axis,
|
||||
y_axis,
|
||||
key,
|
||||
assignee_details,
|
||||
label_details,
|
||||
state_details,
|
||||
cycle_details,
|
||||
module_details,
|
||||
)
|
||||
|
||||
csv_buffer = generate_csv_from_rows(rows)
|
||||
send_export_email(email, slug, csv_buffer, rows)
|
||||
logging.getLogger("plane.worker").info("Email sent successfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def export_analytics_to_csv_email(data, headers, keys, email, slug):
|
||||
try:
|
||||
"""
|
||||
Prepares a CSV from data and sends it as an email attachment.
|
||||
|
||||
Parameters:
|
||||
- data: List of dictionaries (e.g. from .values())
|
||||
- headers: List of CSV column headers
|
||||
- keys: Keys to extract from each data item (dict)
|
||||
- email: Email address to send to
|
||||
- slug: Used for the filename
|
||||
"""
|
||||
# Prepare rows: header + data rows
|
||||
rows = [headers]
|
||||
for item in data:
|
||||
row = [item.get(key, "") for key in keys]
|
||||
rows.append(row)
|
||||
|
||||
# Generate CSV buffer
|
||||
csv_buffer = generate_csv_from_rows(rows)
|
||||
|
||||
# Send email with CSV attachment
|
||||
send_export_email(email=email, slug=slug, csv_buffer=csv_buffer, rows=rows)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BgtasksConfig(AppConfig):
|
||||
name = "plane.bgtasks"
|
||||
@@ -0,0 +1,479 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import List, Dict, Any, Callable, Optional
|
||||
import os
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import F, Window, Subquery
|
||||
from django.db.models.functions import RowNumber
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from pymongo.errors import BulkWriteError
|
||||
from pymongo.collection import Collection
|
||||
from pymongo.operations import InsertOne
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
EmailNotificationLog,
|
||||
PageVersion,
|
||||
APIActivityLog,
|
||||
IssueDescriptionVersion,
|
||||
WebhookLog,
|
||||
)
|
||||
from plane.settings.mongo import MongoConnection
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
BATCH_SIZE = 500
|
||||
|
||||
|
||||
def get_mongo_collection(collection_name: str) -> Optional[Collection]:
|
||||
"""Get MongoDB collection if available, otherwise return None."""
|
||||
if not MongoConnection.is_configured():
|
||||
logger.info("MongoDB not configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
mongo_collection = MongoConnection.get_collection(collection_name)
|
||||
logger.info(f"MongoDB collection '{collection_name}' connected successfully")
|
||||
return mongo_collection
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get MongoDB collection: {str(e)}")
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
|
||||
def flush_to_mongo_and_delete(
|
||||
mongo_collection: Optional[Collection],
|
||||
buffer: List[Dict[str, Any]],
|
||||
ids_to_delete: List[int],
|
||||
model,
|
||||
mongo_available: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Inserts a batch of records into MongoDB and deletes the corresponding rows from PostgreSQL.
|
||||
"""
|
||||
if not buffer:
|
||||
logger.debug("No records to flush - buffer is empty")
|
||||
return
|
||||
|
||||
logger.info(f"Starting batch flush: {len(buffer)} records, {len(ids_to_delete)} IDs to delete")
|
||||
|
||||
mongo_archival_failed = False
|
||||
|
||||
# Try to insert into MongoDB if available
|
||||
if mongo_collection is not None and mongo_available:
|
||||
try:
|
||||
mongo_collection.bulk_write([InsertOne(doc) for doc in buffer])
|
||||
except BulkWriteError as bwe:
|
||||
logger.error(f"MongoDB bulk write error: {str(bwe)}")
|
||||
log_exception(bwe)
|
||||
mongo_archival_failed = True
|
||||
|
||||
# If MongoDB is available and archival failed, log the error and return
|
||||
if mongo_available and mongo_archival_failed:
|
||||
logger.error(f"MongoDB archival failed for {len(buffer)} records")
|
||||
return
|
||||
|
||||
# Delete from PostgreSQL - delete() returns (count, {model: count})
|
||||
delete_result = model.all_objects.filter(id__in=ids_to_delete).delete()
|
||||
deleted_count = delete_result[0] if delete_result and isinstance(delete_result, tuple) else 0
|
||||
logger.info(f"Batch flush completed: {deleted_count} records deleted")
|
||||
|
||||
|
||||
def process_cleanup_task(
|
||||
queryset_func: Callable,
|
||||
transform_func: Callable[[Dict], Dict],
|
||||
model,
|
||||
task_name: str,
|
||||
collection_name: str,
|
||||
):
|
||||
"""
|
||||
Generic function to process cleanup tasks.
|
||||
|
||||
Args:
|
||||
queryset_func: Function that returns the queryset to process
|
||||
transform_func: Function to transform each record for MongoDB
|
||||
model: Django model class
|
||||
task_name: Name of the task for logging
|
||||
collection_name: MongoDB collection name
|
||||
"""
|
||||
logger.info(f"Starting {task_name} cleanup task")
|
||||
|
||||
# Get MongoDB collection
|
||||
mongo_collection = get_mongo_collection(collection_name)
|
||||
mongo_available = mongo_collection is not None
|
||||
|
||||
# Get queryset
|
||||
queryset = queryset_func()
|
||||
|
||||
# Process records in batches
|
||||
buffer: List[Dict[str, Any]] = []
|
||||
ids_to_delete: List[int] = []
|
||||
total_processed = 0
|
||||
total_batches = 0
|
||||
|
||||
for record in queryset:
|
||||
# Transform record for MongoDB
|
||||
buffer.append(transform_func(record))
|
||||
ids_to_delete.append(record["id"])
|
||||
|
||||
# Flush batch when it reaches BATCH_SIZE
|
||||
if len(buffer) >= BATCH_SIZE:
|
||||
total_batches += 1
|
||||
flush_to_mongo_and_delete(
|
||||
mongo_collection=mongo_collection,
|
||||
buffer=buffer,
|
||||
ids_to_delete=ids_to_delete,
|
||||
model=model,
|
||||
mongo_available=mongo_available,
|
||||
)
|
||||
total_processed += len(buffer)
|
||||
buffer.clear()
|
||||
ids_to_delete.clear()
|
||||
|
||||
# Process final batch if any records remain
|
||||
if buffer:
|
||||
total_batches += 1
|
||||
flush_to_mongo_and_delete(
|
||||
mongo_collection=mongo_collection,
|
||||
buffer=buffer,
|
||||
ids_to_delete=ids_to_delete,
|
||||
model=model,
|
||||
mongo_available=mongo_available,
|
||||
)
|
||||
total_processed += len(buffer)
|
||||
|
||||
logger.info(
|
||||
f"{task_name} cleanup task completed",
|
||||
extra={
|
||||
"total_records_processed": total_processed,
|
||||
"total_batches": total_batches,
|
||||
"mongo_available": mongo_available,
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Transform functions for each model
|
||||
def transform_api_log(record: Dict) -> Dict:
|
||||
"""Transform API activity log record."""
|
||||
return {
|
||||
"id": str(record["id"]),
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"token_identifier": str(record["token_identifier"]),
|
||||
"path": record["path"],
|
||||
"method": record["method"],
|
||||
"query_params": record.get("query_params"),
|
||||
"headers": record.get("headers"),
|
||||
"body": record.get("body"),
|
||||
"response_code": record["response_code"],
|
||||
"response_body": record["response_body"],
|
||||
"ip_address": record["ip_address"],
|
||||
"user_agent": record["user_agent"],
|
||||
"created_by_id": str(record["created_by_id"]),
|
||||
}
|
||||
|
||||
|
||||
def transform_email_log(record: Dict) -> Dict:
|
||||
"""Transform email notification log record."""
|
||||
return {
|
||||
"id": str(record["id"]),
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"receiver_id": str(record["receiver_id"]),
|
||||
"triggered_by_id": str(record["triggered_by_id"]),
|
||||
"entity_identifier": str(record["entity_identifier"]),
|
||||
"entity_name": record["entity_name"],
|
||||
"data": record["data"],
|
||||
"processed_at": (str(record["processed_at"]) if record.get("processed_at") else None),
|
||||
"sent_at": str(record["sent_at"]) if record.get("sent_at") else None,
|
||||
"entity": record["entity"],
|
||||
"old_value": str(record["old_value"]),
|
||||
"new_value": str(record["new_value"]),
|
||||
"created_by_id": str(record["created_by_id"]),
|
||||
}
|
||||
|
||||
|
||||
def transform_page_version(record: Dict) -> Dict:
|
||||
"""Transform page version record."""
|
||||
return {
|
||||
"id": str(record["id"]),
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"page_id": str(record["page_id"]),
|
||||
"workspace_id": str(record["workspace_id"]),
|
||||
"owned_by_id": str(record["owned_by_id"]),
|
||||
"description_html": record["description_html"],
|
||||
"description_binary": record["description_binary"],
|
||||
"description_stripped": record["description_stripped"],
|
||||
"description_json": record["description_json"],
|
||||
"sub_pages_data": record["sub_pages_data"],
|
||||
"created_by_id": str(record["created_by_id"]),
|
||||
"updated_by_id": str(record["updated_by_id"]),
|
||||
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
|
||||
"last_saved_at": (str(record["last_saved_at"]) if record.get("last_saved_at") else None),
|
||||
}
|
||||
|
||||
|
||||
def transform_issue_description_version(record: Dict) -> Dict:
|
||||
"""Transform issue description version record."""
|
||||
return {
|
||||
"id": str(record["id"]),
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"issue_id": str(record["issue_id"]),
|
||||
"workspace_id": str(record["workspace_id"]),
|
||||
"project_id": str(record["project_id"]),
|
||||
"created_by_id": str(record["created_by_id"]),
|
||||
"updated_by_id": str(record["updated_by_id"]),
|
||||
"owned_by_id": str(record["owned_by_id"]),
|
||||
"last_saved_at": (str(record["last_saved_at"]) if record.get("last_saved_at") else None),
|
||||
"description_binary": record["description_binary"],
|
||||
"description_html": record["description_html"],
|
||||
"description_stripped": record["description_stripped"],
|
||||
"description_json": record["description_json"],
|
||||
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
|
||||
}
|
||||
|
||||
|
||||
def transform_webhook_log(record: Dict):
|
||||
"""Transfer webhook logs to a new destination."""
|
||||
return {
|
||||
"id": str(record["id"]),
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"workspace_id": str(record["workspace_id"]),
|
||||
"webhook": str(record["webhook"]),
|
||||
# Request
|
||||
"event_type": str(record["event_type"]),
|
||||
"request_method": str(record["request_method"]),
|
||||
"request_headers": str(record["request_headers"]),
|
||||
"request_body": str(record["request_body"]),
|
||||
# Response
|
||||
"response_status": str(record["response_status"]),
|
||||
"response_body": str(record["response_body"]),
|
||||
"response_headers": str(record["response_headers"]),
|
||||
# retry count
|
||||
"retry_count": str(record["retry_count"]),
|
||||
}
|
||||
|
||||
|
||||
# Queryset functions for each cleanup task
|
||||
def get_api_logs_queryset():
|
||||
"""Get API logs older than cutoff days."""
|
||||
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
|
||||
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
|
||||
logger.info(f"API logs cutoff time: {cutoff_time}")
|
||||
|
||||
return (
|
||||
APIActivityLog.all_objects.filter(created_at__lte=cutoff_time)
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"token_identifier",
|
||||
"path",
|
||||
"method",
|
||||
"query_params",
|
||||
"headers",
|
||||
"body",
|
||||
"response_code",
|
||||
"response_body",
|
||||
"ip_address",
|
||||
"user_agent",
|
||||
"created_by_id",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_email_logs_queryset():
|
||||
"""Get email logs older than cutoff days."""
|
||||
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
|
||||
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
|
||||
logger.info(f"Email logs cutoff time: {cutoff_time}")
|
||||
|
||||
return (
|
||||
EmailNotificationLog.all_objects.filter(sent_at__lte=cutoff_time)
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"receiver_id",
|
||||
"triggered_by_id",
|
||||
"entity_identifier",
|
||||
"entity_name",
|
||||
"data",
|
||||
"processed_at",
|
||||
"sent_at",
|
||||
"entity",
|
||||
"old_value",
|
||||
"new_value",
|
||||
"created_by_id",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_page_versions_queryset():
|
||||
"""Get page versions beyond the maximum allowed (20 per page)."""
|
||||
subq = (
|
||||
PageVersion.all_objects.annotate(
|
||||
row_num=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[F("page_id")],
|
||||
order_by=F("created_at").desc(),
|
||||
)
|
||||
)
|
||||
.filter(row_num__gt=20)
|
||||
.values("id")
|
||||
)
|
||||
|
||||
return (
|
||||
PageVersion.all_objects.filter(id__in=Subquery(subq))
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"page_id",
|
||||
"workspace_id",
|
||||
"owned_by_id",
|
||||
"description_html",
|
||||
"description_binary",
|
||||
"description_stripped",
|
||||
"description_json",
|
||||
"sub_pages_data",
|
||||
"created_by_id",
|
||||
"updated_by_id",
|
||||
"deleted_at",
|
||||
"last_saved_at",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_issue_description_versions_queryset():
|
||||
"""Get issue description versions beyond the maximum allowed (20 per issue)."""
|
||||
subq = (
|
||||
IssueDescriptionVersion.all_objects.annotate(
|
||||
row_num=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[F("issue_id")],
|
||||
order_by=F("created_at").desc(),
|
||||
)
|
||||
)
|
||||
.filter(row_num__gt=20)
|
||||
.values("id")
|
||||
)
|
||||
|
||||
return (
|
||||
IssueDescriptionVersion.all_objects.filter(id__in=Subquery(subq))
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"issue_id",
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
"created_by_id",
|
||||
"updated_by_id",
|
||||
"owned_by_id",
|
||||
"last_saved_at",
|
||||
"description_binary",
|
||||
"description_html",
|
||||
"description_stripped",
|
||||
"description_json",
|
||||
"deleted_at",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_webhook_logs_queryset():
|
||||
"""Get email logs older than cutoff days."""
|
||||
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
|
||||
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
|
||||
logger.info(f"Webhook logs cutoff time: {cutoff_time}")
|
||||
|
||||
return (
|
||||
WebhookLog.all_objects.filter(created_at__lte=cutoff_time)
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"workspace_id",
|
||||
"webhook",
|
||||
"event_type",
|
||||
# Request
|
||||
"request_method",
|
||||
"request_headers",
|
||||
"request_body",
|
||||
# Response
|
||||
"response_status",
|
||||
"response_body",
|
||||
"response_headers",
|
||||
"retry_count",
|
||||
)
|
||||
.order_by("created_at")
|
||||
.iterator(chunk_size=100)
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_api_logs():
|
||||
"""Delete old API activity logs."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_api_logs_queryset,
|
||||
transform_func=transform_api_log,
|
||||
model=APIActivityLog,
|
||||
task_name="API Activity Log",
|
||||
collection_name="api_activity_logs",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_email_notification_logs():
|
||||
"""Delete old email notification logs."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_email_logs_queryset,
|
||||
transform_func=transform_email_log,
|
||||
model=EmailNotificationLog,
|
||||
task_name="Email Notification Log",
|
||||
collection_name="email_notification_logs",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_page_versions():
|
||||
"""Delete excess page versions."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_page_versions_queryset,
|
||||
transform_func=transform_page_version,
|
||||
model=PageVersion,
|
||||
task_name="Page Version",
|
||||
collection_name="page_versions",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_issue_description_versions():
|
||||
"""Delete excess issue description versions."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_issue_description_versions_queryset,
|
||||
transform_func=transform_issue_description_version,
|
||||
model=IssueDescriptionVersion,
|
||||
task_name="Issue Description Version",
|
||||
collection_name="issue_description_versions",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_webhook_logs():
|
||||
"""Delete old webhook logs"""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_webhook_logs_queryset,
|
||||
transform_func=transform_webhook_log,
|
||||
model=WebhookLog,
|
||||
task_name="Webhook Log",
|
||||
collection_name="webhook_logs",
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import uuid
|
||||
import base64
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset, Page, Issue
|
||||
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
|
||||
|
||||
|
||||
def get_entity_id_field(entity_type, entity_id):
|
||||
entity_mapping = {
|
||||
FileAsset.EntityTypeContext.WORKSPACE_LOGO: {"workspace_id": entity_id},
|
||||
FileAsset.EntityTypeContext.PROJECT_COVER: {"project_id": entity_id},
|
||||
FileAsset.EntityTypeContext.USER_AVATAR: {"user_id": entity_id},
|
||||
FileAsset.EntityTypeContext.USER_COVER: {"user_id": entity_id},
|
||||
FileAsset.EntityTypeContext.ISSUE_ATTACHMENT: {"issue_id": entity_id},
|
||||
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: {"issue_id": entity_id},
|
||||
FileAsset.EntityTypeContext.PAGE_DESCRIPTION: {"page_id": entity_id},
|
||||
FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: {"comment_id": entity_id},
|
||||
FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: {"draft_issue_id": entity_id},
|
||||
}
|
||||
return entity_mapping.get(entity_type, {})
|
||||
|
||||
|
||||
def extract_asset_ids(html, tag):
|
||||
try:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
return [tag.get("src") for tag in soup.find_all(tag) if tag.get("src")]
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return []
|
||||
|
||||
|
||||
def replace_asset_ids(html, tag, duplicated_assets):
|
||||
try:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for mention_tag in soup.find_all(tag):
|
||||
for asset in duplicated_assets:
|
||||
if mention_tag.get("src") == asset["old_asset_id"]:
|
||||
mention_tag["src"] = asset["new_asset_id"]
|
||||
return str(soup)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return html
|
||||
|
||||
|
||||
def update_description(entity, duplicated_assets, tag):
|
||||
updated_html = replace_asset_ids(entity.description_html, tag, duplicated_assets)
|
||||
entity.description_html = updated_html
|
||||
entity.save()
|
||||
return updated_html
|
||||
|
||||
|
||||
# Get the description binary and description from the live server
|
||||
def sync_with_external_service(entity_name, description_html):
|
||||
try:
|
||||
data = {
|
||||
"description_html": description_html,
|
||||
"variant": "rich" if entity_name == "PAGE" else "document",
|
||||
}
|
||||
|
||||
live_url = settings.LIVE_URL
|
||||
if not live_url:
|
||||
return {}
|
||||
|
||||
url = normalize_url_path(f"{live_url}/convert-document/")
|
||||
|
||||
response = requests.post(url, json=data, headers=None)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log_exception(e)
|
||||
return {}
|
||||
|
||||
|
||||
def copy_assets(entity, entity_identifier, project_id, asset_ids, user_id):
|
||||
duplicated_assets = []
|
||||
workspace = entity.workspace
|
||||
storage = S3Storage()
|
||||
original_assets = FileAsset.objects.filter(workspace=workspace, project_id=project_id, id__in=asset_ids)
|
||||
|
||||
for original_asset in original_assets:
|
||||
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
|
||||
duplicated_asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": original_asset.attributes.get("name"),
|
||||
"type": original_asset.attributes.get("type"),
|
||||
"size": original_asset.attributes.get("size"),
|
||||
},
|
||||
asset=destination_key,
|
||||
size=original_asset.size,
|
||||
workspace=workspace,
|
||||
created_by_id=user_id,
|
||||
entity_type=original_asset.entity_type,
|
||||
project_id=project_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)
|
||||
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
|
||||
|
||||
|
||||
@shared_task
|
||||
def copy_s3_objects_of_description_and_assets(entity_name, entity_identifier, project_id, slug, user_id):
|
||||
"""
|
||||
Step 1: Extract asset ids from the description_html of the entity
|
||||
Step 2: Duplicate the assets
|
||||
Step 3: Update the description_html of the entity with the new asset ids (change the src of img tag)
|
||||
Step 4: Request the live server to generate the description_binary and description for the entity
|
||||
|
||||
"""
|
||||
try:
|
||||
model_class = {"PAGE": Page, "ISSUE": Issue}.get(entity_name)
|
||||
if not model_class:
|
||||
raise ValueError(f"Unsupported entity_name: {entity_name}")
|
||||
|
||||
entity = model_class.objects.get(id=entity_identifier)
|
||||
asset_ids = extract_asset_ids(entity.description_html, "image-component")
|
||||
|
||||
duplicated_assets = copy_assets(entity, entity_identifier, project_id, asset_ids, user_id)
|
||||
|
||||
updated_html = update_description(entity, duplicated_assets, "image-component")
|
||||
|
||||
external_data = sync_with_external_service(entity_name, updated_html)
|
||||
|
||||
if external_data:
|
||||
entity.description_json = external_data.get("description_json")
|
||||
entity.description_binary = base64.b64decode(external_data.get("description_binary"))
|
||||
entity.save()
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return []
|
||||
@@ -0,0 +1,193 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models.fields.related import OneToOneRel
|
||||
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
@shared_task
|
||||
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
|
||||
"""
|
||||
Soft delete related objects for a given model instance
|
||||
"""
|
||||
# Get the model class using app registry
|
||||
model_class = apps.get_model(app_label, model_name)
|
||||
|
||||
# Get the instance using all_objects to ensure we can get even if it's already soft deleted
|
||||
try:
|
||||
instance = model_class.all_objects.get(pk=instance_pk)
|
||||
except model_class.DoesNotExist:
|
||||
return
|
||||
|
||||
# Get all related fields that are reverse relationships
|
||||
all_related = [
|
||||
f for f in instance._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete
|
||||
]
|
||||
|
||||
# Handle each related field
|
||||
for relation in all_related:
|
||||
related_name = relation.get_accessor_name()
|
||||
|
||||
# Skip if the relation doesn't exist
|
||||
if not hasattr(instance, related_name):
|
||||
continue
|
||||
|
||||
# Get the on_delete behavior name
|
||||
on_delete_name = relation.on_delete.__name__ if hasattr(relation.on_delete, "__name__") else ""
|
||||
|
||||
if on_delete_name == "DO_NOTHING":
|
||||
continue
|
||||
|
||||
elif on_delete_name == "SET_NULL":
|
||||
# Handle SET_NULL relationships
|
||||
if isinstance(relation, OneToOneRel):
|
||||
# For OneToOne relationships
|
||||
related_obj = getattr(instance, related_name, None)
|
||||
if related_obj and isinstance(related_obj, models.Model):
|
||||
setattr(related_obj, relation.remote_field.name, None)
|
||||
related_obj.save(update_fields=[relation.remote_field.name])
|
||||
else:
|
||||
# For other relationships
|
||||
related_queryset = getattr(instance, related_name).all()
|
||||
related_queryset.update(**{relation.remote_field.name: None})
|
||||
|
||||
else:
|
||||
# Handle CASCADE and other delete behaviors
|
||||
try:
|
||||
if relation.one_to_one:
|
||||
# Handle OneToOne relationships
|
||||
related_obj = getattr(instance, related_name, None)
|
||||
if related_obj:
|
||||
if hasattr(related_obj, "deleted_at"):
|
||||
if not related_obj.deleted_at:
|
||||
related_obj.deleted_at = timezone.now()
|
||||
related_obj.save()
|
||||
# Recursively handle related objects
|
||||
soft_delete_related_objects(
|
||||
related_obj._meta.app_label,
|
||||
related_obj._meta.model_name,
|
||||
related_obj.pk,
|
||||
using,
|
||||
)
|
||||
else:
|
||||
# Handle other relationships
|
||||
related_queryset = getattr(instance, related_name)(manager="objects").all()
|
||||
|
||||
for related_obj in related_queryset:
|
||||
if hasattr(related_obj, "deleted_at"):
|
||||
if not related_obj.deleted_at:
|
||||
related_obj.deleted_at = timezone.now()
|
||||
related_obj.save()
|
||||
# Recursively handle related objects
|
||||
soft_delete_related_objects(
|
||||
related_obj._meta.app_label,
|
||||
related_obj._meta.model_name,
|
||||
related_obj.pk,
|
||||
using,
|
||||
)
|
||||
except Exception as e:
|
||||
# Log the error or handle as needed
|
||||
print(f"Error handling relation {related_name}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Finally, soft delete the instance itself if it hasn't been deleted yet
|
||||
if hasattr(instance, "deleted_at") and not instance.deleted_at:
|
||||
instance.deleted_at = timezone.now()
|
||||
instance.save()
|
||||
|
||||
|
||||
# @shared_task
|
||||
def restore_related_objects(app_label, model_name, instance_pk, using=None):
|
||||
pass
|
||||
|
||||
|
||||
@shared_task
|
||||
def hard_delete():
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
Cycle,
|
||||
Module,
|
||||
Issue,
|
||||
Page,
|
||||
IssueView,
|
||||
Label,
|
||||
State,
|
||||
IssueActivity,
|
||||
IssueComment,
|
||||
IssueLink,
|
||||
IssueReaction,
|
||||
UserFavorite,
|
||||
ModuleIssue,
|
||||
CycleIssue,
|
||||
Estimate,
|
||||
EstimatePoint,
|
||||
)
|
||||
|
||||
days = settings.HARD_DELETE_AFTER_DAYS
|
||||
# check delete workspace
|
||||
_ = Workspace.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
# check delete project
|
||||
_ = Project.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
# check delete cycle
|
||||
_ = Cycle.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
# check delete module
|
||||
_ = Module.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
# check delete issue
|
||||
_ = Issue.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
# check delete page
|
||||
_ = Page.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
# check delete view
|
||||
_ = IssueView.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
# check delete label
|
||||
_ = Label.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
# check delete state
|
||||
_ = State.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
_ = IssueActivity.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
_ = IssueComment.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
_ = IssueLink.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
_ = IssueReaction.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
_ = UserFavorite.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
_ = ModuleIssue.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
_ = CycleIssue.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
_ = Estimate.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
_ = EstimatePoint.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
# at last, check for every thing which ever is left and delete it
|
||||
# Get all Django models
|
||||
all_models = apps.get_models()
|
||||
|
||||
# Iterate through all models
|
||||
for model in all_models:
|
||||
# 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
|
||||
_ = model.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
|
||||
|
||||
return
|
||||
@@ -0,0 +1,553 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import uuid
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Max
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from faker import Faker
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
User,
|
||||
Project,
|
||||
ProjectMember,
|
||||
State,
|
||||
StateGroup,
|
||||
Label,
|
||||
Cycle,
|
||||
Module,
|
||||
Issue,
|
||||
IssueSequence,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
IssueActivity,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
Page,
|
||||
ProjectPage,
|
||||
PageLabel,
|
||||
Intake,
|
||||
IntakeIssue,
|
||||
)
|
||||
from plane.db.models.intake import SourceType
|
||||
|
||||
|
||||
def create_project(workspace, user_id):
|
||||
fake = Faker()
|
||||
name = fake.name()
|
||||
unique_id = str(uuid.uuid4())[:5]
|
||||
|
||||
project = Project.objects.create(
|
||||
workspace=workspace,
|
||||
name=f"{name}_{unique_id}",
|
||||
identifier=name[: random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1)].upper(),
|
||||
created_by_id=user_id,
|
||||
intake_view=True,
|
||||
)
|
||||
|
||||
# Add current member as project member
|
||||
_ = ProjectMember.objects.create(project=project, member_id=user_id, role=20)
|
||||
|
||||
return project
|
||||
|
||||
|
||||
def create_project_members(workspace, project, members):
|
||||
members = User.objects.filter(email__in=members)
|
||||
|
||||
_ = ProjectMember.objects.bulk_create(
|
||||
[
|
||||
ProjectMember(
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
member=member,
|
||||
role=20,
|
||||
sort_order=random.randint(0, 65535),
|
||||
)
|
||||
for member in members
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def create_states(workspace, project, user_id):
|
||||
states = [
|
||||
{
|
||||
"name": "Backlog",
|
||||
"color": "#A3A3A3",
|
||||
"sequence": 15000,
|
||||
"group": "backlog",
|
||||
"default": True,
|
||||
},
|
||||
{"name": "Todo", "color": "#3A3A3A", "sequence": 25000, "group": "unstarted"},
|
||||
{
|
||||
"name": "In Progress",
|
||||
"color": "#F59E0B",
|
||||
"sequence": 35000,
|
||||
"group": "started",
|
||||
},
|
||||
{"name": "Done", "color": "#16A34A", "sequence": 45000, "group": "completed"},
|
||||
{
|
||||
"name": "Cancelled",
|
||||
"color": "#EF4444",
|
||||
"sequence": 55000,
|
||||
"group": "cancelled",
|
||||
},
|
||||
]
|
||||
|
||||
states = State.objects.bulk_create(
|
||||
[
|
||||
State(
|
||||
name=state["name"],
|
||||
color=state["color"],
|
||||
project=project,
|
||||
sequence=state["sequence"],
|
||||
workspace=workspace,
|
||||
group=state["group"],
|
||||
default=state.get("default", False),
|
||||
created_by_id=user_id,
|
||||
)
|
||||
for state in states
|
||||
]
|
||||
)
|
||||
|
||||
return states
|
||||
|
||||
|
||||
def create_labels(workspace, project, user_id):
|
||||
fake = Faker()
|
||||
Faker.seed(0)
|
||||
|
||||
return Label.objects.bulk_create(
|
||||
[
|
||||
Label(
|
||||
name=fake.color_name(),
|
||||
color=fake.hex_color(),
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
created_by_id=user_id,
|
||||
sort_order=random.randint(0, 65535),
|
||||
)
|
||||
for _ in range(0, 50)
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
|
||||
def create_cycles(workspace, project, user_id, cycle_count):
|
||||
fake = Faker()
|
||||
Faker.seed(0)
|
||||
|
||||
cycles = []
|
||||
used_date_ranges = set() # Track used date ranges
|
||||
|
||||
while len(cycles) <= cycle_count:
|
||||
# Generate a start date, allowing for None
|
||||
start_date_option = [None, fake.date_this_year()]
|
||||
start_date = start_date_option[random.randint(0, 1)]
|
||||
|
||||
# Initialize end_date based on start_date
|
||||
end_date = (
|
||||
None
|
||||
if start_date is None
|
||||
else fake.date_between_dates(
|
||||
date_start=start_date,
|
||||
date_end=datetime.now().date().replace(month=12, day=31),
|
||||
)
|
||||
)
|
||||
|
||||
# Ensure end_date is strictly after start_date if start_date is not None
|
||||
while start_date is not None and (end_date <= start_date or (start_date, end_date) in used_date_ranges):
|
||||
end_date = fake.date_this_year()
|
||||
|
||||
# Add the unique date range to the set
|
||||
(used_date_ranges.add((start_date, end_date)) if (end_date is not None and start_date is not None) else None)
|
||||
|
||||
# Append the cycle with unique date range
|
||||
cycles.append(
|
||||
Cycle(
|
||||
name=fake.name(),
|
||||
owned_by_id=user_id,
|
||||
sort_order=random.randint(0, 65535),
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
)
|
||||
)
|
||||
|
||||
return Cycle.objects.bulk_create(cycles, ignore_conflicts=True)
|
||||
|
||||
|
||||
def create_modules(workspace, project, user_id, module_count):
|
||||
fake = Faker()
|
||||
Faker.seed(0)
|
||||
|
||||
modules = []
|
||||
for _ in range(0, module_count):
|
||||
start_date = [None, fake.date_this_year()][random.randint(0, 1)]
|
||||
end_date = (
|
||||
None
|
||||
if start_date is None
|
||||
else fake.date_between_dates(
|
||||
date_start=start_date,
|
||||
date_end=datetime.now().date().replace(month=12, day=31),
|
||||
)
|
||||
)
|
||||
|
||||
modules.append(
|
||||
Module(
|
||||
name=fake.name(),
|
||||
sort_order=random.randint(0, 65535),
|
||||
start_date=start_date,
|
||||
target_date=end_date,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
)
|
||||
)
|
||||
|
||||
return Module.objects.bulk_create(modules, ignore_conflicts=True)
|
||||
|
||||
|
||||
def create_pages(workspace, project, user_id, pages_count):
|
||||
fake = Faker()
|
||||
Faker.seed(0)
|
||||
|
||||
pages = []
|
||||
for _ in range(0, pages_count):
|
||||
text = fake.text(max_nb_chars=60000)
|
||||
pages.append(
|
||||
Page(
|
||||
name=fake.name(),
|
||||
workspace=workspace,
|
||||
owned_by_id=user_id,
|
||||
access=random.randint(0, 1),
|
||||
color=fake.hex_color(),
|
||||
description_html=f"<p>{text}</p>",
|
||||
archived_at=None,
|
||||
is_locked=False,
|
||||
)
|
||||
)
|
||||
# Bulk create pages
|
||||
pages = Page.objects.bulk_create(pages, ignore_conflicts=True)
|
||||
# Add Page to project
|
||||
ProjectPage.objects.bulk_create(
|
||||
[ProjectPage(page=page, project=project, workspace=workspace) for page in pages],
|
||||
batch_size=1000,
|
||||
)
|
||||
|
||||
|
||||
def create_page_labels(workspace, project, user_id, pages_count):
|
||||
# labels
|
||||
labels = Label.objects.filter(project=project).values_list("id", flat=True)
|
||||
pages = random.sample(
|
||||
list(Page.objects.filter(projects__id=project.id).values_list("id", flat=True)),
|
||||
int(pages_count / 2),
|
||||
)
|
||||
|
||||
# Bulk page labels
|
||||
bulk_page_labels = []
|
||||
for page in pages:
|
||||
for label in random.sample(list(labels), random.randint(0, len(labels) - 1)):
|
||||
bulk_page_labels.append(PageLabel(page_id=page, label_id=label, workspace=workspace))
|
||||
|
||||
# Page labels
|
||||
PageLabel.objects.bulk_create(bulk_page_labels, batch_size=1000, ignore_conflicts=True)
|
||||
|
||||
|
||||
def create_issues(workspace, project, user_id, issue_count):
|
||||
fake = Faker()
|
||||
Faker.seed(0)
|
||||
|
||||
states = (
|
||||
State.objects.filter(workspace=workspace, project=project)
|
||||
.exclude(group=StateGroup.TRIAGE.value)
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True)
|
||||
|
||||
issues = []
|
||||
|
||||
# Get the maximum sequence_id
|
||||
last_id = IssueSequence.objects.filter(project=project).aggregate(largest=Max("sequence"))["largest"]
|
||||
|
||||
last_id = 1 if last_id is None else last_id + 1
|
||||
|
||||
# Get the maximum sort order
|
||||
largest_sort_order = Issue.objects.filter(
|
||||
project=project, state_id=states[random.randint(0, len(states) - 1)]
|
||||
).aggregate(largest=Max("sort_order"))["largest"]
|
||||
|
||||
largest_sort_order = 65535 if largest_sort_order is None else largest_sort_order + 10000
|
||||
|
||||
for _ in range(0, issue_count):
|
||||
start_date = [None, fake.date_this_year()][random.randint(0, 1)]
|
||||
end_date = (
|
||||
None
|
||||
if start_date is None
|
||||
else fake.date_between_dates(
|
||||
date_start=start_date,
|
||||
date_end=datetime.now().date().replace(month=12, day=31),
|
||||
)
|
||||
)
|
||||
|
||||
text = fake.text(max_nb_chars=3000)
|
||||
issues.append(
|
||||
Issue(
|
||||
state_id=states[random.randint(0, len(states) - 1)],
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
name=text[:254],
|
||||
description_html=f"<p>{text}</p>",
|
||||
description_stripped=text,
|
||||
sequence_id=last_id,
|
||||
sort_order=largest_sort_order,
|
||||
start_date=start_date,
|
||||
target_date=end_date,
|
||||
priority=["urgent", "high", "medium", "low", "none"][random.randint(0, 4)],
|
||||
created_by_id=creators[random.randint(0, len(creators) - 1)],
|
||||
)
|
||||
)
|
||||
|
||||
largest_sort_order = largest_sort_order + random.randint(0, 1000)
|
||||
last_id = last_id + 1
|
||||
|
||||
issues = Issue.objects.bulk_create(issues, ignore_conflicts=True, batch_size=1000)
|
||||
# Sequences
|
||||
_ = IssueSequence.objects.bulk_create(
|
||||
[
|
||||
IssueSequence(
|
||||
issue=issue,
|
||||
sequence=issue.sequence_id,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
# Track the issue activities
|
||||
IssueActivity.objects.bulk_create(
|
||||
[
|
||||
IssueActivity(
|
||||
issue=issue,
|
||||
actor_id=user_id,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
comment="created the issue",
|
||||
verb="created",
|
||||
created_by_id=user_id,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def create_intake_issues(workspace, project, user_id, intake_issue_count):
|
||||
issues = create_issues(workspace, project, user_id, intake_issue_count)
|
||||
intake, create = Intake.objects.get_or_create(name="Intake", project=project, is_default=True)
|
||||
IntakeIssue.objects.bulk_create(
|
||||
[
|
||||
IntakeIssue(
|
||||
issue=issue,
|
||||
intake=intake,
|
||||
status=(status := [-2, -1, 0, 1, 2][random.randint(0, 4)]),
|
||||
snoozed_till=(datetime.now() + timedelta(days=random.randint(1, 30)) if status == 0 else None),
|
||||
source=SourceType.IN_APP,
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
|
||||
def create_issue_parent(workspace, project, user_id, issue_count):
|
||||
parent_count = issue_count / 4
|
||||
|
||||
parent_issues = Issue.objects.filter(project=project).values_list("id", flat=True)[: int(parent_count)]
|
||||
sub_issues = Issue.objects.filter(project=project).exclude(pk__in=parent_issues)[: int(issue_count / 2)]
|
||||
|
||||
bulk_sub_issues = []
|
||||
for sub_issue in sub_issues:
|
||||
sub_issue.parent_id = parent_issues[random.randint(0, int(parent_count - 1))]
|
||||
|
||||
Issue.objects.bulk_update(bulk_sub_issues, ["parent"], batch_size=1000)
|
||||
|
||||
|
||||
def create_issue_assignees(workspace, project, user_id, issue_count):
|
||||
# assignees
|
||||
assignees = ProjectMember.objects.filter(project=project).values_list("member_id", flat=True)
|
||||
issues = random.sample(
|
||||
list(Issue.objects.filter(project=project).values_list("id", flat=True)),
|
||||
int(issue_count / 2),
|
||||
)
|
||||
|
||||
# Bulk issue
|
||||
bulk_issue_assignees = []
|
||||
for issue in issues:
|
||||
for assignee in random.sample(list(assignees), random.randint(0, len(assignees) - 1)):
|
||||
bulk_issue_assignees.append(
|
||||
IssueAssignee(
|
||||
issue_id=issue,
|
||||
assignee_id=assignee,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
)
|
||||
)
|
||||
|
||||
# Issue assignees
|
||||
IssueAssignee.objects.bulk_create(bulk_issue_assignees, batch_size=1000, ignore_conflicts=True)
|
||||
|
||||
|
||||
def create_issue_labels(workspace, project, user_id, issue_count):
|
||||
# labels
|
||||
labels = Label.objects.filter(project=project).values_list("id", flat=True)
|
||||
# issues = random.sample(
|
||||
# list(
|
||||
# Issue.objects.filter(project=project).values_list("id", flat=True)
|
||||
# ),
|
||||
# int(issue_count / 2),
|
||||
# )
|
||||
issues = list(Issue.objects.filter(project=project).values_list("id", flat=True))
|
||||
shuffled_labels = list(labels)
|
||||
|
||||
# Bulk issue
|
||||
bulk_issue_labels = []
|
||||
for issue in issues:
|
||||
random.shuffle(shuffled_labels)
|
||||
for label in random.sample(shuffled_labels, random.randint(0, 5)):
|
||||
bulk_issue_labels.append(IssueLabel(issue_id=issue, label_id=label, project=project, workspace=workspace))
|
||||
|
||||
# Issue labels
|
||||
IssueLabel.objects.bulk_create(bulk_issue_labels, batch_size=1000, ignore_conflicts=True)
|
||||
|
||||
|
||||
def create_cycle_issues(workspace, project, user_id, issue_count):
|
||||
# assignees
|
||||
cycles = Cycle.objects.filter(project=project).values_list("id", flat=True)
|
||||
issues = random.sample(
|
||||
list(Issue.objects.filter(project=project).values_list("id", flat=True)),
|
||||
int(issue_count / 2),
|
||||
)
|
||||
|
||||
# Bulk issue
|
||||
bulk_cycle_issues = []
|
||||
for issue in issues:
|
||||
cycle = cycles[random.randint(0, len(cycles) - 1)]
|
||||
bulk_cycle_issues.append(CycleIssue(cycle_id=cycle, issue_id=issue, project=project, workspace=workspace))
|
||||
|
||||
# Issue assignees
|
||||
CycleIssue.objects.bulk_create(bulk_cycle_issues, batch_size=1000, ignore_conflicts=True)
|
||||
|
||||
|
||||
def create_module_issues(workspace, project, user_id, issue_count):
|
||||
# assignees
|
||||
modules = Module.objects.filter(project=project).values_list("id", flat=True)
|
||||
# issues = random.sample(
|
||||
# list(
|
||||
# Issue.objects.filter(project=project).values_list("id", flat=True)
|
||||
# ),
|
||||
# int(issue_count / 2),
|
||||
# )
|
||||
issues = list(Issue.objects.filter(project=project).values_list("id", flat=True))
|
||||
|
||||
shuffled_modules = list(modules)
|
||||
|
||||
# Bulk issue
|
||||
bulk_module_issues = []
|
||||
for issue in issues:
|
||||
random.shuffle(shuffled_modules)
|
||||
for module in random.sample(shuffled_modules, random.randint(0, 5)):
|
||||
bulk_module_issues.append(
|
||||
ModuleIssue(
|
||||
module_id=module,
|
||||
issue_id=issue,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
)
|
||||
)
|
||||
# Issue assignees
|
||||
ModuleIssue.objects.bulk_create(bulk_module_issues, batch_size=1000, ignore_conflicts=True)
|
||||
|
||||
|
||||
@shared_task
|
||||
def create_dummy_data(
|
||||
slug,
|
||||
email,
|
||||
members,
|
||||
issue_count,
|
||||
cycle_count,
|
||||
module_count,
|
||||
pages_count,
|
||||
intake_issue_count,
|
||||
):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
user = User.objects.get(email=email)
|
||||
user_id = user.id
|
||||
|
||||
# Create a project
|
||||
project = create_project(workspace=workspace, user_id=user_id)
|
||||
|
||||
# create project members
|
||||
create_project_members(workspace=workspace, project=project, members=members)
|
||||
|
||||
# Create states
|
||||
create_states(workspace=workspace, project=project, user_id=user_id)
|
||||
|
||||
# Create labels
|
||||
create_labels(workspace=workspace, project=project, user_id=user_id)
|
||||
|
||||
# create cycles
|
||||
create_cycles(workspace=workspace, project=project, user_id=user_id, cycle_count=cycle_count)
|
||||
|
||||
# create modules
|
||||
create_modules(workspace=workspace, project=project, user_id=user_id, module_count=module_count)
|
||||
|
||||
# create pages
|
||||
create_pages(workspace=workspace, project=project, user_id=user_id, pages_count=pages_count)
|
||||
|
||||
# create page labels
|
||||
create_page_labels(workspace=workspace, project=project, user_id=user_id, pages_count=pages_count)
|
||||
|
||||
# create issues
|
||||
create_issues(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count)
|
||||
|
||||
# create intake issues
|
||||
create_intake_issues(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
user_id=user_id,
|
||||
intake_issue_count=intake_issue_count,
|
||||
)
|
||||
|
||||
# create issue parent
|
||||
create_issue_parent(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count)
|
||||
|
||||
# create issue assignees
|
||||
create_issue_assignees(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count)
|
||||
|
||||
# create issue labels
|
||||
create_issue_labels(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count)
|
||||
|
||||
# create cycle issues
|
||||
create_cycle_issues(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count)
|
||||
|
||||
# create module issues
|
||||
create_module_issues(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count)
|
||||
|
||||
return
|
||||
@@ -0,0 +1,306 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import EmailNotificationLog, Issue, User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
def remove_unwanted_characters(input_text):
|
||||
# Remove only control characters and potentially problematic characters for email subjects
|
||||
processed_text = re.sub(r"[\x00-\x1F\x7F-\x9F]", "", input_text)
|
||||
return processed_text
|
||||
|
||||
|
||||
# acquire and delete redis lock
|
||||
def acquire_lock(lock_id, expire_time=300):
|
||||
redis_client = redis_instance()
|
||||
"""Attempt to acquire a lock with a specified expiration time."""
|
||||
return redis_client.set(lock_id, "true", nx=True, ex=expire_time)
|
||||
|
||||
|
||||
def release_lock(lock_id):
|
||||
"""Release a lock."""
|
||||
redis_client = redis_instance()
|
||||
redis_client.delete(lock_id)
|
||||
|
||||
|
||||
@shared_task
|
||||
def stack_email_notification():
|
||||
# get all email notifications
|
||||
email_notifications = EmailNotificationLog.objects.filter(processed_at__isnull=True).order_by("receiver").values()
|
||||
|
||||
# Create the below format for each of the issues
|
||||
# {"issue_id" : { "actor_id1": [ { data }, { data } ], "actor_id2": [ { data }, { data } ] }}
|
||||
|
||||
# Convert to unique receivers list
|
||||
receivers = list(set([str(notification.get("receiver_id")) for notification in email_notifications]))
|
||||
processed_notifications = []
|
||||
# Loop through all the issues to create the emails
|
||||
for receiver_id in receivers:
|
||||
# Notification triggered for the receiver
|
||||
receiver_notifications = [
|
||||
notification for notification in email_notifications if str(notification.get("receiver_id")) == receiver_id
|
||||
]
|
||||
# create payload for all issues
|
||||
payload = {}
|
||||
email_notification_ids = []
|
||||
for receiver_notification in receiver_notifications:
|
||||
payload.setdefault(receiver_notification.get("entity_identifier"), {}).setdefault(
|
||||
str(receiver_notification.get("triggered_by_id")), []
|
||||
).append(receiver_notification.get("data"))
|
||||
# append processed notifications
|
||||
processed_notifications.append(receiver_notification.get("id"))
|
||||
email_notification_ids.append(receiver_notification.get("id"))
|
||||
|
||||
# Create emails for all the issues
|
||||
for issue_id, notification_data in payload.items():
|
||||
send_email_notification.delay(
|
||||
issue_id=issue_id,
|
||||
notification_data=notification_data,
|
||||
receiver_id=receiver_id,
|
||||
email_notification_ids=email_notification_ids,
|
||||
)
|
||||
|
||||
# Update the email notification log
|
||||
EmailNotificationLog.objects.filter(pk__in=processed_notifications).update(processed_at=timezone.now())
|
||||
|
||||
|
||||
def create_payload(notification_data):
|
||||
# return format {"actor_id": { "key": { "old_value": [], "new_value": [] } }}
|
||||
data = {}
|
||||
for actor_id, changes in notification_data.items():
|
||||
for change in changes:
|
||||
issue_activity = change.get("issue_activity")
|
||||
if issue_activity: # Ensure issue_activity is not None
|
||||
field = issue_activity.get("field")
|
||||
old_value = str(issue_activity.get("old_value"))
|
||||
new_value = str(issue_activity.get("new_value"))
|
||||
|
||||
# Append old_value if it's not empty and not already in the list
|
||||
if old_value:
|
||||
(
|
||||
data.setdefault(actor_id, {})
|
||||
.setdefault(field, {})
|
||||
.setdefault("old_value", [])
|
||||
.append(old_value)
|
||||
if old_value not in data.setdefault(actor_id, {}).setdefault(field, {}).get("old_value", [])
|
||||
else None
|
||||
)
|
||||
|
||||
# Append new_value if it's not empty and not already in the list
|
||||
if new_value:
|
||||
(
|
||||
data.setdefault(actor_id, {})
|
||||
.setdefault(field, {})
|
||||
.setdefault("new_value", [])
|
||||
.append(new_value)
|
||||
if new_value not in data.setdefault(actor_id, {}).setdefault(field, {}).get("new_value", [])
|
||||
else None
|
||||
)
|
||||
|
||||
if not data.get("actor_id", {}).get("activity_time", False):
|
||||
data[actor_id]["activity_time"] = str(
|
||||
datetime.fromisoformat(issue_activity.get("activity_time").rstrip("Z")).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def process_mention(mention_component):
|
||||
soup = BeautifulSoup(mention_component, "html.parser")
|
||||
mentions = soup.find_all("mention-component")
|
||||
for mention in mentions:
|
||||
user_id = mention["entity_identifier"]
|
||||
user = User.objects.get(pk=user_id)
|
||||
user_name = user.display_name
|
||||
highlighted_name = f"@{user_name}"
|
||||
mention.replace_with(highlighted_name)
|
||||
return str(soup)
|
||||
|
||||
|
||||
def process_html_content(content):
|
||||
if content is None:
|
||||
return None
|
||||
processed_content_list = []
|
||||
for html_content in content:
|
||||
processed_content = process_mention(html_content)
|
||||
processed_content_list.append(processed_content)
|
||||
return processed_content_list
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_email_notification(issue_id, notification_data, receiver_id, email_notification_ids):
|
||||
# Convert UUIDs to a sorted, concatenated string
|
||||
sorted_ids = sorted(email_notification_ids)
|
||||
ids_str = "_".join(str(id) for id in sorted_ids)
|
||||
lock_id = f"send_email_notif_{issue_id}_{receiver_id}_{ids_str}"
|
||||
|
||||
# acquire the lock for sending emails
|
||||
try:
|
||||
if acquire_lock(lock_id=lock_id):
|
||||
# get the redis instance
|
||||
ri = redis_instance()
|
||||
base_api = ri.get(str(issue_id)).decode() if ri.get(str(issue_id)) else None
|
||||
|
||||
# Skip if base api is not present
|
||||
if not base_api:
|
||||
return
|
||||
|
||||
data = create_payload(notification_data=notification_data)
|
||||
|
||||
# Get email configurations
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
receiver = User.objects.get(pk=receiver_id)
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
template_data = []
|
||||
total_changes = 0
|
||||
comments = []
|
||||
actors_involved = []
|
||||
for actor_id, changes in data.items():
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
total_changes = total_changes + len(changes)
|
||||
comment = changes.pop("comment", False)
|
||||
mention = changes.pop("mention", False)
|
||||
actors_involved.append(actor_id)
|
||||
if comment:
|
||||
comments.append(
|
||||
{
|
||||
"actor_comments": comment,
|
||||
"actor_detail": {
|
||||
"avatar_url": f"{base_api}{actor.avatar_url}",
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
if mention:
|
||||
mention["new_value"] = process_html_content(mention.get("new_value"))
|
||||
mention["old_value"] = process_html_content(mention.get("old_value"))
|
||||
comments.append(
|
||||
{
|
||||
"actor_comments": mention,
|
||||
"actor_detail": {
|
||||
"avatar_url": f"{base_api}{actor.avatar_url}",
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
}
|
||||
)
|
||||
activity_time = changes.pop("activity_time")
|
||||
# Parse the input string into a datetime object
|
||||
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
|
||||
|
||||
if changes:
|
||||
template_data.append(
|
||||
{
|
||||
"actor_detail": {
|
||||
"avatar_url": f"{base_api}{actor.avatar_url}",
|
||||
"first_name": actor.first_name,
|
||||
"last_name": actor.last_name,
|
||||
},
|
||||
"changes": changes,
|
||||
"issue_details": {
|
||||
"name": issue.name,
|
||||
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
|
||||
},
|
||||
"activity_time": str(formatted_time),
|
||||
}
|
||||
)
|
||||
|
||||
summary = "Updates were made to the issue by"
|
||||
|
||||
# Send the mail
|
||||
subject = f"{issue.project.identifier}-{issue.sequence_id} {remove_unwanted_characters(issue.name)}"
|
||||
context = {
|
||||
"data": template_data,
|
||||
"summary": summary,
|
||||
"actors_involved": len(set(actors_involved)),
|
||||
"issue": {
|
||||
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
|
||||
"name": issue.name,
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", # noqa: E501
|
||||
},
|
||||
"receiver": {"email": receiver.email},
|
||||
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", # noqa: E501
|
||||
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", # noqa: E501
|
||||
"workspace": str(issue.project.workspace.slug),
|
||||
"project": str(issue.project.name),
|
||||
"user_preference": f"{base_api}/{str(issue.project.workspace.slug)}/settings/account/notifications/",
|
||||
"comments": comments,
|
||||
"entity_type": "issue",
|
||||
}
|
||||
html_content = render_to_string("emails/notifications/issue-updates.html", context)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
try:
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[receiver.email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info("Email Sent Successfully")
|
||||
|
||||
# Update the logs
|
||||
EmailNotificationLog.objects.filter(pk__in=email_notification_ids).update(sent_at=timezone.now())
|
||||
|
||||
# release the lock
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
# release the lock
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
else:
|
||||
logging.getLogger("plane.worker").info("Duplicate email received skipping")
|
||||
return
|
||||
except (Issue.DoesNotExist, User.DoesNotExist):
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
release_lock(lock_id=lock_id)
|
||||
return
|
||||
@@ -0,0 +1,81 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
|
||||
# third party imports
|
||||
from celery import shared_task
|
||||
from posthog import Posthog
|
||||
|
||||
# module imports
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.db.models import Workspace
|
||||
from plane.utils.analytics_events import USER_INVITED_TO_WORKSPACE, WORKSPACE_DELETED
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
|
||||
def posthogConfiguration():
|
||||
POSTHOG_API_KEY, POSTHOG_HOST = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "POSTHOG_API_KEY",
|
||||
"default": os.environ.get("POSTHOG_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "POSTHOG_HOST",
|
||||
"default": os.environ.get("POSTHOG_HOST", None),
|
||||
},
|
||||
]
|
||||
)
|
||||
if POSTHOG_API_KEY and POSTHOG_HOST:
|
||||
return POSTHOG_API_KEY, POSTHOG_HOST
|
||||
else:
|
||||
return None, None
|
||||
|
||||
|
||||
def preprocess_data_properties(
|
||||
user_id: uuid.UUID, event_name: str, slug: str, data_properties: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
if event_name == USER_INVITED_TO_WORKSPACE or event_name == WORKSPACE_DELETED:
|
||||
try:
|
||||
# Check if the current user is the workspace owner
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
if str(workspace.owner_id) == str(user_id):
|
||||
data_properties["role"] = "owner"
|
||||
else:
|
||||
data_properties["role"] = "admin"
|
||||
except Workspace.DoesNotExist:
|
||||
logger.warning(f"Workspace {slug} does not exist while sending event {event_name} for user {user_id}")
|
||||
data_properties["role"] = "unknown"
|
||||
|
||||
return data_properties
|
||||
|
||||
|
||||
@shared_task
|
||||
def track_event(user_id: uuid.UUID, event_name: str, slug: str, event_properties: Dict[str, Any]):
|
||||
POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration()
|
||||
|
||||
if not (POSTHOG_API_KEY and POSTHOG_HOST):
|
||||
logger.warning("Event tracking is not configured")
|
||||
return
|
||||
|
||||
try:
|
||||
# preprocess the data properties for massaging the payload
|
||||
# in the correct format for posthog
|
||||
data_properties = preprocess_data_properties(user_id, event_name, slug, event_properties)
|
||||
groups = {
|
||||
"workspace": slug,
|
||||
}
|
||||
# track the event using posthog
|
||||
posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
|
||||
posthog.capture(distinct_id=str(user_id), event=event_name, properties=data_properties, groups=groups)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
@@ -0,0 +1,226 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import io
|
||||
import zipfile
|
||||
from typing import List
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
from uuid import UUID
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.db.models import Prefetch
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import ExporterHistory, Issue, IssueComment, IssueRelation, IssueSubscriber
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.porters.exporter import DataExporter
|
||||
from plane.utils.porters.serializers.issue import IssueExportSerializer
|
||||
|
||||
|
||||
def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
|
||||
"""
|
||||
Create a ZIP file from the provided files.
|
||||
"""
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
for filename, file_content in files:
|
||||
zipf.writestr(filename, file_content)
|
||||
|
||||
zip_buffer.seek(0)
|
||||
return zip_buffer
|
||||
|
||||
|
||||
# TODO: Change the upload_to_s3 function to use the new storage method with entry in file asset table
|
||||
def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug: str) -> None:
|
||||
"""
|
||||
Upload a ZIP file to S3 and generate a presigned URL.
|
||||
"""
|
||||
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip"
|
||||
expires_in = 7 * 24 * 60 * 60
|
||||
|
||||
if settings.USE_MINIO:
|
||||
upload_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=Config(signature_version="s3v4"),
|
||||
)
|
||||
upload_s3.upload_fileobj(
|
||||
zip_file,
|
||||
settings.AWS_STORAGE_BUCKET_NAME,
|
||||
file_name,
|
||||
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
|
||||
)
|
||||
|
||||
# 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', '')}/"
|
||||
),
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
presigned_url = presign_s3.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name},
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
else:
|
||||
# If endpoint url is present, use it
|
||||
if settings.AWS_S3_ENDPOINT_URL:
|
||||
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=Config(signature_version="s3v4"),
|
||||
)
|
||||
else:
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
region_name=settings.AWS_REGION,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
# Upload the file to S3
|
||||
s3.upload_fileobj(
|
||||
zip_file,
|
||||
settings.AWS_STORAGE_BUCKET_NAME,
|
||||
file_name,
|
||||
ExtraArgs={"ContentType": "application/zip"},
|
||||
)
|
||||
|
||||
# Generate presigned url for the uploaded file
|
||||
presigned_url = s3.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name},
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
|
||||
# Update the exporter instance with the presigned url
|
||||
if presigned_url:
|
||||
exporter_instance.url = presigned_url
|
||||
exporter_instance.status = "completed"
|
||||
exporter_instance.key = file_name
|
||||
else:
|
||||
exporter_instance.status = "failed"
|
||||
|
||||
exporter_instance.save(update_fields=["status", "url", "key"])
|
||||
|
||||
|
||||
@shared_task
|
||||
def issue_export_task(
|
||||
provider: str,
|
||||
workspace_id: UUID,
|
||||
project_ids: List[str],
|
||||
token_id: str,
|
||||
multiple: bool,
|
||||
slug: str,
|
||||
):
|
||||
"""
|
||||
Export issues from the workspace.
|
||||
provider (str): The provider to export the issues to csv | json | xlsx.
|
||||
token_id (str): The export object token id.
|
||||
multiple (bool): Whether to export the issues to multiple files per project.
|
||||
"""
|
||||
try:
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
exporter_instance.status = "processing"
|
||||
exporter_instance.save(update_fields=["status"])
|
||||
|
||||
# Build base queryset for issues
|
||||
workspace_issues = (
|
||||
Issue.objects.filter(
|
||||
workspace__id=workspace_id,
|
||||
project_id__in=project_ids,
|
||||
project__project_projectmember__member=exporter_instance.initiated_by_id,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__archived_at__isnull=True,
|
||||
)
|
||||
.select_related(
|
||||
"project",
|
||||
"workspace",
|
||||
"state",
|
||||
"created_by",
|
||||
"estimate_point",
|
||||
)
|
||||
.prefetch_related(
|
||||
"labels",
|
||||
"issue_cycle__cycle",
|
||||
"issue_module__module",
|
||||
"assignees",
|
||||
"issue_link",
|
||||
Prefetch(
|
||||
"issue_subscribers",
|
||||
queryset=IssueSubscriber.objects.select_related("subscriber"),
|
||||
),
|
||||
Prefetch(
|
||||
"issue_comments",
|
||||
queryset=IssueComment.objects.select_related("actor").order_by("created_at"),
|
||||
),
|
||||
Prefetch(
|
||||
"issue_relation",
|
||||
queryset=IssueRelation.objects.select_related("related_issue", "related_issue__project"),
|
||||
),
|
||||
Prefetch(
|
||||
"issue_related",
|
||||
queryset=IssueRelation.objects.select_related("issue", "issue__project"),
|
||||
),
|
||||
Prefetch(
|
||||
"parent",
|
||||
queryset=Issue.objects.select_related("type", "project"),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Create exporter for the specified format
|
||||
try:
|
||||
exporter = DataExporter(IssueExportSerializer, format_type=provider)
|
||||
except ValueError as e:
|
||||
# Invalid format type
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
exporter_instance.status = "failed"
|
||||
exporter_instance.reason = str(e)
|
||||
exporter_instance.save(update_fields=["status", "reason"])
|
||||
return
|
||||
|
||||
files = []
|
||||
if multiple:
|
||||
# Export each project separately with its own queryset
|
||||
for project_id in project_ids:
|
||||
project_issues = workspace_issues.filter(project_id=project_id)
|
||||
export_filename = f"{slug}-{project_id}"
|
||||
filename, content = exporter.export(export_filename, project_issues)
|
||||
files.append((filename, content))
|
||||
else:
|
||||
# Export all issues in a single file
|
||||
export_filename = f"{slug}-{workspace_id}"
|
||||
filename, content = exporter.export(export_filename, workspace_issues)
|
||||
files.append((filename, content))
|
||||
|
||||
zip_buffer = create_zip_file(files)
|
||||
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
|
||||
|
||||
except Exception as e:
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
exporter_instance.status = "failed"
|
||||
exporter_instance.reason = str(e)
|
||||
exporter_instance.save(update_fields=["status", "reason"])
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import boto3
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from botocore.client import Config
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import ExporterHistory
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_old_s3_link():
|
||||
# Get a list of keys and IDs to process
|
||||
expired_exporter_history = ExporterHistory.objects.filter(
|
||||
Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8))
|
||||
).values_list("key", "id")
|
||||
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=Config(signature_version="s3v4"),
|
||||
)
|
||||
else:
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
region_name=settings.AWS_REGION,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
for file_name, exporter_id in expired_exporter_history:
|
||||
# Delete object from S3
|
||||
if file_name:
|
||||
if settings.USE_MINIO:
|
||||
s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
|
||||
else:
|
||||
s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
|
||||
|
||||
ExporterHistory.objects.filter(id=exporter_id).update(url=None)
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_unuploaded_file_asset():
|
||||
"""This task deletes unuploaded file assets older than a certain number of days."""
|
||||
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()
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
try:
|
||||
relative_link = f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}"
|
||||
abs_url = str(current_site) + relative_link
|
||||
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
subject = "A new password to your Plane account has been requested"
|
||||
|
||||
context = {
|
||||
"first_name": first_name,
|
||||
"forgot_password_url": abs_url,
|
||||
"email": email,
|
||||
}
|
||||
|
||||
html_content = render_to_string("emails/auth/forgot_password.html", context)
|
||||
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info("Email sent successfully")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from django.db.models import Q
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import Issue, Project, State
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def archive_and_close_old_issues():
|
||||
archive_old_issues()
|
||||
close_old_issues()
|
||||
|
||||
|
||||
def archive_old_issues():
|
||||
try:
|
||||
# Get all the projects whose archive_in is greater than 0
|
||||
projects = Project.objects.filter(archive_in__gt=0)
|
||||
|
||||
for project in projects:
|
||||
project_id = project.id
|
||||
archive_in = project.archive_in
|
||||
|
||||
# Get all the issues whose updated_at in less that the archive_in month
|
||||
issues = Issue.issue_objects.filter(
|
||||
Q(
|
||||
project=project_id,
|
||||
archived_at__isnull=True,
|
||||
updated_at__lte=(timezone.now() - timedelta(days=archive_in * 30)),
|
||||
state__group__in=["completed", "cancelled"],
|
||||
),
|
||||
Q(issue_cycle__isnull=True)
|
||||
| (Q(issue_cycle__cycle__end_date__lt=timezone.now()) & Q(issue_cycle__isnull=False)),
|
||||
Q(issue_module__isnull=True)
|
||||
| (Q(issue_module__module__target_date__lt=timezone.now()) & Q(issue_module__isnull=False)),
|
||||
).filter(
|
||||
Q(issue_intake__status=1)
|
||||
| Q(issue_intake__status=-1)
|
||||
| Q(issue_intake__status=2)
|
||||
| Q(issue_intake__isnull=True)
|
||||
)
|
||||
|
||||
# Check if Issues
|
||||
if issues:
|
||||
# Set the archive time to current time
|
||||
archive_at = timezone.now().date()
|
||||
|
||||
issues_to_update = []
|
||||
for issue in issues:
|
||||
issue.archived_at = archive_at
|
||||
issues_to_update.append(issue)
|
||||
|
||||
# Bulk Update the issues and log the activity
|
||||
if issues_to_update:
|
||||
Issue.objects.bulk_update(issues_to_update, ["archived_at"], batch_size=100)
|
||||
_ = [
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"archived_at": str(archive_at), "automation": True}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
current_instance=json.dumps({"archived_at": None}),
|
||||
subscriber=False,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
)
|
||||
for issue in issues_to_update
|
||||
]
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
def close_old_issues():
|
||||
try:
|
||||
# Get all the projects whose close_in is greater than 0
|
||||
projects = Project.objects.filter(close_in__gt=0).select_related("default_state")
|
||||
|
||||
for project in projects:
|
||||
project_id = project.id
|
||||
close_in = project.close_in
|
||||
|
||||
# Get all the issues whose updated_at in less that the close_in month
|
||||
issues = Issue.issue_objects.filter(
|
||||
Q(
|
||||
project=project_id,
|
||||
archived_at__isnull=True,
|
||||
updated_at__lte=(timezone.now() - timedelta(days=close_in * 30)),
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
),
|
||||
Q(issue_cycle__isnull=True)
|
||||
| (Q(issue_cycle__cycle__end_date__lt=timezone.now()) & Q(issue_cycle__isnull=False)),
|
||||
Q(issue_module__isnull=True)
|
||||
| (Q(issue_module__module__target_date__lt=timezone.now()) & Q(issue_module__isnull=False)),
|
||||
).filter(
|
||||
Q(issue_intake__status=1)
|
||||
| Q(issue_intake__status=-1)
|
||||
| Q(issue_intake__status=2)
|
||||
| Q(issue_intake__isnull=True)
|
||||
)
|
||||
|
||||
# Check if Issues
|
||||
if issues:
|
||||
if project.default_state is None:
|
||||
close_state = State.objects.filter(group="cancelled").first()
|
||||
else:
|
||||
close_state = project.default_state
|
||||
|
||||
issues_to_update = []
|
||||
for issue in issues:
|
||||
issue.state = close_state
|
||||
issues_to_update.append(issue)
|
||||
|
||||
# Bulk Update the issues and log the activity
|
||||
if issues_to_update:
|
||||
Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100)
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
current_instance=None,
|
||||
subscriber=False,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
)
|
||||
for issue in issues_to_update
|
||||
]
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,125 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue, IssueDescriptionVersion, ProjectMember
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
def get_owner_id(issue: Issue) -> Optional[int]:
|
||||
"""Get the owner ID of the issue"""
|
||||
|
||||
if issue.updated_by_id:
|
||||
return issue.updated_by_id
|
||||
|
||||
if issue.created_by_id:
|
||||
return issue.created_by_id
|
||||
|
||||
# Find project admin as fallback
|
||||
project_member = ProjectMember.objects.filter(
|
||||
project_id=issue.project_id,
|
||||
role=20, # Admin role
|
||||
).first()
|
||||
|
||||
return project_member.member_id if project_member else None
|
||||
|
||||
|
||||
@shared_task
|
||||
def sync_issue_description_version(batch_size=5000, offset=0, countdown=300):
|
||||
"""Task to create IssueDescriptionVersion records for existing Issues in batches"""
|
||||
try:
|
||||
with transaction.atomic():
|
||||
base_query = Issue.objects
|
||||
total_issues_count = base_query.count()
|
||||
|
||||
if total_issues_count == 0:
|
||||
return
|
||||
|
||||
# Calculate batch range
|
||||
end_offset = min(offset + batch_size, total_issues_count)
|
||||
|
||||
# Fetch issues with related data
|
||||
issues_batch = (
|
||||
base_query.order_by("created_at")
|
||||
.select_related("workspace", "project")
|
||||
.only(
|
||||
"id",
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
"created_by_id",
|
||||
"updated_by_id",
|
||||
"description_binary",
|
||||
"description_html",
|
||||
"description_stripped",
|
||||
"description_json",
|
||||
)[offset:end_offset]
|
||||
)
|
||||
|
||||
if not issues_batch:
|
||||
return
|
||||
|
||||
version_objects = []
|
||||
for issue in issues_batch:
|
||||
# Validate required fields
|
||||
if not issue.workspace_id or not issue.project_id:
|
||||
logging.warning(f"Skipping {issue.id} - missing workspace_id or project_id")
|
||||
continue
|
||||
|
||||
# Determine owned_by_id
|
||||
owned_by_id = get_owner_id(issue)
|
||||
if owned_by_id is None:
|
||||
logging.warning(f"Skipping issue {issue.id} - missing owned_by")
|
||||
continue
|
||||
|
||||
# Create version object
|
||||
version_objects.append(
|
||||
IssueDescriptionVersion(
|
||||
workspace_id=issue.workspace_id,
|
||||
project_id=issue.project_id,
|
||||
created_by_id=issue.created_by_id,
|
||||
updated_by_id=issue.updated_by_id,
|
||||
owned_by_id=owned_by_id,
|
||||
last_saved_at=timezone.now(),
|
||||
issue_id=issue.id,
|
||||
description_binary=issue.description_binary,
|
||||
description_html=issue.description_html,
|
||||
description_stripped=issue.description_stripped,
|
||||
description_json=issue.description_json,
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create version objects
|
||||
if version_objects:
|
||||
IssueDescriptionVersion.objects.bulk_create(version_objects)
|
||||
|
||||
# Schedule next batch if needed
|
||||
if end_offset < total_issues_count:
|
||||
sync_issue_description_version.apply_async(
|
||||
kwargs={
|
||||
"batch_size": batch_size,
|
||||
"offset": end_offset,
|
||||
"countdown": countdown,
|
||||
},
|
||||
countdown=countdown,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def schedule_issue_description_version(batch_size=5000, countdown=300):
|
||||
sync_issue_description_version.delay(batch_size=int(batch_size), countdown=countdown)
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from celery import shared_task
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from typing import Optional, Dict
|
||||
import json
|
||||
|
||||
from plane.db.models import Issue, IssueDescriptionVersion
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
def should_update_existing_version(
|
||||
version: IssueDescriptionVersion, user_id: str, max_time_difference: int = 600
|
||||
) -> bool:
|
||||
if not version:
|
||||
return
|
||||
|
||||
time_difference = (timezone.now() - version.last_saved_at).total_seconds()
|
||||
return str(version.owned_by_id) == str(user_id) and time_difference <= max_time_difference
|
||||
|
||||
|
||||
def update_existing_version(version: IssueDescriptionVersion, issue) -> None:
|
||||
version.description_json = issue.description_json
|
||||
version.description_html = issue.description_html
|
||||
version.description_binary = issue.description_binary
|
||||
version.description_stripped = issue.description_stripped
|
||||
version.last_saved_at = timezone.now()
|
||||
|
||||
version.save(
|
||||
update_fields=[
|
||||
"description_json",
|
||||
"description_html",
|
||||
"description_binary",
|
||||
"description_stripped",
|
||||
"last_saved_at",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def issue_description_version_task(updated_issue, issue_id, user_id, is_creating=False) -> Optional[bool]:
|
||||
try:
|
||||
# Parse updated issue data
|
||||
current_issue: Dict = json.loads(updated_issue) if updated_issue else {}
|
||||
|
||||
# Get current issue
|
||||
issue = Issue.objects.get(id=issue_id)
|
||||
|
||||
# Check if description has changed
|
||||
if current_issue.get("description_html") == issue.description_html and not is_creating:
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
# Get latest version
|
||||
latest_version = (
|
||||
IssueDescriptionVersion.objects.filter(issue_id=issue_id).order_by("-last_saved_at").first()
|
||||
)
|
||||
|
||||
# Determine whether to update existing or create new version
|
||||
if should_update_existing_version(version=latest_version, user_id=user_id):
|
||||
update_existing_version(latest_version, issue)
|
||||
else:
|
||||
IssueDescriptionVersion.log_issue_description_version(issue, user_id)
|
||||
|
||||
return
|
||||
|
||||
except Issue.DoesNotExist:
|
||||
# Issue no longer exists, skip processing
|
||||
return
|
||||
except json.JSONDecodeError as e:
|
||||
log_exception(f"Invalid JSON for updated_issue: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(f"Error processing issue description version: {e}")
|
||||
return
|
||||
@@ -0,0 +1,236 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import json
|
||||
from typing import Optional, List, Dict
|
||||
from uuid import UUID
|
||||
from itertools import groupby
|
||||
import logging
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
IssueVersion,
|
||||
ProjectMember,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueActivity,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
)
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def issue_task(updated_issue, issue_id, user_id):
|
||||
try:
|
||||
current_issue = json.loads(updated_issue) if updated_issue else {}
|
||||
issue = Issue.objects.get(id=issue_id)
|
||||
|
||||
updated_current_issue = {}
|
||||
for key, value in current_issue.items():
|
||||
if getattr(issue, key) != value:
|
||||
updated_current_issue[key] = value
|
||||
|
||||
if updated_current_issue:
|
||||
issue_version = IssueVersion.objects.filter(issue_id=issue_id).order_by("-last_saved_at").first()
|
||||
|
||||
if (
|
||||
issue_version
|
||||
and str(issue_version.owned_by) == str(user_id)
|
||||
and (timezone.now() - issue_version.last_saved_at).total_seconds() <= 600
|
||||
):
|
||||
for key, value in updated_current_issue.items():
|
||||
setattr(issue_version, key, value)
|
||||
issue_version.last_saved_at = timezone.now()
|
||||
issue_version.save(update_fields=list(updated_current_issue.keys()) + ["last_saved_at"])
|
||||
else:
|
||||
IssueVersion.log_issue_version(issue, user_id)
|
||||
|
||||
return
|
||||
except Issue.DoesNotExist:
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
def get_owner_id(issue: Issue) -> Optional[int]:
|
||||
"""Get the owner ID of the issue"""
|
||||
|
||||
if issue.updated_by_id:
|
||||
return issue.updated_by_id
|
||||
|
||||
if issue.created_by_id:
|
||||
return issue.created_by_id
|
||||
|
||||
# Find project admin as fallback
|
||||
project_member = ProjectMember.objects.filter(
|
||||
project_id=issue.project_id,
|
||||
role=20, # Admin role
|
||||
).first()
|
||||
|
||||
return project_member.member_id if project_member else None
|
||||
|
||||
|
||||
def get_related_data(issue_ids: List[UUID]) -> Dict:
|
||||
"""Get related data for the given issue IDs"""
|
||||
|
||||
cycle_issues = {ci.issue_id: ci.cycle_id for ci in CycleIssue.objects.filter(issue_id__in=issue_ids)}
|
||||
|
||||
# Get assignees with proper grouping
|
||||
assignee_records = list(
|
||||
IssueAssignee.objects.filter(issue_id__in=issue_ids).values_list("issue_id", "assignee_id").order_by("issue_id")
|
||||
)
|
||||
assignees = {}
|
||||
for issue_id, group in groupby(assignee_records, key=lambda x: x[0]):
|
||||
assignees[issue_id] = [str(g[1]) for g in group]
|
||||
|
||||
# Get labels with proper grouping
|
||||
label_records = list(
|
||||
IssueLabel.objects.filter(issue_id__in=issue_ids).values_list("issue_id", "label_id").order_by("issue_id")
|
||||
)
|
||||
labels = {}
|
||||
for issue_id, group in groupby(label_records, key=lambda x: x[0]):
|
||||
labels[issue_id] = [str(g[1]) for g in group]
|
||||
|
||||
# Get modules with proper grouping
|
||||
module_records = list(
|
||||
ModuleIssue.objects.filter(issue_id__in=issue_ids).values_list("issue_id", "module_id").order_by("issue_id")
|
||||
)
|
||||
modules = {}
|
||||
for issue_id, group in groupby(module_records, key=lambda x: x[0]):
|
||||
modules[issue_id] = [str(g[1]) for g in group]
|
||||
|
||||
# Get latest activities
|
||||
latest_activities = {}
|
||||
activities = IssueActivity.objects.filter(issue_id__in=issue_ids).order_by("issue_id", "-created_at")
|
||||
for issue_id, activities_group in groupby(activities, key=lambda x: x.issue_id):
|
||||
first_activity = next(activities_group, None)
|
||||
if first_activity:
|
||||
latest_activities[issue_id] = first_activity.id
|
||||
|
||||
return {
|
||||
"cycle_issues": cycle_issues,
|
||||
"assignees": assignees,
|
||||
"labels": labels,
|
||||
"modules": modules,
|
||||
"activities": latest_activities,
|
||||
}
|
||||
|
||||
|
||||
def create_issue_version(issue: Issue, related_data: Dict) -> Optional[IssueVersion]:
|
||||
"""Create IssueVersion object from the given issue and related data"""
|
||||
|
||||
try:
|
||||
if not issue.workspace_id or not issue.project_id:
|
||||
logging.warning(f"Skipping issue {issue.id} - missing workspace_id or project_id")
|
||||
return None
|
||||
|
||||
owned_by_id = get_owner_id(issue)
|
||||
if owned_by_id is None:
|
||||
logging.warning(f"Skipping issue {issue.id} - missing owned_by")
|
||||
return None
|
||||
|
||||
return IssueVersion(
|
||||
workspace_id=issue.workspace_id,
|
||||
project_id=issue.project_id,
|
||||
created_by_id=issue.created_by_id,
|
||||
updated_by_id=issue.updated_by_id,
|
||||
owned_by_id=owned_by_id,
|
||||
last_saved_at=timezone.now(),
|
||||
activity_id=related_data["activities"].get(issue.id),
|
||||
properties=getattr(issue, "properties", {}),
|
||||
meta=getattr(issue, "meta", {}),
|
||||
issue_id=issue.id,
|
||||
parent=issue.parent_id,
|
||||
state=issue.state_id,
|
||||
estimate_point=issue.estimate_point_id,
|
||||
name=issue.name,
|
||||
priority=issue.priority,
|
||||
start_date=issue.start_date,
|
||||
target_date=issue.target_date,
|
||||
assignees=related_data["assignees"].get(issue.id, []),
|
||||
sequence_id=issue.sequence_id,
|
||||
labels=related_data["labels"].get(issue.id, []),
|
||||
sort_order=issue.sort_order,
|
||||
completed_at=issue.completed_at,
|
||||
archived_at=issue.archived_at,
|
||||
is_draft=issue.is_draft,
|
||||
external_source=issue.external_source,
|
||||
external_id=issue.external_id,
|
||||
type=issue.type_id,
|
||||
cycle=related_data["cycle_issues"].get(issue.id),
|
||||
modules=related_data["modules"].get(issue.id, []),
|
||||
)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
|
||||
@shared_task
|
||||
def sync_issue_version(batch_size=5000, offset=0, countdown=300):
|
||||
"""Task to create IssueVersion records for existing Issues in batches"""
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
base_query = Issue.objects
|
||||
total_issues_count = base_query.count()
|
||||
|
||||
if total_issues_count == 0:
|
||||
return
|
||||
|
||||
end_offset = min(offset + batch_size, total_issues_count)
|
||||
|
||||
# Get issues batch with optimized queries
|
||||
issues_batch = list(
|
||||
base_query.order_by("created_at").select_related("workspace", "project").all()[offset:end_offset]
|
||||
)
|
||||
|
||||
if not issues_batch:
|
||||
return
|
||||
|
||||
# Get all related data in bulk
|
||||
issue_ids = [issue.id for issue in issues_batch]
|
||||
related_data = get_related_data(issue_ids)
|
||||
|
||||
issue_versions = []
|
||||
for issue in issues_batch:
|
||||
version = create_issue_version(issue, related_data)
|
||||
if version:
|
||||
issue_versions.append(version)
|
||||
|
||||
# Bulk create versions
|
||||
if issue_versions:
|
||||
IssueVersion.objects.bulk_create(issue_versions, batch_size=1000)
|
||||
|
||||
# Schedule the next batch if there are more workspaces to process
|
||||
if end_offset < total_issues_count:
|
||||
sync_issue_version.apply_async(
|
||||
kwargs={
|
||||
"batch_size": batch_size,
|
||||
"offset": end_offset,
|
||||
"countdown": countdown,
|
||||
},
|
||||
countdown=countdown,
|
||||
)
|
||||
|
||||
logging.info(f"Processed Issues: {end_offset}")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def schedule_issue_version(batch_size=5000, countdown=300):
|
||||
sync_issue_version.delay(batch_size=int(batch_size), countdown=countdown)
|
||||
@@ -0,0 +1,100 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# Third party imports
|
||||
from pymongo.collection import Collection
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from plane.settings.mongo import MongoConnection
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.db.models import APIActivityLog
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
|
||||
def get_mongo_collection() -> Optional[Collection]:
|
||||
"""
|
||||
Returns the MongoDB collection for external API activity logs.
|
||||
"""
|
||||
if not MongoConnection.is_configured():
|
||||
logger.info("MongoDB not configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
return MongoConnection.get_collection("api_activity_logs")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting MongoDB collection: {str(e)}")
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
|
||||
def safe_decode_body(content: bytes) -> Optional[str]:
|
||||
"""
|
||||
Safely decodes request/response body content, handling binary data.
|
||||
Returns "[Binary Content]" if the content is binary, or a string representation of the content.
|
||||
Returns None if the content is None or empty.
|
||||
"""
|
||||
# If the content is None, return None
|
||||
if content is None:
|
||||
return None
|
||||
|
||||
# If the content is an empty bytes object, return None
|
||||
if content == b"":
|
||||
return None
|
||||
|
||||
# Check if content is binary by looking for common binary file signatures
|
||||
if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"):
|
||||
return "[Binary Content]"
|
||||
|
||||
try:
|
||||
return content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return "[Could not decode content]"
|
||||
|
||||
|
||||
def log_to_mongo(log_document: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Logs the request to MongoDB if available.
|
||||
"""
|
||||
mongo_collection = get_mongo_collection()
|
||||
if mongo_collection is None:
|
||||
logger.error("MongoDB not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
mongo_collection.insert_one(log_document)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
|
||||
|
||||
def log_to_postgres(log_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Fallback to logging to PostgreSQL if MongoDB is unavailable.
|
||||
"""
|
||||
try:
|
||||
APIActivityLog.objects.create(**log_data)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_logs(log_data: Dict[str, Any], mongo_log: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Process logs to save to MongoDB or Postgres based on the configuration
|
||||
"""
|
||||
|
||||
if MongoConnection.is_configured():
|
||||
log_to_mongo(mongo_log)
|
||||
else:
|
||||
log_to_postgres(log_data)
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def magic_link(email, key, token):
|
||||
try:
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
# Send the mail
|
||||
subject = f"Your unique Plane login code is {token}"
|
||||
context = {"code": token, "email": email}
|
||||
|
||||
html_content = render_to_string("emails/auth/magic_signin.html", context)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info("Email sent successfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,674 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import json
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
IssueMention,
|
||||
IssueSubscriber,
|
||||
Project,
|
||||
User,
|
||||
IssueAssignee,
|
||||
Issue,
|
||||
State,
|
||||
EmailNotificationLog,
|
||||
Notification,
|
||||
IssueComment,
|
||||
IssueActivity,
|
||||
UserNotificationPreference,
|
||||
ProjectMember,
|
||||
)
|
||||
from django.db.models import Subquery
|
||||
|
||||
# Third Party imports
|
||||
from celery import shared_task
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
# =========== Issue Description Html Parsing and notification Functions ======================
|
||||
|
||||
|
||||
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
|
||||
aggregated_issue_mentions = []
|
||||
for mention_id in new_mentions:
|
||||
aggregated_issue_mentions.append(
|
||||
IssueMention(
|
||||
mention_id=mention_id,
|
||||
issue=issue,
|
||||
project=project,
|
||||
workspace_id=project.workspace_id,
|
||||
)
|
||||
)
|
||||
|
||||
IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100)
|
||||
IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete()
|
||||
|
||||
|
||||
def get_new_mentions(requested_instance, current_instance):
|
||||
# requested_data is the newer instance of the current issue
|
||||
# current_instance is the older instance of the current issue, saved in the database
|
||||
|
||||
# extract mentions from both the instance of data
|
||||
mentions_older = extract_mentions(current_instance)
|
||||
|
||||
mentions_newer = extract_mentions(requested_instance)
|
||||
|
||||
# Getting Set Difference from mentions_newer
|
||||
new_mentions = [mention for mention in mentions_newer if mention not in mentions_older]
|
||||
|
||||
return new_mentions
|
||||
|
||||
|
||||
# Get Removed Mention
|
||||
def get_removed_mentions(requested_instance, current_instance):
|
||||
# requested_data is the newer instance of the current issue
|
||||
# current_instance is the older instance of the current issue, saved in the database
|
||||
|
||||
# extract mentions from both the instance of data
|
||||
mentions_older = extract_mentions(current_instance)
|
||||
mentions_newer = extract_mentions(requested_instance)
|
||||
|
||||
# Getting Set Difference from mentions_newer
|
||||
removed_mentions = [mention for mention in mentions_older if mention not in mentions_newer]
|
||||
|
||||
return removed_mentions
|
||||
|
||||
|
||||
# Adds mentions as subscribers
|
||||
def extract_mentions_as_subscribers(project_id, issue_id, mentions):
|
||||
# mentions is an array of User IDs representing the FILTERED set of mentioned users
|
||||
|
||||
bulk_mention_subscribers = []
|
||||
|
||||
for mention_id in mentions:
|
||||
# If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification # noqa: E501
|
||||
if (
|
||||
not IssueSubscriber.objects.filter(
|
||||
issue_id=issue_id, subscriber_id=mention_id, project_id=project_id
|
||||
).exists()
|
||||
and not IssueAssignee.objects.filter(
|
||||
project_id=project_id, issue_id=issue_id, assignee_id=mention_id
|
||||
).exists()
|
||||
and not Issue.objects.filter(project_id=project_id, pk=issue_id, created_by_id=mention_id).exists()
|
||||
and ProjectMember.objects.filter(project_id=project_id, member_id=mention_id, is_active=True).exists()
|
||||
):
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
bulk_mention_subscribers.append(
|
||||
IssueSubscriber(
|
||||
workspace_id=project.workspace_id,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
subscriber_id=mention_id,
|
||||
)
|
||||
)
|
||||
return bulk_mention_subscribers
|
||||
|
||||
|
||||
# Parse Issue Description & extracts mentions
|
||||
def extract_mentions(issue_instance):
|
||||
try:
|
||||
# issue_instance has to be a dictionary passed, containing the description_html and other set of activity data. # noqa: E501
|
||||
mentions = []
|
||||
# Convert string to dictionary
|
||||
data = json.loads(issue_instance)
|
||||
html = data.get("description_html")
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
mention_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"})
|
||||
|
||||
mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags]
|
||||
|
||||
return list(set(mentions))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# =========== Comment Parsing and notification Functions ======================
|
||||
def extract_comment_mentions(comment_value):
|
||||
try:
|
||||
mentions = []
|
||||
soup = BeautifulSoup(comment_value, "html.parser")
|
||||
mentions_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"})
|
||||
for mention_tag in mentions_tags:
|
||||
mentions.append(mention_tag["entity_identifier"])
|
||||
return list(set(mentions))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def get_new_comment_mentions(new_value, old_value):
|
||||
mentions_newer = extract_comment_mentions(new_value)
|
||||
if old_value is None:
|
||||
return mentions_newer
|
||||
|
||||
mentions_older = extract_comment_mentions(old_value)
|
||||
# Getting Set Difference from mentions_newer
|
||||
new_mentions = [mention for mention in mentions_newer if mention not in mentions_older]
|
||||
|
||||
return new_mentions
|
||||
|
||||
|
||||
def create_mention_notification(project, notification_comment, issue, actor_id, mention_id, issue_id, activity):
|
||||
return Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities:mentioned",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
message=notification_comment,
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(activity.get("id")),
|
||||
"verb": str(activity.get("verb")),
|
||||
"field": str(activity.get("field")),
|
||||
"actor": str(activity.get("actor_id")),
|
||||
"new_value": str(activity.get("new_value")),
|
||||
"old_value": str(activity.get("old_value")),
|
||||
"old_identifier": (str(activity.get("old_identifier")) if activity.get("old_identifier") else None),
|
||||
"new_identifier": (str(activity.get("new_identifier")) if activity.get("new_identifier") else None),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def notifications(
|
||||
type,
|
||||
issue_id,
|
||||
project_id,
|
||||
actor_id,
|
||||
subscriber,
|
||||
issue_activities_created,
|
||||
requested_data,
|
||||
current_instance,
|
||||
):
|
||||
try:
|
||||
issue_activities_created = (
|
||||
json.loads(issue_activities_created) if issue_activities_created is not None else None
|
||||
)
|
||||
if type not in [
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
"module.activity.deleted",
|
||||
"issue_reaction.activity.created",
|
||||
"issue_reaction.activity.deleted",
|
||||
"comment_reaction.activity.created",
|
||||
"comment_reaction.activity.deleted",
|
||||
"issue_vote.activity.created",
|
||||
"issue_vote.activity.deleted",
|
||||
"issue_draft.activity.created",
|
||||
"issue_draft.activity.updated",
|
||||
"issue_draft.activity.deleted",
|
||||
]:
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
bulk_email_logs = []
|
||||
|
||||
"""
|
||||
Mention Tasks
|
||||
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
|
||||
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
|
||||
"""
|
||||
|
||||
# get the list of active project members
|
||||
project_members = ProjectMember.objects.filter(project_id=project_id, is_active=True).values_list(
|
||||
"member_id", flat=True
|
||||
)
|
||||
|
||||
# Get new mentions from the newer instance
|
||||
new_mentions = get_new_mentions(requested_instance=requested_data, current_instance=current_instance)
|
||||
new_mentions = list(set(new_mentions) & {str(member) for member in project_members})
|
||||
removed_mention = get_removed_mentions(requested_instance=requested_data, current_instance=current_instance)
|
||||
|
||||
comment_mentions = []
|
||||
all_comment_mentions = []
|
||||
|
||||
# Get New Subscribers from the mentions of the newer instance
|
||||
requested_mentions = extract_mentions(issue_instance=requested_data)
|
||||
mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id, issue_id=issue_id, mentions=requested_mentions
|
||||
)
|
||||
|
||||
for issue_activity in issue_activities_created:
|
||||
issue_comment = issue_activity.get("issue_comment")
|
||||
issue_comment_new_value = issue_activity.get("new_value")
|
||||
issue_comment_old_value = issue_activity.get("old_value")
|
||||
if issue_comment is not None:
|
||||
# TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well.
|
||||
|
||||
all_comment_mentions = all_comment_mentions + extract_comment_mentions(issue_comment_new_value)
|
||||
|
||||
new_comment_mentions = get_new_comment_mentions(
|
||||
old_value=issue_comment_old_value,
|
||||
new_value=issue_comment_new_value,
|
||||
)
|
||||
comment_mentions = comment_mentions + new_comment_mentions
|
||||
comment_mentions = [
|
||||
mention for mention in comment_mentions if UUID(mention) in set(project_members)
|
||||
]
|
||||
|
||||
comment_mention_subscribers = extract_mentions_as_subscribers(
|
||||
project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions
|
||||
)
|
||||
"""
|
||||
We will not send subscription activity notification to the below mentioned user sets
|
||||
- Those who have been newly mentioned in the issue description, we will send mention notification to them.
|
||||
- When the activity is a comment_created and there exist a mention in the comment,
|
||||
then we have to send the "mention_in_comment" notification
|
||||
- When the activity is a comment_updated and there exist a mention change,
|
||||
then also we have to send the "mention_in_comment" notification
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------------
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
subscriber__in=Subquery(project_members),
|
||||
)
|
||||
.exclude(subscriber_id__in=list(new_mentions + comment_mentions + [actor_id]))
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
|
||||
if subscriber:
|
||||
# add the user to issue subscriber
|
||||
try:
|
||||
_ = IssueSubscriber.objects.get_or_create(
|
||||
project_id=project_id, issue_id=issue_id, subscriber_id=actor_id
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
issue_assignees = IssueAssignee.objects.filter(
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
assignee__in=Subquery(project_members),
|
||||
).values_list("assignee", flat=True)
|
||||
|
||||
issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)})
|
||||
|
||||
for subscriber in issue_subscribers:
|
||||
if issue.created_by_id and issue.created_by_id == subscriber:
|
||||
sender = "in_app:issue_activities:created"
|
||||
elif subscriber in issue_assignees and issue.created_by_id not in issue_assignees:
|
||||
sender = "in_app:issue_activities:assigned"
|
||||
else:
|
||||
sender = "in_app:issue_activities:subscribed"
|
||||
|
||||
preference = UserNotificationPreference.objects.get(user_id=subscriber)
|
||||
|
||||
for issue_activity in issue_activities_created:
|
||||
# If activity done in blocking then blocked by email should not go
|
||||
if issue_activity.get("issue_detail").get("id") != issue_id:
|
||||
continue
|
||||
|
||||
# Do not send notification for description update
|
||||
if issue_activity.get("field") == "description":
|
||||
continue
|
||||
|
||||
# Check if the value should be sent or not
|
||||
send_email = False
|
||||
if issue_activity.get("field") == "state" and preference.state_change:
|
||||
send_email = True
|
||||
elif (
|
||||
issue_activity.get("field") == "state"
|
||||
and preference.issue_completed
|
||||
and State.objects.filter(
|
||||
project_id=project_id,
|
||||
pk=issue_activity.get("new_identifier"),
|
||||
group="completed",
|
||||
).exists()
|
||||
):
|
||||
send_email = True
|
||||
elif issue_activity.get("field") == "comment" and preference.comment:
|
||||
send_email = True
|
||||
elif preference.property_change:
|
||||
send_email = True
|
||||
else:
|
||||
send_email = False
|
||||
|
||||
# If activity is of issue comment fetch the comment
|
||||
issue_comment = (
|
||||
IssueComment.objects.filter(
|
||||
id=issue_activity.get("issue_comment"),
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
).first()
|
||||
if issue_activity.get("issue_comment")
|
||||
else None
|
||||
)
|
||||
|
||||
# Create in app notification
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender=sender,
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
title=issue_activity.get("comment"),
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str(issue_activity.get("field")),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"issue_comment": str(
|
||||
issue_comment.comment_stripped if issue_comment is not None else ""
|
||||
),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
# Create email notification
|
||||
if send_email:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"project_id": str(issue.project.id),
|
||||
"workspace_slug": str(issue.project.workspace.slug),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str(issue_activity.get("field")),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"issue_comment": str(
|
||||
issue_comment.comment_stripped if issue_comment is not None else ""
|
||||
),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------------------------------------- #
|
||||
|
||||
# Add Mentioned as Issue Subscribers
|
||||
IssueSubscriber.objects.bulk_create(
|
||||
mention_subscribers + comment_mention_subscribers,
|
||||
batch_size=100,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
|
||||
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
|
||||
for mention_id in comment_mentions:
|
||||
if mention_id != actor_id:
|
||||
preference = UserNotificationPreference.objects.get(user_id=mention_id)
|
||||
for issue_activity in issue_activities_created:
|
||||
notification = create_mention_notification(
|
||||
project=project,
|
||||
issue=issue,
|
||||
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", # noqa: E501
|
||||
actor_id=actor_id,
|
||||
mention_id=mention_id,
|
||||
issue_id=issue_id,
|
||||
activity=issue_activity,
|
||||
)
|
||||
|
||||
# check for email notifications
|
||||
if preference.mention:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
"project_id": str(issue.project.id),
|
||||
"workspace_slug": str(issue.project.workspace.slug),
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str("mention"),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
bulk_notifications.append(notification)
|
||||
|
||||
for mention_id in new_mentions:
|
||||
if mention_id != actor_id:
|
||||
preference = UserNotificationPreference.objects.get(user_id=mention_id)
|
||||
if (
|
||||
last_activity is not None
|
||||
and last_activity.field == "description"
|
||||
and actor_id == str(last_activity.actor_id)
|
||||
):
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities:mentioned",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=mention_id,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
message=f"You have been mentioned in the issue {issue.name}",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
"project_id": str(issue.project.id),
|
||||
"workspace_slug": str(issue.project.workspace.slug),
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(last_activity.id),
|
||||
"verb": str(last_activity.verb),
|
||||
"field": str(last_activity.field),
|
||||
"actor": str(last_activity.actor_id),
|
||||
"new_value": str(last_activity.new_value),
|
||||
"old_value": str(last_activity.old_value),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
if preference.mention:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(last_activity.id),
|
||||
"verb": str(last_activity.verb),
|
||||
"field": "mention",
|
||||
"actor": str(last_activity.actor_id),
|
||||
"new_value": str(last_activity.new_value),
|
||||
"old_value": str(last_activity.old_value),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
"activity_time": str(last_activity.created_at),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
for issue_activity in issue_activities_created:
|
||||
notification = create_mention_notification(
|
||||
project=project,
|
||||
issue=issue,
|
||||
notification_comment=f"You have been mentioned in the issue {issue.name}",
|
||||
actor_id=actor_id,
|
||||
mention_id=mention_id,
|
||||
issue_id=issue_id,
|
||||
activity=issue_activity,
|
||||
)
|
||||
if preference.mention:
|
||||
bulk_email_logs.append(
|
||||
EmailNotificationLog(
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(issue.project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.get("id")),
|
||||
"verb": str(issue_activity.get("verb")),
|
||||
"field": str("mention"),
|
||||
"actor": str(issue_activity.get("actor_id")),
|
||||
"new_value": str(issue_activity.get("new_value")),
|
||||
"old_value": str(issue_activity.get("old_value")),
|
||||
"old_identifier": (
|
||||
str(issue_activity.get("old_identifier"))
|
||||
if issue_activity.get("old_identifier")
|
||||
else None
|
||||
),
|
||||
"new_identifier": (
|
||||
str(issue_activity.get("new_identifier"))
|
||||
if issue_activity.get("new_identifier")
|
||||
else None
|
||||
),
|
||||
"activity_time": issue_activity.get("created_at"),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
bulk_notifications.append(notification)
|
||||
|
||||
# save new mentions for the particular issue and remove the mentions that has been deleted from the description # noqa: E501
|
||||
update_mentions_for_issue(
|
||||
issue=issue,
|
||||
project=project,
|
||||
new_mentions=new_mentions,
|
||||
removed_mention=removed_mention,
|
||||
)
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||
EmailNotificationLog.objects.bulk_create(bulk_email_logs, batch_size=100, ignore_conflicts=True)
|
||||
return
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return
|
||||
@@ -0,0 +1,142 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Third-party imports
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# App imports
|
||||
from celery import shared_task
|
||||
from plane.db.models import Page, PageLog
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
COMPONENT_MAP = {
|
||||
"mention-component": {
|
||||
"attributes": ["id", "entity_identifier", "entity_name", "entity_type"],
|
||||
"extract": lambda m: {
|
||||
"entity_name": m.get("entity_name"),
|
||||
"entity_type": None,
|
||||
"entity_identifier": m.get("entity_identifier"),
|
||||
},
|
||||
},
|
||||
"image-component": {
|
||||
"attributes": ["id", "src"],
|
||||
"extract": lambda m: {
|
||||
"entity_name": "image",
|
||||
"entity_type": None,
|
||||
"entity_identifier": m.get("src"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
component_map = {
|
||||
**COMPONENT_MAP,
|
||||
}
|
||||
|
||||
|
||||
def extract_all_components(description_html):
|
||||
"""
|
||||
Extracts all component types from the HTML value in a single pass.
|
||||
Returns a dict mapping component_type -> list of extracted entities.
|
||||
"""
|
||||
try:
|
||||
if not description_html:
|
||||
return {component: [] for component in component_map.keys()}
|
||||
|
||||
soup = BeautifulSoup(description_html, "html.parser")
|
||||
results = {}
|
||||
|
||||
for component, config in component_map.items():
|
||||
attributes = config.get("attributes", ["id"])
|
||||
component_tags = soup.find_all(component)
|
||||
|
||||
entities = []
|
||||
for tag in component_tags:
|
||||
entity = {attr: tag.get(attr) for attr in attributes}
|
||||
entities.append(entity)
|
||||
|
||||
results[component] = entities
|
||||
|
||||
return results
|
||||
|
||||
except Exception:
|
||||
return {component: [] for component in component_map.keys()}
|
||||
|
||||
|
||||
def get_entity_details(component: str, mention: dict):
|
||||
"""
|
||||
Normalizes mention attributes into entity_name, entity_type, entity_identifier.
|
||||
"""
|
||||
config = component_map.get(component)
|
||||
if not config:
|
||||
return {"entity_name": None, "entity_type": None, "entity_identifier": None}
|
||||
return config["extract"](mention)
|
||||
|
||||
|
||||
@shared_task
|
||||
def page_transaction(new_description_html, old_description_html, page_id):
|
||||
"""
|
||||
Tracks changes in page content (mentions, embeds, etc.)
|
||||
and logs them in PageLog for audit and reference.
|
||||
"""
|
||||
try:
|
||||
page = Page.objects.get(pk=page_id)
|
||||
|
||||
has_existing_logs = PageLog.objects.filter(page_id=page_id).exists()
|
||||
|
||||
# Extract all components in a single pass (optimized)
|
||||
old_components = extract_all_components(old_description_html)
|
||||
new_components = extract_all_components(new_description_html)
|
||||
|
||||
new_transactions = []
|
||||
deleted_transaction_ids = set()
|
||||
|
||||
for component in component_map.keys():
|
||||
old_entities = old_components[component]
|
||||
new_entities = new_components[component]
|
||||
|
||||
old_ids = {m.get("id") for m in old_entities if m.get("id")}
|
||||
new_ids = {m.get("id") for m in new_entities if m.get("id")}
|
||||
deleted_transaction_ids.update(old_ids - new_ids)
|
||||
|
||||
for mention in new_entities:
|
||||
mention_id = mention.get("id")
|
||||
if not mention_id or (mention_id in old_ids and has_existing_logs):
|
||||
continue
|
||||
|
||||
details = get_entity_details(component, mention)
|
||||
current_time = timezone.now()
|
||||
|
||||
new_transactions.append(
|
||||
PageLog(
|
||||
transaction=mention_id,
|
||||
page_id=page_id,
|
||||
entity_identifier=details["entity_identifier"],
|
||||
entity_name=details["entity_name"],
|
||||
entity_type=details["entity_type"],
|
||||
workspace_id=page.workspace_id,
|
||||
created_at=current_time,
|
||||
updated_at=current_time,
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk insert and cleanup
|
||||
if new_transactions:
|
||||
PageLog.objects.bulk_create(new_transactions, batch_size=50, ignore_conflicts=True)
|
||||
|
||||
if deleted_transaction_ids:
|
||||
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
|
||||
|
||||
except Page.DoesNotExist:
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,81 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Page, PageVersion
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
PAGE_VERSION_TASK_TIMEOUT = 600
|
||||
|
||||
@shared_task
|
||||
def track_page_version(page_id, existing_instance, user_id):
|
||||
try:
|
||||
# Get the page
|
||||
page = Page.objects.get(id=page_id)
|
||||
|
||||
# Get the current instance
|
||||
current_instance = json.loads(existing_instance) if existing_instance is not None else {}
|
||||
sub_pages = {}
|
||||
|
||||
|
||||
# Create a version if description_html is updated
|
||||
if current_instance.get("description_html") != page.description_html:
|
||||
# Fetch the latest page version
|
||||
page_version = PageVersion.objects.filter(page_id=page_id).order_by("-last_saved_at").first()
|
||||
|
||||
# Get the latest page version if it exists and is owned by the user
|
||||
if (
|
||||
page_version
|
||||
and str(page_version.owned_by_id) == str(user_id)
|
||||
and (timezone.now() - page_version.last_saved_at).total_seconds() <= PAGE_VERSION_TASK_TIMEOUT
|
||||
):
|
||||
page_version.description_html = page.description_html
|
||||
page_version.description_binary = page.description_binary
|
||||
page_version.description_json = page.description
|
||||
page_version.description_stripped = page.description_stripped
|
||||
page_version.sub_pages_data = sub_pages
|
||||
page_version.save(
|
||||
update_fields=[
|
||||
"description_html",
|
||||
"description_binary",
|
||||
"description_json",
|
||||
"description_stripped",
|
||||
"sub_pages_data",
|
||||
"updated_at"
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Create a new page version
|
||||
PageVersion.objects.create(
|
||||
page_id=page_id,
|
||||
workspace_id=page.workspace_id,
|
||||
description_json=page.description,
|
||||
description_html=page.description_html,
|
||||
description_binary=page.description_binary,
|
||||
description_stripped=page.description_stripped,
|
||||
owned_by_id=user_id,
|
||||
last_saved_at=timezone.now(),
|
||||
sub_pages_data=sub_pages,
|
||||
)
|
||||
# If page versions are greater than 20 delete the oldest one
|
||||
if PageVersion.objects.filter(page_id=page_id).count() > 20:
|
||||
# Delete the old page version
|
||||
PageVersion.objects.filter(page_id=page_id).order_by("last_saved_at").first().delete()
|
||||
|
||||
return
|
||||
except Page.DoesNotExist:
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,89 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.db.models import ProjectMember
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
@shared_task
|
||||
def project_add_user_email(current_site, project_member_id, invitor_id):
|
||||
try:
|
||||
# Get the invitor
|
||||
invitor = User.objects.get(pk=invitor_id)
|
||||
inviter_first_name = invitor.first_name
|
||||
# Get the project member
|
||||
project_member = ProjectMember.objects.get(pk=project_member_id)
|
||||
# Get the project member details
|
||||
project_name = project_member.project.name
|
||||
workspace_name = project_member.workspace.name
|
||||
member_email = project_member.member.email
|
||||
project_url = f"{current_site}/{project_member.workspace.slug}/projects/{project_member.project_id}/issues"
|
||||
# set the context
|
||||
context = {
|
||||
"project_name": project_name,
|
||||
"workspace_name": workspace_name,
|
||||
"email": member_email,
|
||||
"inviter_first_name": inviter_first_name,
|
||||
"project_url": project_url,
|
||||
}
|
||||
|
||||
# Get the email configuration
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
# Set the subject
|
||||
subject = "You have been invited to a Plane project"
|
||||
|
||||
# Render the email template
|
||||
html_content = render_to_string("emails/notifications/project_addition.html", context)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
# Initialize the connection
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
# Send the email
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[member_email],
|
||||
connection=connection,
|
||||
)
|
||||
# Attach the html content
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
# Send the email
|
||||
msg.send()
|
||||
# Log the success
|
||||
logging.getLogger("plane.worker").info("Email sent successfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,86 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Project, ProjectMemberInvite, User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def project_invitation(email, project_id, token, current_site, invitor):
|
||||
try:
|
||||
user = User.objects.get(email=invitor)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
project_member_invite = ProjectMemberInvite.objects.get(token=token, email=email)
|
||||
|
||||
relativelink = f"/project-invitations/?invitation_id={project_member_invite.id}&email={email}&slug={project.workspace.slug}&project_id={str(project_id)}" # noqa: E501
|
||||
abs_url = current_site + relativelink
|
||||
|
||||
subject = f"{user.first_name or user.display_name or user.email} invited you to join {project.name} on Plane"
|
||||
|
||||
context = {
|
||||
"email": email,
|
||||
"first_name": user.first_name,
|
||||
"project_name": project.name,
|
||||
"invitation_url": abs_url,
|
||||
"current_site": current_site,
|
||||
}
|
||||
|
||||
html_content = render_to_string("emails/invitations/project_invitation.html", context)
|
||||
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
project_member_invite.message = text_content
|
||||
project_member_invite.save()
|
||||
|
||||
# Configure email connection from the database
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info("Email sent successfully.")
|
||||
return
|
||||
except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist):
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,61 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
from django.utils import timezone
|
||||
from django.db import DatabaseError
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import UserRecentVisit, Workspace
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slug):
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
recent_visited = UserRecentVisit.objects.filter(
|
||||
entity_name=entity_name,
|
||||
entity_identifier=entity_identifier,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace.id,
|
||||
).first()
|
||||
|
||||
if recent_visited:
|
||||
# Check if the database is available
|
||||
try:
|
||||
recent_visited.visited_at = timezone.now()
|
||||
recent_visited.save(update_fields=["visited_at"])
|
||||
except DatabaseError:
|
||||
pass
|
||||
else:
|
||||
recent_visited_count = UserRecentVisit.objects.filter(user_id=user_id, workspace_id=workspace.id).count()
|
||||
if recent_visited_count == 20:
|
||||
recent_visited = (
|
||||
UserRecentVisit.objects.filter(user_id=user_id, workspace_id=workspace.id)
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
recent_visited.delete()
|
||||
|
||||
recent_activity = UserRecentVisit.objects.create(
|
||||
entity_name=entity_name,
|
||||
entity_identifier=entity_identifier,
|
||||
user_id=user_id,
|
||||
visited_at=timezone.now(),
|
||||
project_id=project_id,
|
||||
workspace_id=workspace.id,
|
||||
)
|
||||
recent_activity.created_by_id = user_id
|
||||
recent_activity.updated_by_id = user_id
|
||||
recent_activity.save(update_fields=["created_by_id", "updated_by_id"])
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def get_asset_object_metadata(asset_id):
|
||||
try:
|
||||
# Get the asset
|
||||
asset = FileAsset.objects.get(pk=asset_id)
|
||||
# Create an instance of the S3 storage
|
||||
storage = S3Storage()
|
||||
# Get the storage
|
||||
asset.storage_metadata = storage.get_object_metadata(object_name=asset.asset.name)
|
||||
# Save the asset
|
||||
asset.save(update_fields=["storage_metadata"])
|
||||
return
|
||||
except FileAsset.DoesNotExist:
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,69 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def user_activation_email(current_site, user_id):
|
||||
try:
|
||||
# Send email to user when account is activated
|
||||
user = User.objects.get(id=user_id)
|
||||
subject = f"{user.first_name or user.display_name or user.email} has been activated on Plane"
|
||||
|
||||
context = {"email": str(user.email), "profile_url": current_site + "/profile"}
|
||||
|
||||
# Send email to user
|
||||
html_content = render_to_string("emails/user/user_activation.html", context)
|
||||
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
# Configure email connection from the database
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[user.email],
|
||||
connection=connection,
|
||||
)
|
||||
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info("Email sent successfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def user_deactivation_email(current_site, user_id):
|
||||
try:
|
||||
# Send email to user when account is deactivated
|
||||
user = User.objects.get(id=user_id)
|
||||
subject = f"{user.first_name or user.display_name or user.email} has been deactivated on Plane"
|
||||
|
||||
context = {"email": str(user.email), "login_url": current_site + "/login"}
|
||||
|
||||
# Send email to user
|
||||
html_content = render_to_string("emails/user/user_deactivation.html", context)
|
||||
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
# Configure email connection from the database
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
# Send email
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[user.email],
|
||||
connection=connection,
|
||||
)
|
||||
|
||||
# Attach HTML content
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info("Email sent successfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,114 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_email_update_magic_code(email, token):
|
||||
try:
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
# Send the mail
|
||||
subject = "Verify your new email address"
|
||||
context = {"code": token, "email": email}
|
||||
|
||||
html_content = render_to_string("emails/auth/magic_signin.html", context)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info("Email sent successfully.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_email_update_confirmation(email):
|
||||
"""
|
||||
Send a confirmation email to the user after their email address has been successfully updated.
|
||||
|
||||
Args:
|
||||
email: The new email address that was successfully updated
|
||||
"""
|
||||
try:
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
# Send the confirmation email
|
||||
subject = "Plane email address successfully updated"
|
||||
context = {"email": email}
|
||||
|
||||
html_content = render_to_string("emails/user/email_updated.html", context)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info(f"Email update confirmation sent successfully to {email}.")
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,506 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.db.models import Prefetch
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.template.loader import render_to_string
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
# Module imports
|
||||
from plane.api.serializers import (
|
||||
CycleIssueSerializer,
|
||||
CycleSerializer,
|
||||
IssueCommentSerializer,
|
||||
IssueExpandSerializer,
|
||||
ModuleIssueSerializer,
|
||||
ModuleSerializer,
|
||||
ProjectSerializer,
|
||||
UserLiteSerializer,
|
||||
IntakeIssueSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
CycleIssue,
|
||||
Issue,
|
||||
IssueComment,
|
||||
Module,
|
||||
ModuleIssue,
|
||||
Project,
|
||||
User,
|
||||
Webhook,
|
||||
WebhookLog,
|
||||
IntakeIssue,
|
||||
IssueLabel,
|
||||
IssueAssignee,
|
||||
)
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.settings.mongo import MongoConnection
|
||||
|
||||
|
||||
SERIALIZER_MAPPER = {
|
||||
"project": ProjectSerializer,
|
||||
"issue": IssueExpandSerializer,
|
||||
"cycle": CycleSerializer,
|
||||
"module": ModuleSerializer,
|
||||
"cycle_issue": CycleIssueSerializer,
|
||||
"module_issue": ModuleIssueSerializer,
|
||||
"issue_comment": IssueCommentSerializer,
|
||||
"user": UserLiteSerializer,
|
||||
"intake_issue": IntakeIssueSerializer,
|
||||
}
|
||||
|
||||
MODEL_MAPPER = {
|
||||
"project": Project,
|
||||
"issue": Issue,
|
||||
"cycle": Cycle,
|
||||
"module": Module,
|
||||
"cycle_issue": CycleIssue,
|
||||
"module_issue": ModuleIssue,
|
||||
"issue_comment": IssueComment,
|
||||
"user": User,
|
||||
"intake_issue": IntakeIssue,
|
||||
}
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
|
||||
def get_issue_prefetches():
|
||||
return [
|
||||
Prefetch("label_issue", queryset=IssueLabel.objects.select_related("label")),
|
||||
Prefetch("issue_assignee", queryset=IssueAssignee.objects.select_related("assignee")),
|
||||
]
|
||||
|
||||
|
||||
def save_webhook_log(
|
||||
webhook: Webhook,
|
||||
request_method: str,
|
||||
request_headers: str,
|
||||
request_body: str,
|
||||
response_status: str,
|
||||
response_headers: str,
|
||||
response_body: str,
|
||||
retry_count: int,
|
||||
event_type: str,
|
||||
) -> None:
|
||||
# webhook_logs
|
||||
mongo_collection = MongoConnection.get_collection("webhook_logs")
|
||||
|
||||
log_data = {
|
||||
"workspace_id": str(webhook.workspace_id),
|
||||
"webhook": str(webhook.id),
|
||||
"event_type": str(event_type),
|
||||
"request_method": str(request_method),
|
||||
"request_headers": str(request_headers),
|
||||
"request_body": str(request_body),
|
||||
"response_status": str(response_status),
|
||||
"response_headers": str(response_headers),
|
||||
"response_body": str(response_body),
|
||||
"retry_count": retry_count,
|
||||
}
|
||||
|
||||
mongo_save_success = False
|
||||
if mongo_collection is not None:
|
||||
try:
|
||||
# insert the log data into the mongo collection
|
||||
mongo_collection.insert_one(log_data)
|
||||
logger.info("Webhook log saved successfully to mongo")
|
||||
mongo_save_success = True
|
||||
except Exception as e:
|
||||
log_exception(e, warning=True)
|
||||
logger.error(f"Failed to save webhook log: {e}")
|
||||
mongo_save_success = False
|
||||
|
||||
# if the mongo save is not successful, save the log data into the database
|
||||
if not mongo_save_success:
|
||||
try:
|
||||
# insert the log data into the database
|
||||
WebhookLog.objects.create(**log_data)
|
||||
logger.info("Webhook log saved successfully to database")
|
||||
except Exception as e:
|
||||
log_exception(e, warning=True)
|
||||
logger.error(f"Failed to save webhook log: {e}")
|
||||
|
||||
|
||||
def get_model_data(event: str, event_id: Union[str, List[str]], many: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieve and serialize model data based on the event type.
|
||||
|
||||
Args:
|
||||
event (str): The type of event/model to retrieve data for
|
||||
event_id (Union[str, List[str]]): The ID or list of IDs of the model instance(s)
|
||||
many (bool): Whether to retrieve multiple instances
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Serialized model data
|
||||
|
||||
Raises:
|
||||
ValueError: If serializer is not found for the event
|
||||
ObjectDoesNotExist: If model instance is not found
|
||||
"""
|
||||
model = MODEL_MAPPER.get(event)
|
||||
if model is None:
|
||||
raise ValueError(f"Model not found for event: {event}")
|
||||
|
||||
try:
|
||||
if many:
|
||||
queryset = model.objects.filter(pk__in=event_id)
|
||||
else:
|
||||
queryset = model.objects.get(pk=event_id)
|
||||
|
||||
serializer = SERIALIZER_MAPPER.get(event)
|
||||
|
||||
if serializer is None:
|
||||
raise ValueError(f"Serializer not found for event: {event}")
|
||||
|
||||
issue_prefetches = get_issue_prefetches()
|
||||
if event == "issue":
|
||||
if many:
|
||||
queryset = queryset.prefetch_related(*issue_prefetches)
|
||||
else:
|
||||
issue_id = queryset.id
|
||||
queryset = model.objects.filter(pk=issue_id).prefetch_related(*issue_prefetches).first()
|
||||
|
||||
return serializer(queryset, many=many, context={"expand": ["labels", "assignees"]}).data
|
||||
else:
|
||||
return serializer(queryset, many=many).data
|
||||
except ObjectDoesNotExist:
|
||||
raise ObjectDoesNotExist(f"No {event} found with id: {event_id}")
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_webhook_deactivation_email(webhook_id: str, receiver_id: str, current_site: str, reason: str) -> None:
|
||||
"""
|
||||
Send an email notification when a webhook is deactivated.
|
||||
|
||||
Args:
|
||||
webhook_id (str): ID of the deactivated webhook
|
||||
receiver_id (str): ID of the user to receive the notification
|
||||
current_site (str): Current site URL
|
||||
reason (str): Reason for webhook deactivation
|
||||
"""
|
||||
try:
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
receiver = User.objects.get(pk=receiver_id)
|
||||
webhook = Webhook.objects.get(pk=webhook_id)
|
||||
|
||||
# Get the webhook payload
|
||||
subject = "Webhook Deactivated"
|
||||
message = f"Webhook {webhook.url} has been deactivated due to failed requests."
|
||||
|
||||
# Send the mail
|
||||
context = {
|
||||
"email": receiver.email,
|
||||
"message": message,
|
||||
"webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
|
||||
}
|
||||
html_content = render_to_string("emails/notifications/webhook-deactivate.html", context)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
# Set the email connection
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
# Create the email message
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[receiver.email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logger.info("Email sent successfully.")
|
||||
except Exception as e:
|
||||
log_exception(e, warning=True)
|
||||
logger.error(f"Failed to send email: {e}")
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
autoretry_for=(requests.RequestException,),
|
||||
retry_backoff=600,
|
||||
max_retries=5,
|
||||
retry_jitter=True,
|
||||
)
|
||||
def webhook_send_task(
|
||||
self,
|
||||
webhook_id: str,
|
||||
slug: str,
|
||||
event: str,
|
||||
event_data: Optional[Dict[str, Any]],
|
||||
action: str,
|
||||
current_site: str,
|
||||
activity: Optional[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""
|
||||
Send webhook notifications to configured endpoints.
|
||||
|
||||
Args:
|
||||
webhook (str): Webhook ID
|
||||
slug (str): Workspace slug
|
||||
event (str): Event type
|
||||
event_data (Optional[Dict[str, Any]]): Event data to be sent
|
||||
action (str): HTTP method/action
|
||||
current_site (str): Current site URL
|
||||
activity (Optional[Dict[str, Any]]): Activity data
|
||||
"""
|
||||
try:
|
||||
webhook = Webhook.objects.get(id=webhook_id, workspace__slug=slug)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Autopilot",
|
||||
"X-Plane-Delivery": str(uuid.uuid4()),
|
||||
"X-Plane-Event": event,
|
||||
}
|
||||
|
||||
# # Your secret key
|
||||
event_data = json.loads(json.dumps(event_data, cls=DjangoJSONEncoder)) if event_data is not None else None
|
||||
|
||||
activity = json.loads(json.dumps(activity, cls=DjangoJSONEncoder)) if activity is not None else None
|
||||
|
||||
action = {
|
||||
"POST": "create",
|
||||
"PATCH": "update",
|
||||
"PUT": "update",
|
||||
"DELETE": "delete",
|
||||
}.get(action, action)
|
||||
|
||||
payload = {
|
||||
"event": event,
|
||||
"action": action,
|
||||
"webhook_id": str(webhook.id),
|
||||
"workspace_id": str(webhook.workspace_id),
|
||||
"data": event_data,
|
||||
"activity": activity,
|
||||
}
|
||||
|
||||
# Use HMAC for generating signature
|
||||
if webhook.secret_key:
|
||||
hmac_signature = hmac.new(
|
||||
webhook.secret_key.encode("utf-8"),
|
||||
json.dumps(payload).encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
)
|
||||
signature = hmac_signature.hexdigest()
|
||||
headers["X-Plane-Signature"] = signature
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
logger.error(f"Failed to send webhook: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
# Send the webhook event
|
||||
response = requests.post(webhook.url, headers=headers, json=payload, timeout=30)
|
||||
|
||||
# Log the webhook request
|
||||
save_webhook_log(
|
||||
webhook=webhook,
|
||||
request_method=action,
|
||||
request_headers=headers,
|
||||
request_body=payload,
|
||||
response_status=response.status_code,
|
||||
response_headers=response.headers,
|
||||
response_body=response.text,
|
||||
retry_count=self.request.retries,
|
||||
event_type=event,
|
||||
)
|
||||
logger.info(f"Webhook {webhook.id} sent successfully")
|
||||
except requests.RequestException as e:
|
||||
# Log the failed webhook request
|
||||
save_webhook_log(
|
||||
webhook=webhook,
|
||||
request_method=action,
|
||||
request_headers=headers,
|
||||
request_body=payload,
|
||||
response_status=500,
|
||||
response_headers="",
|
||||
response_body=str(e),
|
||||
retry_count=self.request.retries,
|
||||
event_type=event,
|
||||
)
|
||||
logger.error(f"Webhook {webhook.id} failed with error: {e}")
|
||||
# Retry logic
|
||||
if self.request.retries >= self.max_retries:
|
||||
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
|
||||
if webhook:
|
||||
# send email for the deactivation of the webhook
|
||||
send_webhook_deactivation_email.delay(
|
||||
webhook_id=webhook.id,
|
||||
receiver_id=webhook.created_by_id,
|
||||
reason=str(e),
|
||||
current_site=current_site,
|
||||
)
|
||||
return
|
||||
raise requests.RequestException()
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def webhook_activity(
|
||||
event: str,
|
||||
verb: str,
|
||||
field: Optional[str],
|
||||
old_value: Any,
|
||||
new_value: Any,
|
||||
actor_id: str | uuid.UUID,
|
||||
slug: str,
|
||||
current_site: str,
|
||||
event_id: str | uuid.UUID,
|
||||
old_identifier: Optional[str],
|
||||
new_identifier: Optional[str],
|
||||
) -> None:
|
||||
"""
|
||||
Process and send webhook notifications for various activities in the system.
|
||||
|
||||
This task filters relevant webhooks based on the event type and sends notifications
|
||||
to all active webhooks for the workspace.
|
||||
|
||||
Args:
|
||||
event (str): Type of event (project, issue, module, cycle, issue_comment)
|
||||
verb (str): Action performed (created, updated, deleted)
|
||||
field (Optional[str]): Name of the field that was changed
|
||||
old_value (Any): Previous value of the field
|
||||
new_value (Any): New value of the field
|
||||
actor_id (str | uuid.UUID): ID of the user who performed the action
|
||||
slug (str): Workspace slug
|
||||
current_site (str): Current site URL
|
||||
event_id (str | uuid.UUID): ID of the event object
|
||||
old_identifier (Optional[str]): Previous identifier if any
|
||||
new_identifier (Optional[str]): New identifier if any
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Note:
|
||||
The function silently returns on ObjectDoesNotExist exceptions to handle
|
||||
race conditions where objects might have been deleted.
|
||||
"""
|
||||
try:
|
||||
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
|
||||
|
||||
if event == "project":
|
||||
webhooks = webhooks.filter(project=True)
|
||||
|
||||
if event == "issue":
|
||||
webhooks = webhooks.filter(issue=True)
|
||||
|
||||
if event == "module" or event == "module_issue":
|
||||
webhooks = webhooks.filter(module=True)
|
||||
|
||||
if event == "cycle" or event == "cycle_issue":
|
||||
webhooks = webhooks.filter(cycle=True)
|
||||
|
||||
if event == "issue_comment":
|
||||
webhooks = webhooks.filter(issue_comment=True)
|
||||
|
||||
for webhook in webhooks:
|
||||
webhook_send_task.delay(
|
||||
webhook_id=webhook.id,
|
||||
slug=slug,
|
||||
event=event,
|
||||
event_data=({"id": event_id} if verb == "deleted" else get_model_data(event=event, event_id=event_id)),
|
||||
action=verb,
|
||||
current_site=current_site,
|
||||
activity={
|
||||
"field": field,
|
||||
"new_value": new_value,
|
||||
"old_value": old_value,
|
||||
"actor": get_model_data(event="user", event_id=actor_id),
|
||||
"old_identifier": old_identifier,
|
||||
"new_identifier": new_identifier,
|
||||
},
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
# Return if a does not exist error occurs
|
||||
if isinstance(e, ObjectDoesNotExist):
|
||||
return
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
log_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@shared_task
|
||||
def model_activity(model_name, model_id, requested_data, current_instance, actor_id, slug, origin=None):
|
||||
"""Function takes in two json and computes differences between keys of both the json"""
|
||||
if current_instance is None:
|
||||
webhook_activity.delay(
|
||||
event=model_name,
|
||||
verb="created",
|
||||
field=None,
|
||||
old_value=None,
|
||||
new_value=None,
|
||||
actor_id=actor_id,
|
||||
slug=slug,
|
||||
current_site=origin,
|
||||
event_id=model_id,
|
||||
old_identifier=None,
|
||||
new_identifier=None,
|
||||
)
|
||||
return
|
||||
|
||||
# Load the current instance
|
||||
current_instance = json.loads(current_instance) if current_instance is not None else None
|
||||
|
||||
# Loop through all keys in requested data and check the current value and requested value
|
||||
for key in requested_data:
|
||||
# Check if key is present in current instance or not
|
||||
if key in current_instance:
|
||||
current_value = current_instance.get(key, None)
|
||||
requested_value = requested_data.get(key, None)
|
||||
if current_value != requested_value:
|
||||
webhook_activity.delay(
|
||||
event=model_name,
|
||||
verb="updated",
|
||||
field=key,
|
||||
old_value=current_value,
|
||||
new_value=requested_value,
|
||||
actor_id=actor_id,
|
||||
slug=slug,
|
||||
current_site=origin,
|
||||
event_id=model_id,
|
||||
old_identifier=None,
|
||||
new_identifier=None,
|
||||
)
|
||||
|
||||
return
|
||||
@@ -0,0 +1,270 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
import socket
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import urlparse, urljoin
|
||||
import base64
|
||||
import ipaddress
|
||||
from typing import Dict, Any, Tuple
|
||||
from typing import Optional
|
||||
from plane.db.models import IssueLink
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
|
||||
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
|
||||
|
||||
|
||||
def validate_url_ip(url: str) -> None:
|
||||
"""
|
||||
Validate that a URL doesn't point to a private/internal IP address.
|
||||
Resolves hostnames to IPs before checking.
|
||||
|
||||
Args:
|
||||
url: The URL to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If the URL points to a private/internal IP
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
raise ValueError("Invalid URL: No hostname found")
|
||||
|
||||
# Only allow HTTP and HTTPS to prevent file://, gopher://, etc.
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
|
||||
|
||||
# Resolve hostname to IP addresses — this catches domain names that
|
||||
# point to internal IPs (e.g. attacker.com -> 169.254.169.254)
|
||||
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
raise ValueError("Hostname could not be resolved")
|
||||
|
||||
if not addr_info:
|
||||
raise ValueError("No IP addresses found for the hostname")
|
||||
|
||||
# Check every resolved IP against blocked ranges to prevent SSRF
|
||||
for addr in addr_info:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
raise ValueError("Access to private/internal networks is not allowed")
|
||||
|
||||
|
||||
MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
def safe_get(
|
||||
url: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: int = 1,
|
||||
) -> Tuple[requests.Response, str]:
|
||||
"""
|
||||
Perform a GET request that validates every redirect hop against private IPs.
|
||||
Prevents SSRF by ensuring no redirect lands on a private/internal address.
|
||||
|
||||
Args:
|
||||
url: The URL to fetch
|
||||
headers: Optional request headers
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
A tuple of (final Response object, final URL after redirects)
|
||||
|
||||
Raises:
|
||||
ValueError: If any URL in the redirect chain points to a private IP
|
||||
requests.RequestException: On network errors
|
||||
RuntimeError: If max redirects exceeded
|
||||
"""
|
||||
validate_url_ip(url)
|
||||
|
||||
current_url = url
|
||||
response = requests.get(
|
||||
current_url, headers=headers, timeout=timeout, allow_redirects=False
|
||||
)
|
||||
|
||||
redirect_count = 0
|
||||
while response.is_redirect:
|
||||
if redirect_count >= MAX_REDIRECTS:
|
||||
raise RuntimeError(f"Too many redirects for URL: {url}")
|
||||
redirect_url = response.headers.get("Location")
|
||||
if not redirect_url:
|
||||
break
|
||||
current_url = urljoin(current_url, redirect_url)
|
||||
validate_url_ip(current_url)
|
||||
redirect_count += 1
|
||||
response = requests.get(
|
||||
current_url, headers=headers, timeout=timeout, allow_redirects=False
|
||||
)
|
||||
|
||||
return response, current_url
|
||||
|
||||
|
||||
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Crawls a URL to extract the title and favicon.
|
||||
|
||||
Args:
|
||||
url (str): The URL to crawl
|
||||
|
||||
Returns:
|
||||
str: JSON string containing title and base64-encoded favicon
|
||||
"""
|
||||
try:
|
||||
# Set up headers to mimic a real browser
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
|
||||
}
|
||||
|
||||
soup = None
|
||||
title = None
|
||||
final_url = url
|
||||
|
||||
try:
|
||||
response, final_url = safe_get(url, headers=headers)
|
||||
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
title_tag = soup.find("title")
|
||||
title = title_tag.get_text().strip() if title_tag else None
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
|
||||
except (ValueError, RuntimeError) as e:
|
||||
logger.warning(f"URL validation failed: {str(e)}")
|
||||
|
||||
# Fetch and encode favicon using final URL (after redirects) for correct relative href resolution
|
||||
favicon_base64 = fetch_and_encode_favicon(headers, soup, final_url)
|
||||
|
||||
# Prepare result
|
||||
result = {
|
||||
"title": title,
|
||||
"favicon": favicon_base64["favicon_base64"],
|
||||
"url": url,
|
||||
"favicon_url": favicon_base64["favicon_url"],
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return {
|
||||
"error": f"Unexpected error: {str(e)}",
|
||||
"title": None,
|
||||
"favicon": None,
|
||||
"url": url,
|
||||
}
|
||||
|
||||
|
||||
def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[str]:
|
||||
"""
|
||||
Find the favicon URL from HTML soup.
|
||||
|
||||
Args:
|
||||
soup: BeautifulSoup object
|
||||
base_url: Base URL for resolving relative paths
|
||||
|
||||
Returns:
|
||||
str: Absolute URL to favicon or None
|
||||
"""
|
||||
|
||||
if soup is not None:
|
||||
# Look for various favicon link tags
|
||||
favicon_selectors = [
|
||||
'link[rel="icon"]',
|
||||
'link[rel="shortcut icon"]',
|
||||
'link[rel="apple-touch-icon"]',
|
||||
'link[rel="apple-touch-icon-precomposed"]',
|
||||
]
|
||||
|
||||
for selector in favicon_selectors:
|
||||
favicon_tag = soup.select_one(selector)
|
||||
if favicon_tag and favicon_tag.get("href"):
|
||||
favicon_href = urljoin(base_url, favicon_tag["href"])
|
||||
validate_url_ip(favicon_href)
|
||||
return favicon_href
|
||||
|
||||
# Fallback to /favicon.ico
|
||||
parsed_url = urlparse(base_url)
|
||||
fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico"
|
||||
|
||||
# Check if fallback exists
|
||||
try:
|
||||
validate_url_ip(fallback_url)
|
||||
response = requests.head(fallback_url, timeout=2, allow_redirects=False)
|
||||
|
||||
if response.status_code == 200:
|
||||
return fallback_url
|
||||
except requests.RequestException as e:
|
||||
log_exception(e, warning=True)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def fetch_and_encode_favicon(
|
||||
headers: Dict[str, str], soup: Optional[BeautifulSoup], url: str
|
||||
) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Fetch favicon and encode it as base64.
|
||||
|
||||
Args:
|
||||
favicon_url: URL to the favicon
|
||||
headers: Request headers
|
||||
|
||||
Returns:
|
||||
str: Base64 encoded favicon with data URI prefix or None
|
||||
"""
|
||||
try:
|
||||
favicon_url = find_favicon_url(soup, url)
|
||||
if favicon_url is None:
|
||||
return {
|
||||
"favicon_url": None,
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
|
||||
response, _ = safe_get(favicon_url, headers=headers)
|
||||
|
||||
# Get content type
|
||||
content_type = response.headers.get("content-type", "image/x-icon")
|
||||
|
||||
# Convert to base64
|
||||
favicon_base64 = base64.b64encode(response.content).decode("utf-8")
|
||||
|
||||
# Return as data URI
|
||||
return {
|
||||
"favicon_url": favicon_url,
|
||||
"favicon_base64": f"data:{content_type};base64,{favicon_base64}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch favicon: {e}")
|
||||
return {
|
||||
"favicon_url": None,
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
|
||||
|
||||
@shared_task
|
||||
def crawl_work_item_link_title(id: str, url: str) -> None:
|
||||
meta_data = crawl_work_item_link_title_and_favicon(url)
|
||||
|
||||
try:
|
||||
issue_link = IssueLink.objects.get(id=id)
|
||||
except IssueLink.DoesNotExist:
|
||||
logger.warning(f"IssueLink not found for the id {id} and the url {url}")
|
||||
return
|
||||
|
||||
issue_link.metadata = meta_data
|
||||
issue_link.save()
|
||||
@@ -0,0 +1,89 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User, Workspace, WorkspaceMemberInvite
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@shared_task
|
||||
def workspace_invitation(email, workspace_id, token, current_site, inviter):
|
||||
try:
|
||||
user = User.objects.get(email=inviter)
|
||||
|
||||
workspace = Workspace.objects.get(pk=workspace_id)
|
||||
workspace_member_invite = WorkspaceMemberInvite.objects.get(token=token, email=email)
|
||||
|
||||
# Relative link
|
||||
relative_link = (
|
||||
f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&slug={workspace.slug}&token={token}" # noqa: E501
|
||||
)
|
||||
|
||||
# The complete url including the domain
|
||||
abs_url = str(current_site) + relative_link
|
||||
|
||||
(
|
||||
EMAIL_HOST,
|
||||
EMAIL_HOST_USER,
|
||||
EMAIL_HOST_PASSWORD,
|
||||
EMAIL_PORT,
|
||||
EMAIL_USE_TLS,
|
||||
EMAIL_USE_SSL,
|
||||
EMAIL_FROM,
|
||||
) = get_email_configuration()
|
||||
|
||||
# Subject of the email
|
||||
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" # noqa: E501
|
||||
|
||||
context = {
|
||||
"email": email,
|
||||
"first_name": user.first_name or user.display_name or user.email,
|
||||
"workspace_name": workspace.name,
|
||||
"abs_url": abs_url,
|
||||
}
|
||||
|
||||
html_content = render_to_string("emails/invitations/workspace_invitation.html", context)
|
||||
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
|
||||
workspace_member_invite.message = text_content
|
||||
workspace_member_invite.save()
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
use_ssl=EMAIL_USE_SSL == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_content,
|
||||
from_email=EMAIL_FROM,
|
||||
to=[email],
|
||||
connection=connection,
|
||||
)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
logging.getLogger("plane.worker").info("Email sent successfully")
|
||||
return
|
||||
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist):
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return
|
||||
@@ -0,0 +1,570 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Dict
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectUserProperty,
|
||||
State,
|
||||
Label,
|
||||
Issue,
|
||||
IssueLabel,
|
||||
IssueSequence,
|
||||
IssueActivity,
|
||||
Page,
|
||||
ProjectPage,
|
||||
Cycle,
|
||||
Module,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
IssueView,
|
||||
User,
|
||||
BotTypeEnum,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
|
||||
def read_seed_file(filename):
|
||||
"""
|
||||
Read a JSON file from the seed directory.
|
||||
|
||||
Args:
|
||||
filename (str): Name of the JSON file to read
|
||||
|
||||
Returns:
|
||||
dict: Contents of the JSON file
|
||||
"""
|
||||
file_path = os.path.join(settings.SEED_DIR, "data", filename)
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
return json.load(file)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Seed file {filename} not found in {settings.SEED_DIR}/data")
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Error decoding JSON from {filename}")
|
||||
return None
|
||||
|
||||
|
||||
def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int, uuid.UUID]:
|
||||
"""Creates a project and associated members for a workspace.
|
||||
|
||||
Creates a new project using the workspace name and sets up all necessary
|
||||
member associations and user properties.
|
||||
|
||||
Args:
|
||||
workspace: The workspace to create the project in
|
||||
bot_user: The bot user to use for creating the project
|
||||
Returns:
|
||||
A mapping of seed project IDs to actual project IDs
|
||||
"""
|
||||
project_seeds = read_seed_file("projects.json")
|
||||
project_identifier = "".join(ch for ch in workspace.name if ch.isalnum())[:5]
|
||||
|
||||
# Create members
|
||||
workspace_members = WorkspaceMember.objects.filter(workspace=workspace).values("member_id", "role")
|
||||
|
||||
projects_map: Dict[int, uuid.UUID] = {}
|
||||
|
||||
if not project_seeds:
|
||||
logger.warning("Task: workspace_seed_task -> No project seeds found. Skipping project creation.")
|
||||
return projects_map
|
||||
|
||||
for project_seed in project_seeds:
|
||||
project_id = project_seed.pop("id")
|
||||
# Remove the name from seed data since we want to use workspace name
|
||||
project_seed.pop("name", None)
|
||||
project_seed.pop("identifier", None)
|
||||
|
||||
project = Project(
|
||||
**project_seed,
|
||||
workspace=workspace,
|
||||
name=workspace.name, # Use workspace name
|
||||
identifier=project_identifier,
|
||||
created_by_id=bot_user.id,
|
||||
# Enable all views in seed data
|
||||
cycle_view=True,
|
||||
module_view=True,
|
||||
issue_views_view=True,
|
||||
)
|
||||
project.save(created_by_id=bot_user.id, disable_auto_set_user=True)
|
||||
|
||||
# Create project members
|
||||
ProjectMember.objects.bulk_create(
|
||||
[
|
||||
ProjectMember(
|
||||
project=project,
|
||||
member_id=workspace_member["member_id"],
|
||||
role=workspace_member["role"],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
for workspace_member in workspace_members
|
||||
]
|
||||
)
|
||||
|
||||
# Create issue user properties
|
||||
ProjectUserProperty.objects.bulk_create(
|
||||
[
|
||||
ProjectUserProperty(
|
||||
project=project,
|
||||
user_id=workspace_member["member_id"],
|
||||
workspace_id=workspace.id,
|
||||
display_filters={
|
||||
"layout": "list",
|
||||
"calendar": {"layout": "month", "show_weekends": False},
|
||||
"group_by": "state",
|
||||
"order_by": "sort_order",
|
||||
"sub_issue": True,
|
||||
"sub_group_by": None,
|
||||
"show_empty_groups": True,
|
||||
},
|
||||
display_properties={
|
||||
"key": True,
|
||||
"link": True,
|
||||
"cycle": False,
|
||||
"state": True,
|
||||
"labels": False,
|
||||
"modules": False,
|
||||
"assignee": True,
|
||||
"due_date": False,
|
||||
"estimate": True,
|
||||
"priority": True,
|
||||
"created_on": True,
|
||||
"issue_type": True,
|
||||
"start_date": False,
|
||||
"updated_on": True,
|
||||
"customer_count": True,
|
||||
"sub_issue_count": False,
|
||||
"attachment_count": False,
|
||||
"customer_request_count": True,
|
||||
},
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
for workspace_member in workspace_members
|
||||
]
|
||||
)
|
||||
# update map
|
||||
projects_map[project_id] = project.id
|
||||
logger.info(f"Task: workspace_seed_task -> Project {project_id} created")
|
||||
|
||||
return projects_map
|
||||
|
||||
|
||||
def create_project_states(
|
||||
workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User
|
||||
) -> Dict[int, uuid.UUID]:
|
||||
"""Creates states for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
bot_user: The bot user to use for creating the states
|
||||
Returns:
|
||||
A mapping of seed state IDs to actual state IDs
|
||||
"""
|
||||
|
||||
state_seeds = read_seed_file("states.json")
|
||||
state_map: Dict[int, uuid.UUID] = {}
|
||||
|
||||
if not state_seeds:
|
||||
return state_map
|
||||
|
||||
for state_seed in state_seeds:
|
||||
state_id = state_seed.pop("id")
|
||||
project_id = state_seed.pop("project_id")
|
||||
|
||||
state = State(
|
||||
**state_seed,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
state.save(created_by_id=bot_user.id, disable_auto_set_user=True)
|
||||
state_map[state_id] = state.id
|
||||
logger.info(f"Task: workspace_seed_task -> State {state_id} created")
|
||||
return state_map
|
||||
|
||||
|
||||
def create_project_labels(
|
||||
workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User
|
||||
) -> Dict[int, uuid.UUID]:
|
||||
"""Creates labels for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
bot_user: The bot user to use for creating the labels
|
||||
Returns:
|
||||
A mapping of seed label IDs to actual label IDs
|
||||
"""
|
||||
label_seeds = read_seed_file("labels.json")
|
||||
label_map: Dict[int, uuid.UUID] = {}
|
||||
|
||||
if not label_seeds:
|
||||
return label_map
|
||||
|
||||
for label_seed in label_seeds:
|
||||
label_id = label_seed.pop("id")
|
||||
project_id = label_seed.pop("project_id")
|
||||
label = Label(
|
||||
**label_seed,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
label.save(created_by_id=bot_user.id, disable_auto_set_user=True)
|
||||
label_map[label_id] = label.id
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Label {label_id} created")
|
||||
return label_map
|
||||
|
||||
|
||||
def create_project_issues(
|
||||
workspace: Workspace,
|
||||
project_map: Dict[int, uuid.UUID],
|
||||
states_map: Dict[int, uuid.UUID],
|
||||
labels_map: Dict[int, uuid.UUID],
|
||||
cycles_map: Dict[int, uuid.UUID],
|
||||
module_map: Dict[int, uuid.UUID],
|
||||
bot_user: User,
|
||||
) -> None:
|
||||
"""Creates issues and their associated records for each project.
|
||||
|
||||
Creates issues along with their sequences, activities, and label associations.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
states_map: Mapping of seed state IDs to actual state IDs
|
||||
labels_map: Mapping of seed label IDs to actual label IDs
|
||||
"""
|
||||
issue_seeds = read_seed_file("issues.json")
|
||||
|
||||
if not issue_seeds:
|
||||
return
|
||||
|
||||
for issue_seed in issue_seeds:
|
||||
required_fields = ["id", "labels", "project_id", "state_id"]
|
||||
# get the values
|
||||
for field in required_fields:
|
||||
if field not in issue_seed:
|
||||
logger.error(f"Task: workspace_seed_task -> Required field '{field}' missing in issue seed")
|
||||
continue
|
||||
|
||||
# get the values
|
||||
issue_id = issue_seed.pop("id")
|
||||
labels = issue_seed.pop("labels")
|
||||
project_id = issue_seed.pop("project_id")
|
||||
state_id = issue_seed.pop("state_id")
|
||||
cycle_id = issue_seed.pop("cycle_id")
|
||||
module_ids = issue_seed.pop("module_ids")
|
||||
|
||||
issue = Issue(
|
||||
**issue_seed,
|
||||
state_id=states_map[state_id],
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
issue.save(created_by_id=bot_user.id, disable_auto_set_user=True)
|
||||
IssueSequence.objects.create(
|
||||
issue=issue,
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
IssueActivity.objects.create(
|
||||
issue=issue,
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
comment="created the issue",
|
||||
verb="created",
|
||||
actor_id=bot_user.id,
|
||||
epoch=time.time(),
|
||||
)
|
||||
|
||||
# Create issue labels
|
||||
for label_id in labels:
|
||||
IssueLabel.objects.create(
|
||||
issue=issue,
|
||||
label_id=labels_map[label_id],
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
# Create cycle issues
|
||||
if cycle_id:
|
||||
CycleIssue.objects.create(
|
||||
issue=issue,
|
||||
cycle_id=cycles_map[cycle_id],
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
# Create module issues
|
||||
if module_ids:
|
||||
for module_id in module_ids:
|
||||
ModuleIssue.objects.create(
|
||||
issue=issue,
|
||||
module_id=module_map[module_id],
|
||||
project_id=project_map[project_id],
|
||||
workspace_id=workspace.id,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created")
|
||||
return
|
||||
|
||||
|
||||
def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> None:
|
||||
"""Creates pages for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
bot_user: The bot user to use for creating the pages
|
||||
"""
|
||||
page_seeds = read_seed_file("pages.json")
|
||||
|
||||
if not page_seeds:
|
||||
return
|
||||
|
||||
for page_seed in page_seeds:
|
||||
page_id = page_seed.pop("id")
|
||||
|
||||
page = Page(
|
||||
workspace_id=workspace.id,
|
||||
is_global=False,
|
||||
access=page_seed.get("access", Page.PUBLIC_ACCESS),
|
||||
name=page_seed.get("name"),
|
||||
description_json=page_seed.get("description_json", {}),
|
||||
description_html=page_seed.get("description_html", "<p></p>"),
|
||||
description_binary=page_seed.get("description_binary", None),
|
||||
description_stripped=page_seed.get("description_stripped", None),
|
||||
created_by_id=bot_user.id,
|
||||
updated_by_id=bot_user.id,
|
||||
owned_by_id=bot_user.id,
|
||||
)
|
||||
|
||||
page.save(created_by_id=bot_user.id, disable_auto_set_user=True)
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Page {page_id} created")
|
||||
if page_seed.get("project_id") and page_seed.get("type") == "PROJECT":
|
||||
project_page = ProjectPage(
|
||||
workspace_id=workspace.id,
|
||||
project_id=project_map[page_seed.get("project_id")],
|
||||
page_id=page.id,
|
||||
created_by_id=bot_user.id,
|
||||
updated_by_id=bot_user.id,
|
||||
)
|
||||
project_page.save(created_by_id=bot_user.id, disable_auto_set_user=True)
|
||||
logger.info(f"Task: workspace_seed_task -> Project Page {page_id} created")
|
||||
return
|
||||
|
||||
|
||||
def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> Dict[int, uuid.UUID]:
|
||||
"""Creates cycles for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
bot_user: The bot user to use for creating the cycles
|
||||
Returns:
|
||||
A mapping of seed cycle IDs to actual cycle IDs
|
||||
"""
|
||||
cycle_seeds = read_seed_file("cycles.json")
|
||||
if not cycle_seeds:
|
||||
return {}
|
||||
|
||||
cycle_map: Dict[int, uuid.UUID] = {}
|
||||
|
||||
for cycle_seed in cycle_seeds:
|
||||
cycle_id = cycle_seed.pop("id")
|
||||
project_id = cycle_seed.pop("project_id")
|
||||
type = cycle_seed.pop("type")
|
||||
|
||||
if type == "CURRENT":
|
||||
start_date = timezone.now()
|
||||
end_date = start_date + timedelta(days=14)
|
||||
|
||||
if type == "UPCOMING":
|
||||
# Get the last cycle
|
||||
last_cycle = Cycle.objects.filter(project_id=project_map[project_id]).order_by("-end_date").first()
|
||||
if last_cycle:
|
||||
start_date = last_cycle.end_date + timedelta(days=1)
|
||||
end_date = start_date + timedelta(days=14)
|
||||
else:
|
||||
start_date = timezone.now() + timedelta(days=14)
|
||||
end_date = start_date + timedelta(days=14)
|
||||
|
||||
cycle = Cycle(
|
||||
**cycle_seed,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=bot_user.id,
|
||||
owned_by_id=bot_user.id,
|
||||
)
|
||||
cycle.save(created_by_id=bot_user.id, disable_auto_set_user=True)
|
||||
|
||||
cycle_map[cycle_id] = cycle.id
|
||||
logger.info(f"Task: workspace_seed_task -> Cycle {cycle_id} created")
|
||||
return cycle_map
|
||||
|
||||
|
||||
def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> None:
|
||||
"""Creates modules for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
bot_user: The bot user to use for creating the modules
|
||||
"""
|
||||
module_seeds = read_seed_file("modules.json")
|
||||
if not module_seeds:
|
||||
return {}
|
||||
|
||||
module_map: Dict[int, uuid.UUID] = {}
|
||||
|
||||
for index, module_seed in enumerate(module_seeds):
|
||||
module_id = module_seed.pop("id")
|
||||
project_id = module_seed.pop("project_id")
|
||||
|
||||
start_date = timezone.now() + timedelta(days=index * 2)
|
||||
end_date = start_date + timedelta(days=14)
|
||||
|
||||
module = Module(
|
||||
**module_seed,
|
||||
start_date=start_date,
|
||||
target_date=end_date,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=bot_user.id,
|
||||
)
|
||||
module.save(created_by_id=bot_user.id, disable_auto_set_user=True)
|
||||
module_map[module_id] = module.id
|
||||
logger.info(f"Task: workspace_seed_task -> Module {module_id} created")
|
||||
return module_map
|
||||
|
||||
|
||||
def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> None:
|
||||
"""Creates views for each project in the workspace.
|
||||
|
||||
Args:
|
||||
workspace: The workspace containing the projects
|
||||
project_map: Mapping of seed project IDs to actual project IDs
|
||||
bot_user: The bot user to use for creating the views
|
||||
"""
|
||||
|
||||
view_seeds = read_seed_file("views.json")
|
||||
if not view_seeds:
|
||||
return
|
||||
|
||||
for view_seed in view_seeds:
|
||||
project_id = view_seed.pop("project_id")
|
||||
view_seed.pop("id")
|
||||
issue_view = IssueView(
|
||||
**view_seed,
|
||||
project_id=project_map[project_id],
|
||||
workspace=workspace,
|
||||
created_by_id=bot_user.id,
|
||||
owned_by_id=bot_user.id,
|
||||
)
|
||||
issue_view.save(created_by_id=bot_user.id, disable_auto_set_user=True)
|
||||
|
||||
|
||||
@shared_task
|
||||
def workspace_seed(workspace_id: uuid.UUID) -> None:
|
||||
"""Seeds a new workspace with initial project data.
|
||||
|
||||
Creates a complete workspace setup including:
|
||||
- Projects and project members
|
||||
- Project states
|
||||
- Project labels
|
||||
- Issues and their associations
|
||||
|
||||
Args:
|
||||
workspace_id: ID of the workspace to seed
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Task: workspace_seed_task -> Seeding workspace {workspace_id}")
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(id=workspace_id)
|
||||
|
||||
# Create a bot user for creating all the workspace data
|
||||
bot_user = User.objects.create(
|
||||
username=f"bot_user_{workspace.id}",
|
||||
display_name="Plane",
|
||||
first_name="Plane",
|
||||
last_name="",
|
||||
is_bot=True,
|
||||
bot_type=BotTypeEnum.WORKSPACE_SEED,
|
||||
email=f"bot_user_{workspace.id}@plane.so",
|
||||
password=make_password(uuid.uuid4().hex),
|
||||
is_password_autoset=True,
|
||||
)
|
||||
|
||||
# Add bot user to workspace as member
|
||||
WorkspaceMember.objects.create(
|
||||
workspace=workspace,
|
||||
member=bot_user,
|
||||
role=20,
|
||||
company_role="",
|
||||
)
|
||||
|
||||
# Create a project with the same name as workspace
|
||||
project_map = create_project_and_member(workspace, bot_user)
|
||||
|
||||
# Create project states
|
||||
state_map = create_project_states(workspace, project_map, bot_user)
|
||||
|
||||
# Create project labels
|
||||
label_map = create_project_labels(workspace, project_map, bot_user)
|
||||
|
||||
# Create project cycles
|
||||
cycle_map = create_cycles(workspace, project_map, bot_user)
|
||||
|
||||
# Create project modules
|
||||
module_map = create_modules(workspace, project_map, bot_user)
|
||||
|
||||
# create project issues
|
||||
create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map, bot_user)
|
||||
|
||||
# create project views
|
||||
create_views(workspace, project_map, bot_user)
|
||||
|
||||
# create project pages
|
||||
create_pages(workspace, project_map, bot_user)
|
||||
|
||||
logger.info(f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Task: workspace_seed_task -> Failed to seed workspace {workspace_id}: {str(e)}")
|
||||
raise e
|
||||
Reference in New Issue
Block a user