This commit is contained in:
2026-05-19 11:53:25 -04:00
16 changed files with 596 additions and 67 deletions
+9 -7
View File
@@ -147,25 +147,27 @@ def create_app(config_name='default'):
app.register_blueprint(customers.bp)
app.register_blueprint(scheduled_reports.bp)
# ── Mobile API (Phase 7 / Phase A / Phase B) ─────────────────────────────
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
#
# Flask-WTF's _is_exempt() checks whether the *leaf* blueprint object
# is in _exempt_blueprints. Exempting the parent api_bp does NOT cascade
# to sub-blueprints. Each child blueprint must be exempted individually.
from app.api import register_api, api_bp
from app.api.auth import bp as _api_auth_bp
from app.api.facilities import bp as _api_facilities_bp
from app.api.templates import bp as _api_templates_bp
from app.api.inspections import bp as _api_inspections_bp
from app.api.issues import bp as _api_issues_bp
from app.api.photos import bp as _api_photos_bp
from app.api.auth import bp as _api_auth_bp
from app.api.facilities import bp as _api_facilities_bp
from app.api.templates import bp as _api_templates_bp
from app.api.inspections import bp as _api_inspections_bp
from app.api.issues import bp as _api_issues_bp
from app.api.photos import bp as _api_photos_bp
from app.api.notifications import bp as _api_notifications_bp
csrf.exempt(_api_auth_bp)
csrf.exempt(_api_facilities_bp)
csrf.exempt(_api_templates_bp)
csrf.exempt(_api_inspections_bp)
csrf.exempt(_api_issues_bp)
csrf.exempt(_api_photos_bp)
csrf.exempt(_api_notifications_bp)
register_api(app)
# ── Error handler: 413 Request Entity Too Large ───────────────────────
+5
View File
@@ -5,6 +5,7 @@ Registers the /api/v1 blueprint group.
Phase A: /api/v1/facilities/*, /api/v1/templates/*
Phase B: /api/v1/inspections/*, /api/v1/issues/*, /api/v1/photos/*
Phase C: /api/v1/notifications/*
"""
from flask import Blueprint
@@ -37,4 +38,8 @@ def register_api(app):
api_bp.register_blueprint(issues_bp)
api_bp.register_blueprint(photos_bp)
# Phase C: Notification polling
from app.api.notifications import bp as notifications_bp
api_bp.register_blueprint(notifications_bp)
app.register_blueprint(api_bp)
+1
View File
@@ -50,6 +50,7 @@ def _user_payload(user: User) -> dict:
return {
'id': user.id,
'username': user.username,
'full_name': user.full_name or '',
'email': user.email,
'role': user.role,
'created_at': user.created_at.isoformat() if user.created_at else None,
+48 -5
View File
@@ -50,6 +50,29 @@ def _parse_datetime(value):
def _inspection_payload(inspection):
"""Serialize an Inspection to the dict returned in API responses."""
# Extract form responses from the notes JSON blob.
# Mobile submissions store form data as {"_form_data": {...}, "_inspector_notes": "..."}.
# Web submissions store form data in the form_data column directly.
form_data = {}
inspector_notes = ''
if inspection.notes:
try:
notes_obj = json.loads(inspection.notes)
if isinstance(notes_obj, dict):
form_data = notes_obj.get('_form_data', {}) or {}
inspector_notes = notes_obj.get('_inspector_notes', '') or ''
except (json.JSONDecodeError, TypeError):
pass
# Fallback: web-created inspections store responses in form_data column
if not form_data and inspection.form_data:
form_data = inspection.form_data if isinstance(inspection.form_data, dict) else {}
# Include the template's form_schema so the iPad can render history
# without needing a locally cached copy of the template.
form_schema = []
if inspection.template:
form_schema = inspection.template.get_form_schema()
return {
'id': inspection.id,
'template_id': inspection.template_id,
@@ -59,12 +82,15 @@ def _inspection_payload(inspection):
'area_id': inspection.area_id,
'area_name': inspection.area.name if inspection.area else None,
'status': inspection.status,
'overall_score': inspection.overall_score,
'overall_score': float(inspection.overall_score) if inspection.overall_score is not None else None,
'inspection_date': inspection.inspection_date.isoformat()
if inspection.inspection_date else None,
'completed_at': inspection.completed_at.isoformat()
if inspection.completed_at else None,
'mobile_local_id': inspection.mobile_local_id,
'form_data': form_data,
'form_schema': form_schema,
'inspector_notes': inspector_notes,
# ── Follow-up / re-inspection fields ──────────────────────────────
'follow_up_required': inspection.follow_up_required,
'follow_up_note': inspection.follow_up_note,
@@ -264,6 +290,12 @@ def create_inspection():
# follow_up_required on the parent automatically. This mirrors the web
# list view's implicit logic (which hides the badge when follow_ups.any())
# and ensures the History API response reflects the resolved state.
# Capture parent label strings before commit while ORM objects are loaded.
# log_action() for the parent update must fire AFTER db.session.commit() to
# avoid audit.py's internal commit() persisting the parent flag change before
# the new inspection row is committed — a partial state that would be incorrect
# if the main commit subsequently failed.
_parent_log_args = None
if parent_inspection_id and status == 'completed':
parent_insp = db.session.get(Inspection, parent_inspection_id)
if parent_insp and parent_insp.follow_up_required:
@@ -273,10 +305,13 @@ def create_inspection():
'by_inspection_id=%d | user=%s',
parent_inspection_id, inspection.id, user.username,
)
log_action(ACTION_UPDATE, 'Inspection', parent_inspection_id,
f'{parent_insp.template.name} @ {parent_insp.facility.name}',
f'follow_up_required=False (cleared by re-inspection '
f'#{inspection.id} via mobile API)')
# Snapshot label strings now — ORM objects may be expired after commit
_parent_log_args = (
parent_inspection_id,
f'{parent_insp.template.name} @ {parent_insp.facility.name}',
f'follow_up_required=False (cleared by re-inspection '
f'#{inspection.id} via mobile API)',
)
# ── Notifications ─────────────────────────────────────────────────────
if status == 'completed':
@@ -304,6 +339,14 @@ def create_inspection():
db.session.commit()
# ── Post-commit audit logging ──────────────────────────────────────────
# All log_action() calls must come AFTER db.session.commit() because
# audit.py calls db.session.commit() internally. Calling it before the
# main commit would persist the audit row (and any dirty ORM state) before
# the primary transaction completes.
if _parent_log_args:
log_action(ACTION_UPDATE, 'Inspection', *_parent_log_args)
log_action(ACTION_CREATE, 'Inspection', inspection.id,
f'{template.name} @ {facility.name}',
f'source=mobile; status={status}; score={overall_score}; '
+105 -20
View File
@@ -3,6 +3,10 @@ app/api/issues.py
-----------------
Mobile API endpoint for submitting issues from the iPad app.
GET /api/v1/issues
Returns issues assigned to the authenticated inspector (or all for admin/director).
Used by the iPad to display assigned issues that were created via the web portal.
POST /api/v1/issues
Creates a new issue record.
Accepts a mobile_local_id for idempotency.
@@ -40,7 +44,100 @@ _VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
_VALID_STATUSES = {'open', 'in_progress', 'resolved', 'pending_verification'}
# ── Create Issue ──────────────────────────────────────────────────────────────
def _issue_payload(issue):
"""Serialise an Issue to the dict returned in list/detail responses."""
facility = issue.resolved_facility
return {
'id': issue.id,
'status': issue.status,
'severity': issue.severity,
'description': issue.description,
'assigned_to': issue.assigned_to,
'facility_id': facility.id if facility else None,
'facility_name': facility.name if facility else None,
'reported_at': issue.reported_at.isoformat() if issue.reported_at else None,
'resolved_at': issue.resolved_at.isoformat() if issue.resolved_at else None,
'mobile_local_id': issue.mobile_local_id,
# photo_path is the primary issue photo; result_photos are resolution photos.
# Both are relative paths from the server static root.
'photo_path': issue.photo_path or None,
'result_photos': issue.result_photos or [],
}
# ── List Assigned Issues ──────────────────────────────────────────────────────
@bp.route('/issues', methods=['GET'])
@jwt_required
def list_issues():
"""
Return active issues for the authenticated user.
All roles see all non-resolved issues (inspectors included) so the iPad
shows the full picture of open work at their facilities.
A ?status= filter can be used to override the default exclusion.
Query parameters
----------------
status str Filter by status. Omit to get all non-resolved issues.
limit int Default 100, max 200.
offset int Default 0.
Response 200
------------
{
"ok": true,
"data": {
"issues": [...],
"total": 12,
"limit": 100,
"offset": 0
}
}
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
limit = min(int(request.args.get('limit', 100)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
query = Issue.query
if user.role == 'inspector':
# Inspectors see issues assigned to them OR issues they reported.
# The reported_by path covers issues created on the iPad that haven't
# been assigned yet (assigned_to is NULL until a director assigns them).
# Pre-phase18 rows with reported_by = NULL still surface via assigned_to.
query = query.filter(
db.or_(
Issue.assigned_to == user.id,
Issue.reported_by == user.id,
)
)
else:
# Broader roles: exclude resolved by default so the list stays manageable
status_filter = request.args.get('status')
if status_filter:
query = query.filter(Issue.status == status_filter)
else:
query = query.filter(Issue.status != 'resolved')
total = query.count()
issues = (
query
.order_by(Issue.reported_at.desc())
.offset(offset)
.limit(limit)
.all()
)
payload = [_issue_payload(i) for i in issues]
logger.info('API ISSUES | list | user=%s | count=%d | total=%d',
user.username, len(payload), total)
return api_ok({'issues': payload, 'total': total, 'limit': limit, 'offset': offset})
@bp.route('/issues', methods=['POST'])
@jwt_required
@@ -115,6 +212,7 @@ def create_issue():
photo_path = data.get('photo_path') or None,
status = 'open',
reported_at = now_eastern(),
reported_by = user.id,
mobile_local_id = mobile_local_id,
)
db.session.add(issue)
@@ -165,9 +263,7 @@ def get_issue(issue_id):
Return current status, severity, description, assigned_to, and facility
for a single issue.
Access:
- admin / director / project_manager : any issue
- inspector : only issues where assigned_to == current user
Access: all allowed roles may fetch any issue.
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
@@ -177,21 +273,10 @@ def get_issue(issue_id):
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector' and issue.assigned_to != user.id:
if user.role == 'inspector' and issue.assigned_to != user.id and issue.reported_by != user.id:
return api_error('Access denied', 403)
facility = issue.resolved_facility
return api_ok({
'id': issue.id,
'status': issue.status,
'severity': issue.severity,
'description': issue.description,
'assigned_to': issue.assigned_to,
'facility_id': facility.id if facility else None,
'facility_name': facility.name if facility else None,
'reported_at': issue.reported_at.isoformat() if issue.reported_at else None,
'resolved_at': issue.resolved_at.isoformat() if issue.resolved_at else None,
})
return api_ok(_issue_payload(issue))
# ── Update Issue Status ───────────────────────────────────────────────────────
@@ -218,8 +303,8 @@ def update_issue_status(issue_id):
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector' and issue.assigned_to != user.id:
return api_error('Access denied — you can only update issues assigned to you', 403)
if user.role == 'inspector' and issue.assigned_to != user.id and issue.reported_by != user.id:
return api_error('Access denied — you can only update issues assigned to or reported by you', 403)
data = request.get_json(silent=True) or {}
new_status = (data.get('status') or '').strip().lower()
@@ -249,4 +334,4 @@ def update_issue_status(issue_id):
logger.info('API ISSUES | status_updated | issue_id=%d | %s%s | user=%s',
issue.id, old_status, new_status, user.username)
return api_ok({'issue_id': issue.id, 'status': issue.status})
return api_ok({'issue_id': issue.id, 'status': issue.status})
+165
View File
@@ -0,0 +1,165 @@
"""
app/api/notifications.py
------------------------
Mobile API endpoint for polling in-app notifications.
GET /api/v1/notifications
Returns unread notifications for the authenticated user.
Accepts ?since=<ISO 8601> to fetch only notifications created after
that datetime — used by the iPad poller to avoid re-delivering already-
seen alerts. Returns a maximum of 50 notifications per call.
PATCH /api/v1/notifications/mark-read
Marks a list of notification IDs as read.
Request JSON: { "ids": [1, 2, 3] }
"""
import logging
from datetime import datetime
import sqlalchemy.exc
from flask import Blueprint, request, g
from app import db
from app.models.notification import Notification
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
logger = logging.getLogger(__name__)
bp = Blueprint('api_notifications', __name__)
def _parse_since(value):
"""Parse ?since= ISO 8601 string; return None on failure."""
if not value:
return None
for fmt in ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%d'):
try:
return datetime.strptime(value, fmt)
except (ValueError, TypeError):
pass
return None
def _build_payload(notifications, has_event_type):
"""Serialise notification rows to dicts. Works before and after phase17 migration."""
rows = []
for n in notifications:
rows.append({
'id': n.id,
'title': n.title,
'body': n.body,
'event_type': (n.event_type if has_event_type else None),
'issue_id': n.issue_id,
'created_at': n.created_at.strftime('%Y-%m-%dT%H:%M:%S'),
})
return rows
@bp.route('/notifications', methods=['GET'])
@jwt_required
def list_notifications():
"""
Return unread notifications for the authenticated user.
Query parameters
----------------
since ISO 8601 datetime Only return notifications created after this time.
limit int (default 50, max 50)
Response 200
------------
{ "ok": true, "data": { "notifications": [...], "count": N } }
"""
user = g.api_user
since = _parse_since(request.args.get('since'))
limit = min(int(request.args.get('limit', 50)), 50)
def _run_orm():
q = Notification.query.filter_by(user_id=user.id, is_read=False)
if since:
q = q.filter(Notification.created_at > since)
return q.order_by(Notification.created_at.asc()).limit(limit).all()
# Attempt the ORM query (works after phase17 migration runs).
# If the event_type column does not yet exist in the DB, MySQL raises
# OperationalError: Unknown column 'notifications.event_type' in SELECT.
# getattr() does NOT protect against this — the failure is at the SQL layer.
# The fallback raw-SQL query selects only the pre-phase17 columns so the
# endpoint stays functional before the migration runs.
try:
notifications = _run_orm()
has_event_type = True
except sqlalchemy.exc.OperationalError:
db.session.rollback()
sql_parts = (
"SELECT id, title, body, issue_id, is_read, created_at "
"FROM notifications "
"WHERE user_id = :uid AND is_read = 0 "
)
params = {'uid': user.id, 'lim': limit}
if since:
sql_parts += "AND created_at > :since "
params['since'] = since
sql_parts += "ORDER BY created_at ASC LIMIT :lim"
rows = db.session.execute(db.text(sql_parts), params).fetchall()
class _Row:
"""Minimal shim so _build_payload works with raw SQL rows."""
__slots__ = ('id', 'title', 'body', 'issue_id', 'created_at')
def __init__(self, r):
self.id = r[0]
self.title = r[1]
self.body = r[2]
self.issue_id = r[3]
self.created_at = r[5]
notifications = [_Row(r) for r in rows]
has_event_type = False
payload = _build_payload(notifications, has_event_type)
logger.info('API NOTIFICATIONS | list | user=%s | since=%s | count=%d | has_event_type=%s',
user.username, since, len(payload), has_event_type)
return api_ok({'notifications': payload, 'count': len(payload)})
@bp.route('/notifications/mark-read', methods=['PATCH'])
@jwt_required
def mark_read():
"""
Mark a list of notification IDs as read.
Request JSON: { "ids": [1, 2, 3] }
Response 200
------------
{ "ok": true, "data": { "marked": 3 } }
"""
user = g.api_user
data = request.get_json(silent=True) or {}
ids = data.get('ids') or []
if not isinstance(ids, list):
return api_error('ids must be a list', 400)
if ids:
updated = (
Notification.query
.filter(
Notification.id.in_(ids),
Notification.user_id == user.id,
)
.all()
)
for n in updated:
n.is_read = True
db.session.commit()
count = len(updated)
else:
count = 0
logger.info('API NOTIFICATIONS | mark_read | user=%s | count=%d', user.username, count)
return api_ok({'marked': count})
+6
View File
@@ -54,6 +54,11 @@ class Issue(db.Model):
photo_path = db.Column(db.String(255))
status = db.Column(db.Enum('open', 'in_progress', 'resolved', 'pending_verification'), default='open')
assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'))
# Set at creation time to the user who filed the issue (inspector or admin).
# Nullable for backward compatibility — pre-phase18 rows will be NULL.
# Used by the mobile API to return issues the inspector created but hasn't
# been assigned yet (assigned_to is NULL until a director assigns them).
reported_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
reported_at = db.Column(db.DateTime, default=now_eastern)
resolved_at = db.Column(db.DateTime)
result_notes = db.Column(db.Text)
@@ -75,6 +80,7 @@ class Issue(db.Model):
# with that backref at mapper configuration time (CLAUDE.md rule 31 revised).
facility = db.relationship('Facility', foreign_keys=[facility_id], backref='direct_issues')
assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues')
reporter = db.relationship('User', foreign_keys=[reported_by], backref='reported_issues')
verifier = db.relationship('User', foreign_keys=[verified_by], backref='verified_issues')
comments = db.relationship('IssueComment', backref='issue', lazy='dynamic',
order_by='IssueComment.created_at',
+8
View File
@@ -12,6 +12,9 @@ EVENT_ISSUE_COMMENT = 'issue_comment'
EVENT_ISSUE_FOLLOW = 'issue_follow_update'
EVENT_INSPECTION_DONE = 'inspection_completed'
EVENT_SLA_ALERT = 'sla_alert'
# Fired when an issue is flagged during an inspection (web or mobile).
# Listed here so users can configure email preferences for this event.
EVENT_ISSUE_FLAGGED = 'issue_flagged'
# ── Customer portal events ─────────────────────────────────────────────────
# Fired when an inspection completes or an issue is created/updated at a
@@ -25,6 +28,7 @@ ALL_EVENT_TYPES = {
EVENT_ISSUE_STATUS: 'Issue status changed',
EVENT_ISSUE_COMMENT: 'New comment on issue',
EVENT_ISSUE_FOLLOW: 'Updates on followed issues',
EVENT_ISSUE_FLAGGED: 'Issue flagged (from inspection)',
EVENT_INSPECTION_DONE: 'Inspection completed',
EVENT_SLA_ALERT: 'SLA at-risk / breached alerts',
# Customer-facing — only relevant for customer role accounts
@@ -53,6 +57,10 @@ class Notification(db.Model):
issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=True)
inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id', ondelete='CASCADE'), nullable=True)
# Event type — stored for mobile API polling so the iPad can categorise alerts.
# Added phase17; NULL for notifications created before the migration.
event_type = db.Column(db.String(50), nullable=True)
# Digest tracking: set to True when created, cleared after digest email sent
digest_pending = db.Column(db.Boolean, default=False, nullable=False, index=True)
+4 -4
View File
@@ -23,7 +23,7 @@ issue_assigned : admin ✗ director ✗ inspector ✗ pm ✗ cust
issue_status : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_comment : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_follow_update : admin ✗ director ✗ inspector ✗ pm ✗ customer ✗ (followers implicit)
issue_flagged : admin director inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_flagged : admin director inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_created : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_updated_customer : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓
verification_requested : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗
@@ -113,8 +113,8 @@ MATRIX_DEFAULTS = {
('issue_follow_update', 'customer'): False,
('issue_follow_update', 'custom'): False,
# issue_flagged (from inspection)
('issue_flagged', 'admin'): False,
('issue_flagged', 'director'): False,
('issue_flagged', 'admin'): True,
('issue_flagged', 'director'): True,
('issue_flagged', 'inspector'): False,
('issue_flagged', 'project_manager'): False,
('issue_flagged', 'customer'): True,
@@ -216,4 +216,4 @@ def get_custom_emails_for(event_type: str) -> list:
).first()
if row is None:
return []
return row.get_custom_emails()
return row.get_custom_emails()
+19
View File
@@ -730,6 +730,7 @@ def flag_issue(inspection_id):
status = 'open',
assigned_to = form.assigned_to.data or None,
reported_at = now_eastern(),
reported_by = current_user.id,
)
db.session.add(issue)
@@ -873,6 +874,24 @@ def flag_followup(inspection_id):
inspection.follow_up_note = note
db.session.commit()
# Notify the original inspector so they see it on the iPad
inspector = db.session.get(User, inspection.inspector_id)
if inspector and inspector.id != current_user.id:
note_suffix = f' Note: {note}' if note else ''
notify(
recipient = inspector,
title = f'Follow-Up Required: Inspection #{inspection_id}',
body = (
f'{current_user.username} has requested a follow-up re-inspection '
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
),
link = url_for('inspections.view', inspection_id=inspection_id),
inspection_id = inspection_id,
event_type = EVENT_INSPECTION_DONE,
send_email = True,
)
db.session.commit()
current_app.logger.info(
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r',
inspection_id, current_user.username, note,
+12 -7
View File
@@ -70,12 +70,18 @@ class _SLAFilteredPage:
@login_required
def index():
page = request.args.get('page', 1, type=int)
q = Issue.query.order_by(Issue.reported_at.desc())
# outerjoin Area once here so both the customer-scope filter and the
# facility_filter block can reference Area.facility_id without a cartesian
# product. Issues with no area_id get NULL for all Area columns (outer join).
q = (
Issue.query
.outerjoin(Area, Issue.area_id == Area.id)
.order_by(Issue.reported_at.desc())
)
if current_user.role == 'inspector':
q = q.filter(Issue.assigned_to == current_user.id)
elif current_user.role == 'customer':
from app.models.facility import Area
customer_facility_ids = get_customer_scope(current_user)
if not customer_facility_ids:
q = q.filter(False)
@@ -86,11 +92,10 @@ def index():
Issue.facility_id.in_(customer_facility_ids),
db.and_(
Issue.area_id.isnot(None),
Issue.area_id == Area.id,
Area.facility_id.in_(customer_facility_ids)
Area.facility_id.in_(customer_facility_ids),
)
)
).outerjoin(Area, Issue.area_id == Area.id)
)
severity_filter = request.args.get('severity', '')
status_filter = request.args.get('status', '')
@@ -106,8 +111,7 @@ def index():
q = q.filter(
db.or_(
Issue.facility_id == fid,
db.and_(Issue.area_id.isnot(None),
Area.facility_id == fid)
Area.facility_id == fid,
)
)
# SLA filter — SLA status is computed in Python (not a DB column).
@@ -447,6 +451,7 @@ def create():
status = 'open',
assigned_to = form.assigned_to.data or None,
reported_at = now_eastern(),
reported_by = current_user.id,
)
db.session.add(issue)
db.session.commit()
+6 -1
View File
@@ -44,6 +44,11 @@ def log_action(action: str,
"""
Write a single AuditLog row. Safe to call from any request context.
This function calls db.session.commit() internally.
Always call it AFTER the primary db.session.commit() for the business
transaction never before. Calling it mid-transaction will commit any
dirty ORM state accumulated in the session up to that point.
Parameters
----------
action : One of the ACTION_* constants (or a custom string 50 chars).
@@ -90,4 +95,4 @@ def log_action(action: str,
try:
db.session.rollback()
except Exception:
pass
pass
+1
View File
@@ -207,6 +207,7 @@ def notify(
link = link,
issue_id = issue_id,
inspection_id = inspection_id,
event_type = event_type,
is_read = False,
digest_pending = hold_for_digest,
)