diff --git a/apps/api/plane/app/views/notification/base.py b/apps/api/plane/app/views/notification/base.py index c02248baeb..d1ed4c2398 100644 --- a/apps/api/plane/app/views/notification/base.py +++ b/apps/api/plane/app/views/notification/base.py @@ -18,6 +18,7 @@ from plane.db.models import ( UserNotificationPreference, WorkspaceMember, ) +from plane.db.models.notification import EntityName from plane.utils.paginator import BasePaginator from plane.app.permissions import allow_permission, ROLE @@ -58,7 +59,14 @@ class NotificationViewSet(BaseViewSet, BasePaginator): notifications = ( Notification.objects.filter(workspace__slug=slug, receiver_id=request.user.id) - .filter(entity_name__in=["issue", "epic"]) + .filter( + entity_name__in=[ + EntityName.ISSUE.value, + EntityName.EPIC.value, + EntityName.INITIATIVE.value, + EntityName.TEAMSPACE.value, + ] + ) .annotate(is_inbox_issue=Exists(intake_issue)) .annotate(is_intake_issue=Exists(intake_issue)) .annotate( diff --git a/apps/api/plane/bgtasks/email_notification_task.py b/apps/api/plane/bgtasks/email_notification_task.py index 78b232e5d5..1b4272af1d 100644 --- a/apps/api/plane/bgtasks/email_notification_task.py +++ b/apps/api/plane/bgtasks/email_notification_task.py @@ -19,6 +19,8 @@ from plane.db.models import EmailNotificationLog, Issue, User, IssueSubscriber, from plane.license.utils.instance_value import get_email_configuration from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception +from plane.db.models.notification import EntityName +from plane.ee.models import Teamspace, Initiative from django.conf import settings @@ -28,19 +30,6 @@ def remove_unwanted_characters(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 @@ -70,30 +59,43 @@ def stack_email_notification(): 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, - ) + entity_name = receiver_notification.get("entity_name") + + if entity_name == EntityName.ISSUE.value or entity_name == EntityName.EPIC.value: + # 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, + ) + else: + for entity_id, notification_data in payload.items(): + send_workspace_level_email_notification.delay( + entity_id=entity_id, + entity_name=entity_name, + 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): +def create_payload(notification_data, entity_name): # 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", None) + entity_activity = change.get(f"{entity_name}_activity", None) epic_update = change.get("epic_update", None) - 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")) + + if entity_activity: + field = entity_activity.get("field") + old_value = str(entity_activity.get("old_value")) + new_value = str(entity_activity.get("new_value")) # Append old_value if it's not empty and not already in the list if old_value: @@ -118,7 +120,7 @@ def create_payload(notification_data): 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( + datetime.fromisoformat(entity_activity.get("activity_time").rstrip("Z")).strftime( "%Y-%m-%d %H:%M:%S" ) ) @@ -208,173 +210,226 @@ def process_html_content(content): @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): - base_api = settings.WEB_URL + base_api = settings.WEB_URL - # Skip if base api is not present - if not base_api: - return + # Skip if base api is not present + if not base_api: + return - data = create_payload(notification_data=notification_data) + 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() + # Get email configurations + try: receiver = User.objects.get(pk=receiver_id) issue = Issue.objects.get(pk=issue_id) + except (Issue.DoesNotExist, User.DoesNotExist): + return - if issue.type and issue.type.is_epic: - entity_type = "epic" - else: - entity_type = "work-item" + if issue.type and issue.type.is_epic: + entity_type = "epic" + else: + entity_type = "work-item" - template_data = [] - total_changes = 0 - comments = [] - actors_involved = [] + template_data = [] + 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") + for actor_id, changes in data.items(): + actor = User.objects.get(pk=actor_id) + 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") - field = changes.pop("field") + field = changes.pop("field") - if field == "epic_update": - summary = "Updates were added to the Epic" - template_name = "emails/notifications/epic_updates.html" - formatted_time = activity_time - else: - summary = "Updates were made to the issue by" - template_name = "emails/notifications/issue-updates.html" - formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") + if field == "epic_update": + summary = "Updates were added to the Epic" + template_name = "emails/notifications/epic_updates.html" + formatted_time = activity_time + else: + summary = "Updates were made to the issue by" + template_name = "emails/notifications/issue-updates.html" + 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), - } - ) + 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), + } + ) - issue_identifier = f"{str(issue.project.identifier)}-{str(issue.sequence_id)}" + summary = "Updates were made to the issue by" + issue_identifier = f"{str(issue.project.identifier)}-{str(issue.sequence_id)}" - # 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": issue_identifier, - "name": issue.name, - "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/browse/{issue_identifier}", - }, - "receiver": {"email": receiver.email}, + # 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": issue_identifier, + "name": issue.name, "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/browse/{issue_identifier}", - "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": entity_type, - } - - html_content = render_to_string(template_name, context) - text_content = strip_tags(html_content) + }, + "receiver": {"email": receiver.email}, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/browse/{issue_identifier}", + "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": entity_type, + } + html_content = render_to_string("emails/notifications/issue-updates.html", context) + text_content = strip_tags(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() + try: + send_email(subject, text_content, receiver, html_content, email_notification_ids) - 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 Exception as e: + log_exception(e) 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 + + +@shared_task +def send_workspace_level_email_notification( + entity_id, entity_name, notification_data, receiver_id, email_notification_ids +): + try: + base_api = settings.WEB_URL + + data = create_payload(notification_data=notification_data, entity_name=entity_name) + + ENTITY_MAPPER = {EntityName.TEAMSPACE.value: Teamspace, EntityName.INITIATIVE.value: Initiative} + + entity = ENTITY_MAPPER.get(entity_name).objects.get(pk=entity_id) + + actors_involved = [] + template_data = [] + + for actor_id, changes in data.items(): + actor = User.objects.get(pk=actor_id) + actors_involved.append(actor_id) + 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, + "entity_name": entity.name, + "activity_time": str(formatted_time), + } + ) + + receiver = User.objects.get(pk=receiver_id) + context = { + "data": template_data, + "summary": f"Updates were to the {entity_name} by", + "actors_involved": len(set(actors_involved)), + entity_name: { + f"{entity}_name": entity.name, + # f"{entity}_url": f"{base_api}/{str(entity.workspace.slug)}/browse/{}", + }, + "receiver": {"email": receiver.email}, + "workspace": str(entity.workspace.slug), + } + + html_content = render_to_string("emails/notifications/issue-updates.html", context) + text_content = strip_tags(html_content) + + try: + # Get email configurations + send_email(entity.name, text_content, receiver, html_content, email_notification_ids) + return + except Exception as e: + log_exception(e) + return + except Exception as e: + log_exception(e) + return + + +def send_email(subject, text_content, receiver, html_content, email_notification_ids): + ( + 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=[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()) diff --git a/apps/api/plane/db/models/notification.py b/apps/api/plane/db/models/notification.py index aa58bc30cf..f5f475c256 100644 --- a/apps/api/plane/db/models/notification.py +++ b/apps/api/plane/db/models/notification.py @@ -1,3 +1,6 @@ +# Python imports +from enum import Enum + # Django imports from django.conf import settings from django.db import models @@ -6,6 +9,13 @@ from django.db import models from .base import BaseModel +class EntityName(Enum): + EPIC = "epic" + ISSUE = "issue" + INITIATIVE = "initiative" + TEAMSPACE = "teamspace" + + class Notification(BaseModel): workspace = models.ForeignKey("db.Workspace", related_name="notifications", on_delete=models.CASCADE) project = models.ForeignKey("db.Project", related_name="notifications", on_delete=models.CASCADE, null=True) diff --git a/apps/api/plane/ee/bgtasks/initiative_activity_task.py b/apps/api/plane/ee/bgtasks/initiative_activity_task.py index aa12559176..64e21b7c2e 100644 --- a/apps/api/plane/ee/bgtasks/initiative_activity_task.py +++ b/apps/api/plane/ee/bgtasks/initiative_activity_task.py @@ -1,5 +1,6 @@ # Python imports import json +from datetime import datetime # Third Party imports @@ -7,9 +8,10 @@ from celery import shared_task # Django imports from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Module imports -from plane.db.models import Project, Workspace +from plane.db.models import Project, Workspace, Issue from plane.ee.models import ( Initiative, InitiativeActivity, @@ -18,10 +20,12 @@ from plane.ee.models import ( InitiativeCommentReaction, InitiativeLabel, ) -from plane.db.models import Issue +from plane.ee.serializers import InitiativeActivitySerializer +from plane.ee.bgtasks.notification_task import workspace_notifications from plane.settings.redis import redis_instance from plane.utils.exception_logger import log_exception +from plane.db.models.notification import EntityName # Track Changes in name @@ -124,12 +128,20 @@ def track_end_date( epoch, ): if current_instance.get("end_date") != requested_data.get("end_date"): + # Only save the date similar to new_value + old_value = isoformat = ( + datetime.fromisoformat(current_instance.get("end_date")) + if current_instance.get("end_date") is not None + else "" + ) + old_value = isoformat.date() + initiative_activities.append( InitiativeActivity( initiative_id=initiative_id, actor_id=actor_id, verb="updated", - old_value=(current_instance.get("end_date") if current_instance.get("end_date") is not None else ""), + old_value=old_value, new_value=(requested_data.get("end_date") if requested_data.get("end_date") is not None else ""), field="end_date", workspace_id=workspace_id, @@ -150,14 +162,20 @@ def track_start_date( epoch, ): if current_instance.get("start_date") != requested_data.get("start_date"): + # Only save the date similar to new_value + old_value = isoformat = ( + datetime.fromisoformat(current_instance.get("start_date")) + if current_instance.get("start_date") is not None + else "" + ) + old_value = isoformat.date() + initiative_activities.append( InitiativeActivity( initiative_id=initiative_id, actor_id=actor_id, verb="updated", - old_value=( - current_instance.get("start_date") if current_instance.get("start_date") is not None else "" - ), + old_value=old_value, new_value=(requested_data.get("start_date") if requested_data.get("start_date") is not None else ""), field="start_date", workspace_id=workspace_id, @@ -863,9 +881,20 @@ def initiative_activity( epoch=epoch, ) - # Save all the values to database - _ = InitiativeActivity.objects.bulk_create(initiative_activities) + # Bulk create the initiative activities + initiative_activities_created = InitiativeActivity.objects.bulk_create(initiative_activities) + if notification: + workspace_notifications( + workspace_id=workspace_id, + entity_name=EntityName.INITIATIVE.value, + entity_identifier=initiative_id, + actor_id=actor_id, + activities_created=json.dumps( + InitiativeActivitySerializer(initiative_activities_created, many=True).data, + cls=DjangoJSONEncoder, + ), + ) return except Exception as e: log_exception(e) diff --git a/apps/api/plane/ee/bgtasks/notification_task.py b/apps/api/plane/ee/bgtasks/notification_task.py new file mode 100644 index 0000000000..bab52cf08f --- /dev/null +++ b/apps/api/plane/ee/bgtasks/notification_task.py @@ -0,0 +1,97 @@ +# Python imports +import json + + +# Module imports +from plane.ee.models import Initiative, Teamspace +from plane.db.models import Notification, EmailNotificationLog, UserNotificationPreference +from plane.db.models.notification import EntityName + + +def workspace_notifications(workspace_id, entity_name, entity_identifier, actor_id, activities_created): + """ + Create notifications for workspace level features, such as initiative, teamspaces, etc. + """ + try: + # Check for user notification preference + + # Parse the activities if it's a string + activities = json.loads(activities_created) if activities_created is not None else [] + + if entity_name == EntityName.TEAMSPACE.value: + entity = Teamspace.objects.prefetch_related("members").get(id=entity_identifier, workspace_id=workspace_id) + receivers = [entity_member.member for entity_member in entity.members.all()] + + elif entity_name == EntityName.INITIATIVE.value: + entity = Initiative.objects.get(id=entity_identifier, workspace_id=workspace_id) + receivers = [entity.lead] if entity.lead else [] + + bulk_notifications = [] + bulk_email_notifications = [] + + for receiver in receivers: + for activity in activities: + # User notification preference check + preference = UserNotificationPreference.objects.get(user_id=receiver) + + # Do not send notification for descriiption changes + if activity.get("field") == "description": + continue + + send_email = False + + if activity.get("field") == "comment" and preference.comment: + send_email = True + elif activity.get("field") == "page": + send_email = True + elif preference.property_change: + send_email = True + else: + send_email = False + + if send_email: + bulk_notifications.append( + Notification( + workspace_id=workspace_id, + entity_identifier=entity_identifier, + entity_name=entity_name, + title=activity.get("comment"), + triggered_by_id=actor_id, + receiver_id=receiver.id, + data=build_data(entity, entity_name, activity), + ) + ) + + bulk_email_notifications.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=receiver.id, + entity_identifier=entity_identifier, + entity_name=entity_name, + data=build_data(entity, entity_name, activity), + ) + ) + + Notification.objects.bulk_create(bulk_notifications, batch_size=100, ignore_conflicts=True) + EmailNotificationLog.objects.bulk_create(bulk_email_notifications, batch_size=100, ignore_conflicts=True) + + except Exception as e: + print(e) + return + + +def build_data(entity, entity_name, activity): + return { + entity_name: {"id": str(entity.id), "name": entity.name, "logo_props": entity.logo_props}, + f"{entity_name}_activity": { + "id": str(activity.get("id", "")), + "verb": str(activity.get("verb", "")), + "field": str(activity.get("field", "")), + "actor": str(activity.get("actor", "")), + "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), + "activity_time": activity.get("created_at"), + }, + } diff --git a/apps/api/plane/ee/bgtasks/team_space_activities_task.py b/apps/api/plane/ee/bgtasks/team_space_activities_task.py index 54f211bbdd..67f9be0373 100644 --- a/apps/api/plane/ee/bgtasks/team_space_activities_task.py +++ b/apps/api/plane/ee/bgtasks/team_space_activities_task.py @@ -3,6 +3,7 @@ import json # Django imports from django.utils import timezone +from django.core.serializers.json import DjangoJSONEncoder # Third party imports from celery import shared_task @@ -16,6 +17,9 @@ from plane.ee.models import ( ) from plane.db.models import Label, Project, User, Workspace from plane.utils.exception_logger import log_exception +from plane.ee.bgtasks.notification_task import workspace_notifications +from plane.ee.serializers import TeamspaceActivitySerializer +from plane.db.models.notification import EntityName # Track Changes in name @@ -611,7 +615,16 @@ def delete_view_activity( @shared_task -def team_space_activity(type, requested_data, team_space_id, actor_id, slug, current_instance, epoch): +def team_space_activity( + type, + requested_data, + team_space_id, + actor_id, + slug, + current_instance, + epoch, + notification=False, +): try: # Get workspace workspace = Workspace.objects.get(slug=slug) @@ -654,7 +667,21 @@ def team_space_activity(type, requested_data, team_space_id, actor_id, slug, cur ) # Save all the values to database - _ = TeamspaceActivity.objects.bulk_create(team_space_activities, batch_size=100, ignore_conflicts=True) + teamspace_activities_created = TeamspaceActivity.objects.bulk_create( + team_space_activities, batch_size=100, ignore_conflicts=True + ) + + if notification: + workspace_notifications( + workspace_id=workspace_id, + entity_name=EntityName.TEAMSPACE.value, + entity_identifier=team_space.id, + actor_id=actor_id, + activities_created=json.dumps( + TeamspaceActivitySerializer(teamspace_activities_created, many=True).data, cls=DjangoJSONEncoder + ), + ) + return except Exception as e: diff --git a/apps/api/plane/ee/views/app/teamspace/base.py b/apps/api/plane/ee/views/app/teamspace/base.py index 8f97f0fd4b..0bcababd6c 100644 --- a/apps/api/plane/ee/views/app/teamspace/base.py +++ b/apps/api/plane/ee/views/app/teamspace/base.py @@ -44,9 +44,7 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): Teamspace.objects.annotate( project_ids=Coalesce( Subquery( - TeamspaceProject.objects.filter( - team_space=OuterRef("pk"), workspace__slug=slug - ) + TeamspaceProject.objects.filter(team_space=OuterRef("pk"), workspace__slug=slug) .values("team_space") .annotate(project_ids=ArrayAgg("project_id", distinct=True)) .values("project_ids") @@ -56,9 +54,7 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): ) .annotate( is_member=Exists( - TeamspaceMember.objects.filter( - team_space=OuterRef("pk"), member_id=self.request.user.id - ) + TeamspaceMember.objects.filter(team_space=OuterRef("pk"), member_id=self.request.user.id) ) ) .get(workspace__slug=slug, pk=team_space_id, is_member=True) @@ -77,9 +73,7 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): .annotate( project_ids=Coalesce( Subquery( - TeamspaceProject.objects.filter( - team_space=OuterRef("pk"), workspace__slug=slug - ) + TeamspaceProject.objects.filter(team_space=OuterRef("pk"), workspace__slug=slug) .values("team_space") .annotate(project_ids=ArrayAgg("project_id", distinct=True)) .values("project_ids") @@ -90,9 +84,7 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): .distinct() ) - def get_add_remove_team_space_projects( - self, slug, team_space_id, request_project_ids - ): + def get_add_remove_team_space_projects(self, slug, team_space_id, request_project_ids): # Update team space projects existing_project_ids = [ str(project_id) @@ -108,9 +100,7 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): return project_ids_to_be_added, project_ids_to_be_removed - @allow_permission( - level="WORKSPACE", allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST] - ) + @allow_permission(level="WORKSPACE", allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @check_feature_flag(FeatureFlag.TEAMSPACES) def get(self, request, slug, team_space_id=None): # Get team space by pk @@ -146,9 +136,7 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): ) # Add the lead to the team space if provided and not the creating user - if request.data.get("lead_id") and str( - request.data.get("lead_id") - ) != str(request.user.id): + if request.data.get("lead_id") and str(request.data.get("lead_id")) != str(request.user.id): TeamspaceMember.objects.create( team_space=team_space, workspace=workspace, @@ -173,9 +161,7 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except Workspace.DoesNotExist: - return Response( - {"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND) except Exception as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -188,14 +174,10 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): # Get workspace workspace = Workspace.objects.get(slug=slug) - current_instance = json.dumps( - TeamspaceSerializer(team_space).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(TeamspaceSerializer(team_space).data, cls=DjangoJSONEncoder) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) - serializer = TeamspaceSerializer( - team_space, data=request.data, partial=True - ) + serializer = TeamspaceSerializer(team_space, data=request.data, partial=True) if serializer.is_valid(): team_space = serializer.save() @@ -204,14 +186,9 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): # Add the lead to the team space if provided and not the creating user if ( lead_id - and TeamspaceMember.objects.filter( - team_space=team_space, member_id=lead_id - ).exists() - is False + and TeamspaceMember.objects.filter(team_space=team_space, member_id=lead_id).exists() is False ): - TeamspaceMember.objects.create( - team_space=team_space, workspace=workspace, member_id=lead_id - ) + TeamspaceMember.objects.create(team_space=team_space, workspace=workspace, member_id=lead_id) # Get the list of project ids for request if it exists if "project_ids" in request.data: @@ -219,10 +196,8 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): project_ids = request.data.pop("project_ids", []) # Update team space projects - project_ids_to_be_added, project_ids_to_be_removed = ( - self.get_add_remove_team_space_projects( - slug, team_space.pk, project_ids - ) + project_ids_to_be_added, project_ids_to_be_removed = self.get_add_remove_team_space_projects( + slug, team_space.pk, project_ids ) # Create team space projects @@ -253,6 +228,7 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): requested_data=requested_data, actor_id=str(request.user.id), team_space_id=str(team_space_id), + notification=True, current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) @@ -263,9 +239,7 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except Teamspace.DoesNotExist: - return Response( - {"error": "Team space not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Team space not found"}, status=status.HTTP_404_NOT_FOUND) @allow_permission(level="WORKSPACE", allowed_roles=[ROLE.ADMIN]) @check_feature_flag(FeatureFlag.TEAMSPACES) @@ -275,9 +249,7 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): """ try: # The current deleting user should be part of the team space - if not TeamspaceMember.objects.filter( - team_space_id=team_space_id, member_id=request.user - ).exists(): + if not TeamspaceMember.objects.filter(team_space_id=team_space_id, member_id=request.user).exists(): return Response( {"error": "You are not part of the team space"}, status=status.HTTP_403_FORBIDDEN, @@ -300,6 +272,4 @@ class TeamspaceEndpoint(TeamspaceBaseEndpoint): team_space.delete() return Response(status=status.HTTP_204_NO_CONTENT) except Teamspace.DoesNotExist: - return Response( - {"error": "Team space not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Team space not found"}, status=status.HTTP_404_NOT_FOUND) diff --git a/apps/api/plane/ee/views/app/teamspace/member.py b/apps/api/plane/ee/views/app/teamspace/member.py index 99491f7518..7d623eaaab 100644 --- a/apps/api/plane/ee/views/app/teamspace/member.py +++ b/apps/api/plane/ee/views/app/teamspace/member.py @@ -31,29 +31,21 @@ class TeamspaceMembersEndpoint(TeamspaceBaseEndpoint): def get(self, request, slug, team_space_id=None, pk=None): # Get team space member by pk if pk: - team_space_member = TeamspaceMember.objects.get( - workspace__slug=slug, team_space_id=team_space_id, pk=pk - ) + team_space_member = TeamspaceMember.objects.get(workspace__slug=slug, team_space_id=team_space_id, pk=pk) serializer = TeamspaceMemberSerializer(team_space_member) return Response(serializer.data, status=status.HTTP_200_OK) # Get all team space members for a team space if team_space_id: - team_space_members = TeamspaceMember.objects.filter( - workspace__slug=slug, team_space_id=team_space_id - ) + team_space_members = TeamspaceMember.objects.filter(workspace__slug=slug, team_space_id=team_space_id) serializer = TeamspaceMemberSerializer(team_space_members, many=True) return Response(serializer.data, status=status.HTTP_200_OK) # Get all team spaces for the use - team_spaces = Teamspace.objects.filter( - workspace__slug=slug, members__member_id=self.request.user.id - ) + team_spaces = Teamspace.objects.filter(workspace__slug=slug, members__member_id=self.request.user.id) # Get all team space members for users team spaces - workspace_team_space_members = TeamspaceMember.objects.filter( - workspace__slug=slug, team_space__in=team_spaces - ) + workspace_team_space_members = TeamspaceMember.objects.filter(workspace__slug=slug, team_space__in=team_spaces) serializer = TeamspaceMemberSerializer(workspace_team_space_members, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -73,15 +65,11 @@ class TeamspaceMembersEndpoint(TeamspaceBaseEndpoint): ).values_list("member_id", flat=True) ) - current_instance = json.dumps( - {"member_ids": current_members}, cls=DjangoJSONEncoder - ) + current_instance = json.dumps({"member_ids": current_members}, cls=DjangoJSONEncoder) # Get the list of team space members team_space_members = ( - TeamspaceMember.objects.filter( - workspace__slug=slug, member_id__in=member_ids - ) + TeamspaceMember.objects.filter(workspace__slug=slug, member_id__in=member_ids) .values("member_id", "sort_order") .order_by("sort_order") ) @@ -122,13 +110,9 @@ class TeamspaceMembersEndpoint(TeamspaceBaseEndpoint): ) # Create team space members - TeamspaceMember.objects.bulk_create( - bulk_team_space_members, ignore_conflicts=True, batch_size=100 - ) + TeamspaceMember.objects.bulk_create(bulk_team_space_members, ignore_conflicts=True, batch_size=100) - team_space_members = TeamspaceMember.objects.filter( - workspace__slug=slug, team_space_id=team_space_id - ) + team_space_members = TeamspaceMember.objects.filter(workspace__slug=slug, team_space_id=team_space_id) # Create activity team_space_activity.delay( @@ -137,6 +121,7 @@ class TeamspaceMembersEndpoint(TeamspaceBaseEndpoint): requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), team_space_id=str(team_space.id), + notification=True, current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) @@ -147,9 +132,7 @@ class TeamspaceMembersEndpoint(TeamspaceBaseEndpoint): @check_feature_flag(FeatureFlag.TEAMSPACES) def delete(self, request, slug, team_space_id, pk): # Get team space member - team_space_member = TeamspaceMember.objects.get( - workspace__slug=slug, team_space_id=team_space_id, pk=pk - ) + team_space_member = TeamspaceMember.objects.get(workspace__slug=slug, team_space_id=team_space_id, pk=pk) # Get team space team_space = Teamspace.objects.get(workspace__slug=slug, pk=team_space_id) @@ -162,16 +145,9 @@ class TeamspaceMembersEndpoint(TeamspaceBaseEndpoint): ) # Check if the team space has only one member - if ( - TeamspaceMember.objects.filter( - workspace__slug=slug, team_space_id=team_space_id - ).count() - == 1 - ): + if TeamspaceMember.objects.filter(workspace__slug=slug, team_space_id=team_space_id).count() == 1: return Response( - { - "error": "Cannot delete the last member from team. Delete the team instead." - }, + {"error": "Cannot delete the last member from team. Delete the team instead."}, status=status.HTTP_400_BAD_REQUEST, ) @@ -180,20 +156,16 @@ class TeamspaceMembersEndpoint(TeamspaceBaseEndpoint): workspace__slug=slug, team_space_id=team_space_id ).values_list("member_id", flat=True) - current_instance = json.dumps( - {"member_ids": list(current_team_space_members)}, cls=DjangoJSONEncoder - ) + current_instance = json.dumps({"member_ids": list(current_team_space_members)}, cls=DjangoJSONEncoder) # Get the list of team space members requested_team_space_members = list( - TeamspaceMember.objects.filter( - ~Q(pk=pk), workspace__slug=slug, team_space_id=team_space_id - ).values_list("member_id", flat=True) + TeamspaceMember.objects.filter(~Q(pk=pk), workspace__slug=slug, team_space_id=team_space_id).values_list( + "member_id", flat=True + ) ) - requested_data = json.dumps( - {"member_ids": requested_team_space_members}, cls=DjangoJSONEncoder - ) + requested_data = json.dumps({"member_ids": requested_team_space_members}, cls=DjangoJSONEncoder) # Create activity team_space_activity.delay( @@ -202,6 +174,7 @@ class TeamspaceMembersEndpoint(TeamspaceBaseEndpoint): requested_data=requested_data, actor_id=str(request.user.id), team_space_id=str(team_space.id), + notification=True, current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) diff --git a/apps/api/templates/emails/notifications/initiative-updates.html b/apps/api/templates/emails/notifications/initiative-updates.html new file mode 100644 index 0000000000..fbfec79108 --- /dev/null +++ b/apps/api/templates/emails/notifications/initiative-updates.html @@ -0,0 +1,420 @@ + + + + + + Updates on initiative + + + + + + +
+ +
+ + + + +
+
+ Plane Logo +
+
+
+ +
+
+ + + + +
+

+ {% if initiative.initiative_name %}{{ initiative.initiative_name }}{% elif initiative.name %}{{ initiative.name }}{% else %}Initiative{% endif %} updates +

+

+ {{workspace}}/{% if initiative.initiative_name %}{{ initiative.initiative_name }}{% elif initiative.name %}{{ initiative.name }}{% else %}Initiative{% endif %} +

+
+
+ {% if actors_involved == 1 %} +

+ Changes were made to the initiative by {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %}. +

+ {% else %} +

+ Changes were made to the initiative by {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %}and others. +

+ {% endif %} + + {% for update in data %} + {% if update.changes.name %} + +

+ The initiative title has been updated to {% if initiative.initiative_name %}{{ initiative.initiative_name }}{% elif initiative.name %}{{ initiative.name }}{% endif %} +

+ {% endif %} + {% endfor %} + + + {% if data %} + {% for update in data %} +
+ +
+

Updates

+
+ +
+ + + + + + + +
+ {% if update.actor_detail.avatar_url %} + + {% else %} + + + + +
+ {{ update.actor_detail.first_name.0 }} +
+ {% endif %} +
+

{{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }}

+
+

{{ update.activity_time }}

+
+ + {% if update.changes.status %} + + + + + + + + + +
+ + +

Status:

+
+

{{ update.changes.status.old_value.0|default:"—" }}

+
+ + +

{{update.changes.status.new_value|last|default:"—" }}

+
+ {% endif %} + + {% if update.changes.start_date %} + + + + + + + + + +
+ + +

Start Date:

+
+

{{ update.changes.start_date.old_value.0|default:"—" }}

+
+ + +

{{update.changes.start_date.new_value|last|default:"—" }}

+
+ {% endif %} + + {% if update.changes.end_date %} + + + + + + + + + +
+ + +

End Date:

+
+

{{ update.changes.end_date.old_value.0|default:"—" }}

+
+ + +

{{update.changes.end_date.new_value|last|default:"—" }}

+
+ {% endif %} + + {% if update.changes.lead %} + + + + + + + + + +
+ + +

Lead:

+
+

{{ update.changes.lead.old_value.0|default:"—" }}

+
+ + +

{{update.changes.lead.new_value|last|default:"—" }}

+
+ {% endif %} + + {% if update.changes.labels %} + + + + + + +
+ + Labels: + + {% if update.changes.labels.new_value.0 %} + {{update.changes.labels.new_value.0}} + {% endif %} + {% if update.changes.labels.new_value.1 %} + +{{ update.changes.labels.new_value|length|add:"-1"}} more + {% endif %} + {% if update.changes.labels.old_value.0 %} + {{update.changes.labels.old_value.0}} + {% endif %} + {% if update.changes.labels.old_value.1 %} + +{{ update.changes.labels.old_value|length|add:"-1"}} more + {% endif %} +
+ {% endif %} + + {% if update.changes.projects %} + + + + + {% if update.changes.projects.new_value.0 %} + + {% endif %} + {% if update.changes.projects.new_value.2 %} + + {% endif %} + {% if update.changes.projects.old_value.0 %} + + {% endif %} + {% if update.changes.projects.old_value.2 %} + + {% endif %} + +
+ + Projects: + + {% for project in update.changes.projects.new_value|slice:":2" %} + {{ project }} + {% endfor %} + + +{{ update.changes.projects.new_value|length|add:"-2" }} more + + {% for project in update.changes.projects.old_value|slice:":2" %} + {{ project }} + {% endfor %} + + +{{ update.changes.projects.old_value|length|add:"-2" }} more +
+ {% endif %} + + {% if update.changes.epics %} + + + + + {% if update.changes.epics.new_value.0 %} + + {% endif %} + {% if update.changes.epics.new_value.2 %} + + {% endif %} + {% if update.changes.epics.old_value.0 %} + + {% endif %} + {% if update.changes.epics.old_value.2 %} + + {% endif %} + +
+ + Epics: + + {% for epic in update.changes.epics.new_value|slice:":2" %} + {{ epic }} + {% endfor %} + + +{{ update.changes.epics.new_value|length|add:"-2" }} more + + {% for epic in update.changes.epics.old_value|slice:":2" %} + {{ epic }} + {% endfor %} + + +{{ update.changes.epics.old_value|length|add:"-2" }} more +
+ {% endif %} + + {% if update.changes.link %} + + + + + + + +
+ + +

Links:

+
+ {% for link in update.changes.link.new_value %} + {{ link }} + {% endfor %} + {% if update.changes.link.old_value|length > 0 %} + {% if update.changes.link.old_value.0 != "None" %} +

2 Links were removed

+ {% endif %} + {% endif %} +
+ {% endif %} +
+
+ {% endfor %} + {% endif %} + + + {% if comments.0 %} + +
+ +

Comment

+ + {% for comment in comments %} + + + + + + {% for actor_comment in comment.actor_comments.new_value %} + + + + + {% endfor %} +
+ {% if comment.actor_detail.avatar_url %} + + {% else %} + + + + +
+ {{ comment.actor_detail.first_name.0 }} +
+ {% endif %} +
+

{{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }}

+
+
+

{{ actor_comment|safe }}

+
+
+ {% endfor %} +
+ {% endif %} +
+ +
+ View initiative +
+
+
+ + + + + +
+
+ This email was sent to + {{ receiver.email }}. + If you'd rather not receive this kind of email, + you can unsubscribe to the initiative + or + manage your email preferences. + + +
+
+
+ + diff --git a/apps/api/templates/emails/notifications/teamspace-updates.html b/apps/api/templates/emails/notifications/teamspace-updates.html new file mode 100644 index 0000000000..9223d45b77 --- /dev/null +++ b/apps/api/templates/emails/notifications/teamspace-updates.html @@ -0,0 +1,327 @@ + + + + + + Updates on teamspace + + + + + + +
+ +
+ + + + +
+
+ Plane Logo +
+
+
+ +
+
+ + + + +
+

+ {% if teamspace.teamspace_name %}{{ teamspace.teamspace_name }}{% elif teamspace.name %}{{ teamspace.name }}{% else %}Teamspace{% endif %} updates +

+

+ {{workspace}}/{% if teamspace.teamspace_name %}{{ teamspace.teamspace_name }}{% elif teamspace.name %}{{ teamspace.name }}{% else %}Teamspace{% endif %} +

+
+
+ {% if actors_involved == 1 %} +

+ Changes were made to the teamspace by {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %}. +

+ {% else %} +

+ Changes were made to the teamspace by {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %}and others. +

+ {% endif %} + + {% for update in data %} + {% if update.changes.name %} + +

+ The teamspace title has been updated to {% if teamspace.teamspace_name %}{{ teamspace.teamspace_name }}{% elif teamspace.name %}{{ teamspace.name }}{% endif %} +

+ {% endif %} + {% endfor %} + + + {% if data %} + {% for update in data %} +
+ +
+

Updates

+
+ +
+ + + + + + + +
+ {% if update.actor_detail.avatar_url %} + + {% else %} + + + + +
+ {{ update.actor_detail.first_name.0 }} +
+ {% endif %} +
+

{{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }}

+
+

{{ update.activity_time }}

+
+ + {% if update.changes.lead %} + + + + + + + + + +
+ + +

Lead:

+
+

{{ update.changes.lead.old_value.0|default:"—" }}

+
+ + +

{{update.changes.lead.new_value|last|default:"—" }}

+
+ {% endif %} + + {% if update.changes.labels %} + + + + + + +
+ + Labels: + + {% if update.changes.labels.new_value.0 %} + {{update.changes.labels.new_value.0}} + {% endif %} + {% if update.changes.labels.new_value.1 %} + +{{ update.changes.labels.new_value|length|add:"-1"}} more + {% endif %} + {% if update.changes.labels.old_value.0 %} + {{update.changes.labels.old_value.0}} + {% endif %} + {% if update.changes.labels.old_value.1 %} + +{{ update.changes.labels.old_value|length|add:"-1"}} more + {% endif %} +
+ {% endif %} + + {% if update.changes.projects %} + + + + + {% if update.changes.projects.new_value.0 %} + + {% endif %} + {% if update.changes.projects.new_value.2 %} + + {% endif %} + {% if update.changes.projects.old_value.0 %} + + {% endif %} + {% if update.changes.projects.old_value.2 %} + + {% endif %} + +
+ + Projects: + + {% for project in update.changes.projects.new_value|slice:":2" %} + {{ project }} + {% endfor %} + + +{{ update.changes.projects.new_value|length|add:"-2" }} more + + {% for project in update.changes.projects.old_value|slice:":2" %} + {{ project }} + {% endfor %} + + +{{ update.changes.projects.old_value|length|add:"-2" }} more +
+ {% endif %} + + {% if update.changes.members %} + + + + + {% if update.changes.members.new_value.0 %} + + {% endif %} + {% if update.changes.members.new_value.2 %} + + {% endif %} + {% if update.changes.members.old_value.0 %} + + {% endif %} + {% if update.changes.members.old_value.2 %} + + {% endif %} + +
+ + Members: + + {% for member in update.changes.members.new_value|slice:":2" %} + {{ member }} + {% endfor %} + + +{{ update.changes.members.new_value|length|add:"-2" }} more + + {% for member in update.changes.members.old_value|slice:":2" %} + {{ member }} + {% endfor %} + + +{{ update.changes.members.old_value|length|add:"-2" }} more +
+ {% endif %} +
+
+ {% endfor %} + {% endif %} + + + {% if comments.0 %} + +
+ +

Comment

+ + {% for comment in comments %} + + + + + + {% for actor_comment in comment.actor_comments.new_value %} + + + + + {% endfor %} +
+ {% if comment.actor_detail.avatar_url %} + + {% else %} + + + + +
+ {{ comment.actor_detail.first_name.0 }} +
+ {% endif %} +
+

{{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }}

+
+
+

{{ actor_comment|safe }}

+
+
+ {% endfor %} +
+ {% endif %} +
+ +
+ View teamspace +
+
+
+ + + + + +
+
+ This email was sent to + {{ receiver.email }}. + If you'd rather not receive this kind of email, + you can unsubscribe to the teamspace + or + manage your email preferences. + + +
+
+
+ + diff --git a/apps/api/templates/emails/partials/actor_display.html b/apps/api/templates/emails/partials/actor_display.html new file mode 100644 index 0000000000..bb1bcba653 --- /dev/null +++ b/apps/api/templates/emails/partials/actor_display.html @@ -0,0 +1,38 @@ + + + + + +
+ + + + + +
+ {% if actor_detail.avatar_url %} + + {% else %} + + + + +
+ {{ actor_detail.first_name.0 }} +
+ {% endif %} +
+ + + + + + + +
+ {{ actor_detail.first_name }} {{ actor_detail.last_name }} +
+ {{ activity_time }} +
+
+
diff --git a/apps/api/templates/emails/partials/comment_section.html b/apps/api/templates/emails/partials/comment_section.html new file mode 100644 index 0000000000..6ef2f08718 --- /dev/null +++ b/apps/api/templates/emails/partials/comment_section.html @@ -0,0 +1,58 @@ +{% if comments.0 %} + + + + +
+ + + + +
+ + + + +
+ Comment +
+ + {% for comment in comments %} + + + + + + {% for actor_comment in comment.actor_comments.new_value %} + + + + + {% endfor %} +
+ {% if comment.actor_detail.avatar_url %} + + {% else %} + + + + +
+ {{ comment.actor_detail.first_name.0 }} +
+ {% endif %} +
+

{{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }}

+
+ + + + +
+

{{ actor_comment|safe }}

+
+
+ {% endfor %} +
+
+{% endif %} diff --git a/apps/api/templates/emails/partials/email_base.html b/apps/api/templates/emails/partials/email_base.html new file mode 100644 index 0000000000..d816f78027 --- /dev/null +++ b/apps/api/templates/emails/partials/email_base.html @@ -0,0 +1,20 @@ + + + + + + {{ title|default:"Email Update" }} + + + + + +
+ {% include "emails/partials/email_header.html" %} + + {% block content %}{% endblock %} + + {% include "emails/partials/email_footer.html" %} +
+ + diff --git a/apps/api/templates/emails/partials/email_footer.html b/apps/api/templates/emails/partials/email_footer.html new file mode 100644 index 0000000000..cab649b06b --- /dev/null +++ b/apps/api/templates/emails/partials/email_footer.html @@ -0,0 +1,28 @@ + + + + + +
+
+ This email was sent to + {{ receiver.email }}. + If you'd rather not receive this kind of email, + you can unsubscribe to the {{entity_type}} + or + manage your email preferences. + + + +
+
diff --git a/apps/api/templates/emails/partials/email_header.html b/apps/api/templates/emails/partials/email_header.html new file mode 100644 index 0000000000..25a4b219b1 --- /dev/null +++ b/apps/api/templates/emails/partials/email_header.html @@ -0,0 +1,12 @@ + +
+ + + + +
+
+ Plane Logo +
+
+
diff --git a/apps/api/templates/emails/partials/field_changes/date_change.html b/apps/api/templates/emails/partials/field_changes/date_change.html new file mode 100644 index 0000000000..f610a6e1b6 --- /dev/null +++ b/apps/api/templates/emails/partials/field_changes/date_change.html @@ -0,0 +1,21 @@ +{% if change %} + + + + + + + + +
+ + +

{{ label|default:"Date" }}:

+
+

{{ change.old_value.0|default:"—" }}

+
+ + +

{{change.new_value|last|default:"—" }}

+
+{% endif %} diff --git a/apps/api/templates/emails/partials/field_changes/epics_change.html b/apps/api/templates/emails/partials/field_changes/epics_change.html new file mode 100644 index 0000000000..af043b447b --- /dev/null +++ b/apps/api/templates/emails/partials/field_changes/epics_change.html @@ -0,0 +1,34 @@ +{% if change %} + + + + {% if change.new_value.0 %} + + {% endif %} + {% if change.new_value.2 %} + + {% endif %} + {% if change.old_value.0 %} + + {% endif %} + {% if change.old_value.2 %} + + {% endif %} + +
+ + Epics: + + {% for epic in change.new_value|slice:":2" %} + {{ epic }} + {% endfor %} + + +{{ change.new_value|length|add:"-2" }} more + + {% for epic in change.old_value|slice:":2" %} + {{ epic }} + {% endfor %} + + +{{ change.old_value|length|add:"-2" }} more +
+{% endif %} diff --git a/apps/api/templates/emails/partials/field_changes/labels_change.html b/apps/api/templates/emails/partials/field_changes/labels_change.html new file mode 100644 index 0000000000..79d1af178e --- /dev/null +++ b/apps/api/templates/emails/partials/field_changes/labels_change.html @@ -0,0 +1,24 @@ +{% if change %} + + + + + +
+ + Labels: + + {% if change.new_value.0 %} + {{change.new_value.0}} + {% endif %} + {% if change.new_value.1 %} + +{{ change.new_value|length|add:"-1"}} more + {% endif %} + {% if change.old_value.0 %} + {{change.old_value.0}} + {% endif %} + {% if change.old_value.1 %} + +{{ change.old_value|length|add:"-1"}} more + {% endif %} +
+{% endif %} diff --git a/apps/api/templates/emails/partials/field_changes/lead_change.html b/apps/api/templates/emails/partials/field_changes/lead_change.html new file mode 100644 index 0000000000..d9d29b0ec7 --- /dev/null +++ b/apps/api/templates/emails/partials/field_changes/lead_change.html @@ -0,0 +1,21 @@ +{% if change %} + + + + + + + + +
+ + +

Lead:

+
+

{{ change.old_value.0|default:"—" }}

+
+ + +

{{change.new_value|last|default:"—" }}

+
+{% endif %} diff --git a/apps/api/templates/emails/partials/field_changes/links_change.html b/apps/api/templates/emails/partials/field_changes/links_change.html new file mode 100644 index 0000000000..5081ad05ec --- /dev/null +++ b/apps/api/templates/emails/partials/field_changes/links_change.html @@ -0,0 +1,22 @@ +{% if change %} + + + + + + +
+ + +

Links:

+
+ {% for link in change.new_value %} + {{ link }} + {% endfor %} + {% if change.old_value|length > 0 %} + {% if change.old_value.0 != "None" %} +

2 Links were removed

+ {% endif %} + {% endif %} +
+{% endif %} diff --git a/apps/api/templates/emails/partials/field_changes/members_change.html b/apps/api/templates/emails/partials/field_changes/members_change.html new file mode 100644 index 0000000000..8440674d45 --- /dev/null +++ b/apps/api/templates/emails/partials/field_changes/members_change.html @@ -0,0 +1,34 @@ +{% if change %} + + + + {% if change.new_value.0 %} + + {% endif %} + {% if change.new_value.2 %} + + {% endif %} + {% if change.old_value.0 %} + + {% endif %} + {% if change.old_value.2 %} + + {% endif %} + +
+ + Members: + + {% for member in change.new_value|slice:":2" %} + {{ member }} + {% endfor %} + + +{{ change.new_value|length|add:"-2" }} more + + {% for member in change.old_value|slice:":2" %} + {{ member }} + {% endfor %} + + +{{ change.old_value|length|add:"-2" }} more +
+{% endif %} diff --git a/apps/api/templates/emails/partials/field_changes/projects_change.html b/apps/api/templates/emails/partials/field_changes/projects_change.html new file mode 100644 index 0000000000..1618baea03 --- /dev/null +++ b/apps/api/templates/emails/partials/field_changes/projects_change.html @@ -0,0 +1,34 @@ +{% if change %} + + + + {% if change.new_value.0 %} + + {% endif %} + {% if change.new_value.2 %} + + {% endif %} + {% if change.old_value.0 %} + + {% endif %} + {% if change.old_value.2 %} + + {% endif %} + +
+ + Projects: + + {% for project in change.new_value|slice:":2" %} + {{ project }} + {% endfor %} + + +{{ change.new_value|length|add:"-2" }} more + + {% for project in change.old_value|slice:":2" %} + {{ project }} + {% endfor %} + + +{{ change.old_value|length|add:"-2" }} more +
+{% endif %} diff --git a/apps/api/templates/emails/partials/field_changes/status_change.html b/apps/api/templates/emails/partials/field_changes/status_change.html new file mode 100644 index 0000000000..786f68c1d8 --- /dev/null +++ b/apps/api/templates/emails/partials/field_changes/status_change.html @@ -0,0 +1,21 @@ +{% if change %} + + + + + + + + +
+ + +

Status:

+
+

{{ change.old_value.0|default:"—" }}

+
+ + +

{{change.new_value|last|default:"—" }}

+
+{% endif %} diff --git a/apps/api/templates/emails/partials/update_box.html b/apps/api/templates/emails/partials/update_box.html new file mode 100644 index 0000000000..d436d4a96e --- /dev/null +++ b/apps/api/templates/emails/partials/update_box.html @@ -0,0 +1,18 @@ + + + + + +
+ + + + +
+ + + + +
+ {{ heading|default:"Updates" }} +
+ + + + + +
diff --git a/apps/api/templates/emails/partials/update_box_end.html b/apps/api/templates/emails/partials/update_box_end.html new file mode 100644 index 0000000000..c0840ed2b5 --- /dev/null +++ b/apps/api/templates/emails/partials/update_box_end.html @@ -0,0 +1,11 @@ +
+
+
+ +