Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.

This commit is contained in:
2026-04-17 14:37:36 +03:00
parent eb773c5531
commit 0a8a42d69b
447 changed files with 70696 additions and 492 deletions

113
backend/alembic.ini Normal file
View 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
View 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()

View 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"}

View File

View 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 ###

View File

@@ -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 ###

View File

@@ -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')

View File

@@ -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")

View File

@@ -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"

View File

@@ -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
View 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")

View File

@@ -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

View File

@@ -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)),

View File

@@ -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(

View File

@@ -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:

View 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.

View 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
View 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.

View File

@@ -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}

View File

@@ -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():

View 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
View 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)

View File

100
backend/notes/models.py Normal file
View 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
View 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
View 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
View 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

View File

@@ -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
View 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)

View File

@@ -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
View 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.

View File

@@ -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
View 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)

View File

@@ -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),

View File

@@ -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)

View File

@@ -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 %}

View File

92
backend/tickets/models.py Normal file
View 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
View 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
View 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)

View 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

View File

@@ -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

View File

@@ -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,

View File

@@ -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()