Files
shopdb-flask/plugins/endpoints/models/operatorreport.py
benjamin.baker ab4b9d7ec9 add operator issue reports for endpoint agent
Operators had no way to flag a problem PC from the tray. This adds a report path: the tray captures a reason, free-text note, and optional screenshot and the agent uploads it on the collector token to a new operatorreports table linked to the asset. Ships the endpoints 0009 migration so the table exists before the code queries it; the running DB had drifted behind the code and returned HTTP 500 on upload.
2026-09-01 19:39:32 -04:00

83 lines
3.1 KiB
Python

"""Operator issue reports (screenshot + free-text notes from the tray).
An operator on a shop-floor PC opens the tray "Report an issue" dialog, writes a
free-text description, and optionally attaches a screenshot of their screen. The
agent uploads that to ShopDB, where it becomes one OperatorReport row linked to
the reporting PC's asset. The screenshot bytes live on disk under the instance
uploads dir (like employee photos); this row holds only metadata plus the
relative file path so the image can be served back and purged on retention.
Reports are transient by design: a retention window (default 7 days) purges old
rows and their image files, so this is a rolling operator-signal feed, not a
permanent record.
"""
from datetime import datetime
from shopdb.api import db
def _utciso(value):
"""Naive-UTC datetime -> ISO string marked UTC, or None."""
return value.isoformat() + 'Z' if value else None
class OperatorReport(db.Model):
"""One operator-submitted issue report for a PC asset."""
__tablename__ = 'operatorreports'
operatorreportid = db.Column(db.Integer, primary_key=True)
# The PC this report is about. ON DELETE CASCADE: when the asset goes, its
# operator reports go with it.
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
)
# The dropdown reason. Only 'Other (free text)' is functional today, but the
# column is kept so the richer reason routing can return without a migration.
reason = db.Column(db.String(128), nullable=False, default='Other (free text)')
# The operator's free-text description. Nullable: a screenshot alone is a
# valid report.
details = db.Column(db.Text, nullable=True)
# Screenshot metadata. imagefilename is the stored file name under the
# operator-reports uploads dir; null when the operator declined a screenshot.
imagefilename = db.Column(db.String(255), nullable=True)
imagemimetype = db.Column(db.String(64), nullable=True)
imagebytesize = db.Column(db.Integer, nullable=True)
# The hostname the agent reported at upload time, kept for traceability even
# if the asset link is later re-pointed.
reportedhostname = db.Column(db.String(255), nullable=True)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.Index('idx_operatorreports_asset', 'assetid'),
db.Index('idx_operatorreports_createdat', 'createdat'),
)
def __repr__(self):
return f"<OperatorReport {self.operatorreportid} asset={self.assetid}>"
def has_image(self):
return bool(self.imagefilename)
def to_dict(self):
return {
'operatorreportid': self.operatorreportid,
'assetid': self.assetid,
'reason': self.reason,
'details': self.details,
'hasimage': self.has_image(),
'imagemimetype': self.imagemimetype,
'imagebytesize': self.imagebytesize,
'reportedhostname': self.reportedhostname,
'createdat': _utciso(self.createdat),
}