Files
TimeTracker/app/models/client_note.py
T
Dries Peeters ad9bfbf1ed Fix client deletion errors and add invoice validation
This commit fixes multiple issues preventing client deletion and adds proper validation to prevent deletion when invoices exist.

Database Schema Fixes:

- Migration 103: Add missing quote_number column to quotes table

  - Handles migration from offer_number to quote_number

  - Generates quote numbers for existing quotes if needed

  - Creates required unique index

- Migration 104: Add all missing columns to quotes table

  - Adds subtotal, tax_amount, visible_to_client columns

  - Adds discount fields (discount_type, discount_amount, discount_reason, coupon_code)

  - Adds payment_terms column

  - Adds approval workflow columns (approval_status, approved_by, approved_at, rejected_by, rejection_reason)

  - Creates required indexes and foreign keys

- Migration 105: Fix client_notifications foreign key cascade

  - Updates client_notifications.client_id FK to ON DELETE CASCADE

  - Updates client_notification_preferences.client_id FK to ON DELETE CASCADE

  - Prevents NOT NULL constraint violations during client deletion

Model Updates:

- Add passive_deletes=True to ClientNotification.client relationship

- Add passive_deletes=True to ClientNotificationPreferences.client relationship

- Add passive_deletes=True to ClientAttachment.client relationship

- Update ClientNote.client relationship to use passive_deletes

Route Updates:

- Add invoice validation to delete_client() and bulk_delete_clients()

- Manually delete notifications before client deletion to prevent SQLAlchemy update issues

Fixes:

- Resolves IntegrityError when deleting clients with notifications

- Resolves missing quote_number column errors

- Resolves missing quotes table columns errors

- Prevents deletion of clients with invoices (data integrity)
2026-01-05 22:07:50 +01:00

150 lines
5.2 KiB
Python

from datetime import datetime
from app import db
from app.utils.timezone import now_in_app_timezone
class ClientNote(db.Model):
"""ClientNote model for internal notes about clients"""
__tablename__ = "client_notes"
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
# Reference to client
client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True)
# Author of the note
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
# Internal flag - these notes are always internal and not visible to clients
is_important = db.Column(db.Boolean, default=False, nullable=False)
# Timestamps
created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False)
updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False)
# Relationships
author = db.relationship("User", backref="client_notes")
client = db.relationship("Client", backref=db.backref("notes", lazy="dynamic"), passive_deletes=True)
def __init__(self, content, user_id, client_id, is_important=False):
"""Create a client note.
Args:
content: The note text
user_id: ID of the user creating the note
client_id: ID of the client
is_important: Whether this note is marked as important
"""
if not client_id:
raise ValueError("Note must be associated with a client")
if not content or not content.strip():
raise ValueError("Note content cannot be empty")
self.content = content.strip()
self.user_id = user_id
self.client_id = client_id
self.is_important = is_important
def __repr__(self):
return f'<ClientNote by {self.author.username if self.author else "Unknown"} for Client {self.client_id}>'
@property
def author_name(self):
"""Get the author's display name"""
if self.author:
return self.author.full_name if self.author.full_name else self.author.username
return "Unknown"
@property
def client_name(self):
"""Get the client name"""
return self.client.name if self.client else "Unknown"
def can_edit(self, user):
"""Check if a user can edit this note"""
return user.id == self.user_id or user.is_admin
def can_delete(self, user):
"""Check if a user can delete this note"""
return user.id == self.user_id or user.is_admin
def edit_content(self, new_content, user, is_important=None):
"""Edit the note content
Args:
new_content: New content for the note
user: User making the edit
is_important: Optional new importance flag
"""
if not self.can_edit(user):
raise PermissionError("User does not have permission to edit this note")
if not new_content or not new_content.strip():
raise ValueError("Note content cannot be empty")
self.content = new_content.strip()
if is_important is not None:
self.is_important = is_important
self.updated_at = now_in_app_timezone()
def to_dict(self):
"""Convert note to dictionary for API responses"""
return {
"id": self.id,
"content": self.content,
"client_id": self.client_id,
"client_name": self.client_name,
"user_id": self.user_id,
"author": self.author.username if self.author else None,
"author_full_name": self.author.full_name if self.author and self.author.full_name else None,
"author_name": self.author_name,
"is_important": self.is_important,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@classmethod
def get_client_notes(cls, client_id, order_by_important=False):
"""Get all notes for a client
Args:
client_id: ID of the client
order_by_important: If True, important notes appear first
"""
query = cls.query.filter_by(client_id=client_id)
if order_by_important:
query = query.order_by(cls.is_important.desc(), cls.created_at.desc())
else:
query = query.order_by(cls.created_at.desc())
return query.all()
@classmethod
def get_important_notes(cls, client_id=None):
"""Get all important notes, optionally filtered by client"""
query = cls.query.filter_by(is_important=True)
if client_id:
query = query.filter_by(client_id=client_id)
return query.order_by(cls.created_at.desc()).all()
@classmethod
def get_user_notes(cls, user_id, limit=None):
"""Get recent notes by a user"""
query = cls.query.filter_by(user_id=user_id).order_by(cls.created_at.desc())
if limit:
query = query.limit(limit)
return query.all()
@classmethod
def get_recent_notes(cls, limit=10):
"""Get recent notes across all clients"""
return cls.query.order_by(cls.created_at.desc()).limit(limit).all()