This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -0,0 +1,4 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
+471
View File
@@ -0,0 +1,471 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Global Settings"""
# Python imports
import os
from urllib.parse import urlparse
from urllib.parse import urljoin
# Third party imports
import dj_database_url
# Django imports
from django.core.management.utils import get_random_secret_key
from corsheaders.defaults import default_headers
# Module imports
from plane.utils.url import is_valid_url
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Secret Key
SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", "0"))
# Self-hosted mode
IS_SELF_MANAGED = True
# Allowed Hosts
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
# Application definition
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.staticfiles",
# Inhouse apps
"plane.analytics",
"plane.app",
"plane.space",
"plane.bgtasks",
"plane.db",
"plane.utils",
"plane.web",
"plane.middleware",
"plane.license",
"plane.api",
"plane.authentication",
# Third-party things
"rest_framework",
"corsheaders",
"django_celery_beat",
]
# Middlewares
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"plane.authentication.middleware.session.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"crum.CurrentRequestUserMiddleware",
"django.middleware.gzip.GZipMiddleware",
"plane.middleware.request_body_size.RequestBodySizeLimitMiddleware",
"plane.middleware.logger.APITokenLogMiddleware",
"plane.middleware.logger.RequestLoggerMiddleware",
]
# Rest Framework settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",),
"DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",),
"DEFAULT_THROTTLE_RATES": {
"anon": "30/minute",
"asset_id": "5/minute",
},
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
"DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
"EXCEPTION_HANDLER": "plane.authentication.adapter.exception.auth_exception_handler",
# Preserve original Django URL parameter names (pk) instead of converting to 'id'
"SCHEMA_COERCE_PATH_PK": False,
}
# Django Auth Backend
AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) # default
# Root Urls
ROOT_URLCONF = "plane.urls"
# Templates
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": ["templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]
# CORS Settings
CORS_ALLOW_CREDENTIALS = True
cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "")
# filter out empty strings
cors_allowed_origins = [origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()]
if cors_allowed_origins:
CORS_ALLOWED_ORIGINS = cors_allowed_origins
secure_origins = False if [origin for origin in cors_allowed_origins if "http:" in origin] else True
else:
CORS_ALLOW_ALL_ORIGINS = True
secure_origins = False
CORS_ALLOW_HEADERS = [*default_headers, "X-API-Key"]
# Application Settings
WSGI_APPLICATION = "plane.wsgi.application"
ASGI_APPLICATION = "plane.asgi.application"
# Django Sites
SITE_ID = 1
# User Model
AUTH_USER_MODEL = "db.User"
# Database
if bool(os.environ.get("DATABASE_URL")):
# Parse database configuration from $DATABASE_URL
DATABASES = {"default": dj_database_url.config()}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB"),
"USER": os.environ.get("POSTGRES_USER"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
"HOST": os.environ.get("POSTGRES_HOST"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
}
}
if os.environ.get("ENABLE_READ_REPLICA", "0") == "1":
if bool(os.environ.get("DATABASE_READ_REPLICA_URL")):
# Parse database configuration from $DATABASE_URL
DATABASES["replica"] = dj_database_url.parse(os.environ.get("DATABASE_READ_REPLICA_URL"))
else:
DATABASES["replica"] = {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_READ_REPLICA_DB"),
"USER": os.environ.get("POSTGRES_READ_REPLICA_USER"),
"PASSWORD": os.environ.get("POSTGRES_READ_REPLICA_PASSWORD"),
"HOST": os.environ.get("POSTGRES_READ_REPLICA_HOST"),
"PORT": os.environ.get("POSTGRES_READ_REPLICA_PORT", "5432"),
}
# Database Routers
DATABASE_ROUTERS = ["plane.utils.core.dbrouters.ReadReplicaRouter"]
# Add middleware at the end for read replica routing
MIDDLEWARE.append("plane.middleware.db_routing.ReadReplicaRoutingMiddleware")
# Redis Config
REDIS_URL = os.environ.get("REDIS_URL")
REDIS_SSL = REDIS_URL and "rediss" in REDIS_URL
if REDIS_SSL:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
},
}
}
else:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
}
}
# Password validations
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# Password reset time the number of seconds the uniquely generated uid will be valid
PASSWORD_RESET_TIMEOUT = 3600
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "static-assets", "collected-static")
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),)
# Media Settings
MEDIA_ROOT = "mediafiles"
MEDIA_URL = "/media/"
# Internationalization
LANGUAGE_CODE = "en-us"
USE_I18N = True
USE_L10N = True
# Timezones
USE_TZ = True
TIME_ZONE = "UTC"
# Default Auto Field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Email settings
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# Storage Settings
# Use Minio settings
USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
STORAGES = {"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}}
STORAGES["default"] = {"BACKEND": "plane.settings.storage.S3Storage"}
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
AWS_REGION = os.environ.get("AWS_REGION", "")
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", None) or os.environ.get("MINIO_ENDPOINT_URL", None)
if AWS_S3_ENDPOINT_URL and USE_MINIO:
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
# RabbitMQ connection settings
RABBITMQ_HOST = os.environ.get("RABBITMQ_HOST", "localhost")
RABBITMQ_PORT = os.environ.get("RABBITMQ_PORT", "5672")
RABBITMQ_USER = os.environ.get("RABBITMQ_USER", "guest")
RABBITMQ_PASSWORD = os.environ.get("RABBITMQ_PASSWORD", "guest")
RABBITMQ_VHOST = os.environ.get("RABBITMQ_VHOST", "/")
AMQP_URL = os.environ.get("AMQP_URL")
# Celery Configuration
if AMQP_URL:
CELERY_BROKER_URL = AMQP_URL
else:
CELERY_BROKER_URL = f"amqp://{RABBITMQ_USER}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}"
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_ACCEPT_CONTENT = ["application/json"]
CELERY_IMPORTS = (
# scheduled tasks
"plane.bgtasks.issue_automation_task",
"plane.bgtasks.exporter_expired_task",
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
"plane.bgtasks.cleanup_task",
"plane.license.bgtasks.tracer",
# management tasks
"plane.bgtasks.dummy_data_task",
# issue version tasks
"plane.bgtasks.issue_version_sync",
"plane.bgtasks.issue_description_version_sync",
)
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
# Github Access Token
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
# Analytics
ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
# Posthog settings
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False)
# Skip environment variable configuration
SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1"
DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Cookie Settings
SESSION_COOKIE_SECURE = secure_origins
SESSION_COOKIE_HTTPONLY = True
SESSION_ENGINE = "plane.db.models.session"
SESSION_COOKIE_AGE = int(os.environ.get("SESSION_COOKIE_AGE", 604800))
SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id")
SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
SESSION_SAVE_EVERY_REQUEST = os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1"
# Admin Cookie
ADMIN_SESSION_COOKIE_NAME = "admin-session-id"
ADMIN_SESSION_COOKIE_AGE = int(os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600))
# CSRF cookies
CSRF_COOKIE_SECURE = secure_origins
CSRF_COOKIE_HTTPONLY = True
CSRF_TRUSTED_ORIGINS = cors_allowed_origins
CSRF_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
###### Base URLs ######
# Admin Base URL
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
if ADMIN_BASE_URL and not is_valid_url(ADMIN_BASE_URL):
ADMIN_BASE_URL = None
ADMIN_BASE_PATH = os.environ.get("ADMIN_BASE_PATH", "/god-mode/")
# Space Base URL
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
if SPACE_BASE_URL and not is_valid_url(SPACE_BASE_URL):
SPACE_BASE_URL = None
SPACE_BASE_PATH = os.environ.get("SPACE_BASE_PATH", "/spaces/")
# App Base URL
APP_BASE_URL = os.environ.get("APP_BASE_URL", None)
if APP_BASE_URL and not is_valid_url(APP_BASE_URL):
APP_BASE_URL = None
APP_BASE_PATH = os.environ.get("APP_BASE_PATH", "/")
# Live Base URL
LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL", None)
if LIVE_BASE_URL and not is_valid_url(LIVE_BASE_URL):
LIVE_BASE_URL = None
LIVE_BASE_PATH = os.environ.get("LIVE_BASE_PATH", "/live/")
LIVE_URL = urljoin(LIVE_BASE_URL, LIVE_BASE_PATH) if LIVE_BASE_URL else None
# WEB URL
WEB_URL = os.environ.get("WEB_URL")
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))
# Instance Changelog URL
INSTANCE_CHANGELOG_URL = os.environ.get("INSTANCE_CHANGELOG_URL", "")
ATTACHMENT_MIME_TYPES = [
# Images
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"image/webp",
"image/tiff",
"image/bmp",
# Documents
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/markdown",
"application/rtf",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.presentation",
"application/vnd.oasis.opendocument.graphics",
# Microsoft Visio
"application/vnd.visio",
# Netpbm format
"image/x-portable-graymap",
"image/x-portable-bitmap",
"image/x-portable-pixmap",
# Open Office Bae
"application/vnd.oasis.opendocument.database",
# Audio
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/midi",
"audio/x-midi",
"audio/aac",
"audio/flac",
"audio/x-m4a",
# Video
"video/mp4",
"video/mpeg",
"video/ogg",
"video/webm",
"video/quicktime",
"video/x-msvideo",
"video/x-ms-wmv",
# Archives
"application/zip",
"application/x-rar",
"application/x-rar-compressed",
"application/x-tar",
"application/gzip",
"application/x-zip",
"application/x-zip-compressed",
"application/x-7z-compressed",
"application/x-compressed",
"application/x-compressed-tar",
"application/x-compressed-tar-gz",
"application/x-compressed-tar-bz2",
"application/x-compressed-tar-zip",
"application/x-compressed-tar-7z",
"application/x-compressed-tar-rar",
"application/x-compressed-tar-zip",
# 3D Models
"model/gltf-binary",
"model/gltf+json",
"application/octet-stream", # for .obj files, but be cautious
# Fonts
"font/ttf",
"font/otf",
"font/woff",
"font/woff2",
# Other
"text/css",
"text/javascript",
"application/json",
"text/xml",
"text/csv",
"application/xml",
# SQL
"application/x-sql",
# Gzip
"application/x-gzip",
# Markdown
"text/markdown",
]
# Seed directory path
SEED_DIR = os.path.join(BASE_DIR, "seeds")
ENABLE_DRF_SPECTACULAR = os.environ.get("ENABLE_DRF_SPECTACULAR", "0") == "1"
if ENABLE_DRF_SPECTACULAR:
REST_FRAMEWORK["DEFAULT_SCHEMA_CLASS"] = "drf_spectacular.openapi.AutoSchema"
INSTALLED_APPS.append("drf_spectacular")
from .openapi import SPECTACULAR_SETTINGS # noqa: F401
# MongoDB Settings
MONGO_DB_URL = os.environ.get("MONGO_DB_URL", False)
MONGO_DB_DATABASE = os.environ.get("MONGO_DB_DATABASE", False)
@@ -0,0 +1,94 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Development settings"""
import os
from .common import * # noqa
DEBUG = True
# Debug Toolbar settings
INSTALLED_APPS += ("debug_toolbar",) # noqa
MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) # noqa
DEBUG_TOOLBAR_PATCH_SETTINGS = False
# Only show emails in console don't send it to smtp
EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL, # noqa
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"},
}
}
INTERNAL_IPS = ("127.0.0.1",)
MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") # noqa
LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
LOGGING = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s",
},
},
"handlers": {
"console": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "json",
}
},
"loggers": {
"plane.api.request": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.api": {"level": "INFO", "handlers": ["console"], "propagate": False},
"plane.worker": {"level": "INFO", "handlers": ["console"], "propagate": False},
"plane.exception": {
"level": "ERROR",
"handlers": ["console"],
"propagate": False,
},
"plane.external": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.mongo": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.authentication": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.migrations": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
+126
View File
@@ -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.conf import settings
import logging
# Third party imports
from pymongo import MongoClient
from pymongo.database import Database
from pymongo.collection import Collection
from typing import Optional, TypeVar, Type
T = TypeVar("T", bound="MongoConnection")
# Set up logger
logger = logging.getLogger("plane.mongo")
class MongoConnection:
"""
A singleton class that manages MongoDB connections.
This class ensures only one MongoDB connection is maintained throughout the application.
It provides methods to access the MongoDB client, database, and collections.
Attributes:
_instance (Optional[MongoConnection]): The singleton instance of this class
_client (Optional[MongoClient]): The MongoDB client instance
_db (Optional[Database]): The MongoDB database instance
"""
_instance: Optional["MongoConnection"] = None
_client: Optional[MongoClient] = None
_db: Optional[Database] = None
def __new__(cls: Type[T]) -> T:
"""
Creates a new instance of MongoConnection if one doesn't exist.
Returns:
MongoConnection: The singleton instance
"""
if cls._instance is None:
cls._instance = super(MongoConnection, cls).__new__(cls)
try:
mongo_url = getattr(settings, "MONGO_DB_URL", None)
mongo_db_database = getattr(settings, "MONGO_DB_DATABASE", None)
if not mongo_url or not mongo_db_database:
logger.warning(
"MongoDB connection parameters not configured. MongoDB functionality will be disabled."
)
return cls._instance
cls._client = MongoClient(mongo_url)
cls._db = cls._client[mongo_db_database]
# Test the connection
cls._client.server_info()
logger.info("MongoDB connection established successfully")
except Exception as e:
logger.warning(
f"Failed to initialize MongoDB connection: {str(e)}. MongoDB functionality will be disabled."
)
return cls._instance
@classmethod
def get_client(cls) -> Optional[MongoClient]:
"""
Returns the MongoDB client instance.
Returns:
Optional[MongoClient]: The MongoDB client instance or None if not configured
"""
if cls._client is None:
cls._instance = cls()
return cls._client
@classmethod
def get_db(cls) -> Optional[Database]:
"""
Returns the MongoDB database instance.
Returns:
Optional[Database]: The MongoDB database instance or None if not configured
"""
if cls._db is None:
cls._instance = cls()
return cls._db
@classmethod
def get_collection(cls, collection_name: str) -> Optional[Collection]:
"""
Returns a MongoDB collection by name.
Args:
collection_name (str): The name of the collection to retrieve
Returns:
Optional[Collection]: The MongoDB collection instance or None if not configured
"""
try:
db = cls.get_db()
if db is None:
logger.warning(f"Cannot access collection '{collection_name}': MongoDB not configured")
return None
return db[collection_name]
except Exception as e:
logger.warning(f"Failed to access collection '{collection_name}': {str(e)}")
return None
@classmethod
def is_configured(cls) -> bool:
"""
Check if MongoDB is properly configured and connected.
Returns:
bool: True if MongoDB is configured and connected, False otherwise
"""
if cls._client is None:
cls._instance = cls()
return cls._client is not None and cls._db is not None
@@ -0,0 +1,276 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""
OpenAPI/Swagger configuration for drf-spectacular.
This file contains the complete configuration for API documentation generation.
"""
SPECTACULAR_SETTINGS = {
# ========================================================================
# Basic API Information
# ========================================================================
"TITLE": "The Plane REST API",
"DESCRIPTION": (
"The Plane REST API\n\n"
"Visit our quick start guide and full API documentation at "
"[developers.plane.so](https://developers.plane.so/api-reference/introduction)."
),
"CONTACT": {
"name": "Plane",
"url": "https://plane.so",
"email": "support@plane.so",
},
"VERSION": "0.0.1",
"LICENSE": {
"name": "GNU AGPLv3",
"url": "https://github.com/makeplane/plane/blob/preview/LICENSE.txt",
},
# ========================================================================
# Schema Generation Settings
# ========================================================================
"SERVE_INCLUDE_SCHEMA": False,
"SCHEMA_PATH_PREFIX": "/api/v1/",
"SCHEMA_CACHE_TIMEOUT": 0, # disables caching
# ========================================================================
# Processing Hooks
# ========================================================================
"PREPROCESSING_HOOKS": [
"plane.utils.openapi.hooks.preprocess_filter_api_v1_paths",
],
# ========================================================================
# Server Configuration
# ========================================================================
"SERVERS": [
{"url": "http://localhost:8000", "description": "Local"},
{"url": "https://api.plane.so", "description": "Production"},
],
# ========================================================================
# API Tag Definitions
# ========================================================================
"TAGS": [
# System Features
{
"name": "Assets",
"description": (
"**File Upload & Presigned URLs**\n\n"
"Generate presigned URLs for direct file uploads to cloud storage. Handle user avatars, "
"cover images, and generic project assets with secure upload workflows.\n\n"
"*Key Features:*\n"
"- Generate presigned URLs for S3 uploads\n"
"- Support for user avatars and cover images\n"
"- Generic asset upload for projects\n"
"- File validation and size limits\n\n"
"*Use Cases:* User profile images, project file uploads, secure direct-to-cloud uploads."
),
},
# Project Organization
{
"name": "Cycles",
"description": (
"**Sprint & Development Cycles**\n\n"
"Create and manage development cycles (sprints) to organize work into time-boxed iterations. "
"Track progress, assign work items, and monitor team velocity.\n\n"
"*Key Features:*\n"
"- Create and configure development cycles\n"
"- Assign work items to cycles\n"
"- Track cycle progress and completion\n"
"- Generate cycle analytics and reports\n\n"
"*Use Cases:* Sprint planning, iterative development, progress tracking, team velocity."
),
},
# System Features
{
"name": "Intake",
"description": (
"**Work Item Intake Queue**\n\n"
"Manage incoming work items through a dedicated intake queue for triage and review. "
"Submit, update, and process work items before they enter the main project workflow.\n\n"
"*Key Features:*\n"
"- Submit work items to intake queue\n"
"- Review and triage incoming work items\n"
"- Update intake work item status and properties\n"
"- Accept, reject, or modify work items before approval\n\n"
"*Use Cases:* Work item triage, external submissions, quality review, approval workflows."
),
},
# Project Organization
{
"name": "Labels",
"description": (
"**Labels & Tags**\n\n"
"Create and manage labels to categorize and organize work items. Use color-coded labels "
"for easy identification, filtering, and project organization.\n\n"
"*Key Features:*\n"
"- Create custom labels with colors and descriptions\n"
"- Apply labels to work items for categorization\n"
"- Filter and search by labels\n"
"- Organize labels across projects\n\n"
"*Use Cases:* Priority marking, feature categorization, bug classification, team organization."
),
},
# Team & User Management
{
"name": "Members",
"description": (
"**Team Member Management**\n\n"
"Manage team members, roles, and permissions within projects and workspaces. "
"Control access levels and track member participation.\n\n"
"*Key Features:*\n"
"- Invite and manage team members\n"
"- Assign roles and permissions\n"
"- Control project and workspace access\n"
"- Track member activity and participation\n\n"
"*Use Cases:* Team setup, access control, role management, collaboration."
),
},
# Project Organization
{
"name": "Modules",
"description": (
"**Feature Modules**\n\n"
"Group related work items into modules for better organization and tracking. "
"Plan features, track progress, and manage deliverables at a higher level.\n\n"
"*Key Features:*\n"
"- Create and organize feature modules\n"
"- Group work items by module\n"
"- Track module progress and completion\n"
"- Manage module leads and assignments\n\n"
"*Use Cases:* Feature planning, release organization, progress tracking, team coordination."
),
},
# Core Project Management
{
"name": "Projects",
"description": (
"**Project Management**\n\n"
"Create and manage projects to organize your development work. Configure project settings, "
"manage team access, and control project visibility.\n\n"
"*Key Features:*\n"
"- Create, update, and delete projects\n"
"- Configure project settings and preferences\n"
"- Manage team access and permissions\n"
"- Control project visibility and sharing\n\n"
"*Use Cases:* Project setup, team collaboration, access control, project configuration."
),
},
# Project Organization
{
"name": "States",
"description": (
"**Workflow States**\n\n"
"Define custom workflow states for work items to match your team's process. "
"Configure state transitions and track work item progress through different stages.\n\n"
"*Key Features:*\n"
"- Create custom workflow states\n"
"- Configure state transitions and rules\n"
"- Track work item progress through states\n"
"- Set state-based permissions and automation\n\n"
"*Use Cases:* Custom workflows, status tracking, process automation, progress monitoring."
),
},
# Team & User Management
{
"name": "Users",
"description": (
"**Current User Information**\n\n"
"Get information about the currently authenticated user including profile details "
"and account settings.\n\n"
"*Key Features:*\n"
"- Retrieve current user profile\n"
"- Access user account information\n"
"- View user preferences and settings\n"
"- Get authentication context\n\n"
"*Use Cases:* Profile display, user context, account information, authentication status."
),
},
# Work Item Management
{
"name": "Work Item Activity",
"description": (
"**Activity History & Search**\n\n"
"View activity history and search for work items across the workspace. "
"Get detailed activity logs and find work items using text search.\n\n"
"*Key Features:*\n"
"- View work item activity history\n"
"- Search work items across workspace\n"
"- Track changes and modifications\n"
"- Filter search results by project\n\n"
"*Use Cases:* Activity tracking, work item discovery, change history, workspace search."
),
},
{
"name": "Work Item Attachments",
"description": (
"**Work Item File Attachments**\n\n"
"Generate presigned URLs for uploading files directly to specific work items. "
"Upload and manage attachments associated with work items.\n\n"
"*Key Features:*\n"
"- Generate presigned URLs for work item attachments\n"
"- Upload files directly to work items\n"
"- Retrieve and manage attachment metadata\n"
"- Delete attachments from work items\n\n"
"*Use Cases:* Screenshots, error logs, design files, supporting documents."
),
},
{
"name": "Work Item Comments",
"description": (
"**Comments & Discussions**\n\n"
"Add comments and discussions to work items for team collaboration. "
"Support threaded conversations, mentions, and rich text formatting.\n\n"
"*Key Features:*\n"
"- Add comments to work items\n"
"- Thread conversations and replies\n"
"- Mention users and trigger notifications\n"
"- Rich text and markdown support\n\n"
"*Use Cases:* Team discussions, progress updates, code reviews, decision tracking."
),
},
{
"name": "Work Item Links",
"description": (
"**External Links & References**\n\n"
"Link work items to external resources like documentation, repositories, or design files. "
"Maintain connections between work items and external systems.\n\n"
"*Key Features:*\n"
"- Add external URL links to work items\n"
"- Validate and preview linked resources\n"
"- Organize links by type and category\n"
"- Track link usage and access\n\n"
"*Use Cases:* Documentation links, repository connections, design references, external tools."
),
},
{
"name": "Work Items",
"description": (
"**Work Items & Tasks**\n\n"
"Create and manage work items like tasks, bugs, features, and user stories. "
"The core entities for tracking work in your projects.\n\n"
"*Key Features:*\n"
"- Create, update, and manage work items\n"
"- Assign to team members and set priorities\n"
"- Track progress through workflow states\n"
"- Set due dates, estimates, and relationships\n\n"
"*Use Cases:* Bug tracking, task management, feature development, sprint planning."
),
},
],
# ========================================================================
# Security & Authentication
# ========================================================================
"AUTHENTICATION_WHITELIST": [
"plane.api.middleware.api_authentication.APIKeyAuthentication",
],
# ========================================================================
# Schema Generation Options
# ========================================================================
"COMPONENT_NO_READ_ONLY_REQUIRED": True,
"COMPONENT_SPLIT_REQUEST": True,
"ENUM_NAME_OVERRIDES": {
"ModuleStatusEnum": "plane.db.models.module.ModuleStatus",
"IntakeWorkItemStatusEnum": "plane.db.models.intake.IntakeIssueStatus",
},
}
@@ -0,0 +1,104 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Production settings"""
import os
from .common import * # noqa
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
INSTALLED_APPS += ("scout_apm.django",) # noqa
# Scout Settings
SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
SCOUT_NAME = "Plane"
LOG_DIR = os.path.join(BASE_DIR, "logs") # noqa
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
# Logging configuration
LOGGING = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"verbose": {"format": "%(asctime)s [%(process)d] %(levelname)s %(name)s: %(message)s"},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "json",
"level": "INFO",
},
"file": {
"class": "plane.utils.logging.SizedTimedRotatingFileHandler",
"filename": (
os.path.join(BASE_DIR, "logs", "plane-debug.log") # noqa
if DEBUG
else os.path.join(BASE_DIR, "logs", "plane-error.log") # noqa
),
"when": "s",
"maxBytes": 1024 * 1024 * 1,
"interval": 1,
"backupCount": 5,
"formatter": "json",
"level": "DEBUG" if DEBUG else "ERROR",
},
},
"loggers": {
"plane.api.request": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.api": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.worker": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.exception": {
"level": "DEBUG" if DEBUG else "ERROR",
"handlers": ["console", "file"],
"propagate": False,
},
"plane.external": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.mongo": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.authentication": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.migrations": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
@@ -0,0 +1,24 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import redis
from django.conf import settings
from urllib.parse import urlparse
def redis_instance():
# connect to redis
if settings.REDIS_SSL:
url = urlparse(settings.REDIS_URL)
ri = redis.Redis(
host=url.hostname,
port=url.port,
password=url.password,
ssl=True,
ssl_cert_reqs=None,
)
else:
ri = redis.Redis.from_url(settings.REDIS_URL, db=0)
return ri
@@ -0,0 +1,205 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import os
import uuid
# Third party imports
import boto3
from botocore.exceptions import ClientError
from urllib.parse import quote
# Module imports
from plane.utils.exception_logger import log_exception
from storages.backends.s3boto3 import S3Boto3Storage
class S3Storage(S3Boto3Storage):
def url(self, name, parameters=None, expire=None, http_method=None):
return name
"""S3 storage class to generate presigned URLs for S3 objects"""
def __init__(self, request=None):
# Get the AWS credentials and bucket name from the environment
self.aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID")
# Use the AWS_SECRET_ACCESS_KEY environment variable for the secret key
self.aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY")
# Use the AWS_S3_BUCKET_NAME environment variable for the bucket name
self.aws_storage_bucket_name = os.environ.get("AWS_S3_BUCKET_NAME")
# Use the AWS_REGION environment variable for the region
self.aws_region = os.environ.get("AWS_REGION")
# Use the AWS_S3_ENDPOINT_URL environment variable for the endpoint URL
self.aws_s3_endpoint_url = os.environ.get("AWS_S3_ENDPOINT_URL") or os.environ.get("MINIO_ENDPOINT_URL")
# Use the SIGNED_URL_EXPIRATION environment variable for the expiration time (default: 3600 seconds)
self.signed_url_expiration = int(os.environ.get("SIGNED_URL_EXPIRATION", "3600"))
if os.environ.get("USE_MINIO") == "1":
# Determine protocol based on environment variable
if os.environ.get("MINIO_ENDPOINT_SSL") == "1":
endpoint_protocol = "https"
else:
endpoint_protocol = request.scheme if request else "http"
# Create an S3 client for MinIO
self.s3_client = boto3.client(
"s3",
aws_access_key_id=self.aws_access_key_id,
aws_secret_access_key=self.aws_secret_access_key,
region_name=self.aws_region,
endpoint_url=(f"{endpoint_protocol}://{request.get_host()}" if request else self.aws_s3_endpoint_url),
config=boto3.session.Config(signature_version="s3v4"),
)
else:
# Create an S3 client
self.s3_client = boto3.client(
"s3",
aws_access_key_id=self.aws_access_key_id,
aws_secret_access_key=self.aws_secret_access_key,
region_name=self.aws_region,
endpoint_url=self.aws_s3_endpoint_url,
config=boto3.session.Config(signature_version="s3v4"),
)
def generate_presigned_post(self, object_name, file_type, file_size, expiration=None):
"""Generate a presigned URL to upload an S3 object"""
if expiration is None:
expiration = self.signed_url_expiration
fields = {"Content-Type": file_type}
conditions = [
{"bucket": self.aws_storage_bucket_name},
["content-length-range", 1, file_size],
{"Content-Type": file_type},
]
# Add condition for the object name (key)
if object_name.startswith("${filename}"):
conditions.append(["starts-with", "$key", object_name[: -len("${filename}")]])
else:
fields["key"] = object_name
conditions.append({"key": object_name})
# Generate the presigned POST URL
try:
# Generate a presigned URL for the S3 object
response = self.s3_client.generate_presigned_post(
Bucket=self.aws_storage_bucket_name,
Key=object_name,
Fields=fields,
Conditions=conditions,
ExpiresIn=expiration,
)
# Handle errors
except ClientError as e:
print(f"Error generating presigned POST URL: {e}")
return None
return response
def _get_content_disposition(self, disposition, filename=None):
"""Helper method to generate Content-Disposition header value"""
if filename is None:
filename = uuid.uuid4().hex
if filename:
# Encode the filename to handle special characters
encoded_filename = quote(filename)
return f"{disposition}; filename*=UTF-8''{encoded_filename}"
return disposition
def generate_presigned_url(
self,
object_name,
expiration=None,
http_method="GET",
disposition="inline",
filename=None,
):
"""Generate a presigned URL to share an S3 object"""
if expiration is None:
expiration = self.signed_url_expiration
content_disposition = self._get_content_disposition(disposition, filename)
try:
response = self.s3_client.generate_presigned_url(
"get_object",
Params={
"Bucket": self.aws_storage_bucket_name,
"Key": str(object_name),
"ResponseContentDisposition": content_disposition,
},
ExpiresIn=expiration,
HttpMethod=http_method,
)
except ClientError as e:
log_exception(e)
return None
# The response contains the presigned URL
return response
def get_object_metadata(self, object_name):
"""Get the metadata for an S3 object"""
try:
response = self.s3_client.head_object(Bucket=self.aws_storage_bucket_name, Key=object_name)
except ClientError as e:
log_exception(e)
return None
return {
"ContentType": response.get("ContentType"),
"ContentLength": response.get("ContentLength"),
"LastModified": (response.get("LastModified").isoformat() if response.get("LastModified") else None),
"ETag": response.get("ETag"),
"Metadata": response.get("Metadata", {}),
}
def copy_object(self, object_name, new_object_name):
"""Copy an S3 object to a new location"""
try:
response = self.s3_client.copy_object(
Bucket=self.aws_storage_bucket_name,
CopySource={"Bucket": self.aws_storage_bucket_name, "Key": object_name},
Key=new_object_name,
)
except ClientError as e:
log_exception(e)
return None
return response
def upload_file(
self,
file_obj,
object_name: str,
content_type: str = None,
extra_args: dict = {},
) -> bool:
"""Upload a file directly to S3"""
try:
if content_type:
extra_args["ContentType"] = content_type
self.s3_client.upload_fileobj(
file_obj,
self.aws_storage_bucket_name,
object_name,
ExtraArgs=extra_args,
)
return True
except ClientError as e:
log_exception(e)
return False
def delete_files(self, object_names):
"""Delete an S3 object"""
try:
self.s3_client.delete_objects(
Bucket=self.aws_storage_bucket_name,
Delete={"Objects": [{"Key": object_name} for object_name in object_names]},
)
return True
except ClientError as e:
log_exception(e)
return False
+16
View File
@@ -0,0 +1,16 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Test Settings"""
from .common import * # noqa
DEBUG = True
# Send it in a dummy outbox
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
INSTALLED_APPS.append( # noqa
"plane.tests"
)