commit
9f844751a5
11 changed files with 2652 additions and 2673 deletions
101
backend/common/migrations/0028_restamp_rls_policies.py
Normal file
101
backend/common/migrations/0028_restamp_rls_policies.py
Normal 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),
|
||||
]
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
1495
backend/uv.lock
1495
backend/uv.lock
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1,7 +1,4 @@
|
|||
allowBuilds:
|
||||
'@sentry/cli': true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@sveltejs/kit@2.61.1'
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- '@sentry/cli'
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue