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) # When an IT staff member creates a ticket on behalf of an employee # (e.g. from a phone call or email), this column records who filed it. # NULL means the ticket was self-submitted by the employee. created_by_staff_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) filed_by_staff = db.relationship('User', foreign_keys='Ticket.created_by_staff_id') # 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', ondelete='SET NULL')) 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'' class SystemSetting(db.Model): """Key-value store for application-wide configuration flags. Values are persisted as strings; helper class methods handle typed access so callers never touch raw strings directly. """ __tablename__ = 'system_settings' id = db.Column(db.Integer, primary_key=True) key = db.Column(db.String(100), unique=True, nullable=False, index=True) value = db.Column(db.String(500), nullable=False) description = db.Column(db.String(256)) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @classmethod def get(cls, key, default=None): """Return the raw string value for *key*, or *default* if absent.""" row = cls.query.filter_by(key=key).first() return row.value if row else default @classmethod def get_bool(cls, key, default=True): """Return the value for *key* coerced to bool.""" raw = cls.get(key) if raw is None: return default return raw.lower() in ('1', 'true', 'yes', 'on') @classmethod def set(cls, key, value, description=None): """Upsert *key* = *value*. Caller is responsible for committing.""" row = cls.query.filter_by(key=key).first() if row: row.value = str(value) if description is not None: row.description = description else: row = cls(key=key, value=str(value), description=description) db.session.add(row) return row def __repr__(self): return f'' class TicketLink(db.Model): """Bidirectional link between two related tickets. A single row represents a symmetric relationship — if TKT-A is linked to TKT-B, the link is stored once with ticket_id < linked_ticket_id (enforced at the application layer). Queries must search both columns to find all links for a given ticket. Use cases: duplicate tickets, related outages, follow-up work items. """ __tablename__ = 'ticket_links' id = db.Column(db.Integer, primary_key=True) ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'), nullable=False) linked_ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'), nullable=False) link_type = db.Column(db.String(20), default='related', nullable=False) # link_type values: 'related' | 'duplicate' | 'follow_up' created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) ticket = db.relationship('Ticket', foreign_keys=[ticket_id], backref=db.backref('links_as_source', lazy='dynamic', cascade='all, delete-orphan')) linked_ticket = db.relationship('Ticket', foreign_keys=[linked_ticket_id], backref=db.backref('links_as_target', lazy='dynamic', cascade='all, delete-orphan')) creator = db.relationship('User', foreign_keys=[created_by]) __table_args__ = ( db.UniqueConstraint('ticket_id', 'linked_ticket_id', name='uq_ticket_link'), ) def __repr__(self): return f'' class CannedResponse(db.Model): """Pre-written IT reply templates for common ticket scenarios. IT staff can insert these into comment boxes with one click, saving typing time for repetitive responses like 'please restart and confirm', 'escalating to vendor', or 'issue resolved after patch applied'. """ __tablename__ = 'canned_responses' id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(120), nullable=False) body = db.Column(db.Text, nullable=False) category = db.Column(db.String(50)) # optional grouping label created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) is_active = db.Column(db.Boolean, default=True, nullable=False) creator = db.relationship('User', foreign_keys=[created_by]) def __repr__(self): return f'' class KBFeedback(db.Model): """One thumbs-up or thumbs-down vote per user per KB article. The unique constraint ensures each user can only vote once per article. Updating a vote replaces the existing row via the route logic (upsert). Votes are stored anonymously in aggregate on the KnowledgeBase table via two counters (helpful_count, not_helpful_count) for fast display in the admin list without joining this table on every page load. """ __tablename__ = 'kb_feedback' id = db.Column(db.Integer, primary_key=True) article_id = db.Column(db.Integer, db.ForeignKey('knowledge_base.id', ondelete='CASCADE'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) is_helpful = db.Column(db.Boolean, nullable=False) # True=👍 False=👎 created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) article = db.relationship('KnowledgeBase', backref=db.backref('feedback', lazy='dynamic', cascade='all, delete-orphan')) user = db.relationship('User', foreign_keys=[user_id]) __table_args__ = ( db.UniqueConstraint('article_id', 'user_id', name='uq_kb_feedback'), ) def __repr__(self): return f''