from datetime import datetime from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash from app import db # ─── Enumerations ──────────────────────────────────────────────────────────── class UserRole: EMPLOYEE = 'employee' IT_STAFF = 'it_staff' ADMIN = 'admin' class TicketStatus: OPEN = 'open' IN_PROGRESS = 'in_progress' PENDING = 'pending' RESOLVED = 'resolved' CLOSED = 'closed' class TicketPriority: LOW = 'low' MEDIUM = 'medium' HIGH = 'high' CRITICAL = 'critical' class TicketCategory: HARDWARE = 'hardware' SOFTWARE = 'software' NETWORK = 'network' ACCESS = 'access' EMAIL = 'email' PRINTER = 'printer' PHONE = 'phone' SECURITY = 'security' OTHER = 'other' class NotificationType: TICKET_CREATED = 'ticket_created' TICKET_UPDATED = 'ticket_updated' TICKET_ASSIGNED = 'ticket_assigned' COMMENT_ADDED = 'comment_added' STATUS_CHANGED = 'status_changed' TICKET_RESOLVED = 'ticket_resolved' TICKET_CLOSED = 'ticket_closed' MENTION = 'mention' # ─── Models ────────────────────────────────────────────────────────────────── class User(UserMixin, db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(120), unique=True, nullable=False, index=True) username = db.Column(db.String(64), unique=True, nullable=False, index=True) full_name = db.Column(db.String(128), nullable=False) password_hash= db.Column(db.String(256), nullable=False) role = db.Column(db.String(20), default=UserRole.EMPLOYEE, nullable=False) department = db.Column(db.String(100)) phone = db.Column(db.String(20)) avatar_url = db.Column(db.String(256)) is_active = db.Column(db.Boolean, default=True, nullable=False) email_notif = db.Column(db.Boolean, default=True, nullable=False) web_notif = db.Column(db.Boolean, default=True, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) last_login = db.Column(db.DateTime) # Relationships created_tickets = db.relationship('Ticket', foreign_keys='Ticket.created_by_id', backref='creator', lazy='dynamic') assigned_tickets = db.relationship('Ticket', foreign_keys='Ticket.assigned_to_id', backref='assignee', lazy='dynamic') comments = db.relationship('Comment', backref='author', lazy='dynamic') notifications = db.relationship('Notification', backref='recipient', lazy='dynamic') activity_logs = db.relationship('ActivityLog', backref='user', lazy='dynamic') def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) @property def is_it_staff(self): return self.role in (UserRole.IT_STAFF, UserRole.ADMIN) @property def is_admin(self): return self.role == UserRole.ADMIN def __repr__(self): return f'' class Ticket(db.Model): __tablename__ = 'tickets' id = db.Column(db.Integer, primary_key=True) ticket_number = db.Column(db.String(20), unique=True, nullable=False, index=True) title = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=False) category = db.Column(db.String(50), default=TicketCategory.OTHER, nullable=False) priority = db.Column(db.String(20), default=TicketPriority.MEDIUM, nullable=False) status = db.Column(db.String(20), default=TicketStatus.OPEN, nullable=False, index=True) location = db.Column(db.String(100)) asset_tag = db.Column(db.String(50)) created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) assigned_to_id = db.Column(db.Integer, db.ForeignKey('users.id')) created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) resolved_at = db.Column(db.DateTime) closed_at = db.Column(db.DateTime) due_date = db.Column(db.DateTime) ai_generated = db.Column(db.Boolean, default=False) internal_notes = db.Column(db.Text) resolution_notes = db.Column(db.Text) # Relationships comments = db.relationship('Comment', backref='ticket', lazy='dynamic', cascade='all, delete-orphan') attachments = db.relationship('Attachment', backref='ticket', lazy='dynamic', cascade='all, delete-orphan') notifications= db.relationship('Notification', backref='ticket', lazy='dynamic', cascade='all, delete-orphan') history = db.relationship('TicketHistory', backref='ticket', lazy='dynamic', cascade='all, delete-orphan') def generate_ticket_number(self): """Generate a unique ticket number like TKT-20240101-0001. Concurrency safety ------------------ The naive approach — query the last ticket, increment its sequence, then insert — has a TOCTOU race: two concurrent requests can both read the same "last" ticket and generate the same next number, causing an IntegrityError at commit time. We eliminate the race by appending FOR UPDATE to the SELECT. This acquires a row-level write lock on the last matching ticket for the duration of the current transaction, serialising concurrent callers through the database rather than through application code. The lock is released automatically when the transaction commits or rolls back. MySQL / MariaDB: with_for_update() emits SELECT … FOR UPDATE. SQLite (dev/test): SELECT … FOR UPDATE is silently ignored, which is acceptable because SQLite's connection-level locking already prevents concurrent writes in practice. """ date_str = datetime.utcnow().strftime('%Y%m%d') last = ( Ticket.query .filter(Ticket.ticket_number.like(f'TKT-{date_str}-%')) .order_by(Ticket.id.desc()) .with_for_update() # acquires a write lock; serialises concurrent callers .first() ) if last: seq = int(last.ticket_number.split('-')[-1]) + 1 else: seq = 1 return f'TKT-{date_str}-{seq:04d}' @property def priority_color(self): return { TicketPriority.LOW: 'success', TicketPriority.MEDIUM: 'warning', TicketPriority.HIGH: 'danger', TicketPriority.CRITICAL: 'dark', }.get(self.priority, 'secondary') @property def status_color(self): return { TicketStatus.OPEN: 'primary', TicketStatus.IN_PROGRESS: 'info', TicketStatus.PENDING: 'warning', TicketStatus.RESOLVED: 'success', TicketStatus.CLOSED: 'secondary', }.get(self.status, 'secondary') def __repr__(self): return f'' class Comment(db.Model): __tablename__ = 'comments' id = db.Column(db.Integer, primary_key=True) ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'), nullable=False) author_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) body = db.Column(db.Text, nullable=False) is_internal = db.Column(db.Boolean, default=False) # IT-only internal note created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) attachments = db.relationship('Attachment', backref='comment', lazy='dynamic') def __repr__(self): return f'' class Attachment(db.Model): __tablename__ = 'attachments' id = db.Column(db.Integer, primary_key=True) ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id')) comment_id = db.Column(db.Integer, db.ForeignKey('comments.id')) filename = db.Column(db.String(256), nullable=False) stored_name = db.Column(db.String(256), nullable=False) file_size = db.Column(db.Integer) mime_type = db.Column(db.String(100)) uploaded_by = db.Column(db.Integer, db.ForeignKey('users.id')) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) uploader = db.relationship('User', foreign_keys=[uploaded_by]) def __repr__(self): return f'' class Notification(db.Model): __tablename__ = 'notifications' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id')) type = db.Column(db.String(50), nullable=False) title = db.Column(db.String(200), nullable=False) message = db.Column(db.Text) is_read = db.Column(db.Boolean, default=False, index=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True) link = db.Column(db.String(256)) def __repr__(self): return f'' class TicketHistory(db.Model): __tablename__ = 'ticket_history' id = db.Column(db.Integer, primary_key=True) ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'), nullable=False) changed_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) field_name = db.Column(db.String(50), nullable=False) old_value = db.Column(db.Text) new_value = db.Column(db.Text) changed_at = db.Column(db.DateTime, default=datetime.utcnow) changer = db.relationship('User', foreign_keys=[changed_by]) def __repr__(self): return f'' class ActivityLog(db.Model): __tablename__ = 'activity_logs' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id')) action = db.Column(db.String(100), nullable=False) entity_type = db.Column(db.String(50)) entity_id = db.Column(db.Integer) details = db.Column(db.Text) ip_address = db.Column(db.String(45)) created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True) def __repr__(self): return f'' class KnowledgeBase(db.Model): __tablename__ = 'knowledge_base' id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(200), nullable=False) body = db.Column(db.Text, nullable=False) category = db.Column(db.String(50)) tags = db.Column(db.String(256)) author_id = db.Column(db.Integer, db.ForeignKey('users.id')) is_published= db.Column(db.Boolean, default=True) view_count = db.Column(db.Integer, default=0) created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) author = db.relationship('User', foreign_keys=[author_id]) def __repr__(self): return f'' class KBAttachment(db.Model): """Files and images attached to a Knowledge Base article.""" __tablename__ = 'kb_attachments' id = db.Column(db.Integer, primary_key=True) article_id = db.Column(db.Integer, db.ForeignKey('knowledge_base.id'), nullable=False) filename = db.Column(db.String(256), nullable=False) # original name stored_name = db.Column(db.String(256), nullable=False) # UUID-based on disk mime_type = db.Column(db.String(100)) file_size = db.Column(db.Integer) uploaded_by = db.Column(db.Integer, db.ForeignKey('users.id')) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) article = db.relationship('KnowledgeBase', backref=db.backref('attachments', lazy='dynamic', cascade='all, delete-orphan')) uploader = db.relationship('User', foreign_keys=[uploaded_by]) @property def url(self): return f'/admin/kb/files/{self.stored_name}' def __repr__(self): return f''