Merge pull request #719 from MicroPyramid/dev

Dev
This commit is contained in:
Ashwin Kumar 2026-07-27 06:51:37 +05:30 committed by GitHub
commit 9f844751a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2652 additions and 2673 deletions

View file

@ -0,0 +1,101 @@
# Re-stamp Row-Level Security (RLS) policies on all org-scoped tables.
#
# The policy SQL in common/rls/__init__.py now wraps the session read in a
# scalar sub-select so Postgres hoists it to a one-time InitPlan instead of
# re-evaluating current_setting() once per row scanned:
#
# org_id::text = (select NULLIF(current_setting('app.current_org', true), ''))
#
# get_enable_policy_sql() is only ever called from migrations, and those have
# already been applied on existing databases, so the new SQL would otherwise
# reach fresh installs only. This migration re-runs the codegen over every
# org-scoped table so deployed databases pick it up too.
#
# The re-stamp is value-preserving: the predicate returns the same rows before
# and after (unset context -> NULLIF -> NULL -> no rows, same fail-safe), so
# this is a planner optimization, not a change to tenant isolation.
#
# atomic = False: DROP/CREATE POLICY each take an ACCESS EXCLUSIVE lock on the
# table. Committing per statement keeps each lock brief instead of holding all
# of them until the end of one long transaction. The operation is idempotent
# (the generated SQL opens with DROP POLICY IF EXISTS), so a run that fails
# part-way can simply be re-run.
#
# RLS Configuration: See common/rls/__init__.py for centralized policy definitions
from django.db import migrations
from common.rls import (
RLS_CONFIG,
get_check_table_exists_sql,
get_enable_policy_sql,
)
def check_column_exists(cursor, table, column):
"""Check if a column exists in a table."""
cursor.execute(
"""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = %s
AND column_name = %s
)
""",
[table, column],
)
return cursor.fetchone()[0]
def restamp_rls_policies(apps, schema_editor):
"""Re-create the isolation and insert-check policies on every org-scoped table."""
if schema_editor.connection.vendor != "postgresql":
print("RLS is only supported on PostgreSQL. Skipping.")
return
restamped = 0
with schema_editor.connection.cursor() as cursor:
for table in RLS_CONFIG["tables"]:
# Tables belonging to apps whose migrations have not run yet are
# skipped; those apps stamp their own policies with the current
# codegen, so they already get the new SQL.
cursor.execute(get_check_table_exists_sql(), [table])
if not cursor.fetchone()[0]:
print(f" Skipping {table} (table does not exist)")
continue
if not check_column_exists(cursor, table, "org_id"):
print(f" Skipping {table} (no org_id column)")
continue
# Unconditional: no pg_policies existence guard. The whole point is
# to replace policies that already exist with the InitPlan form.
cursor.execute(get_enable_policy_sql(table))
restamped += 1
print(f" Re-stamped RLS policies on {restamped} table(s)")
def noop_reverse(apps, schema_editor):
"""
No-op.
Rolling back leaves the InitPlan-form policies in place. They are
functionally identical to the previous form, so there is nothing unsafe to
undo, and regenerating the old SQL would mean duplicating the superseded
codegen here purely to write it back.
"""
class Migration(migrations.Migration):
atomic = False
dependencies = [
("common", "0027_personalaccesstoken"),
]
operations = [
migrations.RunPython(restamp_rls_policies, noop_reverse),
]

View file

@ -1045,7 +1045,7 @@ class TestLeadListViewFilters:
"""Tests targeting uncovered filter lines 59-106 in LeadListView.get_context_data."""
def test_filter_by_name(self, admin_client, admin_user, org_a):
"""Filter by name uses first_name AND last_name icontains (lines 59-62)."""
"""Filter by name matches first_name OR last_name icontains (lines 59-62)."""
Lead.objects.create(
first_name="NameFilter",
last_name="NameFilter",
@ -1059,6 +1059,41 @@ class TestLeadListViewFilters:
emails = [lead["email"] for lead in open_leads]
assert "namefilter@example.com" in emails
def test_filter_by_name_matches_first_or_last_name(
self, admin_client, admin_user, org_a
):
"""The name filter matches a partial first or last name (issue #709)."""
_set_rls(org_a)
first_name_match = Lead.objects.create(
first_name="John",
last_name="Smith",
email="john.smith@example.com",
created_by=admin_user,
org=org_a,
)
last_name_match = Lead.objects.create(
first_name="Jane",
last_name="Johnson",
email="jane.johnson@example.com",
created_by=admin_user,
org=org_a,
)
Lead.objects.create(
first_name="Alice",
last_name="Walker",
email="alice.walker@example.com",
created_by=admin_user,
org=org_a,
)
response = admin_client.get(LEADS_LIST_URL, {"name": "john"})
assert response.status_code == 200
returned_ids = {
lead["id"] for lead in response.json()["open_leads"]["open_leads"]
}
assert returned_ids == {str(first_name_match.id), str(last_name_match.id)}
def test_filter_by_salutation(self, admin_client, admin_user, org_a):
"""Filter by salutation (lines 63-66)."""
Lead.objects.create(

View file

@ -66,9 +66,9 @@ class LeadListView(APIView, LimitOffsetPagination):
if params:
if params.get("name"):
name = params.get("name")
queryset = queryset.filter(
Q(first_name__icontains=params.get("name"))
& Q(last_name__icontains=params.get("name"))
Q(first_name__icontains=name) | Q(last_name__icontains=name)
)
if params.get("salutation"):
queryset = queryset.filter(

View file

@ -41,32 +41,32 @@ classifiers = [
]
dependencies = [
"django>=6.0.5",
"celery>=5.6.2",
"python-dotenv>=1.2.1",
"django>=6.0.7",
"celery>=5.6.3",
"python-dotenv>=1.2.2",
"django-cors-headers>=4.9.0",
"djangorestframework>=3.16.1",
"djangorestframework>=3.17.1",
"djangorestframework-simplejwt>=5.5.1",
"drf-spectacular>=0.29.0",
"django-ses>=4.7.1",
"psycopg2-binary>=2.9.11",
"whitenoise>=6.11.0",
"sentry-sdk>=2.53.0",
"drf-spectacular>=0.30.0",
"django-ses>=4.7.2",
"psycopg2-binary>=2.9.12",
"whitenoise>=6.12.0",
"sentry-sdk>=2.66.1",
"django-storages>=1.14.6",
"django-crum>=0.7.9",
"disposable-email-domains>=0.0.162",
"redis>=7.2.0",
"pytz>=2025.2",
"requests>=2.32.5",
"faker>=40.4.0",
"disposable-email-domains>=0.0.231",
"redis>=8.0.1",
"pytz>=2026.3.post1",
"requests>=2.34.2",
"faker>=40.36.0",
"python-dateutil>=2.9.0.post0",
"google-auth>=2.48.0",
"google-auth>=2.56.2",
# PDF generation (also needs system libs: cairo, pango, gdk-pixbuf, libffi, shared-mime-info)
"weasyprint>=68.1",
"weasyprint>=69.0",
"cairocffi>=1.7.1",
# Production WSGI server
"gunicorn>=25.1.0",
"uvicorn[standard]>=0.30",
"gunicorn>=26.0.0",
"uvicorn[standard]>=0.51.0",
]
[project.optional-dependencies]
@ -87,9 +87,9 @@ Changelog = "https://github.com/MicroPyramid/Django-CRM/releases"
[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest>=9.1.1",
"pytest-django>=4.12.0",
"pytest-cov>=7.0.0",
"pytest-cov>=7.1.0",
]
[tool.hatch.build.targets.wheel]

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,16 @@
FROM node:22-slim
FROM node:24-slim
# Enable pnpm via corepack
RUN corepack enable && corepack prepare pnpm@latest --activate
# Enable pnpm via corepack. Pin the version so image builds are reproducible.
RUN corepack enable && corepack prepare pnpm@11.17.0 --activate
WORKDIR /app
# Install dependencies (layer cached)
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile || pnpm install
# Install dependencies (layer cached).
# pnpm-workspace.yaml carries the install settings (onlyBuiltDependencies,
# allowBuilds) that pnpm 10+ no longer reads from package.json — without it
# @sentry/cli's postinstall is silently skipped and its binary never lands.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN pnpm install --frozen-lockfile
EXPOSE 5173

View file

@ -16,45 +16,36 @@
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^25.9.1",
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.27.0",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^25.9.5",
"bits-ui": "^2.18.1",
"eslint": "^10.4.0",
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1",
"globals": "^17.6.0",
"eslint-plugin-svelte": "^3.22.0",
"globals": "^17.7.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.0.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"svelte": "^5.55.9",
"svelte-check": "^4.4.8",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.1",
"svelte": "^5.56.8",
"svelte-check": "^4.7.3",
"svelte-sonner": "^1.1.1",
"tailwindcss": "^4.3.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.0.14"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"@sentry/cli"
],
"overrides": {
"svelte-eslint-parser>eslint-scope": "^9.1.0"
}
"vite": "^8.1.5"
},
"dependencies": {
"@sentry/sveltekit": "^10.53.1",
"axios": "^1.16.1",
"@sentry/sveltekit": "^10.68.0",
"axios": "^1.18.1",
"clsx": "^2.1.1",
"libphonenumber-js": "^1.13.3",
"svelte-dnd-action": "^0.9.69",
"libphonenumber-js": "^1.13.9",
"svelte-dnd-action": "^0.9.74",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0"

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,4 @@
allowBuilds:
'@sentry/cli': true
minimumReleaseAgeExclude:
- '@sveltejs/kit@2.61.1'
onlyBuiltDependencies:
- esbuild
- '@sentry/cli'

View file

@ -3,13 +3,13 @@ name = "bcrm-mcp"
version = "0.1.0"
description = "BottleCRM MCP server — connect your AI agent to BottleCRM"
requires-python = ">=3.11"
dependencies = ["fastmcp>=3.0", "httpx>=0.27", "pydantic>=2.7"]
dependencies = ["fastmcp>=3.4.4", "httpx>=0.28.1", "pydantic>=2.13.4"]
[project.scripts]
bcrm-mcp = "bcrm_mcp.server:main"
[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21"]
dev = ["pytest>=9.1.1", "pytest-asyncio>=1.4.0", "respx>=0.23.1"]
[build-system]
requires = ["hatchling"]

File diff suppressed because it is too large Load diff