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

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