Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
0
backend/notes/__init__.py
Normal file
0
backend/notes/__init__.py
Normal file
100
backend/notes/models.py
Normal file
100
backend/notes/models.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
VALID_TYPES = {"note", "issue"}
|
||||
VALID_STATUSES = {"open", "researching", "resolved"}
|
||||
VALID_SEVERITIES = {"low", "medium", "high", "critical"}
|
||||
VALID_CATEGORIES = {"technical", "install_support", "general"}
|
||||
VALID_ENTITIES = {"device", "app_user", "customer"}
|
||||
|
||||
|
||||
class EntryLinkIn(BaseModel):
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
|
||||
@field_validator("entity_type")
|
||||
@classmethod
|
||||
def check_entity_type(cls, v):
|
||||
if v not in VALID_ENTITIES:
|
||||
raise ValueError(f"entity_type must be one of {VALID_ENTITIES}")
|
||||
return v
|
||||
|
||||
|
||||
class EntryCreate(BaseModel):
|
||||
type: str
|
||||
title: str = Field(..., max_length=500)
|
||||
body: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
links: List[EntryLinkIn] = []
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def check_type(cls, v):
|
||||
if v not in VALID_TYPES:
|
||||
raise ValueError(f"type must be one of {VALID_TYPES}")
|
||||
return v
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def check_status(cls, v):
|
||||
if v is not None and v not in VALID_STATUSES:
|
||||
raise ValueError(f"status must be one of {VALID_STATUSES}")
|
||||
return v
|
||||
|
||||
@field_validator("severity")
|
||||
@classmethod
|
||||
def check_severity(cls, v):
|
||||
if v is not None and v not in VALID_SEVERITIES:
|
||||
raise ValueError(f"severity must be one of {VALID_SEVERITIES}")
|
||||
return v
|
||||
|
||||
@field_validator("category")
|
||||
@classmethod
|
||||
def check_category(cls, v):
|
||||
if v is not None and v not in VALID_CATEGORIES:
|
||||
raise ValueError(f"category must be one of {VALID_CATEGORIES}")
|
||||
return v
|
||||
|
||||
|
||||
class EntryUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=500)
|
||||
body: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class EntryLinkOut(BaseModel):
|
||||
id: UUID
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class EntryOut(BaseModel):
|
||||
id: UUID
|
||||
type: str
|
||||
title: str
|
||||
body: Optional[str]
|
||||
status: Optional[str]
|
||||
severity: Optional[str]
|
||||
category: Optional[str]
|
||||
author_id: str
|
||||
author_name: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
links: List[EntryLinkOut] = []
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class EntryListResponse(BaseModel):
|
||||
data: List[EntryOut]
|
||||
pagination: dict
|
||||
|
||||
|
||||
class LinksReplaceIn(BaseModel):
|
||||
links: List[EntryLinkIn]
|
||||
42
backend/notes/orm.py
Normal file
42
backend/notes/orm.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, String, Text, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from database.postgres import Base
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "crm_entries"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
type = Column(String(10), nullable=False) # 'note' | 'issue'
|
||||
title = Column(String(500), nullable=False)
|
||||
body = Column(Text, nullable=True)
|
||||
status = Column(String(20), nullable=True) # null for notes; open/researching/resolved for issues
|
||||
severity = Column(String(10), nullable=True) # null | low | medium | high | critical
|
||||
category = Column(String(30), nullable=True) # null for notes; technical | install_support | general
|
||||
author_id = Column(String(128), nullable=False) # staff user ID from JWT
|
||||
author_name = Column(String(255), nullable=True) # denormalized for display
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||
|
||||
links = relationship("EntryLink", back_populates="entry", cascade="all, delete-orphan", lazy="noload")
|
||||
|
||||
|
||||
class EntryLink(Base):
|
||||
__tablename__ = "crm_entry_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("entry_id", "entity_type", "entity_id"),
|
||||
)
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
entry_id = Column(UUID(as_uuid=True), ForeignKey("crm_entries.id", ondelete="CASCADE"), nullable=False)
|
||||
entity_type = Column(String(20), nullable=False) # 'device' | 'app_user' | 'customer'
|
||||
entity_id = Column(String(128), nullable=False) # Firestore ID or Postgres UUID as string
|
||||
|
||||
entry = relationship("Entry", back_populates="links")
|
||||
79
backend/notes/router.py
Normal file
79
backend/notes/router.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from uuid import UUID
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from database.postgres import get_pg_session
|
||||
from auth.dependencies import require_permission
|
||||
from auth.models import TokenPayload
|
||||
from notes import service
|
||||
from notes.models import EntryCreate, EntryUpdate, EntryOut, EntryListResponse, LinksReplaceIn
|
||||
|
||||
router = APIRouter(prefix="/api/notes", tags=["notes"])
|
||||
|
||||
|
||||
@router.get("", response_model=EntryListResponse)
|
||||
async def list_entries(
|
||||
type: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
severity: str | None = Query(None),
|
||||
category: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(25, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
rows, total = await service.list_entries(db, type, status, severity, category, page, limit)
|
||||
return {"data": rows, "pagination": {"page": page, "limit": limit, "total": total}}
|
||||
|
||||
|
||||
@router.get("/by-entity/{entity_type}/{entity_id}", response_model=list[EntryOut])
|
||||
async def list_by_entity(
|
||||
entity_type: str, entity_id: str,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
return await service.list_entries_for_entity(db, entity_type, entity_id)
|
||||
|
||||
|
||||
@router.get("/{entry_id}", response_model=EntryOut)
|
||||
async def get_entry(
|
||||
entry_id: UUID,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||
):
|
||||
return await service.get_entry(db, entry_id)
|
||||
|
||||
|
||||
@router.post("", response_model=EntryOut, status_code=201)
|
||||
async def create_entry(
|
||||
body: EntryCreate,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "add")),
|
||||
):
|
||||
return await service.create_entry(db, body, _user.sub, _user.name or _user.email)
|
||||
|
||||
|
||||
@router.patch("/{entry_id}", response_model=EntryOut)
|
||||
async def update_entry(
|
||||
entry_id: UUID, body: EntryUpdate,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.update_entry(db, entry_id, body)
|
||||
|
||||
|
||||
@router.patch("/{entry_id}/links", response_model=EntryOut)
|
||||
async def replace_links(
|
||||
entry_id: UUID, body: LinksReplaceIn,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||
):
|
||||
return await service.replace_links(db, entry_id, body.links)
|
||||
|
||||
|
||||
@router.delete("/{entry_id}", status_code=204)
|
||||
async def delete_entry(
|
||||
entry_id: UUID,
|
||||
db: AsyncSession = Depends(get_pg_session),
|
||||
_user: TokenPayload = Depends(require_permission("crm", "delete")),
|
||||
):
|
||||
await service.delete_entry(db, entry_id)
|
||||
93
backend/notes/service.py
Normal file
93
backend/notes/service.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import uuid
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from notes.orm import Entry, EntryLink
|
||||
from notes.models import EntryCreate, EntryUpdate, EntryLinkIn
|
||||
from shared.exceptions import NotFoundError
|
||||
|
||||
|
||||
async def create_entry(db: AsyncSession, data: EntryCreate, author_id: str, author_name: str) -> Entry:
|
||||
entry = Entry(
|
||||
type=data.type,
|
||||
title=data.title,
|
||||
body=data.body,
|
||||
status=data.status if data.type == "issue" else None,
|
||||
severity=data.severity if data.type == "issue" else None,
|
||||
category=data.category if data.type == "issue" else None,
|
||||
author_id=author_id,
|
||||
author_name=author_name,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush() # get the ID before inserting links
|
||||
|
||||
for link_in in data.links:
|
||||
db.add(EntryLink(entry_id=entry.id, entity_type=link_in.entity_type, entity_id=link_in.entity_id))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
return await _get_entry_with_links(db, entry.id)
|
||||
|
||||
|
||||
async def get_entry(db: AsyncSession, entry_id: uuid.UUID) -> Entry:
|
||||
return await _get_entry_with_links(db, entry_id)
|
||||
|
||||
|
||||
async def list_entries(
|
||||
db: AsyncSession,
|
||||
type: str | None, status: str | None, severity: str | None, category: str | None,
|
||||
page: int, limit: int,
|
||||
) -> tuple[list[Entry], int]:
|
||||
limit = min(100, max(1, limit))
|
||||
offset = (max(1, page) - 1) * limit
|
||||
|
||||
q = select(Entry).options(selectinload(Entry.links))
|
||||
if type: q = q.where(Entry.type == type)
|
||||
if status: q = q.where(Entry.status == status)
|
||||
if severity: q = q.where(Entry.severity == severity)
|
||||
if category: q = q.where(Entry.category == category)
|
||||
|
||||
total_q = select(func.count()).select_from(q.subquery())
|
||||
total = (await db.execute(total_q)).scalar()
|
||||
rows = (await db.execute(q.order_by(Entry.created_at.desc()).limit(limit).offset(offset))).scalars().all()
|
||||
return rows, total
|
||||
|
||||
|
||||
async def update_entry(db: AsyncSession, entry_id: uuid.UUID, data: EntryUpdate) -> Entry:
|
||||
entry = await _get_entry_with_links(db, entry_id)
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(entry, field, value)
|
||||
await db.commit()
|
||||
return await _get_entry_with_links(db, entry_id)
|
||||
|
||||
|
||||
async def delete_entry(db: AsyncSession, entry_id: uuid.UUID):
|
||||
entry = await _get_entry_with_links(db, entry_id)
|
||||
await db.delete(entry)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def replace_links(db: AsyncSession, entry_id: uuid.UUID, links: list[EntryLinkIn]) -> Entry:
|
||||
await _get_entry_with_links(db, entry_id) # raises 404 if not found
|
||||
await db.execute(delete(EntryLink).where(EntryLink.entry_id == entry_id))
|
||||
for link_in in links:
|
||||
db.add(EntryLink(entry_id=entry_id, entity_type=link_in.entity_type, entity_id=link_in.entity_id))
|
||||
await db.commit()
|
||||
return await _get_entry_with_links(db, entry_id)
|
||||
|
||||
|
||||
async def list_entries_for_entity(db: AsyncSession, entity_type: str, entity_id: str) -> list[Entry]:
|
||||
link_sq = select(EntryLink.entry_id).where(
|
||||
EntryLink.entity_type == entity_type,
|
||||
EntryLink.entity_id == entity_id,
|
||||
).subquery()
|
||||
q = select(Entry).options(selectinload(Entry.links)).where(Entry.id.in_(select(link_sq)))
|
||||
return (await db.execute(q.order_by(Entry.created_at.desc()))).scalars().all()
|
||||
|
||||
|
||||
async def _get_entry_with_links(db: AsyncSession, entry_id: uuid.UUID) -> Entry:
|
||||
q = select(Entry).options(selectinload(Entry.links)).where(Entry.id == entry_id)
|
||||
result = (await db.execute(q)).scalar_one_or_none()
|
||||
if not result:
|
||||
raise NotFoundError("Entry")
|
||||
return result
|
||||
Reference in New Issue
Block a user