Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pull-request-build-lint-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
python-version: "3.12.x"
- name: Install Pylint
run: python -m pip install ruff
- name: Install API Dependencies
Expand Down
6 changes: 5 additions & 1 deletion apps/api/plane/api/urls/member.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from django.urls import path

from plane.api.views import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint
from plane.api.views import (
ProjectMemberListCreateAPIEndpoint,
ProjectMemberDetailAPIEndpoint,
WorkspaceMemberAPIEndpoint,
)

urlpatterns = [
# Project members
Expand Down
2 changes: 0 additions & 2 deletions apps/api/plane/api/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import APIException
from rest_framework.generics import GenericAPIView
Expand Down
2 changes: 0 additions & 2 deletions apps/api/plane/api/views/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,7 @@
ADMIN_ONLY_RESPONSE,
REQUIRED_FIELDS_RESPONSE,
MODULE_ISSUE_NOT_FOUND_RESPONSE,
ARCHIVED_RESPONSE,
CANNOT_ARCHIVE_RESPONSE,
UNARCHIVED_RESPONSE,
)


Expand Down
89 changes: 26 additions & 63 deletions apps/api/plane/app/views/search/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,7 @@ def filter_cycles(self, query, slug, project_id, workspace_search):
return (
cycles.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
.values("name", "id", "project_id", "project__identifier", "workspace__slug")
)

def filter_modules(self, query, slug, project_id, workspace_search):
Expand All @@ -155,9 +153,7 @@ def filter_modules(self, query, slug, project_id, workspace_search):
return (
modules.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
.values("name", "id", "project_id", "project__identifier", "workspace__slug")
)

def filter_pages(self, query, slug, project_id, workspace_search):
Expand All @@ -177,9 +173,7 @@ def filter_pages(self, query, slug, project_id, workspace_search):
)
.annotate(
project_ids=Coalesce(
ArrayAgg(
"projects__id", distinct=True, filter=~Q(projects__id=True)
),
ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)),
Value([], output_field=ArrayField(UUIDField())),
)
)
Expand All @@ -196,20 +190,16 @@ def filter_pages(self, query, slug, project_id, workspace_search):
)

if workspace_search == "false" and project_id:
project_subquery = ProjectPage.objects.filter(
page_id=OuterRef("id"), project_id=project_id
).values_list("project_id", flat=True)[:1]
project_subquery = ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=project_id).values_list(
"project_id", flat=True
)[:1]

pages = pages.annotate(project_id=Subquery(project_subquery)).filter(
project_id=project_id
)
pages = pages.annotate(project_id=Subquery(project_subquery)).filter(project_id=project_id)

return (
pages.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_ids", "project_identifiers", "workspace__slug"
)
.values("name", "id", "project_ids", "project_identifiers", "workspace__slug")
)

def filter_views(self, query, slug, project_id, workspace_search):
Expand All @@ -233,9 +223,7 @@ def filter_views(self, query, slug, project_id, workspace_search):
return (
issue_views.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
.values("name", "id", "project_id", "project__identifier", "workspace__slug")
)

def filter_intakes(self, query, slug, project_id, workspace_search):
Expand Down Expand Up @@ -294,9 +282,7 @@ def get(self, request, slug):

# Determine which entities to search
if entities_param:
requested_entities = [
e.strip() for e in entities_param.split(",") if e.strip()
]
requested_entities = [e.strip() for e in entities_param.split(",") if e.strip()]
requested_entities = [e for e in requested_entities if e in MODELS_MAPPER]
else:
requested_entities = list(MODELS_MAPPER.keys())
Expand All @@ -306,9 +292,7 @@ def get(self, request, slug):
for entity in requested_entities:
func = MODELS_MAPPER.get(entity)
if func:
results[entity] = func(
query or None, slug, project_id, workspace_search
)
results[entity] = func(query or None, slug, project_id, workspace_search)

return Response({"results": results}, status=status.HTTP_200_OK)

Expand All @@ -320,7 +304,6 @@ def get(self, request, slug):
query_types = [qt.strip() for qt in query_types]
count = int(request.query_params.get("count", 5))
project_id = request.query_params.get("project_id", None)
issue_id = request.query_params.get("issue_id", None)

response_data = {}

Expand Down Expand Up @@ -367,14 +350,10 @@ def get(self, request, slug):
.order_by("-created_at")
)

users = (
users
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
users = users.distinct().values(
"member__avatar_url",
"member__display_name",
"member__id",
)

response_data["user_mention"] = list(users[:count])
Expand All @@ -389,15 +368,12 @@ def get(self, request, slug):
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
Q(project_projectmember__member=self.request.user) | Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
)
response_data["project"] = list(projects)

Expand Down Expand Up @@ -456,20 +432,16 @@ def get(self, request, slug):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
Expand Down Expand Up @@ -587,9 +559,7 @@ def get(self, request, slug):
)
)
.order_by("-created_at")
.values(
"member__avatar_url", "member__display_name", "member__id"
)[:count]
.values("member__avatar_url", "member__display_name", "member__id")[:count]
)
response_data["user_mention"] = list(users)

Expand All @@ -603,15 +573,12 @@ def get(self, request, slug):
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
Q(project_projectmember__member=self.request.user) | Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
)
response_data["project"] = list(projects)

Expand Down Expand Up @@ -668,20 +635,16 @@ def get(self, request, slug):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
Expand Down
2 changes: 1 addition & 1 deletion apps/api/plane/app/views/user/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def update_email(self, request):
status=status.HTTP_400_BAD_REQUEST,
)

except Exception as e:
except Exception:
return Response(
{"error": "Failed to verify code. Please try again."},
status=status.HTTP_400_BAD_REQUEST,
Expand Down
1 change: 0 additions & 1 deletion apps/api/plane/bgtasks/export_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import io
import zipfile
from typing import List
from collections import defaultdict
import boto3
from botocore.client import Config
from uuid import UUID
Expand Down
24 changes: 11 additions & 13 deletions apps/api/plane/tests/contract/api/test_cycles.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import pytest
from rest_framework import status
from django.db import IntegrityError
from django.utils import timezone
from datetime import datetime, timedelta
from datetime import timedelta
from uuid import uuid4

from plane.db.models import Cycle, Project, ProjectMember
Expand Down Expand Up @@ -58,8 +57,6 @@ def create_cycle(db, project, create_user):
)




@pytest.mark.contract
class TestCycleListCreateAPIEndpoint:
"""Test Cycle List and Create API Endpoint"""
Expand All @@ -85,7 +82,6 @@ def test_create_cycle_success(self, api_key_client, workspace, project, cycle_da
assert created_cycle.project == project
assert created_cycle.owned_by_id is not None


@pytest.mark.django_db
def test_create_cycle_invalid_data(self, api_key_client, workspace, project):
"""Test cycle creation with invalid data"""
Expand Down Expand Up @@ -197,7 +193,7 @@ def test_list_cycles_with_view_filter(self, api_key_client, workspace, project,

# Create cycles in different states
now = timezone.now()

# Current cycle (started but not ended)
Cycle.objects.create(
name="Current Cycle",
Expand All @@ -207,7 +203,7 @@ def test_list_cycles_with_view_filter(self, api_key_client, workspace, project,
end_date=now + timedelta(days=6),
owned_by=create_user,
)

# Upcoming cycle
Cycle.objects.create(
name="Upcoming Cycle",
Expand All @@ -217,7 +213,7 @@ def test_list_cycles_with_view_filter(self, api_key_client, workspace, project,
end_date=now + timedelta(days=8),
owned_by=create_user,
)

# Completed cycle
Cycle.objects.create(
name="Completed Cycle",
Expand All @@ -227,7 +223,7 @@ def test_list_cycles_with_view_filter(self, api_key_client, workspace, project,
end_date=now - timedelta(days=3),
owned_by=create_user,
)

# Draft cycle
Cycle.objects.create(
name="Draft Cycle",
Expand Down Expand Up @@ -320,7 +316,9 @@ def test_update_cycle_invalid_data(self, api_key_client, workspace, project, cre
assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK]

@pytest.mark.django_db
def test_update_cycle_with_external_id_conflict(self, api_key_client, workspace, project, create_cycle, create_user ):
def test_update_cycle_with_external_id_conflict(
self, api_key_client, workspace, project, create_cycle, create_user
):
"""Test cycle update with conflicting external ID"""
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)

Expand Down Expand Up @@ -363,7 +361,7 @@ def test_cycle_metrics_annotation(self, api_key_client, workspace, project, crea
response = api_key_client.get(url)

assert response.status_code == status.HTTP_200_OK

# Check that metrics are included in response
cycle_data = response.data
assert "total_issues" in cycle_data
Expand All @@ -372,11 +370,11 @@ def test_cycle_metrics_annotation(self, api_key_client, workspace, project, crea
assert "started_issues" in cycle_data
assert "unstarted_issues" in cycle_data
assert "backlog_issues" in cycle_data

# All should be 0 for a new cycle
assert cycle_data["total_issues"] == 0
assert cycle_data["completed_issues"] == 0
assert cycle_data["cancelled_issues"] == 0
assert cycle_data["started_issues"] == 0
assert cycle_data["unstarted_issues"] == 0
assert cycle_data["backlog_issues"] == 0
assert cycle_data["backlog_issues"] == 0
6 changes: 3 additions & 3 deletions apps/api/requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ Django==4.2.26
# rest framework
djangorestframework==3.15.2
# postgres
psycopg==3.2.9
psycopg-binary==3.2.9
psycopg-c==3.2.9
psycopg==3.3.0
psycopg-binary==3.3.0
psycopg-c==3.3.0
Comment on lines +8 to +10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the actual requirements file content around the psycopg packages
cat -n apps/api/requirements/base.txt | head -20

Repository: makeplane/plane

Length of output: 497


🏁 Script executed:

# Check if there are multiple requirements files and how psycopg is managed across them
find apps/api/requirements -type f -name "*.txt" | sort

Repository: makeplane/plane

Length of output: 189


🏁 Script executed:

# Look for any setup.py, setup.cfg, or pyproject.toml that might define psycopg extras
fd -t f \( -name "setup.py" -o -name "setup.cfg" -o -name "pyproject.toml" \) apps/

Repository: makeplane/plane

Length of output: 289


🏁 Script executed:

# Search for any comments or documentation about psycopg package choice
rg -B 3 -A 3 "psycopg" apps/api/requirements/ -C 2

Repository: makeplane/plane

Length of output: 399


🌐 Web query:

psycopg 3 binary c extension separate pinning requirements.txt best practice

💡 Result:

Short best-practices for using Psycopg‑3 (binary vs C extension) and pinning in requirements.txt:

  1. Choose the installer that matches your environment

    • For most apps use the prebuilt wheels: pip install "psycopg[binary]". It bundles the C extension and libpq so you avoid build tools and system libpq. [1][2]
    • If you need a system libpq (specific libpq features or unsupported platform) or want to build the extension yourself, install the C build: pip install "psycopg[c]" (requires libpq and build tools) or use the pure‑Python package when appropriate. [1]
  2. Pin explicitly in requirements files (and lock transitive deps)

    • In requirements.txt pin the package and the extra you rely on, e.g.:
      psycopg[binary]==3.2.10
    • If you build from source on CI/production, pin psycopg[c]== or pin the same psycopg version but ensure build deps (libpq, headers, compiler) are provided by your images. [1]
  3. Separate concerns (best practice)

    • Keep runtime requirements (what ships to production) separate from dev/build requirements:
      • requirements.txt (runtime): psycopg[binary]==X.Y.Z
      • requirements-dev.txt or Dockerfile (build/CI): system packages (libpq-dev), compiler, and optional psycopg[c] if you build locally.
    • Alternatively use a lockfile tool (pip-tools, pip‑freeze, poetry.lock) to capture exact transitive versions and platform wheels for reproducible installs.
  4. Be platform-aware

    • Binary wheels are produced for a set of platforms; verify your target platform/wheel exists (macOS ARM, older macOS, Linux manylinux) — if not, use local build or system libpq. Use psycopg.pq.version() / build_version to inspect bundled libpq if needed. [1][4]
  5. CI / container recommendations

    • For containers, either: (a) use psycopg[binary] in your runtime image (simpler), or (b) install system libpq and build tools in build stage and pin psycopg (or psycopg[c]) if you require system libpq. Keep runtime images minimal by only installing runtime wheel. [1][5]

References

  • Psycopg installation docs (binary/local/pure Python and handling dependencies). [1]
  • Psycopg GitHub/README install notes. [2]
  • PyPI / packaging notes about psycopg-binary availability and wheel builds. [4]

If you want, tell me your target OS/Python and whether you control the container/CI and I’ll give a concrete requirements.txt + Dockerfile example.


Correct the psycopg package pinning strategy.

The upgrade to psycopg 3.3.0 is valid and backward-compatible with Django 4.2.26. However, pinning psycopg, psycopg-binary, and psycopg-c separately is incorrect. These are mutually exclusive installation methods, not complementary packages:

  • psycopg[binary]: prebuilt wheel with bundled libpq (recommended for most deployments)
  • psycopg[c]: source build requiring system libpq and build tools
  • psycopg: pure-Python fallback

The requirements file should pin only one option. For standard deployments, replace lines 8-10 with:

psycopg[binary]==3.3.0

If your deployment requires building from source, use psycopg[c]==3.3.0 instead and ensure libpq and build tools are available in your environment.

🤖 Prompt for AI Agents
In apps/api/requirements/base.txt around lines 8 to 10, the three separate pins
for psycopg, psycopg-binary, and psycopg-c are incorrect because they are
mutually exclusive installation options; remove the three lines and replace them
with a single pin for the appropriate install method (for most deployments use
psycopg[binary]==3.3.0; if you must build from source use psycopg[c]==3.3.0) and
ensure any deployment-specific system libs/build-tools are provisioned when
choosing the [c] option.

dj-database-url==2.1.0
# mongo
pymongo==4.6.3
Expand Down
Loading