Files
TimeTracker/app/models/client_time_approval.py
T
MacJediWizard 0a8fbd8329 fix(approvals): bind ClientApprovalStatus enum values to PG, not names
ClientApprovalStatus is defined with uppercase Python names and lowercase
string values (PENDING = "pending", etc.), but the Postgres enum type
clientapprovalstatus is defined with the lowercase values. SQLAlchemy
defaults to binding the enum *name*, so every query against the column
sent "PENDING" and Postgres rejected it with InvalidTextRepresentation.

This made get_pending_approvals_for_client raise on every client portal
request — the navbar context processor catches the exception and returns
0, but the stack trace was logged on every page load.

Pass values_callable to SQLEnum so SQLAlchemy uses the enum *value* (the
lowercase string PG actually stores).

The auto-lint hook reformatted the rest of the file; the only behavioral
change is the values_callable parameter on the status column.
2026-05-06 01:14:53 -04:00

210 lines
6.7 KiB
Python

"""
Client Time Entry Approval models
Similar to manager approval but for client-side approval
"""
import enum
from datetime import datetime
from sqlalchemy import Enum as SQLEnum
from app import db
class ClientApprovalStatus(enum.Enum):
"""Client approval status"""
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
CANCELLED = "cancelled"
class ClientTimeApproval(db.Model):
"""Client-side time entry approval request"""
__tablename__ = "client_time_approvals"
id = db.Column(db.Integer, primary_key=True)
time_entry_id = db.Column(
db.Integer, db.ForeignKey("time_entries.id"), nullable=False, index=True
)
project_id = db.Column(
db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True
)
client_id = db.Column(
db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True
)
# Approval workflow
status = db.Column(
SQLEnum(ClientApprovalStatus, values_callable=lambda x: [e.value for e in x]),
default=ClientApprovalStatus.PENDING,
nullable=False,
index=True,
)
requested_by = db.Column(
db.Integer, db.ForeignKey("users.id"), nullable=False, index=True
)
approved_by = db.Column(
db.Integer, nullable=True
) # Client contact ID (not user ID)
# Timestamps
requested_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
approved_at = db.Column(db.DateTime, nullable=True)
rejected_at = db.Column(db.DateTime, nullable=True)
# Comments
request_comment = db.Column(db.Text, nullable=True)
approval_comment = db.Column(db.Text, nullable=True)
rejection_reason = db.Column(db.Text, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
# Relationships
time_entry = db.relationship(
"TimeEntry", backref=db.backref("client_approvals", lazy="dynamic")
)
project = db.relationship(
"Project", backref=db.backref("client_approvals", lazy="dynamic")
)
client = db.relationship(
"Client", backref=db.backref("time_approvals", lazy="dynamic")
)
requester = db.relationship("User", foreign_keys=[requested_by])
def __repr__(self):
return f"<ClientTimeApproval {self.id} for entry {self.time_entry_id} - {self.status.value}>"
def to_dict(self):
return {
"id": self.id,
"time_entry_id": self.time_entry_id,
"project_id": self.project_id,
"client_id": self.client_id,
"status": self.status.value
if isinstance(self.status, ClientApprovalStatus)
else self.status,
"requested_by": self.requested_by,
"approved_by": self.approved_by,
"requested_at": self.requested_at.isoformat()
if self.requested_at
else None,
"approved_at": self.approved_at.isoformat() if self.approved_at else None,
"rejected_at": self.rejected_at.isoformat() if self.rejected_at else None,
"request_comment": self.request_comment,
"approval_comment": self.approval_comment,
"rejection_reason": self.rejection_reason,
}
def approve(self, contact_id: int, comment: str = None):
"""Approve this request"""
self.status = ClientApprovalStatus.APPROVED
self.approved_by = contact_id
self.approved_at = datetime.utcnow()
self.approval_comment = comment
db.session.commit()
def reject(self, contact_id: int, reason: str):
"""Reject this request"""
self.status = ClientApprovalStatus.REJECTED
self.approved_by = contact_id
self.rejected_at = datetime.utcnow()
self.rejection_reason = reason
db.session.commit()
def cancel(self):
"""Cancel this request"""
self.status = ClientApprovalStatus.CANCELLED
db.session.commit()
class ClientApprovalPolicy(db.Model):
"""Approval policy for client-side approvals"""
__tablename__ = "client_approval_policies"
id = db.Column(db.Integer, primary_key=True)
client_id = db.Column(
db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True
)
project_id = db.Column(
db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True
)
# Approval requirements
requires_approval = db.Column(db.Boolean, default=True, nullable=False)
auto_approve_after_days = db.Column(
db.Integer, nullable=True
) # Auto-approve if no response
# Conditions
min_hours = db.Column(
db.Numeric(10, 2), nullable=True
) # Require approval if >= this many hours
billable_only = db.Column(db.Boolean, default=False, nullable=False)
# Status
enabled = db.Column(db.Boolean, default=True, nullable=False)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
# Relationships
client = db.relationship(
"Client", backref=db.backref("approval_policies", lazy="dynamic")
)
project = db.relationship(
"Project", backref=db.backref("client_approval_policies", lazy="dynamic")
)
def __repr__(self):
return (
f"<ClientApprovalPolicy client={self.client_id} project={self.project_id}>"
)
def to_dict(self):
return {
"id": self.id,
"client_id": self.client_id,
"project_id": self.project_id,
"requires_approval": self.requires_approval,
"auto_approve_after_days": self.auto_approve_after_days,
"min_hours": float(self.min_hours) if self.min_hours else None,
"billable_only": self.billable_only,
"enabled": self.enabled,
}
def applies_to_entry(self, time_entry) -> bool:
"""Check if this policy applies to a time entry"""
if not self.enabled or not self.requires_approval:
return False
# Check project match
if self.project_id and time_entry.project_id != self.project_id:
return False
# Check client match
if time_entry.project.client_id != self.client_id:
return False
# Check billable requirement
if self.billable_only and not time_entry.billable:
return False
# Check minimum hours
if self.min_hours and time_entry.duration_seconds:
hours = time_entry.duration_seconds / 3600
if hours < float(self.min_hours):
return False
return True