base
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from .analytic import AnalyticView
|
||||
from .api import APIActivityLog, APIToken
|
||||
from .asset import FileAsset
|
||||
from .base import BaseModel
|
||||
from .cycle import Cycle, CycleIssue, CycleUserProperties
|
||||
from .deploy_board import DeployBoard
|
||||
from .draft import (
|
||||
DraftIssue,
|
||||
DraftIssueAssignee,
|
||||
DraftIssueLabel,
|
||||
DraftIssueModule,
|
||||
DraftIssueCycle,
|
||||
)
|
||||
from .estimate import Estimate, EstimatePoint
|
||||
from .exporter import ExporterHistory
|
||||
from .importer import Importer
|
||||
from .intake import Intake, IntakeIssue
|
||||
from .integration import (
|
||||
GithubCommentSync,
|
||||
GithubIssueSync,
|
||||
GithubRepository,
|
||||
GithubRepositorySync,
|
||||
Integration,
|
||||
SlackProjectSync,
|
||||
WorkspaceIntegration,
|
||||
)
|
||||
from .issue import (
|
||||
CommentReaction,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
IssueAssignee,
|
||||
IssueBlocker,
|
||||
IssueComment,
|
||||
IssueLabel,
|
||||
IssueLink,
|
||||
IssueMention,
|
||||
IssueReaction,
|
||||
IssueRelation,
|
||||
IssueSequence,
|
||||
IssueSubscriber,
|
||||
IssueVote,
|
||||
IssueVersion,
|
||||
IssueDescriptionVersion,
|
||||
)
|
||||
from .module import Module, ModuleIssue, ModuleLink, ModuleMember, ModuleUserProperties
|
||||
from .notification import EmailNotificationLog, Notification, UserNotificationPreference
|
||||
from .page import Page, PageLabel, PageLog, ProjectPage, PageVersion
|
||||
from .project import (
|
||||
Project,
|
||||
ProjectBaseModel,
|
||||
ProjectIdentifier,
|
||||
ProjectMember,
|
||||
ProjectMemberInvite,
|
||||
ProjectNetwork,
|
||||
ProjectPublicMember,
|
||||
ProjectUserProperty,
|
||||
)
|
||||
from .session import Session
|
||||
from .social_connection import SocialLoginConnection
|
||||
from .state import State, StateGroup, DEFAULT_STATES
|
||||
from .user import Account, Profile, User, BotTypeEnum
|
||||
from .view import IssueView
|
||||
from .webhook import Webhook, WebhookLog
|
||||
from .workspace import (
|
||||
Workspace,
|
||||
WorkspaceBaseModel,
|
||||
WorkspaceMember,
|
||||
WorkspaceMemberInvite,
|
||||
WorkspaceTheme,
|
||||
WorkspaceUserProperties,
|
||||
WorkspaceUserLink,
|
||||
WorkspaceHomePreference,
|
||||
WorkspaceUserPreference,
|
||||
)
|
||||
|
||||
from .favorite import UserFavorite
|
||||
|
||||
from .issue_type import IssueType
|
||||
|
||||
from .recent_visit import UserRecentVisit
|
||||
|
||||
from .label import Label
|
||||
|
||||
from .device import Device, DeviceSession
|
||||
|
||||
from .sticky import Sticky
|
||||
|
||||
from .description import Description, DescriptionVersion
|
||||
@@ -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.
|
||||
|
||||
# Django models
|
||||
from django.db import models
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class AnalyticView(BaseModel):
|
||||
workspace = models.ForeignKey("db.Workspace", related_name="analytics", on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
query = models.JSONField()
|
||||
query_dict = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Analytic"
|
||||
verbose_name_plural = "Analytics"
|
||||
db_table = "analytic_views"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the analytic view"""
|
||||
return f"{self.name} <{self.workspace.name}>"
|
||||
@@ -0,0 +1,76 @@
|
||||
# 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 uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def generate_label_token():
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
def generate_token():
|
||||
return "plane_api_" + uuid4().hex
|
||||
|
||||
|
||||
class APIToken(BaseModel):
|
||||
# Meta information
|
||||
label = models.CharField(max_length=255, default=generate_label_token)
|
||||
description = models.TextField(blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
last_used = models.DateTimeField(null=True)
|
||||
|
||||
# Token
|
||||
token = models.CharField(max_length=255, unique=True, default=generate_token, db_index=True)
|
||||
|
||||
# User Information
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bot_tokens")
|
||||
user_type = models.PositiveSmallIntegerField(choices=((0, "Human"), (1, "Bot")), default=0)
|
||||
workspace = models.ForeignKey("db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True)
|
||||
expired_at = models.DateTimeField(blank=True, null=True)
|
||||
is_service = models.BooleanField(default=False)
|
||||
allowed_rate_limit = models.CharField(max_length=255, default="60/min")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "API Token"
|
||||
verbose_name_plural = "API Tokems"
|
||||
db_table = "api_tokens"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.user.id)
|
||||
|
||||
|
||||
class APIActivityLog(BaseModel):
|
||||
token_identifier = models.CharField(max_length=255)
|
||||
|
||||
# Request Info
|
||||
path = models.CharField(max_length=255)
|
||||
method = models.CharField(max_length=10)
|
||||
query_params = models.TextField(null=True, blank=True)
|
||||
headers = models.TextField(null=True, blank=True)
|
||||
body = models.TextField(null=True, blank=True)
|
||||
|
||||
# Response info
|
||||
response_code = models.PositiveIntegerField()
|
||||
response_body = models.TextField(null=True, blank=True)
|
||||
|
||||
# Meta information
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.CharField(max_length=512, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "API Activity Log"
|
||||
verbose_name_plural = "API Activity Logs"
|
||||
db_table = "api_activity_logs"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.token_identifier)
|
||||
@@ -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
|
||||
from uuid import uuid4
|
||||
|
||||
# Django import
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
# Module import
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
if instance.workspace_id is not None:
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
return f"user-{uuid4().hex}-{filename}"
|
||||
|
||||
|
||||
def file_size(value):
|
||||
if value.size > settings.FILE_SIZE_LIMIT:
|
||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||
|
||||
|
||||
class FileAsset(BaseModel):
|
||||
"""
|
||||
A file asset.
|
||||
"""
|
||||
|
||||
class EntityTypeContext(models.TextChoices):
|
||||
ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT"
|
||||
ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION"
|
||||
COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION"
|
||||
PAGE_DESCRIPTION = "PAGE_DESCRIPTION"
|
||||
USER_COVER = "USER_COVER"
|
||||
USER_AVATAR = "USER_AVATAR"
|
||||
WORKSPACE_LOGO = "WORKSPACE_LOGO"
|
||||
PROJECT_COVER = "PROJECT_COVER"
|
||||
DRAFT_ISSUE_ATTACHMENT = "DRAFT_ISSUE_ATTACHMENT"
|
||||
DRAFT_ISSUE_DESCRIPTION = "DRAFT_ISSUE_DESCRIPTION"
|
||||
|
||||
attributes = models.JSONField(default=dict)
|
||||
asset = models.FileField(upload_to=get_upload_path, max_length=800)
|
||||
user = models.ForeignKey("db.User", on_delete=models.CASCADE, null=True, related_name="assets")
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets")
|
||||
draft_issue = models.ForeignKey("db.DraftIssue", on_delete=models.CASCADE, null=True, related_name="assets")
|
||||
project = models.ForeignKey("db.Project", on_delete=models.CASCADE, null=True, related_name="assets")
|
||||
issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, null=True, related_name="assets")
|
||||
comment = models.ForeignKey("db.IssueComment", on_delete=models.CASCADE, null=True, related_name="assets")
|
||||
page = models.ForeignKey("db.Page", on_delete=models.CASCADE, null=True, related_name="assets")
|
||||
entity_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
entity_identifier = models.CharField(max_length=255, null=True, blank=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
external_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
size = models.FloatField(default=0)
|
||||
is_uploaded = models.BooleanField(default=False)
|
||||
storage_metadata = models.JSONField(default=dict, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "File Asset"
|
||||
verbose_name_plural = "File Assets"
|
||||
db_table = "file_assets"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["entity_type"], name="asset_entity_type_idx"),
|
||||
models.Index(fields=["entity_identifier"], name="asset_entity_identifier_idx"),
|
||||
models.Index(fields=["entity_type", "entity_identifier"], name="asset_entity_idx"),
|
||||
models.Index(fields=["asset"], name="asset_asset_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return str(self.asset)
|
||||
|
||||
@property
|
||||
def asset_url(self):
|
||||
if (
|
||||
self.entity_type == self.EntityTypeContext.WORKSPACE_LOGO
|
||||
or self.entity_type == self.EntityTypeContext.USER_AVATAR
|
||||
or self.entity_type == self.EntityTypeContext.USER_COVER
|
||||
or self.entity_type == self.EntityTypeContext.PROJECT_COVER
|
||||
):
|
||||
return f"/api/assets/v2/static/{self.id}/"
|
||||
|
||||
if self.entity_type == self.EntityTypeContext.ISSUE_ATTACHMENT:
|
||||
return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/issues/{self.issue_id}/attachments/{self.id}/" # noqa: E501
|
||||
|
||||
if self.entity_type in [
|
||||
self.EntityTypeContext.ISSUE_DESCRIPTION,
|
||||
self.EntityTypeContext.COMMENT_DESCRIPTION,
|
||||
self.EntityTypeContext.PAGE_DESCRIPTION,
|
||||
self.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION,
|
||||
]:
|
||||
return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/{self.id}/"
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,47 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import uuid
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Third party imports
|
||||
from crum import get_current_user
|
||||
|
||||
# Module imports
|
||||
from ..mixins import AuditModel
|
||||
|
||||
|
||||
class BaseModel(AuditModel):
|
||||
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, created_by_id=None, disable_auto_set_user=False, **kwargs):
|
||||
if not disable_auto_set_user:
|
||||
# Check if created_by_id is provided
|
||||
if created_by_id:
|
||||
self.created_by_id = created_by_id
|
||||
else:
|
||||
user = get_current_user()
|
||||
|
||||
if user is None or user.is_anonymous:
|
||||
self.created_by = None
|
||||
self.updated_by = None
|
||||
else:
|
||||
# Check if the model is being created or updated
|
||||
if self._state.adding:
|
||||
# If creating, set created_by and leave updated_by as None
|
||||
self.created_by = user
|
||||
self.updated_by = None
|
||||
else:
|
||||
# If updating, set updated_by only
|
||||
self.updated_by = user
|
||||
|
||||
super(BaseModel, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.id)
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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 pytz
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from .project import ProjectBaseModel
|
||||
|
||||
|
||||
def get_default_filters():
|
||||
return {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
}
|
||||
|
||||
|
||||
def get_default_display_filters():
|
||||
return {
|
||||
"group_by": None,
|
||||
"order_by": "-created_at",
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"show_empty_groups": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
}
|
||||
|
||||
|
||||
def get_default_display_properties():
|
||||
return {
|
||||
"assignee": True,
|
||||
"attachment_count": True,
|
||||
"created_on": True,
|
||||
"due_date": True,
|
||||
"estimate": True,
|
||||
"key": True,
|
||||
"labels": True,
|
||||
"link": True,
|
||||
"priority": True,
|
||||
"start_date": True,
|
||||
"state": True,
|
||||
"sub_issue_count": True,
|
||||
"updated_on": True,
|
||||
}
|
||||
|
||||
|
||||
class Cycle(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="Cycle Name")
|
||||
description = models.TextField(verbose_name="Cycle Description", blank=True)
|
||||
start_date = models.DateTimeField(verbose_name="Start Date", blank=True, null=True)
|
||||
end_date = models.DateTimeField(verbose_name="End Date", blank=True, null=True)
|
||||
owned_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="owned_by_cycle",
|
||||
)
|
||||
view_props = models.JSONField(default=dict)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
progress_snapshot = models.JSONField(default=dict)
|
||||
archived_at = models.DateTimeField(null=True)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
# timezone
|
||||
TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
|
||||
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
|
||||
version = models.IntegerField(default=1)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Cycle"
|
||||
verbose_name_plural = "Cycles"
|
||||
db_table = "cycles"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
smallest_sort_order = Cycle.objects.filter(project=self.project).aggregate(
|
||||
smallest=models.Min("sort_order")
|
||||
)["smallest"]
|
||||
|
||||
if smallest_sort_order is not None:
|
||||
self.sort_order = smallest_sort_order - 10000
|
||||
|
||||
super(Cycle, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the cycle"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
|
||||
class CycleIssue(ProjectBaseModel):
|
||||
"""
|
||||
Cycle Issues
|
||||
"""
|
||||
|
||||
issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_cycle")
|
||||
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="issue_cycle")
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue", "cycle", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["cycle", "issue"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="cycle_issue_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Cycle Issue"
|
||||
verbose_name_plural = "Cycle Issues"
|
||||
db_table = "cycle_issues"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.cycle}"
|
||||
|
||||
|
||||
class CycleUserProperties(ProjectBaseModel):
|
||||
cycle = models.ForeignKey("db.Cycle", on_delete=models.CASCADE, related_name="cycle_user_properties")
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="cycle_user_properties",
|
||||
)
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["cycle", "user", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["cycle", "user"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="cycle_user_properties_unique_cycle_user_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Cycle User Property"
|
||||
verbose_name_plural = "Cycle User Properties"
|
||||
db_table = "cycle_user_properties"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.cycle.name} {self.user.email}"
|
||||
@@ -0,0 +1,57 @@
|
||||
# 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 uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from .workspace import WorkspaceBaseModel
|
||||
|
||||
|
||||
def get_anchor():
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
class DeployBoard(WorkspaceBaseModel):
|
||||
TYPE_CHOICES = (
|
||||
("project", "Project"),
|
||||
("issue", "Issue"),
|
||||
("module", "Module"),
|
||||
("cycle", "Task"),
|
||||
("page", "Page"),
|
||||
("view", "View"),
|
||||
("intake", "Intake"),
|
||||
)
|
||||
|
||||
entity_identifier = models.UUIDField(null=True)
|
||||
entity_name = models.CharField(max_length=30, null=True, blank=True)
|
||||
anchor = models.CharField(max_length=255, default=get_anchor, unique=True, db_index=True)
|
||||
is_comments_enabled = models.BooleanField(default=False)
|
||||
is_reactions_enabled = models.BooleanField(default=False)
|
||||
intake = models.ForeignKey("db.Intake", related_name="publish_intake", on_delete=models.SET_NULL, null=True)
|
||||
is_votes_enabled = models.BooleanField(default=False)
|
||||
view_props = models.JSONField(default=dict)
|
||||
is_activity_enabled = models.BooleanField(default=True)
|
||||
is_disabled = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the deploy board"""
|
||||
return f"{self.entity_identifier} <{self.entity_name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["entity_name", "entity_identifier", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["entity_name", "entity_identifier"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="deploy_board_unique_entity_name_entity_identifier_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Deploy Board"
|
||||
verbose_name_plural = "Deploy Boards"
|
||||
db_table = "deploy_boards"
|
||||
ordering = ("-created_at",)
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from django.db import models
|
||||
from django.utils.html import strip_tags
|
||||
from .workspace import WorkspaceBaseModel
|
||||
|
||||
|
||||
class Description(WorkspaceBaseModel):
|
||||
description_json = models.JSONField(default=dict, blank=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Description"
|
||||
verbose_name_plural = "Descriptions"
|
||||
db_table = "descriptions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(Description, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class DescriptionVersion(WorkspaceBaseModel):
|
||||
"""
|
||||
DescriptionVersion is a model used to store historical versions of a Description.
|
||||
"""
|
||||
|
||||
description = models.ForeignKey("db.Description", on_delete=models.CASCADE, related_name="versions")
|
||||
description_json = models.JSONField(default=dict, blank=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Description Version"
|
||||
verbose_name_plural = "Description Versions"
|
||||
db_table = "description_versions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(DescriptionVersion, self).save(*args, **kwargs)
|
||||
@@ -0,0 +1,42 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# models.py
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class Device(BaseModel):
|
||||
class DeviceType(models.TextChoices):
|
||||
ANDROID = "ANDROID", "Android"
|
||||
IOS = "IOS", "iOS"
|
||||
WEB = "WEB", "Web"
|
||||
DESKTOP = "DESKTOP", "Desktop"
|
||||
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="devices")
|
||||
device_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
device_type = models.CharField(max_length=255, choices=DeviceType.choices)
|
||||
push_token = models.CharField(max_length=255, blank=True, null=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "devices"
|
||||
verbose_name = "Device"
|
||||
verbose_name_plural = "Devices"
|
||||
|
||||
|
||||
class DeviceSession(BaseModel):
|
||||
device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="sessions")
|
||||
session = models.ForeignKey("db.Session", on_delete=models.CASCADE, related_name="device_sessions")
|
||||
is_active = models.BooleanField(default=True)
|
||||
user_agent = models.CharField(max_length=255, null=True, blank=True)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
start_time = models.DateTimeField(auto_now_add=True)
|
||||
end_time = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "device_sessions"
|
||||
verbose_name = "Device Session"
|
||||
verbose_name_plural = "Device Sessions"
|
||||
@@ -0,0 +1,224 @@
|
||||
# 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.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
from .workspace import WorkspaceBaseModel
|
||||
|
||||
|
||||
class DraftIssue(WorkspaceBaseModel):
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
("high", "High"),
|
||||
("medium", "Medium"),
|
||||
("low", "Low"),
|
||||
("none", "None"),
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
"db.Issue",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="draft_parent_issue",
|
||||
)
|
||||
state = models.ForeignKey(
|
||||
"db.State",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="state_draft_issue",
|
||||
)
|
||||
estimate_point = models.ForeignKey(
|
||||
"db.EstimatePoint",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="draft_issue_estimates",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
name = models.CharField(max_length=255, verbose_name="Issue Name", blank=True, null=True)
|
||||
description_json = models.JSONField(blank=True, default=dict)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
priority = models.CharField(
|
||||
max_length=30,
|
||||
choices=PRIORITY_CHOICES,
|
||||
verbose_name="Issue Priority",
|
||||
default="none",
|
||||
)
|
||||
start_date = models.DateField(null=True, blank=True)
|
||||
target_date = models.DateField(null=True, blank=True)
|
||||
assignees = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
related_name="draft_assignee",
|
||||
through="DraftIssueAssignee",
|
||||
through_fields=("draft_issue", "assignee"),
|
||||
)
|
||||
labels = models.ManyToManyField("db.Label", blank=True, related_name="draft_labels", through="DraftIssueLabel")
|
||||
sort_order = models.FloatField(default=65535)
|
||||
completed_at = models.DateTimeField(null=True)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
type = models.ForeignKey(
|
||||
"db.IssueType",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="draft_issue_type",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "DraftIssue"
|
||||
verbose_name_plural = "DraftIssues"
|
||||
db_table = "draft_issues"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.state is None:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
|
||||
default_state = State.objects.filter(
|
||||
~models.Q(is_triage=True), project=self.project, default=True
|
||||
).first()
|
||||
if default_state is None:
|
||||
random_state = State.objects.filter(~models.Q(is_triage=True), project=self.project).first()
|
||||
self.state = random_state
|
||||
else:
|
||||
self.state = default_state
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
|
||||
if self.state.group == "completed":
|
||||
self.completed_at = timezone.now()
|
||||
else:
|
||||
self.completed_at = None
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if self._state.adding:
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
largest_sort_order = DraftIssue.objects.filter(project=self.project, state=self.state).aggregate(
|
||||
largest=models.Max("sort_order")
|
||||
)["largest"]
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
|
||||
super(DraftIssue, self).save(*args, **kwargs)
|
||||
|
||||
else:
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(DraftIssue, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the draft issue"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
|
||||
class DraftIssueAssignee(WorkspaceBaseModel):
|
||||
draft_issue = models.ForeignKey(DraftIssue, on_delete=models.CASCADE, related_name="draft_issue_assignee")
|
||||
assignee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="draft_issue_assignee",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["draft_issue", "assignee", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["draft_issue", "assignee"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="draft_issue_assignee_unique_issue_assignee_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Draft Issue Assignee"
|
||||
verbose_name_plural = "Draft Issue Assignees"
|
||||
db_table = "draft_issue_assignees"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.draft_issue.name} {self.assignee.email}"
|
||||
|
||||
|
||||
class DraftIssueLabel(WorkspaceBaseModel):
|
||||
draft_issue = models.ForeignKey("db.DraftIssue", on_delete=models.CASCADE, related_name="draft_label_issue")
|
||||
label = models.ForeignKey("db.Label", on_delete=models.CASCADE, related_name="draft_label_issue")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Draft Issue Label"
|
||||
verbose_name_plural = "Draft Issue Labels"
|
||||
db_table = "draft_issue_labels"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.draft_issue.name} {self.label.name}"
|
||||
|
||||
|
||||
class DraftIssueModule(WorkspaceBaseModel):
|
||||
module = models.ForeignKey("db.Module", on_delete=models.CASCADE, related_name="draft_issue_module")
|
||||
draft_issue = models.ForeignKey("db.DraftIssue", on_delete=models.CASCADE, related_name="draft_issue_module")
|
||||
|
||||
class Meta:
|
||||
unique_together = ["draft_issue", "module", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["draft_issue", "module"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="module_draft_issue_unique_issue_module_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Draft Issue Module"
|
||||
verbose_name_plural = "Draft Issue Modules"
|
||||
db_table = "draft_issue_modules"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.module.name} {self.draft_issue.name}"
|
||||
|
||||
|
||||
class DraftIssueCycle(WorkspaceBaseModel):
|
||||
"""
|
||||
Draft Issue Cycles
|
||||
"""
|
||||
|
||||
draft_issue = models.ForeignKey("db.DraftIssue", on_delete=models.CASCADE, related_name="draft_issue_cycle")
|
||||
cycle = models.ForeignKey("db.Cycle", on_delete=models.CASCADE, related_name="draft_issue_cycle")
|
||||
|
||||
class Meta:
|
||||
unique_together = ["draft_issue", "cycle", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["draft_issue", "cycle"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="draft_issue_cycle_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Draft Issue Cycle"
|
||||
verbose_name_plural = "Draft Issue Cycles"
|
||||
db_table = "draft_issue_cycles"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.cycle}"
|
||||
@@ -0,0 +1,57 @@
|
||||
# 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.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
# Module imports
|
||||
from .project import ProjectBaseModel
|
||||
|
||||
class EstimateType(models.TextChoices):
|
||||
CATEGORIES = "categories", "Categories"
|
||||
POINTS = "points", "Points"
|
||||
|
||||
|
||||
class Estimate(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(verbose_name="Estimate Description", blank=True)
|
||||
type = models.CharField(max_length=255, choices=EstimateType.choices, default=EstimateType.CATEGORIES)
|
||||
last_used = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the estimate"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "project"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="estimate_unique_name_project_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Estimate"
|
||||
verbose_name_plural = "Estimates"
|
||||
db_table = "estimates"
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
class EstimatePoint(ProjectBaseModel):
|
||||
estimate = models.ForeignKey("db.Estimate", on_delete=models.CASCADE, related_name="points")
|
||||
key = models.IntegerField(default=0, validators=[MinValueValidator(0)])
|
||||
description = models.TextField(blank=True)
|
||||
value = models.CharField(max_length=255)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the estimate"""
|
||||
return f"{self.estimate.name} <{self.key}> <{self.value}>"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Estimate Point"
|
||||
verbose_name_plural = "Estimate Points"
|
||||
db_table = "estimate_points"
|
||||
ordering = ("value",)
|
||||
@@ -0,0 +1,67 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import uuid
|
||||
|
||||
# Python imports
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def generate_token():
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
class ExporterHistory(BaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="Exporter Name", null=True, blank=True)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
default="issue_exports",
|
||||
choices=(
|
||||
("issue_exports", "Issue Exports"),
|
||||
("issue_worklogs", "Issue Worklogs"),
|
||||
),
|
||||
)
|
||||
workspace = models.ForeignKey("db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_exporters")
|
||||
project = ArrayField(models.UUIDField(default=uuid.uuid4), blank=True, null=True)
|
||||
provider = models.CharField(max_length=50, choices=(("json", "json"), ("csv", "csv"), ("xlsx", "xlsx")))
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=(
|
||||
("queued", "Queued"),
|
||||
("processing", "Processing"),
|
||||
("completed", "Completed"),
|
||||
("failed", "Failed"),
|
||||
),
|
||||
default="queued",
|
||||
)
|
||||
reason = models.TextField(blank=True)
|
||||
key = models.TextField(blank=True)
|
||||
url = models.URLField(max_length=800, blank=True, null=True)
|
||||
token = models.CharField(max_length=255, default=generate_token, unique=True)
|
||||
initiated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_exporters",
|
||||
)
|
||||
filters = models.JSONField(blank=True, null=True)
|
||||
rich_filters = models.JSONField(default=dict, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Exporter"
|
||||
verbose_name_plural = "Exporters"
|
||||
db_table = "exporters"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the service"""
|
||||
return f"{self.provider} <{self.workspace.name}>"
|
||||
@@ -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.
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from .workspace import WorkspaceBaseModel
|
||||
|
||||
|
||||
class UserFavorite(WorkspaceBaseModel):
|
||||
"""_summary_
|
||||
UserFavorite (model): To store all the favorites of the user
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="favorites")
|
||||
entity_type = models.CharField(max_length=100)
|
||||
entity_identifier = models.UUIDField(null=True, blank=True)
|
||||
name = models.CharField(max_length=255, blank=True, null=True)
|
||||
is_folder = models.BooleanField(default=False)
|
||||
sequence = models.FloatField(default=65535)
|
||||
parent = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="parent_folder",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["entity_type", "user", "entity_identifier", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["entity_type", "entity_identifier", "user"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="user_favorite_unique_entity_type_entity_identifier_user_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "User Favorite"
|
||||
verbose_name_plural = "User Favorites"
|
||||
db_table = "user_favorites"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["entity_type"], name="fav_entity_type_idx"),
|
||||
models.Index(fields=["entity_identifier"], name="fav_entity_identifier_idx"),
|
||||
models.Index(fields=["entity_type", "entity_identifier"], name="fav_entity_idx"),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
if self.project:
|
||||
largest_sequence = UserFavorite.objects.filter(workspace=self.project.workspace).aggregate(
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
else:
|
||||
largest_sequence = UserFavorite.objects.filter(workspace=self.workspace).aggregate(
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
if largest_sequence is not None:
|
||||
self.sequence = largest_sequence + 10000
|
||||
|
||||
super(UserFavorite, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
"""Return user and the entity type"""
|
||||
return f"{self.user.email} <{self.entity_type}>"
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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.conf import settings
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from .project import ProjectBaseModel
|
||||
|
||||
|
||||
class Importer(ProjectBaseModel):
|
||||
service = models.CharField(max_length=50, choices=(("github", "GitHub"), ("jira", "Jira")))
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=(
|
||||
("queued", "Queued"),
|
||||
("processing", "Processing"),
|
||||
("completed", "Completed"),
|
||||
("failed", "Failed"),
|
||||
),
|
||||
default="queued",
|
||||
)
|
||||
initiated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="imports")
|
||||
metadata = models.JSONField(default=dict)
|
||||
config = models.JSONField(default=dict)
|
||||
data = models.JSONField(default=dict)
|
||||
token = models.ForeignKey("db.APIToken", on_delete=models.CASCADE, related_name="importer")
|
||||
imported_data = models.JSONField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Importer"
|
||||
verbose_name_plural = "Importers"
|
||||
db_table = "importers"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the service"""
|
||||
return f"{self.service} <{self.project.name}>"
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.db.models.project import ProjectBaseModel
|
||||
|
||||
|
||||
class Intake(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(verbose_name="Intake Description", blank=True)
|
||||
is_default = models.BooleanField(default=False)
|
||||
view_props = models.JSONField(default=dict)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the intake"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "project"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="intake_unique_name_project_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Intake"
|
||||
verbose_name_plural = "Intakes"
|
||||
db_table = "intakes"
|
||||
ordering = ("name",)
|
||||
|
||||
|
||||
class SourceType(models.TextChoices):
|
||||
IN_APP = "IN_APP"
|
||||
|
||||
|
||||
class IntakeIssueStatus(models.IntegerChoices):
|
||||
PENDING = -2
|
||||
REJECTED = -1
|
||||
SNOOZED = 0
|
||||
ACCEPTED = 1
|
||||
DUPLICATE = 2
|
||||
|
||||
|
||||
class IntakeIssue(ProjectBaseModel):
|
||||
intake = models.ForeignKey("db.Intake", related_name="issue_intake", on_delete=models.CASCADE)
|
||||
issue = models.ForeignKey("db.Issue", related_name="issue_intake", on_delete=models.CASCADE)
|
||||
status = models.IntegerField(
|
||||
choices=(
|
||||
(-2, "Pending"),
|
||||
(-1, "Rejected"),
|
||||
(0, "Snoozed"),
|
||||
(1, "Accepted"),
|
||||
(2, "Duplicate"),
|
||||
),
|
||||
default=-2,
|
||||
)
|
||||
snoozed_till = models.DateTimeField(null=True)
|
||||
duplicate_to = models.ForeignKey(
|
||||
"db.Issue",
|
||||
related_name="intake_duplicate",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
)
|
||||
source = models.CharField(max_length=255, default="IN_APP", null=True, blank=True)
|
||||
source_email = models.TextField(blank=True, null=True)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
extra = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "IntakeIssue"
|
||||
verbose_name_plural = "IntakeIssues"
|
||||
db_table = "intake_issues"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the Issue"""
|
||||
return f"{self.issue.name} <{self.intake.name}>"
|
||||
@@ -0,0 +1,12 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from .base import Integration, WorkspaceIntegration
|
||||
from .github import (
|
||||
GithubRepository,
|
||||
GithubRepositorySync,
|
||||
GithubIssueSync,
|
||||
GithubCommentSync,
|
||||
)
|
||||
from .slack import SlackProjectSync
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import BaseModel
|
||||
from plane.db.mixins import AuditModel
|
||||
|
||||
|
||||
class Integration(AuditModel):
|
||||
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
|
||||
title = models.CharField(max_length=400)
|
||||
provider = models.CharField(max_length=400, unique=True)
|
||||
network = models.PositiveIntegerField(default=1, choices=((1, "Private"), (2, "Public")))
|
||||
description = models.JSONField(default=dict)
|
||||
author = models.CharField(max_length=400, blank=True)
|
||||
webhook_url = models.TextField(blank=True)
|
||||
webhook_secret = models.TextField(blank=True)
|
||||
redirect_url = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict)
|
||||
verified = models.BooleanField(default=False)
|
||||
avatar_url = models.TextField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
"""Return provider of the integration"""
|
||||
return f"{self.provider}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Integration"
|
||||
verbose_name_plural = "Integrations"
|
||||
db_table = "integrations"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class WorkspaceIntegration(BaseModel):
|
||||
workspace = models.ForeignKey("db.Workspace", related_name="workspace_integrations", on_delete=models.CASCADE)
|
||||
# Bot user
|
||||
actor = models.ForeignKey("db.User", related_name="integrations", on_delete=models.CASCADE)
|
||||
integration = models.ForeignKey("db.Integration", related_name="integrated_workspaces", on_delete=models.CASCADE)
|
||||
api_token = models.ForeignKey("db.APIToken", related_name="integrations", on_delete=models.CASCADE)
|
||||
metadata = models.JSONField(default=dict)
|
||||
|
||||
config = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the integration and workspace"""
|
||||
return f"{self.workspace.name} <{self.integration.provider}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "integration"]
|
||||
verbose_name = "Workspace Integration"
|
||||
verbose_name_plural = "Workspace Integrations"
|
||||
db_table = "workspace_integrations"
|
||||
ordering = ("-created_at",)
|
||||
@@ -0,0 +1,87 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.db.models.project import ProjectBaseModel
|
||||
|
||||
|
||||
class GithubRepository(ProjectBaseModel):
|
||||
name = models.CharField(max_length=500)
|
||||
url = models.URLField(null=True)
|
||||
config = models.JSONField(default=dict)
|
||||
repository_id = models.BigIntegerField()
|
||||
owner = models.CharField(max_length=500)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the repo name"""
|
||||
return f"{self.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Repository"
|
||||
verbose_name_plural = "Repositories"
|
||||
db_table = "github_repositories"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class GithubRepositorySync(ProjectBaseModel):
|
||||
repository = models.OneToOneField("db.GithubRepository", on_delete=models.CASCADE, related_name="syncs")
|
||||
credentials = models.JSONField(default=dict)
|
||||
# Bot user
|
||||
actor = models.ForeignKey("db.User", related_name="user_syncs", on_delete=models.CASCADE)
|
||||
workspace_integration = models.ForeignKey(
|
||||
"db.WorkspaceIntegration", related_name="github_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
label = models.ForeignKey("db.Label", on_delete=models.SET_NULL, null=True, related_name="repo_syncs")
|
||||
|
||||
def __str__(self):
|
||||
"""Return the repo sync"""
|
||||
return f"{self.repository.name} <{self.project.name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "repository"]
|
||||
verbose_name = "Github Repository Sync"
|
||||
verbose_name_plural = "Github Repository Syncs"
|
||||
db_table = "github_repository_syncs"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class GithubIssueSync(ProjectBaseModel):
|
||||
repo_issue_id = models.BigIntegerField()
|
||||
github_issue_id = models.BigIntegerField()
|
||||
issue_url = models.URLField(blank=False)
|
||||
issue = models.ForeignKey("db.Issue", related_name="github_syncs", on_delete=models.CASCADE)
|
||||
repository_sync = models.ForeignKey("db.GithubRepositorySync", related_name="issue_syncs", on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the github issue sync"""
|
||||
return f"{self.repository.name}-{self.project.name}-{self.issue.name}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["repository_sync", "issue"]
|
||||
verbose_name = "Github Issue Sync"
|
||||
verbose_name_plural = "Github Issue Syncs"
|
||||
db_table = "github_issue_syncs"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class GithubCommentSync(ProjectBaseModel):
|
||||
repo_comment_id = models.BigIntegerField()
|
||||
comment = models.ForeignKey("db.IssueComment", related_name="comment_syncs", on_delete=models.CASCADE)
|
||||
issue_sync = models.ForeignKey("db.GithubIssueSync", related_name="comment_syncs", on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the github issue sync"""
|
||||
return f"{self.comment.id}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue_sync", "comment"]
|
||||
verbose_name = "Github Comment Sync"
|
||||
verbose_name_plural = "Github Comment Syncs"
|
||||
db_table = "github_comment_syncs"
|
||||
ordering = ("-created_at",)
|
||||
@@ -0,0 +1,35 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.db.models.project import ProjectBaseModel
|
||||
|
||||
|
||||
class SlackProjectSync(ProjectBaseModel):
|
||||
access_token = models.CharField(max_length=300)
|
||||
scopes = models.TextField()
|
||||
bot_user_id = models.CharField(max_length=50)
|
||||
webhook_url = models.URLField(max_length=1000)
|
||||
data = models.JSONField(default=dict)
|
||||
team_id = models.CharField(max_length=30)
|
||||
team_name = models.CharField(max_length=300)
|
||||
workspace_integration = models.ForeignKey(
|
||||
"db.WorkspaceIntegration", related_name="slack_syncs", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the repo name"""
|
||||
return f"{self.project.name}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["team_id", "project"]
|
||||
verbose_name = "Slack Project Sync"
|
||||
verbose_name_plural = "Slack Project Syncs"
|
||||
db_table = "slack_project_syncs"
|
||||
ordering = ("-created_at",)
|
||||
@@ -0,0 +1,812 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python import
|
||||
from uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models, transaction, connection
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from django import apps
|
||||
|
||||
# Module imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
from plane.db.mixins import SoftDeletionManager
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from .project import ProjectBaseModel
|
||||
from plane.utils.uuid import convert_uuid_to_integer
|
||||
from .description import Description
|
||||
from plane.db.mixins import ChangeTrackerMixin
|
||||
from .state import StateGroup
|
||||
|
||||
|
||||
def get_default_properties():
|
||||
return {
|
||||
"assignee": True,
|
||||
"start_date": True,
|
||||
"due_date": True,
|
||||
"labels": True,
|
||||
"key": True,
|
||||
"priority": True,
|
||||
"state": True,
|
||||
"sub_issue_count": True,
|
||||
"link": True,
|
||||
"attachment_count": True,
|
||||
"estimate": True,
|
||||
"created_on": True,
|
||||
"updated_on": True,
|
||||
}
|
||||
|
||||
|
||||
def get_default_filters():
|
||||
return {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
}
|
||||
|
||||
|
||||
def get_default_display_filters():
|
||||
return {
|
||||
"group_by": None,
|
||||
"order_by": "-created_at",
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"show_empty_groups": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
}
|
||||
|
||||
|
||||
def get_default_display_properties():
|
||||
return {
|
||||
"assignee": True,
|
||||
"attachment_count": True,
|
||||
"created_on": True,
|
||||
"due_date": True,
|
||||
"estimate": True,
|
||||
"key": True,
|
||||
"labels": True,
|
||||
"link": True,
|
||||
"priority": True,
|
||||
"start_date": True,
|
||||
"state": True,
|
||||
"sub_issue_count": True,
|
||||
"updated_on": True,
|
||||
}
|
||||
|
||||
|
||||
# TODO: Handle identifiers for Bulk Inserts - nk
|
||||
class IssueManager(SoftDeletionManager):
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.exclude(state__group=StateGroup.TRIAGE.value)
|
||||
.exclude(archived_at__isnull=False)
|
||||
.exclude(project__archived_at__isnull=False)
|
||||
.exclude(is_draft=True)
|
||||
)
|
||||
|
||||
|
||||
class Issue(ProjectBaseModel):
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
("high", "High"),
|
||||
("medium", "Medium"),
|
||||
("low", "Low"),
|
||||
("none", "None"),
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="parent_issue",
|
||||
)
|
||||
state = models.ForeignKey(
|
||||
"db.State",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="state_issue",
|
||||
)
|
||||
point = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(12)], null=True, blank=True)
|
||||
estimate_point = models.ForeignKey(
|
||||
"db.EstimatePoint",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="issue_estimates",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
name = models.CharField(max_length=255, verbose_name="Issue Name")
|
||||
description_json = models.JSONField(blank=True, default=dict)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
priority = models.CharField(
|
||||
max_length=30,
|
||||
choices=PRIORITY_CHOICES,
|
||||
verbose_name="Issue Priority",
|
||||
default="none",
|
||||
)
|
||||
start_date = models.DateField(null=True, blank=True)
|
||||
target_date = models.DateField(null=True, blank=True)
|
||||
assignees = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
related_name="assignee",
|
||||
through="IssueAssignee",
|
||||
through_fields=("issue", "assignee"),
|
||||
)
|
||||
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
|
||||
labels = models.ManyToManyField("db.Label", blank=True, related_name="labels", through="IssueLabel")
|
||||
sort_order = models.FloatField(default=65535)
|
||||
completed_at = models.DateTimeField(null=True)
|
||||
archived_at = models.DateField(null=True)
|
||||
is_draft = models.BooleanField(default=False)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
type = models.ForeignKey(
|
||||
"db.IssueType",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="issue_type",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
issue_objects = IssueManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue"
|
||||
verbose_name_plural = "Issues"
|
||||
db_table = "issues"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.state is None:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
|
||||
default_state = State.objects.filter(
|
||||
~models.Q(is_triage=True), project=self.project, default=True
|
||||
).first()
|
||||
if default_state is None:
|
||||
random_state = State.objects.filter(~models.Q(is_triage=True), project=self.project).first()
|
||||
self.state = random_state
|
||||
else:
|
||||
self.state = default_state
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
|
||||
if self.state.group == "completed":
|
||||
self.completed_at = timezone.now()
|
||||
else:
|
||||
self.completed_at = None
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if self._state.adding:
|
||||
with transaction.atomic():
|
||||
# Create a lock for this specific project using a transaction-level advisory lock
|
||||
# This ensures only one transaction per project can execute this code at a time
|
||||
# The lock is automatically released when the transaction ends
|
||||
lock_key = convert_uuid_to_integer(self.project.id)
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# Get an exclusive transaction-level lock using the project ID as the lock key
|
||||
cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key])
|
||||
|
||||
# Get the last sequence for the project
|
||||
last_sequence = IssueSequence.objects.filter(project=self.project).aggregate(
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
self.sequence_id = last_sequence + 1 if last_sequence else 1
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate(
|
||||
largest=models.Max("sort_order")
|
||||
)["largest"]
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
|
||||
super(Issue, self).save(*args, **kwargs)
|
||||
|
||||
IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project)
|
||||
else:
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(Issue, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the issue"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
|
||||
class IssueBlocker(ProjectBaseModel):
|
||||
block = models.ForeignKey(Issue, related_name="blocker_issues", on_delete=models.CASCADE)
|
||||
blocked_by = models.ForeignKey(Issue, related_name="blocked_issues", on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Blocker"
|
||||
verbose_name_plural = "Issue Blockers"
|
||||
db_table = "issue_blockers"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.block.name} {self.blocked_by.name}"
|
||||
|
||||
|
||||
class IssueRelationChoices(models.TextChoices):
|
||||
DUPLICATE = "duplicate", "Duplicate"
|
||||
RELATES_TO = "relates_to", "Relates To"
|
||||
BLOCKED_BY = "blocked_by", "Blocked By"
|
||||
START_BEFORE = "start_before", "Start Before"
|
||||
FINISH_BEFORE = "finish_before", "Finish Before"
|
||||
IMPLEMENTED_BY = "implemented_by", "Implemented By"
|
||||
|
||||
|
||||
# Bidirectional relation pairs: (forward, reverse)
|
||||
# Defined after class to avoid enum metaclass conflicts
|
||||
IssueRelationChoices._RELATION_PAIRS = (
|
||||
("blocked_by", "blocking"),
|
||||
("relates_to", "relates_to"), # symmetric
|
||||
("duplicate", "duplicate"), # symmetric
|
||||
("start_before", "start_after"),
|
||||
("finish_before", "finish_after"),
|
||||
("implemented_by", "implements"),
|
||||
)
|
||||
|
||||
# Generate reverse mapping from pairs
|
||||
IssueRelationChoices._REVERSE_MAPPING = {forward: reverse for forward, reverse in IssueRelationChoices._RELATION_PAIRS}
|
||||
|
||||
|
||||
class IssueRelation(ProjectBaseModel):
|
||||
issue = models.ForeignKey(Issue, related_name="issue_relation", on_delete=models.CASCADE)
|
||||
related_issue = models.ForeignKey(Issue, related_name="issue_related", on_delete=models.CASCADE)
|
||||
relation_type = models.CharField(
|
||||
max_length=20,
|
||||
verbose_name="Issue Relation Type",
|
||||
default=IssueRelationChoices.BLOCKED_BY,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue", "related_issue", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["issue", "related_issue"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="issue_relation_unique_issue_related_issue_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Issue Relation"
|
||||
verbose_name_plural = "Issue Relations"
|
||||
db_table = "issue_relations"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.related_issue.name}"
|
||||
|
||||
|
||||
class IssueMention(ProjectBaseModel):
|
||||
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_mention")
|
||||
mention = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="issue_mention")
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue", "mention", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["issue", "mention"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="issue_mention_unique_issue_mention_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Issue Mention"
|
||||
verbose_name_plural = "Issue Mentions"
|
||||
db_table = "issue_mentions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.mention.email}"
|
||||
|
||||
|
||||
class IssueAssignee(ProjectBaseModel):
|
||||
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_assignee")
|
||||
assignee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_assignee",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue", "assignee", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["issue", "assignee"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="issue_assignee_unique_issue_assignee_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Issue Assignee"
|
||||
verbose_name_plural = "Issue Assignees"
|
||||
db_table = "issue_assignees"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.assignee.email}"
|
||||
|
||||
|
||||
class IssueLink(ProjectBaseModel):
|
||||
title = models.CharField(max_length=255, null=True, blank=True)
|
||||
url = models.TextField()
|
||||
issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_link")
|
||||
metadata = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Link"
|
||||
verbose_name_plural = "Issue Links"
|
||||
db_table = "issue_links"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.url}"
|
||||
|
||||
|
||||
def get_upload_path(instance, filename):
|
||||
return f"{instance.workspace.id}/{uuid4().hex}-{filename}"
|
||||
|
||||
|
||||
def file_size(value):
|
||||
# File limit check is only for cloud hosted
|
||||
if value.size > settings.FILE_SIZE_LIMIT:
|
||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||
|
||||
|
||||
class IssueAttachment(ProjectBaseModel):
|
||||
attributes = models.JSONField(default=dict)
|
||||
asset = models.FileField(upload_to=get_upload_path, validators=[file_size])
|
||||
issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_attachment")
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Attachment"
|
||||
verbose_name_plural = "Issue Attachments"
|
||||
db_table = "issue_attachments"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.asset}"
|
||||
|
||||
|
||||
class IssueActivity(ProjectBaseModel):
|
||||
issue = models.ForeignKey(Issue, on_delete=models.DO_NOTHING, null=True, related_name="issue_activity")
|
||||
verb = models.CharField(max_length=255, verbose_name="Action", default="created")
|
||||
field = models.CharField(max_length=255, verbose_name="Field Name", blank=True, null=True)
|
||||
old_value = models.TextField(verbose_name="Old Value", blank=True, null=True)
|
||||
new_value = models.TextField(verbose_name="New Value", blank=True, null=True)
|
||||
|
||||
comment = models.TextField(verbose_name="Comment", blank=True)
|
||||
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
|
||||
issue_comment = models.ForeignKey(
|
||||
"db.IssueComment",
|
||||
on_delete=models.DO_NOTHING,
|
||||
related_name="issue_comment",
|
||||
null=True,
|
||||
)
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name="issue_activities",
|
||||
)
|
||||
old_identifier = models.UUIDField(null=True)
|
||||
new_identifier = models.UUIDField(null=True)
|
||||
epoch = models.FloatField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Activity"
|
||||
verbose_name_plural = "Issue Activities"
|
||||
db_table = "issue_activities"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return issue of the comment"""
|
||||
return str(self.issue)
|
||||
|
||||
|
||||
class IssueComment(ChangeTrackerMixin, ProjectBaseModel):
|
||||
comment_stripped = models.TextField(verbose_name="Comment", blank=True)
|
||||
comment_json = models.JSONField(blank=True, default=dict)
|
||||
comment_html = models.TextField(blank=True, default="<p></p>")
|
||||
description = models.OneToOneField(
|
||||
"db.Description", on_delete=models.CASCADE, related_name="issue_comment_description", null=True
|
||||
)
|
||||
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
|
||||
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments")
|
||||
# System can also create comment
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="comments",
|
||||
null=True,
|
||||
)
|
||||
access = models.CharField(
|
||||
choices=(("INTERNAL", "INTERNAL"), ("EXTERNAL", "EXTERNAL")),
|
||||
default="INTERNAL",
|
||||
max_length=100,
|
||||
)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
edited_at = models.DateTimeField(null=True, blank=True)
|
||||
parent = models.ForeignKey(
|
||||
"self", on_delete=models.CASCADE, null=True, blank=True, related_name="parent_issue_comment"
|
||||
)
|
||||
|
||||
TRACKED_FIELDS = ["comment_stripped", "comment_json", "comment_html"]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Custom save method for IssueComment that manages the associated Description model.
|
||||
|
||||
This method handles creation and updates of both the comment and its description in a
|
||||
single atomic transaction to ensure data consistency.
|
||||
"""
|
||||
|
||||
self.comment_stripped = strip_tags(self.comment_html) if self.comment_html != "" else ""
|
||||
is_creating = self._state.adding
|
||||
|
||||
# Prepare description defaults
|
||||
description_defaults = {
|
||||
"workspace_id": self.workspace_id,
|
||||
"project_id": self.project_id,
|
||||
"created_by_id": self.created_by_id,
|
||||
"updated_by_id": self.updated_by_id,
|
||||
"description_stripped": self.comment_stripped,
|
||||
"description_json": self.comment_json,
|
||||
"description_html": self.comment_html,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
super(IssueComment, self).save(*args, **kwargs)
|
||||
|
||||
if is_creating or not self.description_id:
|
||||
# Create new description for new comment
|
||||
description = Description.objects.create(**description_defaults)
|
||||
self.description_id = description.id
|
||||
super(IssueComment, self).save(update_fields=["description_id"])
|
||||
else:
|
||||
field_mapping = {
|
||||
"comment_html": "description_html",
|
||||
"comment_stripped": "description_stripped",
|
||||
"comment_json": "description_json",
|
||||
}
|
||||
|
||||
# Use _changes_on_save which is captured by ChangeTrackerMixin.save()
|
||||
# before the tracked fields are reset
|
||||
changed_fields = {
|
||||
desc_field: getattr(self, comment_field)
|
||||
for comment_field, desc_field in field_mapping.items()
|
||||
if comment_field in self._changes_on_save
|
||||
}
|
||||
|
||||
# Update description only if comment fields changed
|
||||
if changed_fields and self.description_id:
|
||||
Description.objects.filter(pk=self.description_id).update(
|
||||
**changed_fields, updated_by_id=self.updated_by_id, updated_at=self.updated_at
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Comment"
|
||||
verbose_name_plural = "Issue Comments"
|
||||
db_table = "issue_comments"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return issue of the comment"""
|
||||
return str(self.issue)
|
||||
|
||||
|
||||
class IssueLabel(ProjectBaseModel):
|
||||
issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="label_issue")
|
||||
label = models.ForeignKey("db.Label", on_delete=models.CASCADE, related_name="label_issue")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Label"
|
||||
verbose_name_plural = "Issue Labels"
|
||||
db_table = "issue_labels"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.label.name}"
|
||||
|
||||
|
||||
class IssueSequence(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
Issue,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="issue_sequence",
|
||||
null=True, # This is set to null because we want to keep the sequence even if the issue is deleted
|
||||
)
|
||||
sequence = models.PositiveBigIntegerField(default=1, db_index=True)
|
||||
deleted = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Sequence"
|
||||
verbose_name_plural = "Issue Sequences"
|
||||
db_table = "issue_sequences"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class IssueSubscriber(ProjectBaseModel):
|
||||
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_subscribers")
|
||||
subscriber = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_subscribers",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue", "subscriber", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["issue", "subscriber"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="issue_subscriber_unique_issue_subscriber_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Issue Subscriber"
|
||||
verbose_name_plural = "Issue Subscribers"
|
||||
db_table = "issue_subscribers"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.subscriber.email}"
|
||||
|
||||
|
||||
class IssueReaction(ProjectBaseModel):
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_reactions",
|
||||
)
|
||||
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_reactions")
|
||||
reaction = models.TextField()
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue", "actor", "reaction", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["issue", "actor", "reaction"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="issue_reaction_unique_issue_actor_reaction_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Issue Reaction"
|
||||
verbose_name_plural = "Issue Reactions"
|
||||
db_table = "issue_reactions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.actor.email}"
|
||||
|
||||
|
||||
class CommentReaction(ProjectBaseModel):
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="comment_reactions",
|
||||
)
|
||||
comment = models.ForeignKey(IssueComment, on_delete=models.CASCADE, related_name="comment_reactions")
|
||||
reaction = models.TextField()
|
||||
|
||||
class Meta:
|
||||
unique_together = ["comment", "actor", "reaction", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["comment", "actor", "reaction"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="comment_reaction_unique_comment_actor_reaction_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Comment Reaction"
|
||||
verbose_name_plural = "Comment Reactions"
|
||||
db_table = "comment_reactions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.actor.email}"
|
||||
|
||||
|
||||
class IssueVote(ProjectBaseModel):
|
||||
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes")
|
||||
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes")
|
||||
vote = models.IntegerField(choices=((-1, "DOWNVOTE"), (1, "UPVOTE")), default=1)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue", "actor", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["issue", "actor"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="issue_vote_unique_issue_actor_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Issue Vote"
|
||||
verbose_name_plural = "Issue Votes"
|
||||
db_table = "issue_votes"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.actor.email}"
|
||||
|
||||
|
||||
class IssueVersion(ProjectBaseModel):
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
("high", "High"),
|
||||
("medium", "Medium"),
|
||||
("low", "Low"),
|
||||
("none", "None"),
|
||||
)
|
||||
|
||||
parent = models.UUIDField(blank=True, null=True)
|
||||
state = models.UUIDField(blank=True, null=True)
|
||||
estimate_point = models.UUIDField(blank=True, null=True)
|
||||
name = models.CharField(max_length=255, verbose_name="Issue Name")
|
||||
priority = models.CharField(
|
||||
max_length=30,
|
||||
choices=PRIORITY_CHOICES,
|
||||
verbose_name="Issue Priority",
|
||||
default="none",
|
||||
)
|
||||
start_date = models.DateField(null=True, blank=True)
|
||||
target_date = models.DateField(null=True, blank=True)
|
||||
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
|
||||
labels = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
completed_at = models.DateTimeField(null=True)
|
||||
archived_at = models.DateField(null=True)
|
||||
is_draft = models.BooleanField(default=False)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
type = models.UUIDField(blank=True, null=True)
|
||||
cycle = models.UUIDField(null=True, blank=True)
|
||||
modules = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||
properties = models.JSONField(default=dict) # issue properties
|
||||
meta = models.JSONField(default=dict) # issue meta
|
||||
last_saved_at = models.DateTimeField(default=timezone.now)
|
||||
|
||||
issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="versions")
|
||||
activity = models.ForeignKey(
|
||||
"db.IssueActivity",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name="versions",
|
||||
)
|
||||
owned_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_versions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Version"
|
||||
verbose_name_plural = "Issue Versions"
|
||||
db_table = "issue_versions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
@classmethod
|
||||
def log_issue_version(cls, issue, user):
|
||||
try:
|
||||
"""
|
||||
Log the issue version
|
||||
"""
|
||||
|
||||
Module = apps.get_model("db.Module")
|
||||
CycleIssue = apps.get_model("db.CycleIssue")
|
||||
IssueAssignee = apps.get_model("db.IssueAssignee")
|
||||
IssueLabel = apps.get_model("db.IssueLabel")
|
||||
|
||||
cycle_issue = CycleIssue.objects.filter(issue=issue).first()
|
||||
|
||||
cls.objects.create(
|
||||
issue=issue,
|
||||
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=list(IssueAssignee.objects.filter(issue=issue).values_list("assignee_id", flat=True)),
|
||||
sequence_id=issue.sequence_id,
|
||||
labels=list(IssueLabel.objects.filter(issue=issue).values_list("label_id", flat=True)),
|
||||
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=cycle_issue.cycle_id if cycle_issue else None,
|
||||
modules=list(Module.objects.filter(issue=issue).values_list("id", flat=True)),
|
||||
properties={},
|
||||
meta={},
|
||||
last_saved_at=timezone.now(),
|
||||
owned_by=user,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
|
||||
|
||||
class IssueDescriptionVersion(ProjectBaseModel):
|
||||
issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="description_versions")
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_json = models.JSONField(default=dict, blank=True)
|
||||
last_saved_at = models.DateTimeField(default=timezone.now)
|
||||
owned_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_description_versions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Description Version"
|
||||
verbose_name_plural = "Issue Description Versions"
|
||||
db_table = "issue_description_versions"
|
||||
|
||||
@classmethod
|
||||
def log_issue_description_version(cls, issue, user):
|
||||
try:
|
||||
"""
|
||||
Log the issue description version
|
||||
"""
|
||||
cls.objects.create(
|
||||
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=user,
|
||||
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,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return False
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
# Module imports
|
||||
from .project import ProjectBaseModel
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class IssueType(BaseModel):
|
||||
workspace = models.ForeignKey("db.Workspace", related_name="issue_types", on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
is_epic = models.BooleanField(default=False)
|
||||
is_default = models.BooleanField(default=False)
|
||||
is_active = models.BooleanField(default=True)
|
||||
level = models.FloatField(default=0)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue Type"
|
||||
verbose_name_plural = "Issue Types"
|
||||
db_table = "issue_types"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ProjectIssueType(ProjectBaseModel):
|
||||
issue_type = models.ForeignKey("db.IssueType", related_name="project_issue_types", on_delete=models.CASCADE)
|
||||
level = models.PositiveIntegerField(default=0)
|
||||
is_default = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "issue_type", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "issue_type"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="project_issue_type_unique_project_issue_type_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Project Issue Type"
|
||||
verbose_name_plural = "Project Issue Types"
|
||||
db_table = "project_issue_types"
|
||||
ordering = ("project", "issue_type")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.project} - {self.issue_type}"
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
from .workspace import WorkspaceBaseModel
|
||||
|
||||
|
||||
class Label(WorkspaceBaseModel):
|
||||
parent = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="parent_label",
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
color = models.CharField(max_length=255, blank=True)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
# Enforce uniqueness of name when project is NULL and deleted_at is NULL
|
||||
models.UniqueConstraint(
|
||||
fields=["name"],
|
||||
condition=Q(project__isnull=True, deleted_at__isnull=True),
|
||||
name="unique_name_when_project_null_and_not_deleted",
|
||||
),
|
||||
# Enforce uniqueness of project and name when project is not NULL and deleted_at is NULL
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "name"],
|
||||
condition=Q(project__isnull=False, deleted_at__isnull=True),
|
||||
name="unique_project_name_when_not_deleted",
|
||||
),
|
||||
]
|
||||
verbose_name = "Label"
|
||||
verbose_name_plural = "Labels"
|
||||
db_table = "labels"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
# Get the maximum sequence value from the database
|
||||
last_id = Label.objects.filter(project=self.project).aggregate(largest=models.Max("sort_order"))["largest"]
|
||||
# if last_id is not None
|
||||
if last_id is not None:
|
||||
self.sort_order = last_id + 10000
|
||||
|
||||
super(Label, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name)
|
||||
@@ -0,0 +1,217 @@
|
||||
# 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.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
# Module imports
|
||||
from .project import ProjectBaseModel
|
||||
|
||||
|
||||
def get_default_filters():
|
||||
return {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
}
|
||||
|
||||
|
||||
def get_default_display_filters():
|
||||
return {
|
||||
"group_by": None,
|
||||
"order_by": "-created_at",
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"show_empty_groups": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
}
|
||||
|
||||
|
||||
def get_default_display_properties():
|
||||
return {
|
||||
"assignee": True,
|
||||
"attachment_count": True,
|
||||
"created_on": True,
|
||||
"due_date": True,
|
||||
"estimate": True,
|
||||
"key": True,
|
||||
"labels": True,
|
||||
"link": True,
|
||||
"priority": True,
|
||||
"start_date": True,
|
||||
"state": True,
|
||||
"sub_issue_count": True,
|
||||
"updated_on": True,
|
||||
}
|
||||
|
||||
|
||||
class ModuleStatus(models.TextChoices):
|
||||
BACKLOG = "backlog"
|
||||
PLANNED = "planned"
|
||||
IN_PROGRESS = "in-progress"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class Module(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="Module Name")
|
||||
description = models.TextField(verbose_name="Module Description", blank=True)
|
||||
description_text = models.JSONField(verbose_name="Module Description RT", blank=True, null=True)
|
||||
description_html = models.JSONField(verbose_name="Module Description HTML", blank=True, null=True)
|
||||
start_date = models.DateField(null=True)
|
||||
target_date = models.DateField(null=True)
|
||||
status = models.CharField(
|
||||
choices=(
|
||||
("backlog", "Backlog"),
|
||||
("planned", "Planned"),
|
||||
("in-progress", "In Progress"),
|
||||
("paused", "Paused"),
|
||||
("completed", "Completed"),
|
||||
("cancelled", "Cancelled"),
|
||||
),
|
||||
default="planned",
|
||||
max_length=20,
|
||||
)
|
||||
lead = models.ForeignKey("db.User", on_delete=models.SET_NULL, related_name="module_leads", null=True)
|
||||
members = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
related_name="module_members",
|
||||
through="ModuleMember",
|
||||
through_fields=("module", "member"),
|
||||
)
|
||||
view_props = models.JSONField(default=dict)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
archived_at = models.DateTimeField(null=True)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "project"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="module_unique_name_project_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Module"
|
||||
verbose_name_plural = "Modules"
|
||||
db_table = "modules"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
smallest_sort_order = Module.objects.filter(project=self.project).aggregate(
|
||||
smallest=models.Min("sort_order")
|
||||
)["smallest"]
|
||||
|
||||
if smallest_sort_order is not None:
|
||||
self.sort_order = smallest_sort_order - 10000
|
||||
|
||||
super(Module, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} {self.start_date} {self.target_date}"
|
||||
|
||||
|
||||
class ModuleMember(ProjectBaseModel):
|
||||
module = models.ForeignKey("db.Module", on_delete=models.CASCADE)
|
||||
member = models.ForeignKey("db.User", on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["module", "member", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["module", "member"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="module_member_unique_module_member_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Module Member"
|
||||
verbose_name_plural = "Module Members"
|
||||
db_table = "module_members"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.module.name} {self.member}"
|
||||
|
||||
|
||||
class ModuleIssue(ProjectBaseModel):
|
||||
module = models.ForeignKey("db.Module", on_delete=models.CASCADE, related_name="issue_module")
|
||||
issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_module")
|
||||
|
||||
class Meta:
|
||||
unique_together = ["issue", "module", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["issue", "module"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="module_issue_unique_issue_module_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Module Issue"
|
||||
verbose_name_plural = "Module Issues"
|
||||
db_table = "module_issues"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.module.name} {self.issue.name}"
|
||||
|
||||
|
||||
class ModuleLink(ProjectBaseModel):
|
||||
title = models.CharField(max_length=255, blank=True, null=True)
|
||||
url = models.URLField()
|
||||
module = models.ForeignKey(Module, on_delete=models.CASCADE, related_name="link_module")
|
||||
metadata = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Module Link"
|
||||
verbose_name_plural = "Module Links"
|
||||
db_table = "module_links"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.module.name} {self.url}"
|
||||
|
||||
|
||||
class ModuleUserProperties(ProjectBaseModel):
|
||||
module = models.ForeignKey("db.Module", on_delete=models.CASCADE, related_name="module_user_properties")
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="module_user_properties",
|
||||
)
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["module", "user", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["module", "user"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="module_user_properties_unique_module_user_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Module User Property"
|
||||
verbose_name_plural = "Module User Property"
|
||||
db_table = "module_user_properties"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.module.name} {self.user.email}"
|
||||
@@ -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.
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
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)
|
||||
data = models.JSONField(null=True)
|
||||
entity_identifier = models.UUIDField(null=True)
|
||||
entity_name = models.CharField(max_length=255)
|
||||
title = models.TextField()
|
||||
message = models.JSONField(null=True)
|
||||
message_html = models.TextField(blank=True, default="<p></p>")
|
||||
message_stripped = models.TextField(blank=True, null=True)
|
||||
sender = models.CharField(max_length=255)
|
||||
triggered_by = models.ForeignKey(
|
||||
"db.User",
|
||||
related_name="triggered_notifications",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
)
|
||||
receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE)
|
||||
read_at = models.DateTimeField(null=True)
|
||||
snoozed_till = models.DateTimeField(null=True)
|
||||
archived_at = models.DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Notification"
|
||||
verbose_name_plural = "Notifications"
|
||||
db_table = "notifications"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["entity_identifier"], name="notif_entity_identifier_idx"),
|
||||
models.Index(fields=["entity_name"], name="notif_entity_name_idx"),
|
||||
models.Index(fields=["read_at"], name="notif_read_at_idx"),
|
||||
models.Index(fields=["receiver", "read_at"], name="notif_entity_idx"),
|
||||
models.Index(
|
||||
fields=["receiver", "workspace", "read_at", "created_at"],
|
||||
name="notif_receiver_status_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["receiver", "workspace", "entity_name", "read_at"],
|
||||
name="notif_receiver_entity_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["receiver", "workspace", "snoozed_till", "archived_at"],
|
||||
name="notif_receiver_state_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["receiver", "workspace", "sender"],
|
||||
name="notif_receiver_sender_idx",
|
||||
),
|
||||
models.Index(
|
||||
fields=["workspace", "entity_identifier", "entity_name"],
|
||||
name="notif_entity_lookup_idx",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the notifications"""
|
||||
return f"{self.receiver.email} <{self.workspace.name}>"
|
||||
|
||||
|
||||
def get_default_preference():
|
||||
return {
|
||||
"property_change": {"email": True},
|
||||
"state": {"email": True},
|
||||
"comment": {"email": True},
|
||||
"mentions": {"email": True},
|
||||
}
|
||||
|
||||
|
||||
class UserNotificationPreference(BaseModel):
|
||||
# user it is related to
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notification_preferences",
|
||||
)
|
||||
# workspace if it is applicable
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_notification_preferences",
|
||||
null=True,
|
||||
)
|
||||
# project
|
||||
project = models.ForeignKey(
|
||||
"db.Project",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="project_notification_preferences",
|
||||
null=True,
|
||||
)
|
||||
|
||||
# preference fields
|
||||
property_change = models.BooleanField(default=True)
|
||||
state_change = models.BooleanField(default=True)
|
||||
comment = models.BooleanField(default=True)
|
||||
mention = models.BooleanField(default=True)
|
||||
issue_completed = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "UserNotificationPreference"
|
||||
verbose_name_plural = "UserNotificationPreferences"
|
||||
db_table = "user_notification_preferences"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return the user"""
|
||||
return f"<{self.user}>"
|
||||
|
||||
|
||||
class EmailNotificationLog(BaseModel):
|
||||
# receiver
|
||||
receiver = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="email_notifications",
|
||||
)
|
||||
triggered_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="triggered_emails",
|
||||
)
|
||||
# entity - can be issues, pages, etc.
|
||||
entity_identifier = models.UUIDField(null=True)
|
||||
entity_name = models.CharField(max_length=255)
|
||||
# data
|
||||
data = models.JSONField(null=True)
|
||||
# sent at
|
||||
processed_at = models.DateTimeField(null=True)
|
||||
sent_at = models.DateTimeField(null=True)
|
||||
entity = models.CharField(max_length=200)
|
||||
old_value = models.CharField(max_length=300, blank=True, null=True)
|
||||
new_value = models.CharField(max_length=300, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Email Notification Log"
|
||||
verbose_name_plural = "Email Notification Logs"
|
||||
db_table = "email_notification_logs"
|
||||
ordering = ("-created_at",)
|
||||
@@ -0,0 +1,182 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def get_view_props():
|
||||
return {"full_width": False}
|
||||
|
||||
|
||||
class Page(BaseModel):
|
||||
PRIVATE_ACCESS = 1
|
||||
PUBLIC_ACCESS = 0
|
||||
DEFAULT_SORT_ORDER = 65535
|
||||
|
||||
ACCESS_CHOICES = ((PRIVATE_ACCESS, "Private"), (PUBLIC_ACCESS, "Public"))
|
||||
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="pages")
|
||||
name = models.TextField(blank=True)
|
||||
description_json = models.JSONField(default=dict, blank=True)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages")
|
||||
access = models.PositiveSmallIntegerField(choices=((0, "Public"), (1, "Private")), default=0)
|
||||
color = models.CharField(max_length=255, blank=True)
|
||||
labels = models.ManyToManyField("db.Label", blank=True, related_name="pages", through="db.PageLabel")
|
||||
parent = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="child_page",
|
||||
)
|
||||
archived_at = models.DateField(null=True)
|
||||
is_locked = models.BooleanField(default=False)
|
||||
view_props = models.JSONField(default=get_view_props)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
is_global = models.BooleanField(default=False)
|
||||
projects = models.ManyToManyField("db.Project", related_name="pages", through="db.ProjectPage")
|
||||
moved_to_page = models.UUIDField(null=True, blank=True)
|
||||
moved_to_project = models.UUIDField(null=True, blank=True)
|
||||
sort_order = models.FloatField(default=DEFAULT_SORT_ORDER)
|
||||
|
||||
external_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page"
|
||||
verbose_name_plural = "Pages"
|
||||
db_table = "pages"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return owner email and page name"""
|
||||
return f"{self.owned_by.email} <{self.name}>"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(Page, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class PageLog(BaseModel):
|
||||
TYPE_CHOICES = (
|
||||
("to_do", "To Do"),
|
||||
("issue", "issue"),
|
||||
("image", "Image"),
|
||||
("video", "Video"),
|
||||
("file", "File"),
|
||||
("link", "Link"),
|
||||
("cycle", "Cycle"),
|
||||
("module", "Module"),
|
||||
("back_link", "Back Link"),
|
||||
("forward_link", "Forward Link"),
|
||||
("page_mention", "Page Mention"),
|
||||
("user_mention", "User Mention"),
|
||||
)
|
||||
transaction = models.UUIDField(default=uuid.uuid4)
|
||||
page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE)
|
||||
entity_identifier = models.UUIDField(null=True, blank=True)
|
||||
entity_name = models.CharField(max_length=30, verbose_name="Transaction Type")
|
||||
entity_type = models.CharField(max_length=30, verbose_name="Entity Type", null=True, blank=True)
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log")
|
||||
|
||||
class Meta:
|
||||
unique_together = ["page", "transaction"]
|
||||
verbose_name = "Page Log"
|
||||
verbose_name_plural = "Page Logs"
|
||||
db_table = "page_logs"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["entity_type"], name="pagelog_entity_type_idx"),
|
||||
models.Index(fields=["entity_identifier"], name="pagelog_entity_id_idx"),
|
||||
models.Index(fields=["entity_name"], name="pagelog_entity_name_idx"),
|
||||
models.Index(fields=["entity_type", "entity_identifier"], name="pagelog_type_id_idx"),
|
||||
models.Index(fields=["entity_name", "entity_identifier"], name="pagelog_name_id_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.page.name} {self.entity_name}"
|
||||
|
||||
|
||||
class PageLabel(BaseModel):
|
||||
label = models.ForeignKey("db.Label", on_delete=models.CASCADE, related_name="page_labels")
|
||||
page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="page_labels")
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_label")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page Label"
|
||||
verbose_name_plural = "Page Labels"
|
||||
db_table = "page_labels"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.page.name} {self.label.name}"
|
||||
|
||||
|
||||
class ProjectPage(BaseModel):
|
||||
project = models.ForeignKey("db.Project", on_delete=models.CASCADE, related_name="project_pages")
|
||||
page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="project_pages")
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="project_pages")
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "page", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "page"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="project_page_unique_project_page_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Project Page"
|
||||
verbose_name_plural = "Project Pages"
|
||||
db_table = "project_pages"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.project.name} {self.page.name}"
|
||||
|
||||
|
||||
class PageVersion(BaseModel):
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="page_versions")
|
||||
page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="page_versions")
|
||||
last_saved_at = models.DateTimeField(default=timezone.now)
|
||||
owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="page_versions")
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_json = models.JSONField(default=dict, blank=True)
|
||||
sub_pages_data = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page Version"
|
||||
verbose_name_plural = "Page Versions"
|
||||
db_table = "page_versions"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(PageVersion, self).save(*args, **kwargs)
|
||||
@@ -0,0 +1,373 @@
|
||||
# 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 pytz
|
||||
from uuid import uuid4
|
||||
from enum import Enum
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
# Module imports
|
||||
from plane.db.mixins import AuditModel
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
|
||||
|
||||
|
||||
class ROLE(Enum):
|
||||
ADMIN = 20
|
||||
MEMBER = 15
|
||||
GUEST = 5
|
||||
|
||||
|
||||
class ProjectNetwork(Enum):
|
||||
SECRET = 0
|
||||
PUBLIC = 2
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(0, "Secret"), (2, "Public")]
|
||||
|
||||
|
||||
def get_default_props():
|
||||
return {
|
||||
"filters": {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
},
|
||||
"display_filters": {
|
||||
"group_by": None,
|
||||
"order_by": "-created_at",
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"show_empty_groups": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_default_preferences():
|
||||
return {"pages": {"block_display": True}, "navigation": {"default_tab": "work_items", "hide_in_more_menu": []}}
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
NETWORK_CHOICES = ((0, "Secret"), (2, "Public"))
|
||||
name = models.CharField(max_length=255, verbose_name="Project Name")
|
||||
description = models.TextField(verbose_name="Project Description", blank=True)
|
||||
description_text = models.JSONField(verbose_name="Project Description RT", blank=True, null=True)
|
||||
description_html = models.JSONField(verbose_name="Project Description HTML", blank=True, null=True)
|
||||
network = models.PositiveSmallIntegerField(default=2, choices=NETWORK_CHOICES)
|
||||
workspace = models.ForeignKey("db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project")
|
||||
identifier = models.CharField(max_length=12, verbose_name="Project Identifier", db_index=True)
|
||||
default_assignee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="default_assignee",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
project_lead = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="project_lead",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
emoji = models.CharField(max_length=255, null=True, blank=True)
|
||||
icon_prop = models.JSONField(null=True)
|
||||
module_view = models.BooleanField(default=False)
|
||||
cycle_view = models.BooleanField(default=False)
|
||||
issue_views_view = models.BooleanField(default=False)
|
||||
page_view = models.BooleanField(default=True)
|
||||
intake_view = models.BooleanField(default=False)
|
||||
is_time_tracking_enabled = models.BooleanField(default=False)
|
||||
is_issue_type_enabled = models.BooleanField(default=False)
|
||||
guest_view_all_features = models.BooleanField(default=False)
|
||||
cover_image = models.TextField(blank=True, null=True)
|
||||
cover_image_asset = models.ForeignKey(
|
||||
"db.FileAsset",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="project_cover_image",
|
||||
)
|
||||
estimate = models.ForeignKey("db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True)
|
||||
archive_in = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)])
|
||||
close_in = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)])
|
||||
logo_props = models.JSONField(default=dict)
|
||||
default_state = models.ForeignKey("db.State", on_delete=models.SET_NULL, null=True, related_name="default_state")
|
||||
archived_at = models.DateTimeField(null=True)
|
||||
# timezone
|
||||
TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
|
||||
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
|
||||
# external_id for imports
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Track if timezone is provided, if so, don't override it with the workspace timezone when saving
|
||||
self.is_timezone_provided = kwargs.get("timezone") is not None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def cover_image_url(self):
|
||||
# Return cover image url
|
||||
if self.cover_image_asset:
|
||||
return self.cover_image_asset.asset_url
|
||||
|
||||
# Return cover image url
|
||||
if self.cover_image:
|
||||
return self.cover_image
|
||||
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the project"""
|
||||
return f"{self.name} <{self.workspace.name}>"
|
||||
|
||||
FORBIDDEN_IDENTIFIER_CHARS_PATTERN = r"^.*[&+,:;$^}{*=?@#|'<>.()%!-].*$"
|
||||
|
||||
class Meta:
|
||||
unique_together = [
|
||||
["identifier", "workspace", "deleted_at"],
|
||||
["name", "workspace", "deleted_at"],
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["identifier", "workspace"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="project_unique_identifier_workspace_when_deleted_at_null",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "workspace"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="project_unique_name_workspace_when_deleted_at_null",
|
||||
),
|
||||
]
|
||||
verbose_name = "Project"
|
||||
verbose_name_plural = "Projects"
|
||||
db_table = "projects"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
from plane.db.models import Workspace
|
||||
|
||||
self.identifier = self.identifier.strip().upper()
|
||||
is_creating = self._state.adding
|
||||
|
||||
if is_creating and not self.is_timezone_provided:
|
||||
workspace = Workspace.objects.get(id=self.workspace_id)
|
||||
self.timezone = workspace.timezone
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ProjectBaseModel(BaseModel):
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="project_%(class)s")
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_%(class)s")
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.workspace = self.project.workspace
|
||||
super(ProjectBaseModel, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class ProjectMemberInvite(ProjectBaseModel):
|
||||
email = models.CharField(max_length=255)
|
||||
accepted = models.BooleanField(default=False)
|
||||
token = models.CharField(max_length=255)
|
||||
message = models.TextField(null=True)
|
||||
responded_at = models.DateTimeField(null=True)
|
||||
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Project Member Invite"
|
||||
verbose_name_plural = "Project Member Invites"
|
||||
db_table = "project_member_invites"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.project.name} {self.email} {self.accepted}"
|
||||
|
||||
|
||||
class ProjectMember(ProjectBaseModel):
|
||||
member = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="member_project",
|
||||
)
|
||||
comment = models.TextField(blank=True, null=True)
|
||||
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5)
|
||||
view_props = models.JSONField(default=get_default_props)
|
||||
default_props = models.JSONField(default=get_default_props)
|
||||
preferences = models.JSONField(default=get_default_preferences)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding and self.member:
|
||||
# Get the minimum sort_order for this member in the workspace
|
||||
min_sort_order_result = ProjectUserProperty.objects.filter(
|
||||
workspace_id=self.project.workspace_id, user=self.member
|
||||
).aggregate(min_sort_order=models.Min("sort_order"))
|
||||
min_sort_order = min_sort_order_result.get("min_sort_order")
|
||||
|
||||
# create project user property with project sort order
|
||||
ProjectUserProperty.objects.create(
|
||||
workspace_id=self.project.workspace_id,
|
||||
project=self.project,
|
||||
user=self.member,
|
||||
sort_order=(min_sort_order - 10000 if min_sort_order is not None else 65535),
|
||||
)
|
||||
|
||||
super(ProjectMember, self).save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "member", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "member"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="project_member_unique_project_member_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Project Member"
|
||||
verbose_name_plural = "Project Members"
|
||||
db_table = "project_members"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return members of the project"""
|
||||
return f"{self.member.email} <{self.project.name}>"
|
||||
|
||||
|
||||
# TODO: Remove workspace relation later
|
||||
class ProjectIdentifier(AuditModel):
|
||||
workspace = models.ForeignKey("db.Workspace", models.CASCADE, related_name="project_identifiers", null=True)
|
||||
project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="project_identifier")
|
||||
name = models.CharField(max_length=12, db_index=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "workspace", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "workspace"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="unique_name_workspace_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Project Identifier"
|
||||
verbose_name_plural = "Project Identifiers"
|
||||
db_table = "project_identifiers"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
def get_anchor():
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
def get_default_views():
|
||||
return {
|
||||
"list": True,
|
||||
"kanban": True,
|
||||
"calendar": True,
|
||||
"gantt": True,
|
||||
"spreadsheet": True,
|
||||
}
|
||||
|
||||
|
||||
# DEPRECATED TODO:
|
||||
# used to get the old anchors for the project deploy boards
|
||||
class ProjectDeployBoard(ProjectBaseModel):
|
||||
anchor = models.CharField(max_length=255, default=get_anchor, unique=True, db_index=True)
|
||||
comments = models.BooleanField(default=False)
|
||||
reactions = models.BooleanField(default=False)
|
||||
intake = models.ForeignKey("db.Intake", related_name="board_intake", on_delete=models.SET_NULL, null=True)
|
||||
votes = models.BooleanField(default=False)
|
||||
views = models.JSONField(default=get_default_views)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "anchor"]
|
||||
verbose_name = "Project Deploy Board"
|
||||
verbose_name_plural = "Project Deploy Boards"
|
||||
db_table = "project_deploy_boards"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return project and anchor"""
|
||||
return f"{self.anchor} <{self.project.name}>"
|
||||
|
||||
|
||||
class ProjectPublicMember(ProjectBaseModel):
|
||||
member = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="public_project_members",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "member", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "member"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="project_public_member_unique_project_member_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Project Public Member"
|
||||
verbose_name_plural = "Project Public Members"
|
||||
db_table = "project_public_members"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class ProjectUserProperty(ProjectBaseModel):
|
||||
from .issue import get_default_filters, get_default_display_filters, get_default_display_properties
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="project_property_user",
|
||||
)
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
preferences = models.JSONField(default=get_default_preferences)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Project User Property"
|
||||
verbose_name_plural = "Project User Properties"
|
||||
db_table = "project_user_properties"
|
||||
ordering = ("-created_at",)
|
||||
unique_together = ["user", "project", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "project"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="project_user_property_unique_user_project_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
"""Return properties status of the project"""
|
||||
return str(self.user)
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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.db import models
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from .workspace import WorkspaceBaseModel
|
||||
|
||||
|
||||
class EntityNameEnum(models.TextChoices):
|
||||
VIEW = "VIEW", "View"
|
||||
PAGE = "PAGE", "Page"
|
||||
ISSUE = "ISSUE", "Issue"
|
||||
CYCLE = "CYCLE", "Cycle"
|
||||
MODULE = "MODULE", "Module"
|
||||
PROJECT = "PROJECT", "Project"
|
||||
|
||||
|
||||
class UserRecentVisit(WorkspaceBaseModel):
|
||||
entity_identifier = models.UUIDField(null=True)
|
||||
entity_name = models.CharField(max_length=30)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="user_recent_visit",
|
||||
)
|
||||
visited_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "User Recent Visit"
|
||||
verbose_name_plural = "User Recent Visits"
|
||||
db_table = "user_recent_visits"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.entity_name} {self.user.email}"
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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 string
|
||||
|
||||
# Django imports
|
||||
from django.contrib.sessions.backends.db import SessionStore as DBSessionStore
|
||||
from django.contrib.sessions.base_session import AbstractBaseSession
|
||||
from django.db import models
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
VALID_KEY_CHARS = string.ascii_lowercase + string.digits
|
||||
|
||||
|
||||
class Session(AbstractBaseSession):
|
||||
device_info = models.JSONField(null=True, blank=True, default=None)
|
||||
session_key = models.CharField(max_length=128, primary_key=True)
|
||||
user_id = models.CharField(null=True, max_length=50, db_index=True)
|
||||
|
||||
@classmethod
|
||||
def get_session_store_class(cls):
|
||||
return SessionStore
|
||||
|
||||
class Meta(AbstractBaseSession.Meta):
|
||||
db_table = "sessions"
|
||||
|
||||
|
||||
class SessionStore(DBSessionStore):
|
||||
@classmethod
|
||||
def get_model_class(cls):
|
||||
return Session
|
||||
|
||||
def _get_new_session_key(self):
|
||||
"""
|
||||
Return a new session key that is not present in the current backend.
|
||||
Override this method to use a custom session key generation mechanism.
|
||||
"""
|
||||
while True:
|
||||
session_key = get_random_string(128, VALID_KEY_CHARS)
|
||||
if not self.exists(session_key):
|
||||
return session_key
|
||||
|
||||
def create_model_instance(self, data):
|
||||
obj = super().create_model_instance(data)
|
||||
try:
|
||||
user_id = data.get("_auth_user_id")
|
||||
except (ValueError, TypeError):
|
||||
user_id = None
|
||||
obj.user_id = user_id
|
||||
|
||||
# Save the device info
|
||||
device_info = data.get("device_info")
|
||||
obj.device_info = device_info if isinstance(device_info, dict) else None
|
||||
return obj
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
# Module import
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class SocialLoginConnection(BaseModel):
|
||||
medium = models.CharField(
|
||||
max_length=20,
|
||||
choices=(
|
||||
("Google", "google"),
|
||||
("Github", "github"),
|
||||
("GitLab", "gitlab"),
|
||||
("Jira", "jira"),
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
last_login_at = models.DateTimeField(default=timezone.now, null=True)
|
||||
last_received_at = models.DateTimeField(default=timezone.now, null=True)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="user_login_connections",
|
||||
)
|
||||
token_data = models.JSONField(null=True)
|
||||
extra_data = models.JSONField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Social Login Connection"
|
||||
verbose_name_plural = "Social Login Connections"
|
||||
db_table = "social_login_connections"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the user and medium"""
|
||||
return f"{self.medium} <{self.user.email}>"
|
||||
@@ -0,0 +1,126 @@
|
||||
# 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.db import models
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.db.models import Q
|
||||
|
||||
# Module imports
|
||||
from .project import ProjectBaseModel
|
||||
from plane.db.mixins import SoftDeletionManager
|
||||
|
||||
class StateGroup(models.TextChoices):
|
||||
BACKLOG = "backlog", "Backlog"
|
||||
UNSTARTED = "unstarted", "Unstarted"
|
||||
STARTED = "started", "Started"
|
||||
COMPLETED = "completed", "Completed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
TRIAGE = "triage", "Triage"
|
||||
|
||||
|
||||
# Default states
|
||||
DEFAULT_STATES = [
|
||||
{
|
||||
"name": "Backlog",
|
||||
"color": "#60646C",
|
||||
"sequence": 15000,
|
||||
"group": StateGroup.BACKLOG.value,
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "Todo",
|
||||
"color": "#60646C",
|
||||
"sequence": 25000,
|
||||
"group": StateGroup.UNSTARTED.value,
|
||||
},
|
||||
{
|
||||
"name": "In Progress",
|
||||
"color": "#F59E0B",
|
||||
"sequence": 35000,
|
||||
"group": StateGroup.STARTED.value,
|
||||
},
|
||||
{
|
||||
"name": "Done",
|
||||
"color": "#46A758",
|
||||
"sequence": 45000,
|
||||
"group": StateGroup.COMPLETED.value,
|
||||
},
|
||||
{
|
||||
"name": "Cancelled",
|
||||
"color": "#9AA4BC",
|
||||
"sequence": 55000,
|
||||
"group": StateGroup.CANCELLED.value,
|
||||
},
|
||||
{
|
||||
"name": "Triage",
|
||||
"color": "#4E5355",
|
||||
"sequence": 65000,
|
||||
"group": StateGroup.TRIAGE.value,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class StateManager(SoftDeletionManager):
|
||||
"""Default manager - excludes triage states"""
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().exclude(group=StateGroup.TRIAGE.value)
|
||||
|
||||
|
||||
class TriageStateManager(SoftDeletionManager):
|
||||
"""Manager for triage states only"""
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(group=StateGroup.TRIAGE.value)
|
||||
|
||||
|
||||
class State(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="State Name")
|
||||
description = models.TextField(verbose_name="State Description", blank=True)
|
||||
color = models.CharField(max_length=255, verbose_name="State Color")
|
||||
slug = models.SlugField(max_length=100, blank=True)
|
||||
sequence = models.FloatField(default=65535)
|
||||
group = models.CharField(
|
||||
choices=StateGroup.choices,
|
||||
default=StateGroup.BACKLOG,
|
||||
max_length=20,
|
||||
)
|
||||
is_triage = models.BooleanField(default=False)
|
||||
default = models.BooleanField(default=False)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
objects = StateManager()
|
||||
all_state_objects = models.Manager()
|
||||
triage_objects = TriageStateManager()
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the state"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "project"],
|
||||
condition=Q(deleted_at__isnull=True),
|
||||
name="state_unique_name_project_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "State"
|
||||
verbose_name_plural = "States"
|
||||
db_table = "states"
|
||||
ordering = ("sequence",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.slug = slugify(self.name)
|
||||
if self._state.adding:
|
||||
# Get the maximum sequence value from the database
|
||||
last_id = State.objects.filter(project=self.project).aggregate(largest=models.Max("sequence"))["largest"]
|
||||
# if last_id is not None
|
||||
if last_id is not None:
|
||||
self.sequence = last_id + 15000
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
@@ -0,0 +1,57 @@
|
||||
# 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.conf import settings
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from .base import BaseModel
|
||||
|
||||
# Third party imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
class Sticky(BaseModel):
|
||||
name = models.TextField(null=True, blank=True)
|
||||
|
||||
description = models.JSONField(blank=True, default=dict)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
|
||||
logo_props = models.JSONField(default=dict)
|
||||
color = models.CharField(max_length=255, blank=True, null=True)
|
||||
background_color = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="stickies")
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies")
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Sticky"
|
||||
verbose_name_plural = "Stickies"
|
||||
db_table = "stickies"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
if self._state.adding:
|
||||
# Get the maximum sequence value from the database
|
||||
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(largest=models.Max("sort_order"))[
|
||||
"largest"
|
||||
]
|
||||
# if last_id is not None
|
||||
if last_id is not None:
|
||||
self.sort_order = last_id + 10000
|
||||
|
||||
super(Sticky, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name)
|
||||
@@ -0,0 +1,312 @@
|
||||
# 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 random
|
||||
import string
|
||||
import uuid
|
||||
|
||||
import pytz
|
||||
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, UserManager
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset
|
||||
from ..mixins import TimeAuditModel
|
||||
from plane.utils.color import get_random_color
|
||||
|
||||
|
||||
def get_default_onboarding():
|
||||
return {
|
||||
"profile_complete": False,
|
||||
"workspace_create": False,
|
||||
"workspace_invite": False,
|
||||
"workspace_join": False,
|
||||
}
|
||||
|
||||
|
||||
def get_mobile_default_onboarding():
|
||||
return {
|
||||
"profile_complete": False,
|
||||
"workspace_create": False,
|
||||
"workspace_join": False,
|
||||
}
|
||||
|
||||
|
||||
def get_default_product_tour():
|
||||
return {
|
||||
"work_items": False,
|
||||
"cycles": False,
|
||||
"modules": False,
|
||||
"intake": False,
|
||||
"pages": False,
|
||||
}
|
||||
|
||||
|
||||
class BotTypeEnum(models.TextChoices):
|
||||
WORKSPACE_SEED = "WORKSPACE_SEED", "Workspace Seed"
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
|
||||
username = models.CharField(max_length=128, unique=True)
|
||||
# user fields
|
||||
mobile_number = models.CharField(max_length=255, blank=True, null=True)
|
||||
email = models.CharField(max_length=255, null=True, blank=True, unique=True)
|
||||
|
||||
# identity
|
||||
display_name = models.CharField(max_length=255, default="")
|
||||
first_name = models.CharField(max_length=255, blank=True)
|
||||
last_name = models.CharField(max_length=255, blank=True)
|
||||
# avatar
|
||||
avatar = models.TextField(blank=True)
|
||||
avatar_asset = models.ForeignKey(
|
||||
FileAsset,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="user_avatar",
|
||||
)
|
||||
# cover image
|
||||
cover_image = models.URLField(blank=True, null=True, max_length=800)
|
||||
cover_image_asset = models.ForeignKey(
|
||||
FileAsset,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="user_cover_image",
|
||||
)
|
||||
|
||||
# tracking metrics
|
||||
date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Created At")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Last Modified At")
|
||||
last_location = models.CharField(max_length=255, blank=True)
|
||||
created_location = models.CharField(max_length=255, blank=True)
|
||||
|
||||
# the is' es
|
||||
is_superuser = models.BooleanField(default=False)
|
||||
is_managed = models.BooleanField(default=False)
|
||||
is_password_expired = models.BooleanField(default=False)
|
||||
is_active = models.BooleanField(default=True)
|
||||
is_staff = models.BooleanField(default=False)
|
||||
is_email_verified = models.BooleanField(default=False)
|
||||
is_password_autoset = models.BooleanField(default=False)
|
||||
is_password_reset_required = models.BooleanField(default=False)
|
||||
# random token generated
|
||||
token = models.CharField(max_length=64, blank=True)
|
||||
|
||||
last_active = models.DateTimeField(default=timezone.now, null=True)
|
||||
last_login_time = models.DateTimeField(null=True)
|
||||
last_logout_time = models.DateTimeField(null=True)
|
||||
last_login_ip = models.CharField(max_length=255, blank=True)
|
||||
last_logout_ip = models.CharField(max_length=255, blank=True)
|
||||
last_login_medium = models.CharField(max_length=20, default="email")
|
||||
last_login_uagent = models.TextField(blank=True)
|
||||
token_updated_at = models.DateTimeField(null=True)
|
||||
# my_issues_prop = models.JSONField(null=True)
|
||||
|
||||
is_bot = models.BooleanField(default=False)
|
||||
bot_type = models.CharField(max_length=30, verbose_name="Bot Type", blank=True, null=True)
|
||||
|
||||
# timezone
|
||||
USER_TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
|
||||
user_timezone = models.CharField(max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES)
|
||||
|
||||
# email validation
|
||||
is_email_valid = models.BooleanField(default=False)
|
||||
|
||||
# masking
|
||||
masked_at = models.DateTimeField(null=True)
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = ["username"]
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = "User"
|
||||
verbose_name_plural = "Users"
|
||||
db_table = "users"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} <{self.email}>"
|
||||
|
||||
@property
|
||||
def avatar_url(self):
|
||||
# Return the logo asset url if it exists
|
||||
if self.avatar_asset:
|
||||
return self.avatar_asset.asset_url
|
||||
|
||||
# Return the logo url if it exists
|
||||
if self.avatar:
|
||||
return self.avatar
|
||||
return None
|
||||
|
||||
@property
|
||||
def cover_image_url(self):
|
||||
# Return the logo asset url if it exists
|
||||
if self.cover_image_asset:
|
||||
return self.cover_image_asset.asset_url
|
||||
|
||||
# Return the logo url if it exists
|
||||
if self.cover_image:
|
||||
return self.cover_image
|
||||
return None
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
"""Return user's full name (first + last)."""
|
||||
return f"{self.first_name} {self.last_name}".strip()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email = self.email.lower().strip()
|
||||
self.mobile_number = self.mobile_number
|
||||
|
||||
if self.token_updated_at is not None:
|
||||
self.token = uuid.uuid4().hex + uuid.uuid4().hex
|
||||
self.token_updated_at = timezone.now()
|
||||
|
||||
if not self.display_name:
|
||||
self.display_name = (
|
||||
self.email.split("@")[0]
|
||||
if len(self.email.split("@"))
|
||||
else "".join(random.choice(string.ascii_letters) for _ in range(6))
|
||||
)
|
||||
|
||||
if self.is_superuser:
|
||||
self.is_staff = True
|
||||
|
||||
super(User, self).save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_display_name(cls, email):
|
||||
if not email:
|
||||
return "".join(random.choice(string.ascii_letters) for _ in range(6))
|
||||
return (
|
||||
email.split("@")[0]
|
||||
if len(email.split("@")) == 2
|
||||
else "".join(random.choice(string.ascii_letters) for _ in range(6))
|
||||
)
|
||||
|
||||
|
||||
class Profile(TimeAuditModel):
|
||||
SUNDAY = 0
|
||||
MONDAY = 1
|
||||
TUESDAY = 2
|
||||
WEDNESDAY = 3
|
||||
THURSDAY = 4
|
||||
FRIDAY = 5
|
||||
SATURDAY = 6
|
||||
|
||||
class NotificationViewMode(models.TextChoices):
|
||||
FULL = "full", "Full"
|
||||
COMPACT = "compact", "Compact"
|
||||
|
||||
START_OF_THE_WEEK_CHOICES = (
|
||||
(SUNDAY, "Sunday"),
|
||||
(MONDAY, "Monday"),
|
||||
(TUESDAY, "Tuesday"),
|
||||
(WEDNESDAY, "Wednesday"),
|
||||
(THURSDAY, "Thursday"),
|
||||
(FRIDAY, "Friday"),
|
||||
(SATURDAY, "Saturday"),
|
||||
)
|
||||
|
||||
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
|
||||
# User
|
||||
user = models.OneToOneField("db.User", on_delete=models.CASCADE, related_name="profile")
|
||||
# General
|
||||
theme = models.JSONField(default=dict)
|
||||
is_app_rail_docked = models.BooleanField(default=True)
|
||||
# Onboarding
|
||||
is_tour_completed = models.BooleanField(default=False)
|
||||
onboarding_step = models.JSONField(default=get_default_onboarding)
|
||||
use_case = models.TextField(blank=True, null=True)
|
||||
role = models.CharField(max_length=300, null=True, blank=True) # job role
|
||||
is_onboarded = models.BooleanField(default=False)
|
||||
# Last visited workspace
|
||||
last_workspace_id = models.UUIDField(null=True)
|
||||
# address data
|
||||
billing_address_country = models.CharField(max_length=255, default="INDIA")
|
||||
billing_address = models.JSONField(null=True)
|
||||
has_billing_address = models.BooleanField(default=False)
|
||||
company_name = models.CharField(max_length=255, blank=True)
|
||||
notification_view_mode = models.CharField(
|
||||
max_length=255, choices=NotificationViewMode.choices, default=NotificationViewMode.FULL
|
||||
)
|
||||
is_smooth_cursor_enabled = models.BooleanField(default=False)
|
||||
# mobile
|
||||
is_mobile_onboarded = models.BooleanField(default=False)
|
||||
mobile_onboarding_step = models.JSONField(default=get_mobile_default_onboarding)
|
||||
mobile_timezone_auto_set = models.BooleanField(default=False)
|
||||
# language
|
||||
language = models.CharField(max_length=255, default="ru")
|
||||
start_of_the_week = models.PositiveSmallIntegerField(choices=START_OF_THE_WEEK_CHOICES, default=SUNDAY)
|
||||
goals = models.JSONField(default=dict)
|
||||
background_color = models.CharField(max_length=255, default=get_random_color)
|
||||
|
||||
# navigation tour
|
||||
is_navigation_tour_completed = models.BooleanField(default=False)
|
||||
|
||||
# marketing
|
||||
has_marketing_email_consent = models.BooleanField(default=False)
|
||||
is_subscribed_to_changelog = models.BooleanField(default=False)
|
||||
product_tour = models.JSONField(default=get_default_product_tour)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Profile"
|
||||
verbose_name_plural = "Profiles"
|
||||
db_table = "profiles"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class Account(TimeAuditModel):
|
||||
PROVIDER_CHOICES = (
|
||||
("google", "Google"),
|
||||
("github", "Github"),
|
||||
("gitlab", "GitLab"),
|
||||
)
|
||||
|
||||
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
|
||||
user = models.ForeignKey("db.User", on_delete=models.CASCADE, related_name="accounts")
|
||||
provider_account_id = models.CharField(max_length=255)
|
||||
provider = models.CharField(choices=PROVIDER_CHOICES)
|
||||
access_token = models.TextField()
|
||||
access_token_expired_at = models.DateTimeField(null=True)
|
||||
refresh_token = models.TextField(null=True, blank=True)
|
||||
refresh_token_expired_at = models.DateTimeField(null=True)
|
||||
last_connected_at = models.DateTimeField(default=timezone.now)
|
||||
id_token = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["provider", "provider_account_id"]
|
||||
verbose_name = "Account"
|
||||
verbose_name_plural = "Accounts"
|
||||
db_table = "accounts"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_notification(sender, instance, created, **kwargs):
|
||||
# create preferences
|
||||
if created and not instance.is_bot:
|
||||
# Module imports
|
||||
from plane.db.models import UserNotificationPreference
|
||||
|
||||
UserNotificationPreference.objects.create(
|
||||
user=instance,
|
||||
property_change=True,
|
||||
state_change=True,
|
||||
comment=True,
|
||||
mention=True,
|
||||
issue_completed=True,
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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.conf import settings
|
||||
from django.db import models
|
||||
|
||||
# Module import
|
||||
from .workspace import WorkspaceBaseModel
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
def get_default_filters():
|
||||
return {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
}
|
||||
|
||||
|
||||
def get_default_display_filters():
|
||||
return {
|
||||
"group_by": None,
|
||||
"order_by": "-created_at",
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"show_empty_groups": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
}
|
||||
|
||||
|
||||
def get_default_display_properties():
|
||||
return {
|
||||
"assignee": True,
|
||||
"attachment_count": True,
|
||||
"created_on": True,
|
||||
"due_date": True,
|
||||
"estimate": True,
|
||||
"key": True,
|
||||
"labels": True,
|
||||
"link": True,
|
||||
"priority": True,
|
||||
"start_date": True,
|
||||
"state": True,
|
||||
"sub_issue_count": True,
|
||||
"updated_on": True,
|
||||
}
|
||||
|
||||
|
||||
class IssueView(WorkspaceBaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="View Name")
|
||||
description = models.TextField(verbose_name="View Description", blank=True)
|
||||
query = models.JSONField(verbose_name="View Query")
|
||||
filters = models.JSONField(default=dict)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
access = models.PositiveSmallIntegerField(default=1, choices=((0, "Private"), (1, "Public")))
|
||||
sort_order = models.FloatField(default=65535)
|
||||
logo_props = models.JSONField(default=dict)
|
||||
owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="views")
|
||||
is_locked = models.BooleanField(default=False)
|
||||
archived_at = models.DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue View"
|
||||
verbose_name_plural = "Issue Views"
|
||||
db_table = "issue_views"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
query_params = self.filters
|
||||
self.query = issue_filters(query_params, "POST") if query_params else {}
|
||||
|
||||
if self._state.adding:
|
||||
if self.project:
|
||||
largest_sort_order = IssueView.objects.filter(project=self.project).aggregate(
|
||||
largest=models.Max("sort_order")
|
||||
)["largest"]
|
||||
else:
|
||||
largest_sort_order = IssueView.objects.filter(workspace=self.workspace, project__isnull=True).aggregate(
|
||||
largest=models.Max("sort_order")
|
||||
)["largest"]
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
|
||||
super(IssueView, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the View"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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 uuid import uuid4
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import BaseModel, ProjectBaseModel
|
||||
|
||||
|
||||
def generate_token():
|
||||
return "plane_wh_" + uuid4().hex
|
||||
|
||||
|
||||
def validate_schema(value):
|
||||
parsed_url = urlparse(value)
|
||||
if parsed_url.scheme not in ["http", "https"]:
|
||||
raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.")
|
||||
|
||||
|
||||
def validate_domain(value):
|
||||
parsed_url = urlparse(value)
|
||||
domain = parsed_url.netloc
|
||||
if domain in ["localhost", "127.0.0.1"]:
|
||||
raise ValidationError("Local URLs are not allowed.")
|
||||
|
||||
|
||||
class Webhook(BaseModel):
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks")
|
||||
url = models.URLField(validators=[validate_schema, validate_domain], max_length=1024)
|
||||
is_active = models.BooleanField(default=True)
|
||||
secret_key = models.CharField(max_length=255, default=generate_token)
|
||||
project = models.BooleanField(default=False)
|
||||
issue = models.BooleanField(default=False)
|
||||
module = models.BooleanField(default=False)
|
||||
cycle = models.BooleanField(default=False)
|
||||
issue_comment = models.BooleanField(default=False)
|
||||
is_internal = models.BooleanField(default=False)
|
||||
version = models.CharField(default="v1", max_length=50)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.slug} {self.url}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "url", "deleted_at"]
|
||||
verbose_name = "Webhook"
|
||||
verbose_name_plural = "Webhooks"
|
||||
db_table = "webhooks"
|
||||
ordering = ("-created_at",)
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "url"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="webhook_url_unique_url_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class WebhookLog(BaseModel):
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs")
|
||||
# Associated webhook
|
||||
webhook = models.UUIDField()
|
||||
|
||||
# Basic request details
|
||||
event_type = models.CharField(max_length=255, blank=True, null=True)
|
||||
request_method = models.CharField(max_length=10, blank=True, null=True)
|
||||
request_headers = models.TextField(blank=True, null=True)
|
||||
request_body = models.TextField(blank=True, null=True)
|
||||
|
||||
# Response details
|
||||
response_status = models.TextField(blank=True, null=True)
|
||||
response_headers = models.TextField(blank=True, null=True)
|
||||
response_body = models.TextField(blank=True, null=True)
|
||||
|
||||
# Retry Count
|
||||
retry_count = models.PositiveSmallIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Webhook Log"
|
||||
verbose_name_plural = "Webhook Logs"
|
||||
db_table = "webhook_logs"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event_type} {str(self.webhook)}"
|
||||
|
||||
|
||||
class ProjectWebhook(ProjectBaseModel):
|
||||
webhook = models.ForeignKey("db.Webhook", on_delete=models.CASCADE, related_name="project_webhooks")
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "webhook", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "webhook"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="project_webhook_unique_project_webhook_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Project Webhook"
|
||||
verbose_name_plural = "Project Webhooks"
|
||||
db_table = "project_webhooks"
|
||||
ordering = ("-created_at",)
|
||||
@@ -0,0 +1,455 @@
|
||||
# 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 pytz
|
||||
from typing import Optional, Any
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
# Module imports
|
||||
from .base import BaseModel
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
from plane.utils.color import get_random_color
|
||||
|
||||
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
|
||||
|
||||
|
||||
def get_default_props():
|
||||
return {
|
||||
"filters": {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
},
|
||||
"display_filters": {
|
||||
"group_by": None,
|
||||
"order_by": "-created_at",
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"show_empty_groups": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
},
|
||||
"display_properties": {
|
||||
"assignee": True,
|
||||
"attachment_count": True,
|
||||
"created_on": True,
|
||||
"due_date": True,
|
||||
"estimate": True,
|
||||
"key": True,
|
||||
"labels": True,
|
||||
"link": True,
|
||||
"priority": True,
|
||||
"start_date": True,
|
||||
"state": True,
|
||||
"sub_issue_count": True,
|
||||
"updated_on": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_default_filters():
|
||||
return {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
}
|
||||
|
||||
|
||||
def get_default_display_filters():
|
||||
return {
|
||||
"display_filters": {
|
||||
"group_by": None,
|
||||
"order_by": "-created_at",
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"show_empty_groups": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_default_display_properties():
|
||||
return {
|
||||
"display_properties": {
|
||||
"assignee": True,
|
||||
"attachment_count": True,
|
||||
"created_on": True,
|
||||
"due_date": True,
|
||||
"estimate": True,
|
||||
"key": True,
|
||||
"labels": True,
|
||||
"link": True,
|
||||
"priority": True,
|
||||
"start_date": True,
|
||||
"state": True,
|
||||
"sub_issue_count": True,
|
||||
"updated_on": True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def get_issue_props():
|
||||
return {"subscribed": True, "assigned": True, "created": True, "all_issues": True}
|
||||
|
||||
|
||||
def slug_validator(value):
|
||||
if value in RESTRICTED_WORKSPACE_SLUGS:
|
||||
raise ValidationError("Slug is not valid")
|
||||
|
||||
|
||||
class Workspace(BaseModel):
|
||||
TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones))
|
||||
|
||||
name = models.CharField(max_length=80, verbose_name="Workspace Name")
|
||||
logo = models.TextField(verbose_name="Logo", blank=True, null=True)
|
||||
logo_asset = models.ForeignKey(
|
||||
"db.FileAsset",
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="workspace_logo",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="owner_workspace",
|
||||
)
|
||||
slug = models.SlugField(max_length=48, db_index=True, unique=True, validators=[slug_validator])
|
||||
organization_size = models.CharField(max_length=20, blank=True, null=True)
|
||||
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
|
||||
background_color = models.CharField(max_length=255, default=get_random_color)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the Workspace"""
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def logo_url(self):
|
||||
# Return the logo asset url if it exists
|
||||
if self.logo_asset:
|
||||
return self.logo_asset.asset_url
|
||||
|
||||
# Return the logo url if it exists
|
||||
if self.logo:
|
||||
return self.logo
|
||||
return None
|
||||
|
||||
def delete(self, using: Optional[str] = None, soft: bool = True, *args: Any, **kwargs: Any):
|
||||
"""
|
||||
Override the delete method to append epoch timestamp to the slug when soft deleting.
|
||||
|
||||
Args:
|
||||
using: The database alias to use for the deletion.
|
||||
soft: Whether to perform a soft delete (True) or hard delete (False).
|
||||
*args: Additional positional arguments.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
# Call the parent class's delete method first
|
||||
result = super().delete(using=using, soft=soft, *args, **kwargs)
|
||||
|
||||
# If it's a soft delete and the model still exists (not hard deleted)
|
||||
if soft and hasattr(self, "deleted_at") and self.deleted_at:
|
||||
# Use the deleted_at timestamp to update the slug
|
||||
deletion_timestamp: int = int(self.deleted_at.timestamp())
|
||||
self.slug = f"{self.slug}__{deletion_timestamp}"
|
||||
self.save(update_fields=["slug"])
|
||||
|
||||
return result
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Workspace"
|
||||
verbose_name_plural = "Workspaces"
|
||||
db_table = "workspaces"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class WorkspaceBaseModel(BaseModel):
|
||||
workspace = models.ForeignKey("db.Workspace", models.CASCADE, related_name="workspace_%(class)s")
|
||||
project = models.ForeignKey("db.Project", models.CASCADE, related_name="project_%(class)s", null=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.project:
|
||||
self.workspace = self.project.workspace
|
||||
super(WorkspaceBaseModel, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class WorkspaceMember(BaseModel):
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_member")
|
||||
member = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="member_workspace",
|
||||
)
|
||||
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5)
|
||||
company_role = models.TextField(null=True, blank=True)
|
||||
view_props = models.JSONField(default=get_default_props)
|
||||
default_props = models.JSONField(default=get_default_props)
|
||||
issue_props = models.JSONField(default=get_issue_props)
|
||||
is_active = models.BooleanField(default=True)
|
||||
getting_started_checklist = models.JSONField(default=dict)
|
||||
tips = models.JSONField(default=dict)
|
||||
explored_features = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "member", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "member"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="workspace_member_unique_workspace_member_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Workspace Member"
|
||||
verbose_name_plural = "Workspace Members"
|
||||
db_table = "workspace_members"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return members of the workspace"""
|
||||
return f"{self.member.email} <{self.workspace.name}>"
|
||||
|
||||
|
||||
class WorkspaceMemberInvite(BaseModel):
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite")
|
||||
email = models.CharField(max_length=255)
|
||||
accepted = models.BooleanField(default=False)
|
||||
token = models.CharField(max_length=255)
|
||||
message = models.TextField(null=True)
|
||||
responded_at = models.DateTimeField(null=True)
|
||||
role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=5)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["email", "workspace", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["email", "workspace"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="workspace_member_invite_unique_email_workspace_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Workspace Member Invite"
|
||||
verbose_name_plural = "Workspace Member Invites"
|
||||
db_table = "workspace_member_invites"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.name} {self.email} {self.accepted}"
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="Team Name")
|
||||
description = models.TextField(verbose_name="Team Description", blank=True)
|
||||
workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name="workspace_team")
|
||||
logo_props = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the team"""
|
||||
return f"{self.name} <{self.workspace.name}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "workspace", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "workspace"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="team_unique_name_workspace_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Team"
|
||||
verbose_name_plural = "Teams"
|
||||
db_table = "teams"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class WorkspaceTheme(BaseModel):
|
||||
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="themes")
|
||||
name = models.CharField(max_length=300)
|
||||
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes")
|
||||
colors = models.JSONField(default=dict)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name) + str(self.actor.email)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "name", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "name"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="workspace_theme_unique_workspace_name_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Workspace Theme"
|
||||
verbose_name_plural = "Workspace Themes"
|
||||
db_table = "workspace_themes"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
|
||||
class WorkspaceUserProperties(BaseModel):
|
||||
class NavigationControlPreference(models.TextChoices):
|
||||
ACCORDION = "ACCORDION", "Accordion"
|
||||
TABBED = "TABBED", "Tabbed"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_user_properties",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_user_properties",
|
||||
)
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
rich_filters = models.JSONField(default=dict)
|
||||
navigation_project_limit = models.IntegerField(default=10)
|
||||
navigation_control_preference = models.CharField(
|
||||
max_length=25,
|
||||
choices=NavigationControlPreference.choices,
|
||||
default=NavigationControlPreference.ACCORDION,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "user", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "user"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="workspace_user_properties_unique_workspace_user_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Workspace User Property"
|
||||
verbose_name_plural = "Workspace User Property"
|
||||
db_table = "workspace_user_properties"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.name} {self.user.email}"
|
||||
|
||||
|
||||
class WorkspaceUserLink(WorkspaceBaseModel):
|
||||
title = models.CharField(max_length=255, null=True, blank=True)
|
||||
url = models.TextField()
|
||||
metadata = models.JSONField(default=dict)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="owner_workspace_user_link",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Workspace User Link"
|
||||
verbose_name_plural = "Workspace User Links"
|
||||
db_table = "workspace_user_links"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.id} {self.url}"
|
||||
|
||||
|
||||
class WorkspaceHomePreference(BaseModel):
|
||||
"""Preference for the home page of a workspace for a user"""
|
||||
|
||||
class HomeWidgetKeys(models.TextChoices):
|
||||
QUICK_LINKS = "quick_links", "Quick Links"
|
||||
RECENTS = "recents", "Recents"
|
||||
MY_STICKIES = "my_stickies", "My Stickies"
|
||||
NEW_AT_PLANE = "new_at_plane", "New at Plane"
|
||||
QUICK_TUTORIAL = "quick_tutorial", "Quick Tutorial"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_user_home_preferences",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_user_home_preferences",
|
||||
)
|
||||
key = models.CharField(max_length=255)
|
||||
is_enabled = models.BooleanField(default=True)
|
||||
config = models.JSONField(default=dict)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "user", "key", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "user", "key"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="workspace_user_home_preferences_unique_workspace_user_key_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Workspace Home Preference"
|
||||
verbose_name_plural = "Workspace Home Preferences"
|
||||
db_table = "workspace_home_preferences"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.name} {self.user.email} {self.key}"
|
||||
|
||||
|
||||
class WorkspaceUserPreference(BaseModel):
|
||||
"""Preference for the workspace for a user"""
|
||||
|
||||
class UserPreferenceKeys(models.TextChoices):
|
||||
VIEWS = "views", "Views"
|
||||
ACTIVE_CYCLES = "active_cycles", "Active Cycles"
|
||||
ANALYTICS = "analytics", "Analytics"
|
||||
DRAFTS = "drafts", "Drafts"
|
||||
YOUR_WORK = "your_work", "Your Work"
|
||||
ARCHIVES = "archives", "Archives"
|
||||
STICKIES = "stickies", "Stickies"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_user_preferences",
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_user_preferences",
|
||||
)
|
||||
key = models.CharField(max_length=255)
|
||||
is_pinned = models.BooleanField(default=False)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "user", "key", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "user", "key"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Workspace User Preference"
|
||||
verbose_name_plural = "Workspace User Preferences"
|
||||
db_table = "workspace_user_preferences"
|
||||
ordering = ("-created_at",)
|
||||
Reference in New Issue
Block a user