Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
113
backend/alembic.ini
Normal file
113
backend/alembic.ini
Normal file
@@ -0,0 +1,113 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||
# Any required deps can installed by adding `tzdata` to the `[alembic]` section
|
||||
# of pyproject.toml.
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in "version_locations" directory
|
||||
# New in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# NOTE: The database URL is set programmatically in env.py from settings.
|
||||
# Do not set sqlalchemy.url here.
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
44
backend/alembic/env.py
Normal file
44
backend/alembic/env.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from alembic import context
|
||||
from config import settings
|
||||
|
||||
# Import all models so Alembic can see them
|
||||
from database.models import Base # noqa: F401 — triggers all ORM imports
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = settings.database_url
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
engine = create_async_engine(settings.database_url)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(do_run_migrations)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${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, 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:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
0
backend/alembic/versions/.gitkeep
Normal file
0
backend/alembic/versions/.gitkeep
Normal file
@@ -0,0 +1,83 @@
|
||||
"""rename_entries_to_crm_entries
|
||||
|
||||
Revision ID: 244a0b0f35be
|
||||
Revises: 485d40e86e4b
|
||||
Create Date: 2026-04-15 20:05:20.835281
|
||||
|
||||
"""
|
||||
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 = '244a0b0f35be'
|
||||
down_revision: Union[str, None] = '485d40e86e4b'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Drop FK from support_tickets -> entries before touching the entries table
|
||||
op.drop_constraint('support_tickets_linked_entry_id_fkey', 'support_tickets', type_='foreignkey')
|
||||
# 2. Drop dependent table first, then parent
|
||||
op.drop_table('entry_links')
|
||||
op.drop_table('entries')
|
||||
# 3. Create new tables with crm_ prefix
|
||||
op.create_table('crm_entries',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('type', sa.String(length=10), nullable=False),
|
||||
sa.Column('title', sa.String(length=500), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=True),
|
||||
sa.Column('severity', sa.String(length=10), nullable=True),
|
||||
sa.Column('author_id', sa.String(length=128), nullable=False),
|
||||
sa.Column('author_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('crm_entry_links',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('entry_id', sa.UUID(), nullable=False),
|
||||
sa.Column('entity_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('entity_id', sa.String(length=128), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entry_id'], ['crm_entries.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('entry_id', 'entity_type', 'entity_id')
|
||||
)
|
||||
# 4. Recreate FK on support_tickets pointing at new table
|
||||
op.create_foreign_key(None, 'support_tickets', 'crm_entries', ['linked_entry_id'], ['id'], ondelete='SET NULL')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'support_tickets', type_='foreignkey')
|
||||
op.create_foreign_key('support_tickets_linked_entry_id_fkey', 'support_tickets', 'entries', ['linked_entry_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_table('entries',
|
||||
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('type', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
||||
sa.Column('title', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
|
||||
sa.Column('body', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('status', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||
sa.Column('severity', sa.VARCHAR(length=10), autoincrement=False, nullable=True),
|
||||
sa.Column('author_id', sa.VARCHAR(length=128), autoincrement=False, nullable=False),
|
||||
sa.Column('author_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name='entries_pkey'),
|
||||
postgresql_ignore_search_path=False
|
||||
)
|
||||
op.create_table('entry_links',
|
||||
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('entry_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||
sa.Column('entity_type', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('entity_id', sa.VARCHAR(length=128), autoincrement=False, nullable=False),
|
||||
sa.ForeignKeyConstraint(['entry_id'], ['entries.id'], name='entry_links_entry_id_fkey', ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name='entry_links_pkey'),
|
||||
sa.UniqueConstraint('entry_id', 'entity_type', 'entity_id', name='entry_links_entry_id_entity_type_entity_id_key')
|
||||
)
|
||||
op.drop_table('crm_entry_links')
|
||||
op.drop_table('crm_entries')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,82 @@
|
||||
"""initial_notes_and_tickets
|
||||
|
||||
Revision ID: 485d40e86e4b
|
||||
Revises:
|
||||
Create Date: 2026-04-15 20:01:04.225959
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '485d40e86e4b'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('entries',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('type', sa.String(length=10), nullable=False),
|
||||
sa.Column('title', sa.String(length=500), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=True),
|
||||
sa.Column('severity', sa.String(length=10), nullable=True),
|
||||
sa.Column('author_id', sa.String(length=128), nullable=False),
|
||||
sa.Column('author_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('entry_links',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('entry_id', sa.UUID(), nullable=False),
|
||||
sa.Column('entity_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('entity_id', sa.String(length=128), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entry_id'], ['entries.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('entry_id', 'entity_type', 'entity_id')
|
||||
)
|
||||
op.create_table('support_tickets',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('customer_id', sa.String(length=128), nullable=False),
|
||||
sa.Column('customer_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('device_id', sa.String(length=128), nullable=True),
|
||||
sa.Column('device_serial', sa.String(length=64), nullable=True),
|
||||
sa.Column('subject', sa.String(length=500), nullable=False),
|
||||
sa.Column('status', sa.String(length=30), nullable=False),
|
||||
sa.Column('priority', sa.String(length=10), nullable=True),
|
||||
sa.Column('opened_via', sa.String(length=20), nullable=True),
|
||||
sa.Column('linked_entry_id', sa.UUID(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['linked_entry_id'], ['entries.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('ticket_messages',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('ticket_id', sa.UUID(), nullable=False),
|
||||
sa.Column('sender_type', sa.String(length=10), nullable=False),
|
||||
sa.Column('sender_id', sa.String(length=128), nullable=False),
|
||||
sa.Column('sender_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('ticket_messages')
|
||||
op.drop_table('support_tickets')
|
||||
op.drop_table('entry_links')
|
||||
op.drop_table('entries')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,23 @@
|
||||
"""add_category_to_crm_entries
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 244a0b0f35be
|
||||
Create Date: 2026-04-16 09:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = '244a0b0f35be'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('crm_entries', sa.Column('category', sa.String(length=30), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('crm_entries', 'category')
|
||||
@@ -0,0 +1,507 @@
|
||||
"""phase_0_schema_foundation
|
||||
|
||||
Adds all Phase 0 tables:
|
||||
- _migration_runs (migration tracking)
|
||||
- audit_log (staff action audit trail)
|
||||
- crm_products
|
||||
- crm_customers
|
||||
- crm_orders
|
||||
- crm_comms_log
|
||||
- crm_media
|
||||
- crm_sync_state
|
||||
- crm_quotations
|
||||
- crm_quotation_items
|
||||
- staff
|
||||
- console_settings
|
||||
- public_features
|
||||
- melody_drafts
|
||||
- built_melodies
|
||||
- mfg_audit_log
|
||||
- device_alerts
|
||||
- commands (raw SQL — no ORM model)
|
||||
- heartbeats (raw SQL — no ORM model)
|
||||
- device_logs (partitioned by month — raw SQL)
|
||||
- device_logs_2025_01 … device_logs_2026_06 (initial partitions)
|
||||
|
||||
Revision ID: b1c2d3e4f5a6
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-04-17 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
|
||||
# revision identifiers
|
||||
revision: str = "b1c2d3e4f5a6"
|
||||
down_revision: Union[str, None] = "a1b2c3d4e5f6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ------------------------------------------------------------------ #
|
||||
# _migration_runs
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"_migration_runs",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column("script_name", sa.String(256), nullable=False),
|
||||
sa.Column("ran_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("source_rows", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("dest_rows", sa.BigInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("success", sa.String(8), nullable=False, server_default="ok"),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# audit_log
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"audit_log",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("actor_id", sa.String(128), nullable=False),
|
||||
sa.Column("actor_name", sa.String(255), nullable=False),
|
||||
sa.Column("action", sa.String(64), nullable=False),
|
||||
sa.Column("entity_type", sa.String(64), nullable=False),
|
||||
sa.Column("entity_id", sa.String(128), nullable=False),
|
||||
sa.Column("entity_label", sa.String(500), nullable=True),
|
||||
sa.Column("changes", JSONB, nullable=True),
|
||||
sa.Column("meta", JSONB, nullable=True),
|
||||
)
|
||||
op.create_index("idx_audit_actor", "audit_log", ["actor_id", "occurred_at"])
|
||||
op.create_index("idx_audit_entity", "audit_log", ["entity_type","entity_id", "occurred_at"])
|
||||
op.create_index("idx_audit_action", "audit_log", ["action", "occurred_at"])
|
||||
op.create_index("idx_audit_occurred", "audit_log", ["occurred_at"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# staff
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"staff",
|
||||
sa.Column("id", sa.String(128), primary_key=True),
|
||||
sa.Column("firestore_id", sa.String(128), nullable=True, unique=True),
|
||||
sa.Column("email", sa.String(256), nullable=False, unique=True),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("role", sa.String(64), nullable=False, server_default="staff"),
|
||||
sa.Column("permissions", JSONB, nullable=False, server_default="{}"),
|
||||
sa.Column("hashed_password", sa.String(256), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# console_settings & public_features
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"console_settings",
|
||||
sa.Column("key", sa.String(128), primary_key=True),
|
||||
sa.Column("value", JSONB, nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
op.create_table(
|
||||
"public_features",
|
||||
sa.Column("key", sa.String(128), primary_key=True),
|
||||
sa.Column("value", JSONB, nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# crm_products
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"crm_products",
|
||||
sa.Column("id", sa.String(128), primary_key=True),
|
||||
sa.Column("firestore_id", sa.String(128), nullable=True, unique=True),
|
||||
sa.Column("name", sa.String(500), nullable=False),
|
||||
sa.Column("sku", sa.String(128), nullable=True),
|
||||
sa.Column("category", sa.String(128), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("unit_cost", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("currency", sa.String(10), nullable=False, server_default="EUR"),
|
||||
sa.Column("unit_type", sa.String(32), nullable=False, server_default="pcs"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# crm_customers
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"crm_customers",
|
||||
sa.Column("id", sa.String(128), primary_key=True),
|
||||
sa.Column("firestore_id", sa.String(128), nullable=True, unique=True),
|
||||
sa.Column("title", sa.String(32), nullable=True),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("surname", sa.String(255), nullable=True),
|
||||
sa.Column("organization", sa.String(500), nullable=True),
|
||||
sa.Column("religion", sa.String(64), nullable=True),
|
||||
sa.Column("language", sa.String(10), nullable=False, server_default="el"),
|
||||
sa.Column("folder_id", sa.String(128), nullable=False, unique=True),
|
||||
sa.Column("relationship_status", sa.String(64), nullable=False, server_default="lead"),
|
||||
sa.Column("nextcloud_folder", sa.String(500), nullable=True),
|
||||
sa.Column("contacts", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("notes", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("location", JSONB, nullable=True),
|
||||
sa.Column("tags", ARRAY(sa.String()), nullable=False, server_default="{}"),
|
||||
sa.Column("owned_items", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("linked_user_ids", ARRAY(sa.String()), nullable=False, server_default="{}"),
|
||||
sa.Column("technical_issues", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("install_support", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("transaction_history", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("crm_summary", JSONB, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("idx_crm_customers_rel_status", "crm_customers", ["relationship_status"])
|
||||
op.create_index("idx_crm_customers_name", "crm_customers", ["name", "surname"])
|
||||
op.create_index("idx_crm_customers_tags", "crm_customers", ["tags"],
|
||||
postgresql_using="gin")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# crm_orders
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"crm_orders",
|
||||
sa.Column("id", sa.String(128), primary_key=True),
|
||||
sa.Column("customer_id", sa.String(128),
|
||||
sa.ForeignKey("crm_customers.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("order_number", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("title", sa.String(500), nullable=True),
|
||||
sa.Column("created_by", sa.String(128), nullable=True),
|
||||
sa.Column("status", sa.String(64), nullable=False,
|
||||
server_default="negotiating"),
|
||||
sa.Column("status_updated_date", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("status_updated_by", sa.String(128), nullable=True),
|
||||
sa.Column("items", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("subtotal", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("discount", JSONB, nullable=True),
|
||||
sa.Column("total_price", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("currency", sa.String(10), nullable=False, server_default="EUR"),
|
||||
sa.Column("shipping", JSONB, nullable=True),
|
||||
sa.Column("payment_status", JSONB, nullable=False, server_default="{}"),
|
||||
sa.Column("invoice_path", sa.String(500), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("timeline", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("idx_crm_orders_customer", "crm_orders", ["customer_id"])
|
||||
op.create_index("idx_crm_orders_status", "crm_orders", ["status"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# crm_comms_log
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"crm_comms_log",
|
||||
sa.Column("id", sa.String(128), primary_key=True),
|
||||
sa.Column("customer_id", sa.String(128),
|
||||
sa.ForeignKey("crm_customers.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("type", sa.String(32), nullable=False),
|
||||
sa.Column("mail_account", sa.String(256), nullable=True),
|
||||
sa.Column("direction", sa.String(16), nullable=False),
|
||||
sa.Column("subject", sa.String(500), nullable=True),
|
||||
sa.Column("body", sa.Text(), nullable=True),
|
||||
sa.Column("body_html", sa.Text(), nullable=True),
|
||||
sa.Column("attachments", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("ext_message_id", sa.String(500), nullable=True),
|
||||
sa.Column("from_addr", sa.String(500), nullable=True),
|
||||
sa.Column("to_addrs", sa.Text(), nullable=True),
|
||||
sa.Column("logged_by", sa.String(128), nullable=True),
|
||||
sa.Column("is_important", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("is_read", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("idx_crm_comms_customer", "crm_comms_log", ["customer_id", "occurred_at"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# crm_media
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"crm_media",
|
||||
sa.Column("id", sa.String(128), primary_key=True),
|
||||
sa.Column("customer_id", sa.String(128),
|
||||
sa.ForeignKey("crm_customers.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("order_id", sa.String(128), nullable=True),
|
||||
sa.Column("filename", sa.String(500), nullable=False),
|
||||
sa.Column("nextcloud_path", sa.String(1000), nullable=False),
|
||||
sa.Column("thumbnail_path", sa.String(1000), nullable=True),
|
||||
sa.Column("mime_type", sa.String(128), nullable=True),
|
||||
sa.Column("direction", sa.String(16), nullable=True),
|
||||
sa.Column("tags", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("uploaded_by", sa.String(128), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("idx_crm_media_customer", "crm_media", ["customer_id"])
|
||||
op.create_index("idx_crm_media_order", "crm_media", ["order_id"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# crm_sync_state
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"crm_sync_state",
|
||||
sa.Column("key", sa.String(128), primary_key=True),
|
||||
sa.Column("value", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# crm_quotations
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"crm_quotations",
|
||||
sa.Column("id", sa.String(128), primary_key=True),
|
||||
sa.Column("quotation_number", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("title", sa.String(500), nullable=True),
|
||||
sa.Column("subtitle", sa.String(500), nullable=True),
|
||||
sa.Column("customer_id", sa.String(128),
|
||||
sa.ForeignKey("crm_customers.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("language", sa.String(10), nullable=False, server_default="en"),
|
||||
sa.Column("status", sa.String(32), nullable=False, server_default="draft"),
|
||||
sa.Column("order_type", sa.String(64), nullable=True),
|
||||
sa.Column("shipping_method", sa.String(64), nullable=True),
|
||||
sa.Column("estimated_shipping_date", sa.String(32), nullable=True),
|
||||
sa.Column("global_discount_label", sa.String(128), nullable=True),
|
||||
sa.Column("global_discount_percent", sa.Numeric(8, 4), nullable=False, server_default="0"),
|
||||
sa.Column("vat_percent", sa.Numeric(8, 4), nullable=False, server_default="24"),
|
||||
sa.Column("global_vat_percent", sa.Numeric(8, 4), nullable=False, server_default="24"),
|
||||
sa.Column("shipping_cost", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("shipping_cost_discount", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("install_cost", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("install_cost_discount", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("extras_label", sa.String(256), nullable=True),
|
||||
sa.Column("extras_cost", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("comments", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("quick_notes", JSONB, nullable=False, server_default="{}"),
|
||||
sa.Column("subtotal_before_discount", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("global_discount_amount", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("new_subtotal", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("vat_amount", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("final_total", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("nextcloud_pdf_path", sa.String(1000), nullable=True),
|
||||
sa.Column("nextcloud_pdf_url", sa.String(1000), nullable=True),
|
||||
sa.Column("client_org", sa.String(500), nullable=True),
|
||||
sa.Column("client_name", sa.String(500), nullable=True),
|
||||
sa.Column("client_location", sa.String(500), nullable=True),
|
||||
sa.Column("client_phone", sa.String(64), nullable=True),
|
||||
sa.Column("client_email", sa.String(256), nullable=True),
|
||||
sa.Column("is_legacy", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("legacy_date", sa.String(32), nullable=True),
|
||||
sa.Column("legacy_pdf_path", sa.String(1000), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("idx_crm_quotations_customer", "crm_quotations", ["customer_id"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# crm_quotation_items
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"crm_quotation_items",
|
||||
sa.Column("id", sa.String(128), primary_key=True),
|
||||
sa.Column("quotation_id", sa.String(128),
|
||||
sa.ForeignKey("crm_quotations.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("product_id", sa.String(128), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("description_en", sa.Text(), nullable=True),
|
||||
sa.Column("description_gr", sa.Text(), nullable=True),
|
||||
sa.Column("unit_type", sa.String(32), nullable=False, server_default="pcs"),
|
||||
sa.Column("unit_cost", sa.Numeric(12, 4), nullable=False, server_default="0"),
|
||||
sa.Column("discount_percent", sa.Numeric(8, 4), nullable=False, server_default="0"),
|
||||
sa.Column("vat_percent", sa.Numeric(8, 4), nullable=False, server_default="24"),
|
||||
sa.Column("quantity", sa.Numeric(12, 4), nullable=False, server_default="1"),
|
||||
sa.Column("line_total", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.create_index("idx_crm_quotation_items_quotation", "crm_quotation_items",
|
||||
["quotation_id", "sort_order"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# melody_drafts
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"melody_drafts",
|
||||
sa.Column("id", sa.String(128), primary_key=True),
|
||||
sa.Column("status", sa.String(32), nullable=False, server_default="draft"),
|
||||
sa.Column("data", JSONB, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("idx_melody_drafts_status", "melody_drafts", ["status"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# built_melodies
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"built_melodies",
|
||||
sa.Column("id", sa.String(128), primary_key=True),
|
||||
sa.Column("name", sa.String(500), nullable=False),
|
||||
sa.Column("pid", sa.String(128), nullable=False),
|
||||
sa.Column("steps", JSONB, nullable=False),
|
||||
sa.Column("binary_path", sa.String(1000), nullable=True),
|
||||
sa.Column("progmem_code", sa.Text(), nullable=True),
|
||||
sa.Column("assigned_melody_ids", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# mfg_audit_log
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"mfg_audit_log",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column("admin_user", sa.String(256), nullable=False),
|
||||
sa.Column("action", sa.String(128), nullable=False),
|
||||
sa.Column("serial_number", sa.String(128), nullable=True),
|
||||
sa.Column("detail", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("idx_mfg_audit_time", "mfg_audit_log", ["timestamp"])
|
||||
op.create_index("idx_mfg_audit_action", "mfg_audit_log", ["action"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# device_alerts
|
||||
# ------------------------------------------------------------------ #
|
||||
op.create_table(
|
||||
"device_alerts",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column("device_serial", sa.String(128), nullable=False),
|
||||
sa.Column("subsystem", sa.String(128), nullable=False),
|
||||
sa.Column("state", sa.String(64), nullable=False),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("device_serial", "subsystem", name="uq_device_alerts_serial_subsystem"),
|
||||
)
|
||||
op.create_index("idx_device_alerts_serial", "device_alerts", ["device_serial"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# commands (raw SQL — mirrors SQLite schema, no ORM model)
|
||||
# ------------------------------------------------------------------ #
|
||||
op.execute("""
|
||||
CREATE TABLE commands (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
device_serial TEXT NOT NULL,
|
||||
command_name TEXT NOT NULL,
|
||||
command_payload TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
response_payload TEXT,
|
||||
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
responded_at TIMESTAMPTZ
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX idx_commands_serial_time ON commands(device_serial, sent_at DESC)")
|
||||
op.execute("CREATE INDEX idx_commands_status ON commands(status)")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# heartbeats (raw SQL — mirrors SQLite schema, no ORM model)
|
||||
# ------------------------------------------------------------------ #
|
||||
op.execute("""
|
||||
CREATE TABLE heartbeats (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
device_serial TEXT NOT NULL,
|
||||
device_id TEXT,
|
||||
firmware_version TEXT,
|
||||
ip_address TEXT,
|
||||
gateway TEXT,
|
||||
uptime_ms BIGINT,
|
||||
uptime_display TEXT,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX idx_heartbeats_serial_time ON heartbeats(device_serial, received_at DESC)")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# device_logs — partitioned by month on received_at
|
||||
# ------------------------------------------------------------------ #
|
||||
op.execute("""
|
||||
CREATE TABLE device_logs (
|
||||
id BIGSERIAL,
|
||||
device_serial TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
device_timestamp BIGINT,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (id, received_at)
|
||||
) PARTITION BY RANGE (received_at)
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE INDEX idx_device_logs_serial_time
|
||||
ON device_logs(device_serial, received_at DESC)
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE INDEX idx_device_logs_level
|
||||
ON device_logs(level, received_at DESC)
|
||||
""")
|
||||
|
||||
# Create partitions: 2025-01 through 2026-06 (covers all existing data + near future)
|
||||
partitions = [
|
||||
("2025_01", "2025-01-01", "2025-02-01"),
|
||||
("2025_02", "2025-02-01", "2025-03-01"),
|
||||
("2025_03", "2025-03-01", "2025-04-01"),
|
||||
("2025_04", "2025-04-01", "2025-05-01"),
|
||||
("2025_05", "2025-05-01", "2025-06-01"),
|
||||
("2025_06", "2025-06-01", "2025-07-01"),
|
||||
("2025_07", "2025-07-01", "2025-08-01"),
|
||||
("2025_08", "2025-08-01", "2025-09-01"),
|
||||
("2025_09", "2025-09-01", "2025-10-01"),
|
||||
("2025_10", "2025-10-01", "2025-11-01"),
|
||||
("2025_11", "2025-11-01", "2025-12-01"),
|
||||
("2025_12", "2025-12-01", "2026-01-01"),
|
||||
("2026_01", "2026-01-01", "2026-02-01"),
|
||||
("2026_02", "2026-02-01", "2026-03-01"),
|
||||
("2026_03", "2026-03-01", "2026-04-01"),
|
||||
("2026_04", "2026-04-01", "2026-05-01"),
|
||||
("2026_05", "2026-05-01", "2026-06-01"),
|
||||
("2026_06", "2026-06-01", "2026-07-01"),
|
||||
]
|
||||
for suffix, start, end in partitions:
|
||||
op.execute(f"""
|
||||
CREATE TABLE device_logs_{suffix} PARTITION OF device_logs
|
||||
FOR VALUES FROM ('{start}') TO ('{end}')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop in reverse dependency order
|
||||
op.execute("DROP TABLE IF EXISTS device_logs CASCADE") # drops all partitions too
|
||||
op.execute("DROP TABLE IF EXISTS heartbeats CASCADE")
|
||||
op.execute("DROP TABLE IF EXISTS commands CASCADE")
|
||||
op.drop_table("device_alerts")
|
||||
op.drop_table("mfg_audit_log")
|
||||
op.drop_table("built_melodies")
|
||||
op.drop_table("melody_drafts")
|
||||
op.drop_table("crm_quotation_items")
|
||||
op.drop_table("crm_quotations")
|
||||
op.drop_table("crm_sync_state")
|
||||
op.drop_table("crm_media")
|
||||
op.drop_table("crm_comms_log")
|
||||
op.drop_table("crm_orders")
|
||||
op.drop_table("crm_customers")
|
||||
op.drop_table("crm_products")
|
||||
op.drop_table("public_features")
|
||||
op.drop_table("console_settings")
|
||||
op.drop_table("staff")
|
||||
op.drop_table("audit_log")
|
||||
op.drop_table("_migration_runs")
|
||||
@@ -26,6 +26,9 @@ class Settings(BaseSettings):
|
||||
sqlite_db_path: str = "./data/database.db"
|
||||
mqtt_data_retention_days: int = 90
|
||||
|
||||
# Postgres
|
||||
database_url: str = "postgresql+asyncpg://bellsystems_user:password@postgres:5432/bellsystems_db"
|
||||
|
||||
# Local file storage
|
||||
built_melodies_storage_path: str = "./storage/built_melodies"
|
||||
firmware_storage_path: str = "./storage/firmware"
|
||||
|
||||
@@ -28,6 +28,16 @@ class MailListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
@router.get("/latest-batch", response_model=dict)
|
||||
async def latest_comm_batch(
|
||||
ids: str = Query(..., description="Comma-separated customer IDs"),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
"""Return the latest comm summary (id, type, occurred_at) keyed by customer_id."""
|
||||
customer_ids = [i.strip() for i in ids.split(",") if i.strip()]
|
||||
return await service.get_latest_comm_batch(customer_ids)
|
||||
|
||||
|
||||
@router.get("/all", response_model=CommListResponse)
|
||||
async def list_all_comms(
|
||||
type: Optional[str] = Query(None),
|
||||
|
||||
239
backend/crm/orm.py
Normal file
239
backend/crm/orm.py
Normal file
@@ -0,0 +1,239 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import (
|
||||
BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer,
|
||||
Numeric, String, Text, UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from database.postgres import Base
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class CrmProduct(Base):
|
||||
__tablename__ = "crm_products"
|
||||
|
||||
id = Column(String(128), primary_key=True) # Firestore doc ID
|
||||
firestore_id = Column(String(128), unique=True) # same as id during transition
|
||||
name = Column(String(500), nullable=False)
|
||||
sku = Column(String(128))
|
||||
category = Column(String(128))
|
||||
description = Column(Text)
|
||||
unit_cost = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
currency = Column(String(10), nullable=False, default="EUR")
|
||||
unit_type = Column(String(32), nullable=False, default="pcs")
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||
|
||||
|
||||
class CrmCustomer(Base):
|
||||
__tablename__ = "crm_customers"
|
||||
__table_args__ = (
|
||||
Index("idx_crm_customers_rel_status", "relationship_status"),
|
||||
Index("idx_crm_customers_name", "name", "surname"),
|
||||
Index("idx_crm_customers_tags", "tags", postgresql_using="gin"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True) # Firestore doc ID
|
||||
firestore_id = Column(String(128), unique=True)
|
||||
title = Column(String(32))
|
||||
name = Column(String(255), nullable=False)
|
||||
surname = Column(String(255))
|
||||
organization = Column(String(500))
|
||||
religion = Column(String(64))
|
||||
language = Column(String(10), nullable=False, default="el")
|
||||
folder_id = Column(String(128), unique=True, nullable=False)
|
||||
relationship_status = Column(String(64), nullable=False, default="lead")
|
||||
nextcloud_folder = Column(String(500))
|
||||
contacts = Column(JSONB, nullable=False, default=list)
|
||||
notes = Column(JSONB, nullable=False, default=list)
|
||||
location = Column(JSONB)
|
||||
tags = Column(ARRAY(String), nullable=False, default=list)
|
||||
owned_items = Column(JSONB, nullable=False, default=list)
|
||||
linked_user_ids = Column(ARRAY(String), nullable=False, default=list)
|
||||
technical_issues = Column(JSONB, nullable=False, default=list)
|
||||
install_support = Column(JSONB, nullable=False, default=list)
|
||||
transaction_history = Column(JSONB, nullable=False, default=list)
|
||||
crm_summary = Column(JSONB)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
orders = relationship("CrmOrder", back_populates="customer",
|
||||
cascade="all, delete-orphan", lazy="noload")
|
||||
quotations = relationship("CrmQuotation", back_populates="customer",
|
||||
cascade="all, delete-orphan", lazy="noload")
|
||||
comms = relationship("CrmCommsLog", back_populates="customer",
|
||||
cascade="all, delete-orphan", lazy="noload")
|
||||
media = relationship("CrmMedia", back_populates="customer", lazy="noload")
|
||||
|
||||
|
||||
class CrmOrder(Base):
|
||||
__tablename__ = "crm_orders"
|
||||
__table_args__ = (
|
||||
Index("idx_crm_orders_customer", "customer_id"),
|
||||
Index("idx_crm_orders_status", "status"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True) # Firestore doc ID
|
||||
customer_id = Column(String(128), ForeignKey("crm_customers.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
order_number = Column(String(64), unique=True, nullable=False)
|
||||
title = Column(String(500))
|
||||
created_by = Column(String(128))
|
||||
status = Column(String(64), nullable=False, default="negotiating")
|
||||
status_updated_date = Column(DateTime(timezone=True))
|
||||
status_updated_by = Column(String(128))
|
||||
items = Column(JSONB, nullable=False, default=list)
|
||||
subtotal = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
discount = Column(JSONB)
|
||||
total_price = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
currency = Column(String(10), nullable=False, default="EUR")
|
||||
shipping = Column(JSONB)
|
||||
payment_status = Column(JSONB, nullable=False, default=dict)
|
||||
invoice_path = Column(String(500))
|
||||
notes = Column(Text)
|
||||
timeline = Column(JSONB, nullable=False, default=list)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
customer = relationship("CrmCustomer", back_populates="orders")
|
||||
|
||||
|
||||
class CrmCommsLog(Base):
|
||||
__tablename__ = "crm_comms_log"
|
||||
__table_args__ = (
|
||||
Index("idx_crm_comms_customer", "customer_id", "occurred_at"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True)
|
||||
customer_id = Column(String(128), ForeignKey("crm_customers.id", ondelete="SET NULL"),
|
||||
nullable=True)
|
||||
type = Column(String(32), nullable=False) # email | sms | call | note | ...
|
||||
mail_account = Column(String(256))
|
||||
direction = Column(String(16), nullable=False) # inbound | outbound
|
||||
subject = Column(String(500))
|
||||
body = Column(Text)
|
||||
body_html = Column(Text)
|
||||
attachments = Column(JSONB, nullable=False, default=list)
|
||||
ext_message_id = Column(String(500))
|
||||
from_addr = Column(String(500))
|
||||
to_addrs = Column(Text) # JSON array as text or comma-sep
|
||||
logged_by = Column(String(128))
|
||||
is_important = Column(Boolean, nullable=False, default=False)
|
||||
is_read = Column(Boolean, nullable=False, default=True)
|
||||
occurred_at = Column(DateTime(timezone=True), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
|
||||
customer = relationship("CrmCustomer", back_populates="comms")
|
||||
|
||||
|
||||
class CrmMedia(Base):
|
||||
__tablename__ = "crm_media"
|
||||
__table_args__ = (
|
||||
Index("idx_crm_media_customer", "customer_id"),
|
||||
Index("idx_crm_media_order", "order_id"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True)
|
||||
customer_id = Column(String(128), ForeignKey("crm_customers.id", ondelete="SET NULL"),
|
||||
nullable=True)
|
||||
order_id = Column(String(128))
|
||||
filename = Column(String(500), nullable=False)
|
||||
nextcloud_path = Column(String(1000), nullable=False)
|
||||
thumbnail_path = Column(String(1000))
|
||||
mime_type = Column(String(128))
|
||||
direction = Column(String(16))
|
||||
tags = Column(JSONB, nullable=False, default=list)
|
||||
uploaded_by = Column(String(128))
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
|
||||
customer = relationship("CrmCustomer", back_populates="media")
|
||||
|
||||
|
||||
class CrmSyncState(Base):
|
||||
__tablename__ = "crm_sync_state"
|
||||
|
||||
key = Column(String(128), primary_key=True)
|
||||
value = Column(Text)
|
||||
|
||||
|
||||
class CrmQuotation(Base):
|
||||
__tablename__ = "crm_quotations"
|
||||
__table_args__ = (
|
||||
Index("idx_crm_quotations_customer", "customer_id"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True)
|
||||
quotation_number = Column(String(64), unique=True, nullable=False)
|
||||
title = Column(String(500))
|
||||
subtitle = Column(String(500))
|
||||
customer_id = Column(String(128), ForeignKey("crm_customers.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
language = Column(String(10), nullable=False, default="en")
|
||||
status = Column(String(32), nullable=False, default="draft")
|
||||
order_type = Column(String(64))
|
||||
shipping_method = Column(String(64))
|
||||
estimated_shipping_date = Column(String(32)) # stored as DATE string
|
||||
global_discount_label = Column(String(128))
|
||||
global_discount_percent = Column(Numeric(8, 4), nullable=False, default=0)
|
||||
vat_percent = Column(Numeric(8, 4), nullable=False, default=24)
|
||||
global_vat_percent = Column(Numeric(8, 4), nullable=False, default=24)
|
||||
shipping_cost = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
shipping_cost_discount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
install_cost = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
install_cost_discount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
extras_label = Column(String(256))
|
||||
extras_cost = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
comments = Column(JSONB, nullable=False, default=list)
|
||||
quick_notes = Column(JSONB, nullable=False, default=dict)
|
||||
subtotal_before_discount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
global_discount_amount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
new_subtotal = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
vat_amount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
final_total = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
nextcloud_pdf_path = Column(String(1000))
|
||||
nextcloud_pdf_url = Column(String(1000))
|
||||
# Client snapshot fields (denormalised for PDF generation)
|
||||
client_org = Column(String(500))
|
||||
client_name = Column(String(500))
|
||||
client_location = Column(String(500))
|
||||
client_phone = Column(String(64))
|
||||
client_email = Column(String(256))
|
||||
# Legacy quotation fields
|
||||
is_legacy = Column(Boolean, nullable=False, default=False)
|
||||
legacy_date = Column(String(32))
|
||||
legacy_pdf_path = Column(String(1000))
|
||||
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
customer = relationship("CrmCustomer", back_populates="quotations")
|
||||
items = relationship("CrmQuotationItem", back_populates="quotation",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="CrmQuotationItem.sort_order", lazy="noload")
|
||||
|
||||
|
||||
class CrmQuotationItem(Base):
|
||||
__tablename__ = "crm_quotation_items"
|
||||
__table_args__ = (
|
||||
Index("idx_crm_quotation_items_quotation", "quotation_id", "sort_order"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True)
|
||||
quotation_id = Column(String(128), ForeignKey("crm_quotations.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
product_id = Column(String(128))
|
||||
description = Column(Text)
|
||||
description_en = Column(Text)
|
||||
description_gr = Column(Text)
|
||||
unit_type = Column(String(32), nullable=False, default="pcs")
|
||||
unit_cost = Column(Numeric(12, 4), nullable=False, default=0)
|
||||
discount_percent = Column(Numeric(8, 4), nullable=False, default=0)
|
||||
vat_percent = Column(Numeric(8, 4), nullable=False, default=24)
|
||||
quantity = Column(Numeric(12, 4), nullable=False, default=1)
|
||||
line_total = Column(Numeric(12, 2), nullable=False, default=0)
|
||||
sort_order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
quotation = relationship("CrmQuotation", back_populates="items")
|
||||
@@ -5,9 +5,10 @@ from pydantic import BaseModel
|
||||
|
||||
class QuotationStatus(str, Enum):
|
||||
draft = "draft"
|
||||
built = "built"
|
||||
sent = "sent"
|
||||
accepted = "accepted"
|
||||
rejected = "rejected"
|
||||
declined = "declined"
|
||||
|
||||
|
||||
class QuotationItemCreate(BaseModel):
|
||||
@@ -39,6 +40,7 @@ class QuotationCreate(BaseModel):
|
||||
estimated_shipping_date: Optional[str] = None
|
||||
global_discount_label: Optional[str] = None
|
||||
global_discount_percent: float = 0.0
|
||||
global_vat_percent: float = 24.0
|
||||
shipping_cost: float = 0.0
|
||||
shipping_cost_discount: float = 0.0
|
||||
install_cost: float = 0.0
|
||||
@@ -70,6 +72,7 @@ class QuotationUpdate(BaseModel):
|
||||
estimated_shipping_date: Optional[str] = None
|
||||
global_discount_label: Optional[str] = None
|
||||
global_discount_percent: Optional[float] = None
|
||||
global_vat_percent: Optional[float] = None
|
||||
shipping_cost: Optional[float] = None
|
||||
shipping_cost_discount: Optional[float] = None
|
||||
install_cost: Optional[float] = None
|
||||
@@ -104,6 +107,7 @@ class QuotationInDB(BaseModel):
|
||||
estimated_shipping_date: Optional[str] = None
|
||||
global_discount_label: Optional[str] = None
|
||||
global_discount_percent: float = 0.0
|
||||
global_vat_percent: float = 24.0
|
||||
shipping_cost: float = 0.0
|
||||
shipping_cost_discount: float = 0.0
|
||||
install_cost: float = 0.0
|
||||
|
||||
@@ -42,6 +42,7 @@ def _float(d: Decimal) -> float:
|
||||
def _calculate_totals(
|
||||
items: list,
|
||||
global_discount_percent: float,
|
||||
global_vat_percent: float,
|
||||
shipping_cost: float,
|
||||
shipping_cost_discount: float,
|
||||
install_cost: float,
|
||||
@@ -50,21 +51,20 @@ def _calculate_totals(
|
||||
) -> dict:
|
||||
"""
|
||||
Calculate all monetary totals using Decimal arithmetic (ROUND_HALF_UP).
|
||||
VAT is computed per-item from each item's vat_percent field.
|
||||
VAT is a single global rate applied to items only (not shipping or install).
|
||||
Shipping and install costs carry 0% VAT.
|
||||
Returns a dict of floats ready for DB storage.
|
||||
"""
|
||||
# Per-line totals and per-item VAT
|
||||
# Per-line totals (items only)
|
||||
item_totals = []
|
||||
item_vat = Decimal(0)
|
||||
for item in items:
|
||||
cost = _d(item.get("unit_cost", 0))
|
||||
qty = _d(item.get("quantity", 1))
|
||||
disc = _d(item.get("discount_percent", 0))
|
||||
net = cost * qty * (1 - disc / 100)
|
||||
item_totals.append(net)
|
||||
vat_pct = _d(item.get("vat_percent", 24))
|
||||
item_vat += net * (vat_pct / 100)
|
||||
|
||||
items_net = sum(item_totals, Decimal(0))
|
||||
|
||||
# Shipping net (VAT = 0%)
|
||||
ship_gross = _d(shipping_cost)
|
||||
@@ -76,16 +76,17 @@ def _calculate_totals(
|
||||
install_disc = _d(install_cost_discount)
|
||||
install_net = install_gross * (1 - install_disc / 100)
|
||||
|
||||
subtotal = sum(item_totals, Decimal(0)) + ship_net + install_net
|
||||
subtotal = items_net + ship_net + install_net
|
||||
|
||||
global_disc_pct = _d(global_discount_percent)
|
||||
global_disc_amount = subtotal * (global_disc_pct / 100)
|
||||
new_subtotal = subtotal - global_disc_amount
|
||||
|
||||
# Global discount proportionally reduces VAT too
|
||||
if subtotal > 0:
|
||||
disc_ratio = new_subtotal / subtotal
|
||||
vat_amount = item_vat * disc_ratio
|
||||
# VAT applies only to items portion, scaled by the global discount ratio
|
||||
vat_pct = _d(global_vat_percent)
|
||||
if subtotal > 0 and items_net > 0:
|
||||
items_ratio = items_net / subtotal
|
||||
vat_amount = new_subtotal * items_ratio * (vat_pct / 100)
|
||||
else:
|
||||
vat_amount = Decimal(0)
|
||||
|
||||
@@ -109,14 +110,16 @@ def _calc_line_total(item) -> float:
|
||||
|
||||
|
||||
async def _generate_quotation_number(db) -> str:
|
||||
year = datetime.utcnow().year
|
||||
prefix = f"QT-{year}-"
|
||||
now = datetime.utcnow()
|
||||
yy = now.strftime("%y")
|
||||
mm = now.strftime("%m")
|
||||
prefix = f"QT-{yy}-{mm}-"
|
||||
rows = await db.execute_fetchall(
|
||||
"SELECT quotation_number FROM crm_quotations WHERE quotation_number LIKE ? ORDER BY quotation_number DESC LIMIT 1",
|
||||
(f"{prefix}%",),
|
||||
)
|
||||
if rows:
|
||||
last_num = rows[0][0] # e.g. "QT-2026-012"
|
||||
last_num = rows[0][0] # e.g. "QT-26-04-012"
|
||||
try:
|
||||
seq = int(last_num[len(prefix):]) + 1
|
||||
except ValueError:
|
||||
@@ -174,13 +177,16 @@ async def list_all_quotations() -> list[dict]:
|
||||
doc = fstore.collection("crm_customers").document(cid).get()
|
||||
if doc.exists:
|
||||
d = doc.to_dict()
|
||||
parts = [d.get("name", ""), d.get("surname", ""), d.get("organization", "")]
|
||||
label = " ".join(p for p in parts if p).strip()
|
||||
customer_names[cid] = label or cid
|
||||
name_parts = [d.get("name", ""), d.get("surname", "")]
|
||||
full_name = " ".join(p for p in name_parts if p).strip()
|
||||
org = (d.get("organization", "") or "").strip()
|
||||
customer_names[cid] = {"name": full_name or cid, "org": org}
|
||||
except Exception:
|
||||
customer_names[cid] = cid
|
||||
customer_names[cid] = {"name": cid, "org": ""}
|
||||
for item in items:
|
||||
item["customer_name"] = customer_names.get(item["customer_id"], "")
|
||||
info = customer_names.get(item["customer_id"], {"name": "", "org": ""})
|
||||
item["customer_name"] = info["name"]
|
||||
item["customer_org"] = info["org"]
|
||||
return items
|
||||
|
||||
|
||||
@@ -222,6 +228,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
||||
totals = _calculate_totals(
|
||||
items_raw,
|
||||
data.global_discount_percent,
|
||||
data.global_vat_percent,
|
||||
data.shipping_cost,
|
||||
data.shipping_cost_discount,
|
||||
data.install_cost,
|
||||
@@ -236,7 +243,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
||||
"""INSERT INTO crm_quotations (
|
||||
id, quotation_number, title, subtitle, customer_id,
|
||||
language, status, order_type, shipping_method, estimated_shipping_date,
|
||||
global_discount_label, global_discount_percent,
|
||||
global_discount_label, global_discount_percent, global_vat_percent,
|
||||
shipping_cost, shipping_cost_discount, install_cost, install_cost_discount,
|
||||
extras_label, extras_cost, comments, quick_notes,
|
||||
subtotal_before_discount, global_discount_amount, new_subtotal, vat_amount, final_total,
|
||||
@@ -247,7 +254,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?,
|
||||
?, 'draft', ?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?,
|
||||
@@ -259,7 +266,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
||||
(
|
||||
qid, quotation_number, data.title, data.subtitle, data.customer_id,
|
||||
data.language, data.order_type, data.shipping_method, data.estimated_shipping_date,
|
||||
data.global_discount_label, data.global_discount_percent,
|
||||
data.global_discount_label, data.global_discount_percent, data.global_vat_percent,
|
||||
data.shipping_cost, data.shipping_cost_discount, data.install_cost, data.install_cost_discount,
|
||||
data.extras_label, data.extras_cost, comments_json, quick_notes_json,
|
||||
totals["subtotal_before_discount"], totals["global_discount_amount"],
|
||||
@@ -317,7 +324,7 @@ async def update_quotation(quotation_id: str, data: QuotationUpdate, generate_pd
|
||||
|
||||
scalar_fields = [
|
||||
"title", "subtitle", "language", "status", "order_type", "shipping_method",
|
||||
"estimated_shipping_date", "global_discount_label", "global_discount_percent",
|
||||
"estimated_shipping_date", "global_discount_label", "global_discount_percent", "global_vat_percent",
|
||||
"shipping_cost", "shipping_cost_discount", "install_cost",
|
||||
"install_cost_discount", "extras_label", "extras_cost",
|
||||
"client_org", "client_name", "client_location", "client_phone", "client_email",
|
||||
@@ -352,6 +359,7 @@ async def update_quotation(quotation_id: str, data: QuotationUpdate, generate_pd
|
||||
totals = _calculate_totals(
|
||||
items_raw,
|
||||
float(merged.get("global_discount_percent", 0)),
|
||||
float(merged.get("global_vat_percent", 24)),
|
||||
float(merged.get("shipping_cost", 0)),
|
||||
float(merged.get("shipping_cost_discount", 0)),
|
||||
float(merged.get("install_cost", 0)),
|
||||
|
||||
@@ -305,6 +305,33 @@ async def get_last_comm_timestamp(customer_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
async def get_latest_comm_batch(customer_ids: list[str]) -> dict[str, dict]:
|
||||
"""Return a dict of customer_id → {id, type, occurred_at} for the latest comm per customer.
|
||||
Uses a single SQL query — no N+1 regardless of list size.
|
||||
"""
|
||||
if not customer_ids:
|
||||
return {}
|
||||
db = await mqtt_db.get_db()
|
||||
placeholders = ",".join("?" * len(customer_ids))
|
||||
rows = await db.execute_fetchall(
|
||||
f"""
|
||||
SELECT customer_id, id, type, COALESCE(occurred_at, created_at) AS ts
|
||||
FROM crm_comms_log
|
||||
WHERE customer_id IN ({placeholders})
|
||||
AND customer_id IS NOT NULL AND customer_id != ''
|
||||
ORDER BY ts DESC
|
||||
""",
|
||||
customer_ids,
|
||||
)
|
||||
# Keep only the first (latest) row per customer
|
||||
result: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
cid = row[0]
|
||||
if cid not in result:
|
||||
result[cid] = {"id": row[1], "type": row[2], "occurred_at": row[3]}
|
||||
return result
|
||||
|
||||
|
||||
async def list_customers_sorted_by_latest_comm(customers: list[CustomerInDB]) -> list[CustomerInDB]:
|
||||
"""Re-sort a list of customers so those with the most recent comm come first."""
|
||||
timestamps = await asyncio.gather(
|
||||
|
||||
@@ -208,6 +208,7 @@ async def init_db():
|
||||
"ALTER TABLE crm_quotation_items ADD COLUMN description_en TEXT",
|
||||
"ALTER TABLE crm_quotation_items ADD COLUMN description_gr TEXT",
|
||||
"ALTER TABLE built_melodies ADD COLUMN is_builtin INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE crm_quotations ADD COLUMN global_vat_percent REAL NOT NULL DEFAULT 24",
|
||||
]
|
||||
for m in _migrations:
|
||||
try:
|
||||
|
||||
23
backend/database/models.py
Normal file
23
backend/database/models.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from database.postgres import Base # noqa: F401 — Base must be imported for Alembic autogenerate
|
||||
|
||||
# Import all ORM models here so Alembic autogenerate detects them.
|
||||
# Add each new model file as it is created.
|
||||
|
||||
# --- Existing ---
|
||||
from notes.orm import Entry, EntryLink # noqa: F401
|
||||
from tickets.orm import SupportTicket, TicketMessage # noqa: F401
|
||||
|
||||
# --- Phase 0 ---
|
||||
from shared.orm import MigrationRun, AuditLog # noqa: F401
|
||||
from crm.orm import ( # noqa: F401
|
||||
CrmProduct, CrmCustomer, CrmOrder,
|
||||
CrmCommsLog, CrmMedia, CrmSyncState,
|
||||
CrmQuotation, CrmQuotationItem,
|
||||
)
|
||||
from staff.orm import Staff # noqa: F401
|
||||
from settings.orm import ConsoleSetting, PublicFeature # noqa: F401
|
||||
from melodies.orm import MelodyDraft, BuiltMelody # noqa: F401
|
||||
from manufacturing.orm import MfgAuditLog # noqa: F401
|
||||
from devices.orm import DeviceAlert # noqa: F401
|
||||
# NOTE: device_logs, commands, heartbeats are partitioned/raw-SQL tables —
|
||||
# they are NOT ORM models and are created via op.execute() in the migration.
|
||||
16
backend/database/postgres.py
Normal file
16
backend/database/postgres.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, pool_size=10, echo=False)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_pg_session() -> AsyncSession:
|
||||
"""FastAPI dependency — yields a DB session and closes it after the request."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
31
backend/devices/orm.py
Normal file
31
backend/devices/orm.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import BigInteger, Column, DateTime, Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from database.postgres import Base
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class DeviceAlert(Base):
|
||||
"""Current alert state per device+subsystem (upserted, not appended)."""
|
||||
__tablename__ = "device_alerts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("device_serial", "subsystem"),
|
||||
Index("idx_device_alerts_serial", "device_serial"),
|
||||
)
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
device_serial = Column(String(128), nullable=False)
|
||||
subsystem = Column(String(128), nullable=False)
|
||||
state = Column(String(64), nullable=False)
|
||||
message = Column(Text)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||
|
||||
|
||||
# NOTE: device_logs, commands, and heartbeats are NOT declared as ORM models here.
|
||||
# device_logs is a partitioned table — SQLAlchemy ORM does not support declarative
|
||||
# partitioned tables cleanly. All three tables are created via raw SQL in the
|
||||
# Alembic migration and accessed via raw queries in database/core.py (SQLite now)
|
||||
# and will be accessed via raw async SQL after Phase 5 cutover.
|
||||
@@ -451,11 +451,17 @@ async def remove_user_from_device(
|
||||
data = device_doc.to_dict() or {}
|
||||
user_list = data.get("user_list", []) or []
|
||||
|
||||
# Remove any entry that resolves to this user_id
|
||||
new_list = [
|
||||
entry for entry in user_list
|
||||
if not (isinstance(entry, str) and entry.split("/")[-1] == user_id)
|
||||
]
|
||||
from google.cloud.firestore_v1 import DocumentReference as DocRef
|
||||
|
||||
def resolves_to(entry, uid: str) -> bool:
|
||||
if isinstance(entry, DocRef):
|
||||
return entry.id == uid
|
||||
if isinstance(entry, str):
|
||||
return entry.split("/")[-1] == uid
|
||||
return False
|
||||
|
||||
# Remove any entry that resolves to this user_id (handles both DocRef and string paths)
|
||||
new_list = [entry for entry in user_list if not resolves_to(entry, user_id)]
|
||||
device_ref.update({"user_list": new_list})
|
||||
|
||||
return {"status": "removed", "user_id": user_id}
|
||||
|
||||
@@ -25,6 +25,8 @@ from crm.media_router import router as crm_media_router
|
||||
from crm.nextcloud_router import router as crm_nextcloud_router
|
||||
from crm.quotations_router import router as crm_quotations_router
|
||||
from public.router import router as public_router
|
||||
from notes.router import router as notes_router
|
||||
from tickets.router import router as tickets_router
|
||||
from crm.nextcloud import close_client as close_nextcloud_client, keepalive_ping as nextcloud_keepalive
|
||||
from crm.mail_accounts import get_mail_accounts
|
||||
from mqtt.client import mqtt_manager
|
||||
@@ -70,6 +72,8 @@ app.include_router(crm_media_router)
|
||||
app.include_router(crm_nextcloud_router)
|
||||
app.include_router(crm_quotations_router)
|
||||
app.include_router(public_router)
|
||||
app.include_router(notes_router)
|
||||
app.include_router(tickets_router)
|
||||
|
||||
|
||||
async def nextcloud_keepalive_loop():
|
||||
|
||||
22
backend/manufacturing/orm.py
Normal file
22
backend/manufacturing/orm.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import BigInteger, Column, DateTime, Index, String, Text
|
||||
from database.postgres import Base
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class MfgAuditLog(Base):
|
||||
__tablename__ = "mfg_audit_log"
|
||||
__table_args__ = (
|
||||
Index("idx_mfg_audit_time", "timestamp"),
|
||||
Index("idx_mfg_audit_action", "action"),
|
||||
)
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
timestamp = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
admin_user = Column(String(256), nullable=False)
|
||||
action = Column(String(128), nullable=False)
|
||||
serial_number = Column(String(128))
|
||||
detail = Column(Text)
|
||||
39
backend/melodies/orm.py
Normal file
39
backend/melodies/orm.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Boolean, Column, DateTime, Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from database.postgres import Base
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class MelodyDraft(Base):
|
||||
__tablename__ = "melody_drafts"
|
||||
__table_args__ = (
|
||||
Index("idx_melody_drafts_status", "status"),
|
||||
)
|
||||
|
||||
id = Column(String(128), primary_key=True)
|
||||
status = Column(String(32), nullable=False, default="draft")
|
||||
# 'data' stores the full melody definition as JSON (was TEXT/JSON in SQLite)
|
||||
data = Column(JSONB, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||
|
||||
|
||||
class BuiltMelody(Base):
|
||||
__tablename__ = "built_melodies"
|
||||
|
||||
id = Column(String(128), primary_key=True)
|
||||
name = Column(String(500), nullable=False)
|
||||
pid = Column(String(128), nullable=False)
|
||||
# 'steps' is a JSON array of step definitions
|
||||
steps = Column(JSONB, nullable=False)
|
||||
binary_path = Column(String(1000))
|
||||
progmem_code = Column(Text)
|
||||
# JSON array of melody IDs this built melody is assigned to
|
||||
assigned_melody_ids = Column(JSONB, nullable=False, default=list)
|
||||
is_builtin = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||
0
backend/notes/__init__.py
Normal file
0
backend/notes/__init__.py
Normal file
100
backend/notes/models.py
Normal file
100
backend/notes/models.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
VALID_TYPES = {"note", "issue"}
|
||||
VALID_STATUSES = {"open", "researching", "resolved"}
|
||||
VALID_SEVERITIES = {"low", "medium", "high", "critical"}
|
||||
VALID_CATEGORIES = {"technical", "install_support", "general"}
|
||||
VALID_ENTITIES = {"device", "app_user", "customer"}
|
||||
|
||||
|
||||
class EntryLinkIn(BaseModel):
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
|
||||
@field_validator("entity_type")
|
||||
@classmethod
|
||||
def check_entity_type(cls, v):
|
||||
if v not in VALID_ENTITIES:
|
||||
raise ValueError(f"entity_type must be one of {VALID_ENTITIES}")
|
||||
return v
|
||||
|
||||
|
||||
class EntryCreate(BaseModel):
|
||||
type: str
|
||||
title: str = Field(..., max_length=500)
|
||||
body: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
links: List[EntryLinkIn] = []
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def check_type(cls, v):
|
||||
if v not in VALID_TYPES:
|
||||
raise ValueError(f"type must be one of {VALID_TYPES}")
|
||||
return v
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def check_status(cls, v):
|
||||
if v is not None and v not in VALID_STATUSES:
|
||||
raise ValueError(f"status must be one of {VALID_STATUSES}")
|
||||
return v
|
||||
|
||||
@field_validator("severity")
|
||||
@classmethod
|
||||
def check_severity(cls, v):
|
||||
if v is not None and v not in VALID_SEVERITIES:
|
||||
raise ValueError(f"severity must be one of {VALID_SEVERITIES}")
|
||||
return v
|
||||
|
||||
@field_validator("category")
|
||||
@classmethod
|
||||
def check_category(cls, v):
|
||||
if v is not None and v not in VALID_CATEGORIES:
|
||||
raise ValueError(f"category must be one of {VALID_CATEGORIES}")
|
||||
return v
|
||||
|
||||
|
||||
class EntryUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=500)
|
||||
body: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class EntryLinkOut(BaseModel):
|
||||
id: UUID
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class EntryOut(BaseModel):
|
||||
id: UUID
|
||||
type: str
|
||||
title: str
|
||||
body: Optional[str]
|
||||
status: Optional[str]
|
||||
severity: Optional[str]
|
||||
category: Optional[str]
|
||||
author_id: str
|
||||
author_name: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
links: List[EntryLinkOut] = []
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class EntryListResponse(BaseModel):
|
||||
data: List[EntryOut]
|
||||
pagination: dict
|
||||
|
||||
|
||||
class LinksReplaceIn(BaseModel):
|
||||
links: List[EntryLinkIn]
|
||||
42
backend/notes/orm.py
Normal file
42
backend/notes/orm.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, String, Text, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from database.postgres import Base
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "crm_entries"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
type = Column(String(10), nullable=False) # 'note' | 'issue'
|
||||
title = Column(String(500), nullable=False)
|
||||
body = Column(Text, nullable=True)
|
||||
status = Column(String(20), nullable=True) # null for notes; open/researching/resolved for issues
|
||||
severity = Column(String(10), nullable=True) # null | low | medium | high | critical
|
||||
category = Column(String(30), nullable=True) # null for notes; technical | install_support | general
|
||||
author_id = Column(String(128), nullable=False) # staff user ID from JWT
|
||||
author_name = Column(String(255), nullable=True) # denormalized for display
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||
|
||||
links = relationship("EntryLink", back_populates="entry", cascade="all, delete-orphan", lazy="noload")
|
||||
|
||||
|
||||
class EntryLink(Base):
|
||||
__tablename__ = "crm_entry_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("entry_id", "entity_type", "entity_id"),
|
||||
)
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
entry_id = Column(UUID(as_uuid=True), ForeignKey("crm_entries.id", ondelete="CASCADE"), nullable=False)
|
||||
entity_type = Column(String(20), nullable=False) # 'device' | 'app_user' | 'customer'
|
||||
entity_id = Column(String(128), nullable=False) # Firestore ID or Postgres UUID as string
|
||||
|
||||
entry = relationship("Entry", back_populates="links")
|
||||
79
backend/notes/router.py
Normal file
79
backend/notes/router.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from database.postgres import get_pg_session
|
||||
from auth.dependencies import require_permission
|
||||
from auth.models import TokenPayload
|
||||
from notes import service
|
||||
from notes.models import EntryCreate, EntryUpdate, EntryOut, EntryListResponse, LinksReplaceIn
|
||||
|
||||
router = APIRouter(prefix="/api/notes", tags=["notes"])
|
||||
|
||||
|
||||
@router.get("", response_model=EntryListResponse)
|
||||
async def list_entries(
|
||||
type: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
severity: str | None = Query(None),
|
||||
category: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(25, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
rows, total = await service.list_entries(db, type, status, severity, category, page, limit)
|
||||
return {"data": rows, "pagination": {"page": page, "limit": limit, "total": total}}
|
||||
|
||||
|
||||
@router.get("/by-entity/{entity_type}/{entity_id}", response_model=list[EntryOut])
|
||||
async def list_by_entity(
|
||||
entity_type: str, entity_id: str,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
return await service.list_entries_for_entity(db, entity_type, entity_id)
|
||||
|
||||
|
||||
@router.get("/{entry_id}", response_model=EntryOut)
|
||||
async def get_entry(
|
||||
entry_id: UUID,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
return await service.get_entry(db, entry_id)
|
||||
|
||||
|
||||
@router.post("", response_model=EntryOut, status_code=201)
|
||||
async def create_entry(
|
||||
body: EntryCreate,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "add")),
|
||||
):
|
||||
return await service.create_entry(db, body, _user.sub, _user.name or _user.email)
|
||||
|
||||
|
||||
@router.patch("/{entry_id}", response_model=EntryOut)
|
||||
async def update_entry(
|
||||
entry_id: UUID, body: EntryUpdate,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.update_entry(db, entry_id, body)
|
||||
|
||||
|
||||
@router.patch("/{entry_id}/links", response_model=EntryOut)
|
||||
async def replace_links(
|
||||
entry_id: UUID, body: LinksReplaceIn,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.replace_links(db, entry_id, body.links)
|
||||
|
||||
|
||||
@router.delete("/{entry_id}", status_code=204)
|
||||
async def delete_entry(
|
||||
entry_id: UUID,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "delete")),
|
||||
):
|
||||
await service.delete_entry(db, entry_id)
|
||||
93
backend/notes/service.py
Normal file
93
backend/notes/service.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import uuid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from notes.orm import Entry, EntryLink
|
||||
from notes.models import EntryCreate, EntryUpdate, EntryLinkIn
|
||||
from shared.exceptions import NotFoundError
|
||||
|
||||
|
||||
async def create_entry(db: AsyncSession, data: EntryCreate, author_id: str, author_name: str) -> Entry:
|
||||
entry = Entry(
|
||||
type=data.type,
|
||||
title=data.title,
|
||||
body=data.body,
|
||||
status=data.status if data.type == "issue" else None,
|
||||
severity=data.severity if data.type == "issue" else None,
|
||||
category=data.category if data.type == "issue" else None,
|
||||
author_id=author_id,
|
||||
author_name=author_name,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush() # get the ID before inserting links
|
||||
|
||||
for link_in in data.links:
|
||||
db.add(EntryLink(entry_id=entry.id, entity_type=link_in.entity_type, entity_id=link_in.entity_id))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
return await _get_entry_with_links(db, entry.id)
|
||||
|
||||
|
||||
async def get_entry(db: AsyncSession, entry_id: uuid.UUID) -> Entry:
|
||||
return await _get_entry_with_links(db, entry_id)
|
||||
|
||||
|
||||
async def list_entries(
|
||||
db: AsyncSession,
|
||||
type: str | None, status: str | None, severity: str | None, category: str | None,
|
||||
page: int, limit: int,
|
||||
) -> tuple[list[Entry], int]:
|
||||
limit = min(100, max(1, limit))
|
||||
offset = (max(1, page) - 1) * limit
|
||||
|
||||
q = select(Entry).options(selectinload(Entry.links))
|
||||
if type: q = q.where(Entry.type == type)
|
||||
if status: q = q.where(Entry.status == status)
|
||||
if severity: q = q.where(Entry.severity == severity)
|
||||
if category: q = q.where(Entry.category == category)
|
||||
|
||||
total_q = select(func.count()).select_from(q.subquery())
|
||||
total = (await db.execute(total_q)).scalar()
|
||||
rows = (await db.execute(q.order_by(Entry.created_at.desc()).limit(limit).offset(offset))).scalars().all()
|
||||
return rows, total
|
||||
|
||||
|
||||
async def update_entry(db: AsyncSession, entry_id: uuid.UUID, data: EntryUpdate) -> Entry:
|
||||
entry = await _get_entry_with_links(db, entry_id)
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(entry, field, value)
|
||||
await db.commit()
|
||||
return await _get_entry_with_links(db, entry_id)
|
||||
|
||||
|
||||
async def delete_entry(db: AsyncSession, entry_id: uuid.UUID):
|
||||
entry = await _get_entry_with_links(db, entry_id)
|
||||
await db.delete(entry)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def replace_links(db: AsyncSession, entry_id: uuid.UUID, links: list[EntryLinkIn]) -> Entry:
|
||||
await _get_entry_with_links(db, entry_id) # raises 404 if not found
|
||||
await db.execute(delete(EntryLink).where(EntryLink.entry_id == entry_id))
|
||||
for link_in in links:
|
||||
db.add(EntryLink(entry_id=entry_id, entity_type=link_in.entity_type, entity_id=link_in.entity_id))
|
||||
await db.commit()
|
||||
return await _get_entry_with_links(db, entry_id)
|
||||
|
||||
|
||||
async def list_entries_for_entity(db: AsyncSession, entity_type: str, entity_id: str) -> list[Entry]:
|
||||
link_sq = select(EntryLink.entry_id).where(
|
||||
EntryLink.entity_type == entity_type,
|
||||
EntryLink.entity_id == entity_id,
|
||||
).subquery()
|
||||
q = select(Entry).options(selectinload(Entry.links)).where(Entry.id.in_(select(link_sq)))
|
||||
return (await db.execute(q.order_by(Entry.created_at.desc()))).scalars().all()
|
||||
|
||||
|
||||
async def _get_entry_with_links(db: AsyncSession, entry_id: uuid.UUID) -> Entry:
|
||||
q = select(Entry).options(selectinload(Entry.links)).where(Entry.id == entry_id)
|
||||
result = (await db.execute(q)).scalar_one_or_none()
|
||||
if not result:
|
||||
raise NotFoundError("Entry")
|
||||
return result
|
||||
@@ -14,4 +14,7 @@ httpx>=0.27.0
|
||||
weasyprint>=62.0
|
||||
jinja2>=3.1.0
|
||||
Pillow>=10.0.0
|
||||
pdf2image>=1.17.0
|
||||
pdf2image>=1.17.0
|
||||
asyncpg==0.30.0
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
alembic==1.14.0
|
||||
26
backend/settings/orm.py
Normal file
26
backend/settings/orm.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, DateTime, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from database.postgres import Base
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class ConsoleSetting(Base):
|
||||
"""Key/value store for console configuration (replaces Firestore 'settings' doc)."""
|
||||
__tablename__ = "console_settings"
|
||||
|
||||
key = Column(String(128), primary_key=True)
|
||||
value = Column(JSONB) # any JSON value
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||
|
||||
|
||||
class PublicFeature(Base):
|
||||
"""Public-facing feature flags and configuration (replaces Firestore 'public_features' doc)."""
|
||||
__tablename__ = "public_features"
|
||||
|
||||
key = Column(String(128), primary_key=True)
|
||||
value = Column(JSONB)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||
@@ -18,3 +18,8 @@ class AuthorizationError(HTTPException):
|
||||
class NotFoundError(HTTPException):
|
||||
def __init__(self, resource: str = "Resource"):
|
||||
super().__init__(status_code=404, detail=f"{resource} not found")
|
||||
|
||||
|
||||
class ValidationError(HTTPException):
|
||||
def __init__(self, detail: str = "Validation error"):
|
||||
super().__init__(status_code=422, detail=detail)
|
||||
|
||||
46
backend/shared/orm.py
Normal file
46
backend/shared/orm.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import BigInteger, Column, DateTime, Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from database.postgres import Base
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class MigrationRun(Base):
|
||||
"""Tracks every migration script execution — what ran, when, row counts, success/failure."""
|
||||
__tablename__ = "_migration_runs"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
script_name = Column(String(256), nullable=False)
|
||||
ran_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
source_rows = Column(BigInteger, nullable=False, default=0)
|
||||
dest_rows = Column(BigInteger, nullable=False, default=0)
|
||||
success = Column(String(8), nullable=False, default="ok") # 'ok' | 'error'
|
||||
notes = Column(Text)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Staff action audit trail — all create/update/delete/command events."""
|
||||
__tablename__ = "audit_log"
|
||||
__table_args__ = (
|
||||
Index("idx_audit_actor", "actor_id", "occurred_at"),
|
||||
Index("idx_audit_entity", "entity_type", "entity_id", "occurred_at"),
|
||||
Index("idx_audit_action", "action", "occurred_at"),
|
||||
Index("idx_audit_occurred", "occurred_at"),
|
||||
)
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
occurred_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
actor_id = Column(String(128), nullable=False)
|
||||
actor_name = Column(String(255), nullable=False)
|
||||
action = Column(String(64), nullable=False)
|
||||
# CREATE | UPDATE | DELETE | COMMAND | PUBLISH | UNPUBLISH |
|
||||
# LOGIN | LOGOUT | PERMISSION_CHANGE | STATUS_CHANGE
|
||||
entity_type = Column(String(64), nullable=False)
|
||||
# customer | order | device | melody | product | staff | ticket | note | quotation | ...
|
||||
entity_id = Column(String(128), nullable=False)
|
||||
entity_label = Column(String(500)) # denormalised human name
|
||||
changes = Column(JSONB) # {"field": {"old": x, "new": y}} — null for CREATE/DELETE
|
||||
meta = Column(JSONB) # extra context: ip_address, command_name, etc.
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, Optional
|
||||
from auth.models import StaffPermissions
|
||||
|
||||
|
||||
@@ -35,3 +35,7 @@ class StaffResponse(BaseModel):
|
||||
class StaffListResponse(BaseModel):
|
||||
staff: list[StaffResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PreferencesUpdate(BaseModel):
|
||||
prefs: Dict[str, Any]
|
||||
|
||||
23
backend/staff/orm.py
Normal file
23
backend/staff/orm.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Boolean, Column, DateTime, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from database.postgres import Base
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Staff(Base):
|
||||
__tablename__ = "staff"
|
||||
|
||||
id = Column(String(128), primary_key=True) # Firestore doc ID during transition
|
||||
firestore_id = Column(String(128), unique=True) # same as id during transition
|
||||
email = Column(String(256), unique=True, nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
role = Column(String(64), nullable=False, default="staff")
|
||||
permissions = Column(JSONB, nullable=False, default=dict)
|
||||
hashed_password = Column(String(256), nullable=False)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||
@@ -1,10 +1,12 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import Any
|
||||
from auth.dependencies import get_current_user, require_staff_management
|
||||
from auth.models import TokenPayload
|
||||
from staff import service
|
||||
from staff.models import (
|
||||
StaffCreate, StaffUpdate, StaffPasswordUpdate,
|
||||
StaffResponse, StaffListResponse,
|
||||
PreferencesUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/staff", tags=["staff"])
|
||||
@@ -15,6 +17,22 @@ async def get_current_staff(current_user: TokenPayload = Depends(get_current_use
|
||||
return await service.get_staff_me(current_user.sub)
|
||||
|
||||
|
||||
@router.get("/me/preferences", response_model=dict)
|
||||
async def get_preferences(current_user: TokenPayload = Depends(get_current_user)):
|
||||
"""Return all UI preferences for the current staff member."""
|
||||
return await service.get_preferences(current_user.sub)
|
||||
|
||||
|
||||
@router.patch("/me/preferences/{page_key}", response_model=dict)
|
||||
async def update_preferences(
|
||||
page_key: str,
|
||||
body: PreferencesUpdate,
|
||||
current_user: TokenPayload = Depends(get_current_user),
|
||||
):
|
||||
"""Merge preference keys for a specific page into the staff member's stored prefs."""
|
||||
return await service.update_preferences(current_user.sub, page_key, body.prefs)
|
||||
|
||||
|
||||
@router.get("", response_model=StaffListResponse)
|
||||
async def list_staff(
|
||||
search: str = Query(None),
|
||||
|
||||
@@ -157,6 +157,33 @@ async def update_staff_password(staff_id: str, new_password: str, current_user_r
|
||||
return {"message": "Password updated successfully"}
|
||||
|
||||
|
||||
async def get_preferences(staff_id: str) -> dict:
|
||||
"""Return the ui_prefs map for a staff member, defaulting to {} if not set."""
|
||||
db = get_db()
|
||||
doc = db.collection("admin_users").document(staff_id).get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("Staff member not found")
|
||||
return doc.to_dict().get("ui_prefs", {})
|
||||
|
||||
|
||||
async def update_preferences(staff_id: str, page_key: str, prefs: dict) -> dict:
|
||||
"""Merge a page-level preferences dict into ui_prefs.<page_key> on the staff document.
|
||||
Only the supplied keys are overwritten — other keys in the same page block survive.
|
||||
"""
|
||||
db = get_db()
|
||||
doc_ref = db.collection("admin_users").document(staff_id)
|
||||
doc = doc_ref.get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("Staff member not found")
|
||||
|
||||
existing_prefs = doc.to_dict().get("ui_prefs", {})
|
||||
page_prefs = {**existing_prefs.get(page_key, {}), **prefs}
|
||||
existing_prefs[page_key] = page_prefs
|
||||
|
||||
doc_ref.update({"ui_prefs": existing_prefs})
|
||||
return existing_prefs
|
||||
|
||||
|
||||
async def delete_staff(staff_id: str, current_user_role: str, current_user_id: str) -> dict:
|
||||
db = get_db()
|
||||
doc_ref = db.collection("admin_users").document(staff_id)
|
||||
|
||||
@@ -370,12 +370,11 @@
|
||||
{% set L_DISC = "Έκπτ." %}
|
||||
{% set L_QTY = "Ποσ." %}
|
||||
{% set L_UNIT = "Μον." %}
|
||||
{% set L_VAT_COL = "Φ.Π.Α." %}
|
||||
{% set L_TOTAL = "Σύνολο" %}
|
||||
{% set L_SUBTOTAL = "Υποσύνολο" %}
|
||||
{% set L_GLOBAL_DISC = quotation.global_discount_label or "Έκπτωση" %}
|
||||
{% set L_NEW_SUBTOTAL = "Νέο Υποσύνολο" %}
|
||||
{% set L_VAT = "ΣΥΝΟΛΟ Φ.Π.Α." %}
|
||||
{% set L_VAT = "ΣΥΝΟΛΟ Φ.Π.Α. " ~ (quotation.global_vat_percent | int) ~ "%" %}
|
||||
{% set L_SHIPPING_COST = "Μεταφορικά / Shipping" %}
|
||||
{% set L_INSTALL_COST = "Εγκατάσταση / Installation" %}
|
||||
{% set L_EXTRAS = quotation.extras_label or "Άλλα" %}
|
||||
@@ -403,12 +402,11 @@
|
||||
{% set L_DISC = "Disc." %}
|
||||
{% set L_QTY = "Qty" %}
|
||||
{% set L_UNIT = "Unit" %}
|
||||
{% set L_VAT_COL = "VAT" %}
|
||||
{% set L_TOTAL = "Total" %}
|
||||
{% set L_SUBTOTAL = "Subtotal" %}
|
||||
{% set L_GLOBAL_DISC = quotation.global_discount_label or "Discount" %}
|
||||
{% set L_NEW_SUBTOTAL = "New Subtotal" %}
|
||||
{% set L_VAT = "Total VAT" %}
|
||||
{% set L_VAT = "Total VAT " ~ (quotation.global_vat_percent | int) ~ "%" %}
|
||||
{% set L_SHIPPING_COST = "Shipping / Transport" %}
|
||||
{% set L_INSTALL_COST = "Installation" %}
|
||||
{% set L_EXTRAS = quotation.extras_label or "Extras" %}
|
||||
@@ -469,7 +467,7 @@
|
||||
|
||||
<div class="order-block">
|
||||
<div class="block-title">{{ L_ORDER_META }}</div>
|
||||
<table class="fields"><tbody>{% if quotation.order_type %}<tr><td class="lbl">{{ L_ORDER_TYPE }}</td><td class="val">{{ quotation.order_type }}</td></tr>{% endif %}{% if quotation.shipping_method %}<tr><td class="lbl">{{ L_SHIP_METHOD }}</td><td class="val">{{ quotation.shipping_method }}</td></tr>{% endif %}{% if quotation.estimated_shipping_date %}<tr><td class="lbl">{{ L_SHIP_DATE }}</td><td class="val">{{ quotation.estimated_shipping_date }}</td></tr>{% else %}<tr><td class="lbl">{{ L_SHIP_DATE }}</td><td class="val text-muted">—</td></tr>{% endif %}</tbody></table>
|
||||
<table class="fields"><tbody>{% if quotation.order_type %}<tr><td class="lbl">{{ L_ORDER_TYPE }}</td><td class="val">{{ quotation.order_type }}</td></tr>{% endif %}{% if quotation.shipping_method %}<tr><td class="lbl">{{ L_SHIP_METHOD }}</td><td class="val">{{ quotation.shipping_method }}</td></tr>{% endif %}{% if quotation.estimated_shipping_date %}{% set _dp = quotation.estimated_shipping_date.split('-') %}{% set _dfmt = _dp[2] + '/' + _dp[1] + '/' + _dp[0] if _dp | length == 3 else quotation.estimated_shipping_date %}<tr><td class="lbl">{{ L_SHIP_DATE }}</td><td class="val">{{ _dfmt }}</td></tr>{% else %}<tr><td class="lbl">{{ L_SHIP_DATE }}</td><td class="val text-muted">—</td></tr>{% endif %}</tbody></table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -478,13 +476,12 @@
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:38%">{{ L_DESC }}</th>
|
||||
<th class="right" style="width:11%">{{ L_UNIT_COST }}</th>
|
||||
<th class="center" style="width:7%">{{ L_DISC }}</th>
|
||||
<th class="center" style="width:7%">{{ L_QTY }}</th>
|
||||
<th class="center" style="width:7%">{{ L_UNIT }}</th>
|
||||
<th class="center" style="width:6%">{{ L_VAT_COL }}</th>
|
||||
<th class="right" style="width:12%">{{ L_TOTAL }}</th>
|
||||
<th style="width:44%">{{ L_DESC }}</th>
|
||||
<th class="right" style="width:13%">{{ L_UNIT_COST }}</th>
|
||||
<th class="center" style="width:8%">{{ L_DISC }}</th>
|
||||
<th class="center" style="width:8%">{{ L_QTY }}</th>
|
||||
<th class="center" style="width:8%">{{ L_UNIT }}</th>
|
||||
<th class="right" style="width:14%">{{ L_TOTAL }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -501,26 +498,19 @@
|
||||
</td>
|
||||
<td class="center">{{ item.quantity | int if item.quantity == (item.quantity | int) else item.quantity }}</td>
|
||||
<td class="center muted">{{ item.unit_type }}</td>
|
||||
<td class="center">
|
||||
{% if item.vat_percent and item.vat_percent > 0 %}
|
||||
{{ item.vat_percent | int }}%
|
||||
{% else %}
|
||||
<span class="dash">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="right">{{ item.line_total | format_money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if quotation.items | length == 0 %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-muted" style="text-align:center; padding: 12px;">—</td>
|
||||
<td colspan="6" class="text-muted" style="text-align:center; padding: 12px;">—</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{# ── Shipping / Install as special rows ── #}
|
||||
{% set has_special = (quotation.shipping_cost and quotation.shipping_cost > 0) or (quotation.install_cost and quotation.install_cost > 0) %}
|
||||
{% if has_special %}
|
||||
<tr class="special-spacer"><td colspan="7"></td></tr>
|
||||
<tr class="special-spacer"><td colspan="6"></td></tr>
|
||||
{% endif %}
|
||||
|
||||
{% if quotation.shipping_cost and quotation.shipping_cost > 0 %}
|
||||
@@ -531,7 +521,6 @@
|
||||
<td class="center"><span class="dash">—</span></td>
|
||||
<td class="center">1</td>
|
||||
<td class="center muted">—</td>
|
||||
<td class="center"><span class="dash">—</span></td>
|
||||
<td class="right">{{ ship_net | format_money }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
@@ -544,7 +533,6 @@
|
||||
<td class="center"><span class="dash">—</span></td>
|
||||
<td class="center">1</td>
|
||||
<td class="center muted">—</td>
|
||||
<td class="center"><span class="dash">—</span></td>
|
||||
<td class="right">{{ install_net | format_money }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
0
backend/tickets/__init__.py
Normal file
0
backend/tickets/__init__.py
Normal file
92
backend/tickets/models.py
Normal file
92
backend/tickets/models.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
VALID_STATUSES = {"open", "waiting_on_customer", "waiting_on_staff", "resolved", "closed"}
|
||||
VALID_PRIORITIES = {"low", "medium", "high", "urgent"}
|
||||
VALID_OPENED_VIA = {"app", "email", "phone", "staff"}
|
||||
VALID_SENDERS = {"staff", "customer"}
|
||||
|
||||
|
||||
class TicketCreate(BaseModel):
|
||||
customer_id: str
|
||||
customer_name: Optional[str] = None
|
||||
subject: str = Field(..., max_length=500)
|
||||
device_id: Optional[str] = None
|
||||
device_serial: Optional[str] = None
|
||||
opened_via: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
|
||||
@field_validator("priority")
|
||||
@classmethod
|
||||
def check_priority(cls, v):
|
||||
if v is not None and v not in VALID_PRIORITIES:
|
||||
raise ValueError(f"priority must be one of {VALID_PRIORITIES}")
|
||||
return v
|
||||
|
||||
|
||||
class TicketUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
device_id: Optional[str] = None
|
||||
device_serial: Optional[str] = None
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def check_status(cls, v):
|
||||
if v is not None and v not in VALID_STATUSES:
|
||||
raise ValueError(f"status must be one of {VALID_STATUSES}")
|
||||
return v
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
sender_type: str
|
||||
sender_id: str
|
||||
sender_name: Optional[str] = None
|
||||
body: str
|
||||
is_internal: bool = False
|
||||
|
||||
@field_validator("sender_type")
|
||||
@classmethod
|
||||
def check_sender_type(cls, v):
|
||||
if v not in VALID_SENDERS:
|
||||
raise ValueError(f"sender_type must be one of {VALID_SENDERS}")
|
||||
return v
|
||||
|
||||
|
||||
class EscalateIn(BaseModel):
|
||||
entry_id: UUID
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
id: UUID
|
||||
sender_type: str
|
||||
sender_id: str
|
||||
sender_name: Optional[str]
|
||||
body: str
|
||||
is_internal: bool
|
||||
created_at: datetime
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TicketOut(BaseModel):
|
||||
id: UUID
|
||||
customer_id: str
|
||||
customer_name: Optional[str]
|
||||
device_id: Optional[str]
|
||||
device_serial: Optional[str]
|
||||
subject: str
|
||||
status: str
|
||||
priority: Optional[str]
|
||||
opened_via: Optional[str]
|
||||
linked_entry_id: Optional[UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
messages: List[MessageOut] = []
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TicketListResponse(BaseModel):
|
||||
data: List[TicketOut]
|
||||
pagination: dict
|
||||
46
backend/tickets/orm.py
Normal file
46
backend/tickets/orm.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, String, Text, DateTime, Boolean, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from database.postgres import Base
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class SupportTicket(Base):
|
||||
__tablename__ = "support_tickets"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
customer_id = Column(String(128), nullable=False) # Firestore ID (moves to UUID when customers migrate)
|
||||
customer_name = Column(String(255), nullable=True) # denormalized snapshot
|
||||
device_id = Column(String(128), nullable=True) # Firestore ID
|
||||
device_serial = Column(String(64), nullable=True) # denormalized snapshot
|
||||
subject = Column(String(500), nullable=False)
|
||||
status = Column(String(30), nullable=False, default="open")
|
||||
# open | waiting_on_customer | waiting_on_staff | resolved | closed
|
||||
priority = Column(String(10), nullable=True) # low | medium | high | urgent
|
||||
opened_via = Column(String(20), nullable=True) # app | email | phone | staff
|
||||
linked_entry_id = Column(UUID(as_uuid=True), ForeignKey("crm_entries.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||
|
||||
messages = relationship("TicketMessage", back_populates="ticket",
|
||||
cascade="all, delete-orphan", order_by="TicketMessage.created_at", lazy="noload")
|
||||
|
||||
|
||||
class TicketMessage(Base):
|
||||
__tablename__ = "ticket_messages"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
ticket_id = Column(UUID(as_uuid=True), ForeignKey("support_tickets.id", ondelete="CASCADE"), nullable=False)
|
||||
sender_type = Column(String(10), nullable=False) # 'staff' | 'customer'
|
||||
sender_id = Column(String(128), nullable=False)
|
||||
sender_name = Column(String(255), nullable=True)
|
||||
body = Column(Text, nullable=False)
|
||||
is_internal = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
|
||||
ticket = relationship("SupportTicket", back_populates="messages")
|
||||
87
backend/tickets/router.py
Normal file
87
backend/tickets/router.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from database.postgres import get_pg_session
|
||||
from auth.dependencies import require_permission
|
||||
from auth.models import TokenPayload
|
||||
from tickets import service
|
||||
from tickets.models import TicketCreate, TicketUpdate, MessageCreate, EscalateIn, TicketOut, TicketListResponse
|
||||
|
||||
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
|
||||
|
||||
|
||||
@router.get("", response_model=TicketListResponse)
|
||||
async def list_tickets(
|
||||
status: str | None = Query(None),
|
||||
priority: str | None = Query(None),
|
||||
customer_id: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(25, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
rows, total = await service.list_tickets(db, status, priority, customer_id, page, limit)
|
||||
return {"data": rows, "pagination": {"page": page, "limit": limit, "total": total}}
|
||||
|
||||
|
||||
@router.get("/by-customer/{customer_id}", response_model=list[TicketOut])
|
||||
async def list_by_customer(
|
||||
customer_id: str,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
return await service.list_by_customer(db, customer_id)
|
||||
|
||||
|
||||
@router.get("/by-device/{device_id}", response_model=list[TicketOut])
|
||||
async def list_by_device(
|
||||
device_id: str,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
return await service.list_by_device(db, device_id)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=TicketOut)
|
||||
async def get_ticket(
|
||||
ticket_id: UUID,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
return await service.get_ticket(db, ticket_id)
|
||||
|
||||
|
||||
@router.post("", response_model=TicketOut, status_code=201)
|
||||
async def create_ticket(
|
||||
body: TicketCreate,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "add")),
|
||||
):
|
||||
return await service.create_ticket(db, body)
|
||||
|
||||
|
||||
@router.patch("/{ticket_id}", response_model=TicketOut)
|
||||
async def update_ticket(
|
||||
ticket_id: UUID, body: TicketUpdate,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.update_ticket(db, ticket_id, body)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/messages", response_model=TicketOut)
|
||||
async def add_message(
|
||||
ticket_id: UUID, body: MessageCreate,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.add_message(db, ticket_id, body)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/escalate", response_model=TicketOut)
|
||||
async def escalate(
|
||||
ticket_id: UUID, body: EscalateIn,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.escalate_to_issue(db, ticket_id, body.entry_id)
|
||||
91
backend/tickets/service.py
Normal file
91
backend/tickets/service.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import uuid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from tickets.orm import SupportTicket, TicketMessage
|
||||
from tickets.models import TicketCreate, TicketUpdate, MessageCreate
|
||||
from shared.exceptions import NotFoundError
|
||||
|
||||
|
||||
async def create_ticket(db: AsyncSession, data: TicketCreate) -> SupportTicket:
|
||||
ticket = SupportTicket(**data.model_dump())
|
||||
db.add(ticket)
|
||||
await db.commit()
|
||||
return await _get_ticket(db, ticket.id, include_internal=True)
|
||||
|
||||
|
||||
async def get_ticket(db: AsyncSession, ticket_id: uuid.UUID, include_internal: bool = True) -> SupportTicket:
|
||||
return await _get_ticket(db, ticket_id, include_internal)
|
||||
|
||||
|
||||
async def list_tickets(
|
||||
db: AsyncSession,
|
||||
status: str | None, priority: str | None, customer_id: str | None,
|
||||
page: int, limit: int,
|
||||
) -> tuple[list[SupportTicket], int]:
|
||||
limit = min(100, max(1, limit))
|
||||
offset = (max(1, page) - 1) * limit
|
||||
|
||||
q = select(SupportTicket).options(selectinload(SupportTicket.messages))
|
||||
if status: q = q.where(SupportTicket.status == status)
|
||||
if priority: q = q.where(SupportTicket.priority == priority)
|
||||
if customer_id: q = q.where(SupportTicket.customer_id == customer_id)
|
||||
|
||||
total = (await db.execute(select(func.count()).select_from(q.subquery()))).scalar()
|
||||
rows = (await db.execute(q.order_by(SupportTicket.created_at.desc()).limit(limit).offset(offset))).scalars().all()
|
||||
return rows, total
|
||||
|
||||
|
||||
async def update_ticket(db: AsyncSession, ticket_id: uuid.UUID, data: TicketUpdate) -> SupportTicket:
|
||||
ticket = await _get_ticket(db, ticket_id)
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(ticket, field, value)
|
||||
await db.commit()
|
||||
return await _get_ticket(db, ticket_id)
|
||||
|
||||
|
||||
async def add_message(db: AsyncSession, ticket_id: uuid.UUID, data: MessageCreate) -> SupportTicket:
|
||||
ticket = await _get_ticket(db, ticket_id)
|
||||
msg = TicketMessage(ticket_id=ticket_id, **data.model_dump())
|
||||
db.add(msg)
|
||||
|
||||
# Auto-advance ticket status based on who replied (skip if already resolved/closed)
|
||||
if ticket.status not in ("resolved", "closed"):
|
||||
if data.sender_type == "staff" and not data.is_internal:
|
||||
ticket.status = "waiting_on_customer"
|
||||
elif data.sender_type == "customer":
|
||||
ticket.status = "waiting_on_staff"
|
||||
|
||||
await db.commit()
|
||||
return await _get_ticket(db, ticket_id)
|
||||
|
||||
|
||||
async def escalate_to_issue(db: AsyncSession, ticket_id: uuid.UUID, entry_id: uuid.UUID) -> SupportTicket:
|
||||
ticket = await _get_ticket(db, ticket_id)
|
||||
ticket.linked_entry_id = entry_id
|
||||
await db.commit()
|
||||
return await _get_ticket(db, ticket_id)
|
||||
|
||||
|
||||
async def list_by_customer(db: AsyncSession, customer_id: str) -> list[SupportTicket]:
|
||||
q = select(SupportTicket).options(selectinload(SupportTicket.messages)).where(
|
||||
SupportTicket.customer_id == customer_id
|
||||
).order_by(SupportTicket.created_at.desc())
|
||||
return (await db.execute(q)).scalars().all()
|
||||
|
||||
|
||||
async def list_by_device(db: AsyncSession, device_id: str) -> list[SupportTicket]:
|
||||
q = select(SupportTicket).options(selectinload(SupportTicket.messages)).where(
|
||||
SupportTicket.device_id == device_id
|
||||
).order_by(SupportTicket.created_at.desc())
|
||||
return (await db.execute(q)).scalars().all()
|
||||
|
||||
|
||||
async def _get_ticket(db: AsyncSession, ticket_id: uuid.UUID, include_internal: bool = True) -> SupportTicket:
|
||||
q = select(SupportTicket).options(selectinload(SupportTicket.messages)).where(SupportTicket.id == ticket_id)
|
||||
result = (await db.execute(q)).scalar_one_or_none()
|
||||
if not result:
|
||||
raise NotFoundError("Ticket")
|
||||
if not include_internal:
|
||||
result.messages = [m for m in result.messages if not m.is_internal]
|
||||
return result
|
||||
@@ -41,3 +41,11 @@ class UserInDB(UserCreate):
|
||||
class UserListResponse(BaseModel):
|
||||
users: List[UserInDB]
|
||||
total: int
|
||||
|
||||
|
||||
class SetPasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
new_password: str = "Bell1234!" # default reset value
|
||||
|
||||
@@ -4,6 +4,7 @@ from auth.models import TokenPayload
|
||||
from auth.dependencies import require_permission
|
||||
from users.models import (
|
||||
UserCreate, UserUpdate, UserInDB, UserListResponse,
|
||||
SetPasswordRequest, ResetPasswordRequest,
|
||||
)
|
||||
from users import service
|
||||
|
||||
@@ -95,6 +96,26 @@ async def unassign_device(
|
||||
return service.unassign_device(user_id, device_id)
|
||||
|
||||
|
||||
@router.post("/{user_id}/set-password", status_code=204)
|
||||
async def set_password(
|
||||
user_id: str,
|
||||
body: SetPasswordRequest,
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "full_edit")),
|
||||
):
|
||||
"""Set a new password for the user via Firebase Auth (requires uid on the user doc)."""
|
||||
service.set_password(user_id, body.password)
|
||||
|
||||
|
||||
@router.post("/{user_id}/reset-password", status_code=204)
|
||||
async def reset_password(
|
||||
user_id: str,
|
||||
body: ResetPasswordRequest,
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "full_edit")),
|
||||
):
|
||||
"""Reset a user's password to the supplied value (default: Bell1234!)."""
|
||||
service.set_password(user_id, body.new_password)
|
||||
|
||||
|
||||
@router.post("/{user_id}/photo")
|
||||
async def upload_photo(
|
||||
user_id: str,
|
||||
|
||||
@@ -2,8 +2,9 @@ from datetime import datetime
|
||||
|
||||
from google.cloud.firestore_v1 import DocumentReference
|
||||
|
||||
from firebase_admin import auth as firebase_auth
|
||||
from shared.firebase import get_db, get_bucket
|
||||
from shared.exceptions import NotFoundError
|
||||
from shared.exceptions import NotFoundError, ValidationError
|
||||
from users.models import UserCreate, UserUpdate, UserInDB
|
||||
|
||||
COLLECTION = "users"
|
||||
@@ -252,6 +253,31 @@ def get_user_devices(user_doc_id: str) -> list[dict]:
|
||||
return devices
|
||||
|
||||
|
||||
def set_password(user_doc_id: str, new_password: str) -> None:
|
||||
"""Set a Firebase Auth password for a user via their Firestore document ID.
|
||||
|
||||
Requires the user document to have a non-empty `uid` field — populated
|
||||
automatically for users who registered via the Flutter app.
|
||||
"""
|
||||
if not new_password or len(new_password) < 6:
|
||||
raise ValidationError("Password must be at least 6 characters.")
|
||||
|
||||
db = get_db()
|
||||
doc_ref = db.collection(COLLECTION).document(user_doc_id)
|
||||
doc = doc_ref.get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("User")
|
||||
|
||||
uid = doc.to_dict().get("uid", "")
|
||||
if not uid:
|
||||
raise ValidationError("This user has no Firebase Auth UID — they may not have signed up via the app yet.")
|
||||
|
||||
try:
|
||||
firebase_auth.update_user(uid, password=new_password)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Firebase Auth error: {e}")
|
||||
|
||||
|
||||
def upload_photo(user_doc_id: str, file_bytes: bytes, filename: str, content_type: str) -> str:
|
||||
"""Upload a profile photo to Firebase Storage and update the user's photo_url."""
|
||||
db = get_db()
|
||||
|
||||
Reference in New Issue
Block a user