feat: initial public release

ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
This commit is contained in:
James Cottrill
2026-04-13 14:20:15 +00:00
commit fbf26453f2
341 changed files with 62807 additions and 0 deletions

1
apps/api/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

61
apps/api/alembic/env.py Normal file
View File

@@ -0,0 +1,61 @@
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from src.models import Base
# Alembic Config object
config = context.config
# Override sqlalchemy.url from environment if set
database_url = os.environ.get("DATABASE_URL")
if database_url:
# Alembic needs the synchronous driver
database_url = database_url.replace("postgresql+asyncpg://", "postgresql://")
config.set_main_option("sqlalchemy.url", database_url)
# Set up Python logging from the config file
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,442 @@
"""initial schema
Revision ID: 0001
Revises:
Create Date: 2026-04-13
Creates the full core schema plus seeds the default cookie categories.
"""
import uuid
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '0001'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('cookie_categories',
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('slug', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_essential', sa.Boolean(), nullable=False),
sa.Column('display_order', sa.Integer(), server_default='0', nullable=False),
sa.Column('tcf_purpose_ids', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gcm_consent_types', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name'),
sa.UniqueConstraint('slug')
)
op.create_table('organisations',
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('contact_email', sa.String(length=255), nullable=True),
sa.Column('billing_plan', sa.String(length=50), server_default='free', nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_organisations_slug'), 'organisations', ['slug'], unique=True)
op.create_table('known_cookies',
sa.Column('name_pattern', sa.String(length=255), nullable=False),
sa.Column('domain_pattern', sa.String(length=255), nullable=False),
sa.Column('category_id', sa.UUID(), nullable=False),
sa.Column('vendor', sa.String(length=255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_regex', sa.Boolean(), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['cookie_categories.id'], ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name_pattern', 'domain_pattern', name='uq_known_cookies_name_domain')
)
op.create_index(op.f('ix_known_cookies_name_pattern'), 'known_cookies', ['name_pattern'], unique=False)
op.create_table('org_configs',
sa.Column('organisation_id', sa.UUID(), nullable=False),
sa.Column('blocking_mode', sa.String(length=20), nullable=True),
sa.Column('regional_modes', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('tcf_enabled', sa.Boolean(), nullable=True),
sa.Column('tcf_publisher_cc', sa.String(length=2), nullable=True),
sa.Column('gpp_enabled', sa.Boolean(), nullable=True),
sa.Column('gpp_supported_apis', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpc_enabled', sa.Boolean(), nullable=True),
sa.Column('gpc_jurisdictions', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpc_global_honour', sa.Boolean(), nullable=True),
sa.Column('gcm_enabled', sa.Boolean(), nullable=True),
sa.Column('gcm_default', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('shopify_privacy_enabled', sa.Boolean(), nullable=True),
sa.Column('banner_config', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('privacy_policy_url', sa.Text(), nullable=True),
sa.Column('terms_url', sa.Text(), nullable=True),
sa.Column('scan_schedule_cron', sa.String(length=100), nullable=True),
sa.Column('scan_max_pages', sa.Integer(), nullable=True),
sa.Column('consent_expiry_days', sa.Integer(), nullable=True),
sa.Column('consent_retention_days', sa.Integer(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['organisation_id'], ['organisations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('organisation_id')
)
op.create_table('site_groups',
sa.Column('organisation_id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['organisation_id'], ['organisations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('organisation_id', 'name', name='uq_site_groups_org_name')
)
op.create_index(op.f('ix_site_groups_organisation_id'), 'site_groups', ['organisation_id'], unique=False)
op.create_table('users',
sa.Column('organisation_id', sa.UUID(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=False),
sa.Column('role', sa.String(length=20), server_default='viewer', nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['organisation_id'], ['organisations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_organisation_id'), 'users', ['organisation_id'], unique=False)
op.create_table('site_group_configs',
sa.Column('site_group_id', sa.UUID(), nullable=False),
sa.Column('blocking_mode', sa.String(length=20), nullable=True),
sa.Column('regional_modes', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('tcf_enabled', sa.Boolean(), nullable=True),
sa.Column('tcf_publisher_cc', sa.String(length=2), nullable=True),
sa.Column('gpp_enabled', sa.Boolean(), nullable=True),
sa.Column('gpp_supported_apis', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpc_enabled', sa.Boolean(), nullable=True),
sa.Column('gpc_jurisdictions', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpc_global_honour', sa.Boolean(), nullable=True),
sa.Column('gcm_enabled', sa.Boolean(), nullable=True),
sa.Column('gcm_default', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('shopify_privacy_enabled', sa.Boolean(), nullable=True),
sa.Column('banner_config', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('privacy_policy_url', sa.Text(), nullable=True),
sa.Column('terms_url', sa.Text(), nullable=True),
sa.Column('scan_schedule_cron', sa.String(length=100), nullable=True),
sa.Column('scan_max_pages', sa.Integer(), nullable=True),
sa.Column('consent_expiry_days', sa.Integer(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['site_group_id'], ['site_groups.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('site_group_id')
)
op.create_table('sites',
sa.Column('organisation_id', sa.UUID(), nullable=False),
sa.Column('domain', sa.String(length=255), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('additional_domains', postgresql.ARRAY(sa.String(length=255)), nullable=True),
sa.Column('site_group_id', sa.UUID(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['organisation_id'], ['organisations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['site_group_id'], ['site_groups.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('organisation_id', 'domain', name='uq_sites_org_domain')
)
op.create_index(op.f('ix_sites_domain'), 'sites', ['domain'], unique=False)
op.create_index(op.f('ix_sites_organisation_id'), 'sites', ['organisation_id'], unique=False)
op.create_index(op.f('ix_sites_site_group_id'), 'sites', ['site_group_id'], unique=False)
op.create_table('consent_records',
sa.Column('site_id', sa.UUID(), nullable=False),
sa.Column('visitor_id', sa.String(length=255), nullable=False),
sa.Column('ip_hash', sa.String(length=64), nullable=True),
sa.Column('user_agent_hash', sa.String(length=64), nullable=True),
sa.Column('action', sa.String(length=30), nullable=False),
sa.Column('categories_accepted', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('categories_rejected', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('tc_string', sa.Text(), nullable=True),
sa.Column('gcm_state', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpp_string', sa.Text(), nullable=True),
sa.Column('gpc_detected', sa.Boolean(), nullable=True),
sa.Column('gpc_honoured', sa.Boolean(), nullable=True),
sa.Column('ab_test_id', sa.UUID(), nullable=True),
sa.Column('ab_variant_id', sa.UUID(), nullable=True),
sa.Column('page_url', sa.Text(), nullable=True),
sa.Column('country_code', sa.String(length=5), nullable=True),
sa.Column('region_code', sa.String(length=10), nullable=True),
sa.Column('consented_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_consent_records_ab_test_id'), 'consent_records', ['ab_test_id'], unique=False)
op.create_index(op.f('ix_consent_records_consented_at'), 'consent_records', ['consented_at'], unique=False)
op.create_index(op.f('ix_consent_records_site_id'), 'consent_records', ['site_id'], unique=False)
op.create_index(op.f('ix_consent_records_visitor_id'), 'consent_records', ['visitor_id'], unique=False)
op.create_table('cookie_allow_list',
sa.Column('site_id', sa.UUID(), nullable=False),
sa.Column('category_id', sa.UUID(), nullable=False),
sa.Column('name_pattern', sa.String(length=255), nullable=False),
sa.Column('domain_pattern', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['cookie_categories.id'], ondelete='RESTRICT'),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('site_id', 'name_pattern', 'domain_pattern', name='uq_allow_list_site_name_domain')
)
op.create_index(op.f('ix_cookie_allow_list_site_id'), 'cookie_allow_list', ['site_id'], unique=False)
op.create_table('cookies',
sa.Column('site_id', sa.UUID(), nullable=False),
sa.Column('category_id', sa.UUID(), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('domain', sa.String(length=255), nullable=False),
sa.Column('storage_type', sa.String(length=30), server_default='cookie', nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('vendor', sa.String(length=255), nullable=True),
sa.Column('path', sa.String(length=500), nullable=True),
sa.Column('max_age_seconds', sa.Integer(), nullable=True),
sa.Column('is_http_only', sa.Boolean(), nullable=True),
sa.Column('is_secure', sa.Boolean(), nullable=True),
sa.Column('same_site', sa.String(length=10), nullable=True),
sa.Column('review_status', sa.String(length=20), server_default='pending', nullable=False),
sa.Column('first_seen_at', sa.String(length=50), nullable=True),
sa.Column('last_seen_at', sa.String(length=50), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['cookie_categories.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('site_id', 'name', 'domain', 'storage_type', name='uq_cookies_site_name_domain_type')
)
op.create_index(op.f('ix_cookies_category_id'), 'cookies', ['category_id'], unique=False)
op.create_index(op.f('ix_cookies_name'), 'cookies', ['name'], unique=False)
op.create_index(op.f('ix_cookies_site_id'), 'cookies', ['site_id'], unique=False)
op.create_table('scan_jobs',
sa.Column('site_id', sa.UUID(), nullable=False),
sa.Column('status', sa.String(length=20), server_default='pending', nullable=False),
sa.Column('trigger', sa.String(length=20), server_default='manual', nullable=False),
sa.Column('pages_scanned', sa.Integer(), server_default='0', nullable=False),
sa.Column('pages_total', sa.Integer(), nullable=True),
sa.Column('cookies_found', sa.Integer(), server_default='0', nullable=False),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_scan_jobs_site_id'), 'scan_jobs', ['site_id'], unique=False)
op.create_index(op.f('ix_scan_jobs_status'), 'scan_jobs', ['status'], unique=False)
op.create_table('site_configs',
sa.Column('site_id', sa.UUID(), nullable=False),
sa.Column('blocking_mode', sa.String(length=20), server_default='opt_in', nullable=False),
sa.Column('regional_modes', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('tcf_enabled', sa.Boolean(), nullable=False),
sa.Column('tcf_publisher_cc', sa.String(length=2), nullable=True),
sa.Column('gpp_enabled', sa.Boolean(), nullable=False),
sa.Column('gpp_supported_apis', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpc_enabled', sa.Boolean(), nullable=False),
sa.Column('gpc_jurisdictions', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('gpc_global_honour', sa.Boolean(), nullable=False),
sa.Column('gcm_enabled', sa.Boolean(), nullable=False),
sa.Column('gcm_default', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('shopify_privacy_enabled', sa.Boolean(), nullable=False),
sa.Column('banner_config', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('display_mode', sa.String(length=30), server_default='bottom_banner', nullable=False),
sa.Column('privacy_policy_url', sa.Text(), nullable=True),
sa.Column('terms_url', sa.Text(), nullable=True),
sa.Column('scan_schedule_cron', sa.String(length=100), nullable=True),
sa.Column('scan_max_pages', sa.Integer(), server_default='50', nullable=False),
sa.Column('consent_expiry_days', sa.Integer(), server_default='365', nullable=False),
sa.Column('consent_retention_days', sa.Integer(), nullable=True),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('site_id')
)
op.create_table('translations',
sa.Column('site_id', sa.UUID(), nullable=False),
sa.Column('locale', sa.String(length=10), nullable=False),
sa.Column('strings', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('site_id', 'locale', name='uq_translations_site_locale')
)
op.create_index(op.f('ix_translations_site_id'), 'translations', ['site_id'], unique=False)
op.create_table('scan_results',
sa.Column('scan_job_id', sa.UUID(), nullable=False),
sa.Column('page_url', sa.Text(), nullable=False),
sa.Column('cookie_name', sa.String(length=255), nullable=False),
sa.Column('cookie_domain', sa.String(length=255), nullable=False),
sa.Column('storage_type', sa.String(length=30), server_default='cookie', nullable=False),
sa.Column('attributes', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('script_source', sa.Text(), nullable=True),
sa.Column('auto_category', sa.String(length=50), nullable=True),
sa.Column('initiator_chain', postgresql.ARRAY(sa.Text()), nullable=True, comment='Ordered script URLs from root initiator to leaf'),
sa.Column('found_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['scan_job_id'], ['scan_jobs.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_scan_results_scan_job_id'), 'scan_results', ['scan_job_id'], unique=False)
# ### end Alembic commands ###
# ── Seed default cookie categories ───────────────────────────────
cookie_categories_table = sa.table(
"cookie_categories",
sa.column("id", sa.UUID()),
sa.column("name", sa.String),
sa.column("slug", sa.String),
sa.column("description", sa.Text),
sa.column("is_essential", sa.Boolean),
sa.column("display_order", sa.Integer),
sa.column("tcf_purpose_ids", postgresql.JSONB),
sa.column("gcm_consent_types", postgresql.JSONB),
)
op.bulk_insert(
cookie_categories_table,
[
{
"id": uuid.UUID("10000000-0000-0000-0000-000000000001"),
"name": "Necessary",
"slug": "necessary",
"description": (
"Essential cookies required for the website to function. "
"These cannot be disabled."
),
"is_essential": True,
"display_order": 0,
"tcf_purpose_ids": None,
"gcm_consent_types": ["functionality_storage", "security_storage"],
},
{
"id": uuid.UUID("10000000-0000-0000-0000-000000000002"),
"name": "Functional",
"slug": "functional",
"description": (
"Cookies that enable enhanced functionality and personalisation, "
"such as remembering preferences."
),
"is_essential": False,
"display_order": 1,
"tcf_purpose_ids": [1],
"gcm_consent_types": ["functionality_storage", "personalization_storage"],
},
{
"id": uuid.UUID("10000000-0000-0000-0000-000000000003"),
"name": "Analytics",
"slug": "analytics",
"description": (
"Cookies used to collect information about how visitors use the website, "
"helping to improve the site."
),
"is_essential": False,
"display_order": 2,
"tcf_purpose_ids": [7, 8, 9],
"gcm_consent_types": ["analytics_storage"],
},
{
"id": uuid.UUID("10000000-0000-0000-0000-000000000004"),
"name": "Marketing",
"slug": "marketing",
"description": (
"Cookies used to deliver personalised advertisements and "
"track advertising campaign performance."
),
"is_essential": False,
"display_order": 3,
"tcf_purpose_ids": [2, 3, 4, 5, 6, 10, 11],
"gcm_consent_types": ["ad_storage", "ad_user_data", "ad_personalization"],
},
{
"id": uuid.UUID("10000000-0000-0000-0000-000000000005"),
"name": "Personalisation",
"slug": "personalisation",
"description": (
"Cookies that enable content personalisation based on "
"user profiles and browsing behaviour."
),
"is_essential": False,
"display_order": 4,
"tcf_purpose_ids": [3, 4, 6],
"gcm_consent_types": ["personalization_storage"],
},
],
)
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_scan_results_scan_job_id'), table_name='scan_results')
op.drop_table('scan_results')
op.drop_index(op.f('ix_translations_site_id'), table_name='translations')
op.drop_table('translations')
op.drop_table('site_configs')
op.drop_index(op.f('ix_scan_jobs_status'), table_name='scan_jobs')
op.drop_index(op.f('ix_scan_jobs_site_id'), table_name='scan_jobs')
op.drop_table('scan_jobs')
op.drop_index(op.f('ix_cookies_site_id'), table_name='cookies')
op.drop_index(op.f('ix_cookies_name'), table_name='cookies')
op.drop_index(op.f('ix_cookies_category_id'), table_name='cookies')
op.drop_table('cookies')
op.drop_index(op.f('ix_cookie_allow_list_site_id'), table_name='cookie_allow_list')
op.drop_table('cookie_allow_list')
op.drop_index(op.f('ix_consent_records_visitor_id'), table_name='consent_records')
op.drop_index(op.f('ix_consent_records_site_id'), table_name='consent_records')
op.drop_index(op.f('ix_consent_records_consented_at'), table_name='consent_records')
op.drop_index(op.f('ix_consent_records_ab_test_id'), table_name='consent_records')
op.drop_table('consent_records')
op.drop_index(op.f('ix_sites_site_group_id'), table_name='sites')
op.drop_index(op.f('ix_sites_organisation_id'), table_name='sites')
op.drop_index(op.f('ix_sites_domain'), table_name='sites')
op.drop_table('sites')
op.drop_table('site_group_configs')
op.drop_index(op.f('ix_users_organisation_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_site_groups_organisation_id'), table_name='site_groups')
op.drop_table('site_groups')
op.drop_table('org_configs')
op.drop_index(op.f('ix_known_cookies_name_pattern'), table_name='known_cookies')
op.drop_table('known_cookies')
op.drop_index(op.f('ix_organisations_slug'), table_name='organisations')
op.drop_table('organisations')
op.drop_table('cookie_categories')
# ### end Alembic commands ###

View File

@@ -0,0 +1,36 @@
"""composite index on consent_records(site_id, consented_at)
Revision ID: 0002
Revises: 0001
Create Date: 2026-04-13
The most common analytic query pattern is "consents for site X in date
range" (consent rates, trends, regional breakdowns). The single-column
indexes on ``site_id`` and ``consented_at`` each help a little, but a
composite index is materially faster for the combined filter.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0002"
down_revision: Union[str, Sequence[str], None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ix_consent_records_site_consented_at",
"consent_records",
["site_id", "consented_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
"ix_consent_records_site_consented_at",
table_name="consent_records",
)