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.Text, 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'' class PasswordResetToken(db.Model): """Single-use time-limited token for self-service password reset. Tokens are stored hashed (SHA-256) so a database breach does not expose valid reset links. Each token expires after 1 hour and is deleted on first use. """ __tablename__ = 'password_reset_tokens' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True) expires_at = db.Column(db.DateTime, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) user = db.relationship('User', foreign_keys=[user_id]) @classmethod def generate(cls, user_id): """Create a new token, persist it, and return the raw token string. Caller must commit the session. """ import secrets, hashlib from datetime import timedelta raw = secrets.token_urlsafe(32) hashed= hashlib.sha256(raw.encode()).hexdigest() token = cls( user_id = user_id, token_hash = hashed, expires_at = datetime.utcnow() + timedelta(hours=1), ) db.session.add(token) return raw, token @classmethod def verify(cls, raw): """Return the token row if raw is valid and unexpired, else None.""" import hashlib hashed = hashlib.sha256(raw.encode()).hexdigest() row = cls.query.filter_by(token_hash=hashed).first() if row and row.expires_at > datetime.utcnow(): return row return None def __repr__(self): return f'' class TicketTemplate(db.Model): """Pre-filled ticket scaffolds selectable by employees on the New Ticket form. Templates reduce friction for common request types (e.g. VPN Access, New Laptop Setup) and improve ticket quality by pre-populating category, priority, and a structured description prompt. """ __tablename__ = 'ticket_templates' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(120), nullable=False) description = db.Column(db.Text, nullable=False, default='') # Pre-filled form values category = db.Column(db.String(50), nullable=False, default='other') priority = db.Column(db.String(20), nullable=False, default='medium') title_hint = db.Column(db.String(200), nullable=False, default='') # UI grouping icon = db.Column(db.String(40), nullable=False, default='bi-file-text') is_active = db.Column(db.Boolean, default=True, nullable=False) sort_order = db.Column(db.Integer, default=0) created_by = db.Column(db.Integer, db.ForeignKey('users.id')) created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) creator = db.relationship('User', foreign_keys=[created_by]) def __repr__(self): return f'' class TicketSatisfaction(db.Model): """One satisfaction rating per resolved ticket. Sent automatically when a ticket moves to Resolved status. The rating (1-5 stars) and optional comment are submitted via a token-authenticated endpoint so the employee does not need to be logged in to respond (they click from the email). """ __tablename__ = 'ticket_satisfaction' id = db.Column(db.Integer, primary_key=True) ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id', ondelete='CASCADE'), unique=True, nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False) # survey_token authenticates the survey URL in the email — single-use survey_token= db.Column(db.String(64), unique=True, nullable=False, index=True) rating = db.Column(db.Integer) # 1-5; NULL until submitted comment = db.Column(db.Text) sent_at = db.Column(db.DateTime, default=datetime.utcnow) submitted_at= db.Column(db.DateTime) ticket = db.relationship('Ticket', backref=db.backref('satisfaction', uselist=False, cascade='all, delete-orphan')) user = db.relationship('User', foreign_keys=[user_id]) @classmethod def create_for_ticket(cls, ticket): """Return a survey row ready for emailing. Behaviour: - No existing row → create and return a new one. - Existing row, not yet submitted (rating is NULL) → return it so the email can be re-sent. This covers the case where the row was created but the email thread crashed before sending. - Existing row, already submitted → return None (survey is complete). Caller is responsible for committing the session. """ import secrets existing = cls.query.filter_by(ticket_id=ticket.id).first() if existing: if existing.submitted: # Employee already rated — do not re-send return None # Row exists but email was never sent (prior crash) — re-use it # Regenerate the token so any stale links in old emails are invalidated existing.survey_token = secrets.token_urlsafe(32) return existing row = cls( ticket_id = ticket.id, user_id = ticket.created_by_id, survey_token = secrets.token_urlsafe(32), ) db.session.add(row) return row @property def submitted(self): return self.rating is not None def __repr__(self): return f''