05/02 Phase B
This commit is contained in:
+9
-8
@@ -147,24 +147,25 @@ def create_app(config_name='default'):
|
|||||||
app.register_blueprint(customers.bp)
|
app.register_blueprint(customers.bp)
|
||||||
app.register_blueprint(scheduled_reports.bp)
|
app.register_blueprint(scheduled_reports.bp)
|
||||||
|
|
||||||
# ── Mobile API (Phase 7 / Phase A) ──────────────────────────────────────
|
# ── Mobile API (Phase 7 / Phase A / Phase B) ─────────────────────────────
|
||||||
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
|
||||||
#
|
#
|
||||||
# Flask-WTF's _is_exempt() checks whether the *leaf* blueprint object
|
# Flask-WTF's _is_exempt() checks whether the *leaf* blueprint object
|
||||||
# (e.g. api_auth, api_facilities) is in _exempt_blueprints. Exempting
|
# is in _exempt_blueprints. Exempting the parent api_bp does NOT cascade
|
||||||
# the parent api_bp alone does NOT cascade to its sub-blueprints because
|
# to sub-blueprints. Each child blueprint must be exempted individually.
|
||||||
# request.blueprint returns the dotted child name ('api.api_auth'), and
|
|
||||||
# current_app.blueprints maps that to the child Blueprint object — which
|
|
||||||
# is never equal to the parent object in the exempt set.
|
|
||||||
#
|
|
||||||
# Fix: import every child blueprint object and exempt each one explicitly.
|
|
||||||
from app.api import register_api, api_bp
|
from app.api import register_api, api_bp
|
||||||
from app.api.auth import bp as _api_auth_bp
|
from app.api.auth import bp as _api_auth_bp
|
||||||
from app.api.facilities import bp as _api_facilities_bp
|
from app.api.facilities import bp as _api_facilities_bp
|
||||||
from app.api.templates import bp as _api_templates_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
|
||||||
csrf.exempt(_api_auth_bp)
|
csrf.exempt(_api_auth_bp)
|
||||||
csrf.exempt(_api_facilities_bp)
|
csrf.exempt(_api_facilities_bp)
|
||||||
csrf.exempt(_api_templates_bp)
|
csrf.exempt(_api_templates_bp)
|
||||||
|
csrf.exempt(_api_inspections_bp)
|
||||||
|
csrf.exempt(_api_issues_bp)
|
||||||
|
csrf.exempt(_api_photos_bp)
|
||||||
register_api(app)
|
register_api(app)
|
||||||
|
|
||||||
# ── Error handler: 413 Request Entity Too Large ───────────────────────
|
# ── Error handler: 413 Request Entity Too Large ───────────────────────
|
||||||
|
|||||||
+13
-45
@@ -3,70 +3,38 @@ app/api/__init__.py
|
|||||||
-------------------
|
-------------------
|
||||||
Registers the /api/v1 blueprint group.
|
Registers the /api/v1 blueprint group.
|
||||||
|
|
||||||
All mobile API routes live under the prefix /api/v1/.
|
Phase A: /api/v1/facilities/*, /api/v1/templates/*
|
||||||
This module is imported once from app/__init__.py — see the integration
|
Phase B: /api/v1/inspections/*, /api/v1/issues/*, /api/v1/photos/*
|
||||||
instructions at the bottom of this file.
|
|
||||||
|
|
||||||
Blueprint layout
|
|
||||||
----------------
|
|
||||||
/api/v1/auth/login → api_auth.login
|
|
||||||
/api/v1/auth/refresh → api_auth.refresh
|
|
||||||
/api/v1/auth/logout → api_auth.logout
|
|
||||||
/api/v1/auth/me → api_auth.me
|
|
||||||
/api/v1/devices/register → api_auth.register_device
|
|
||||||
|
|
||||||
Phase A (iPad App):
|
|
||||||
/api/v1/facilities → api_facilities.list_facilities
|
|
||||||
/api/v1/facilities/<id>/areas → api_facilities.list_areas
|
|
||||||
/api/v1/templates → api_templates.list_templates
|
|
||||||
/api/v1/templates/<id> → api_templates.get_template
|
|
||||||
|
|
||||||
Future phases will add:
|
|
||||||
/api/v1/inspections → api_inspections.*
|
|
||||||
/api/v1/issues → api_issues.*
|
|
||||||
/api/v1/notifications → api_notifications.*
|
|
||||||
/api/v1/photos/upload → api_photos.*
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
from app.api.errors import register_error_handlers
|
from app.api.errors import register_error_handlers
|
||||||
|
|
||||||
# Parent blueprint — all sub-blueprints registered under this prefix
|
|
||||||
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
|
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
|
||||||
|
|
||||||
# Register JSON error handlers so Flask exceptions within /api/v1/
|
|
||||||
# return JSON instead of HTML error pages.
|
|
||||||
register_error_handlers(api_bp)
|
register_error_handlers(api_bp)
|
||||||
|
|
||||||
|
|
||||||
def register_api(app):
|
def register_api(app):
|
||||||
"""
|
"""
|
||||||
Import and register all API sub-blueprints onto api_bp, then
|
Register all API sub-blueprints.
|
||||||
register api_bp on the Flask app.
|
CSRF exemption for each child blueprint is handled in app/__init__.py.
|
||||||
|
|
||||||
Called once from create_app() in app/__init__.py.
|
|
||||||
|
|
||||||
NOTE: CSRF exemption for each sub-blueprint is handled in app/__init__.py
|
|
||||||
before this function is called, because csrf.exempt() must receive the
|
|
||||||
child Blueprint object directly — exempting the parent api_bp does not
|
|
||||||
cascade to its sub-blueprints.
|
|
||||||
"""
|
"""
|
||||||
# ── Phase 7 (Web): Auth ──────────────────────────────────────────────
|
# Phase 7: Auth
|
||||||
from app.api.auth import bp as auth_bp
|
from app.api.auth import bp as auth_bp
|
||||||
api_bp.register_blueprint(auth_bp)
|
api_bp.register_blueprint(auth_bp)
|
||||||
|
|
||||||
# ── Phase A (iPad): Reference data ──────────────────────────────────
|
# Phase A: Reference data
|
||||||
from app.api.facilities import bp as facilities_bp
|
from app.api.facilities import bp as facilities_bp
|
||||||
from app.api.templates import bp as templates_bp
|
from app.api.templates import bp as templates_bp
|
||||||
api_bp.register_blueprint(facilities_bp)
|
api_bp.register_blueprint(facilities_bp)
|
||||||
api_bp.register_blueprint(templates_bp)
|
api_bp.register_blueprint(templates_bp)
|
||||||
|
|
||||||
# ── Phase B+ (iPad): Inspections, issues, photos ─────────────────────
|
# Phase B: Offline inspection submission
|
||||||
# from app.api.inspections import bp as inspections_bp
|
from app.api.inspections import bp as inspections_bp
|
||||||
# from app.api.issues import bp as issues_bp
|
from app.api.issues import bp as issues_bp
|
||||||
# from app.api.photos import bp as photos_bp
|
from app.api.photos import bp as photos_bp
|
||||||
# api_bp.register_blueprint(inspections_bp)
|
api_bp.register_blueprint(inspections_bp)
|
||||||
# api_bp.register_blueprint(issues_bp)
|
api_bp.register_blueprint(issues_bp)
|
||||||
# api_bp.register_blueprint(photos_bp)
|
api_bp.register_blueprint(photos_bp)
|
||||||
|
|
||||||
app.register_blueprint(api_bp)
|
app.register_blueprint(api_bp)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
app/api/__init__.py
|
||||||
|
-------------------
|
||||||
|
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/*
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
from app.api.errors import register_error_handlers
|
||||||
|
|
||||||
|
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
|
||||||
|
register_error_handlers(api_bp)
|
||||||
|
|
||||||
|
|
||||||
|
def register_api(app):
|
||||||
|
"""
|
||||||
|
Register all API sub-blueprints.
|
||||||
|
CSRF exemption for each child blueprint is handled in app/__init__.py.
|
||||||
|
"""
|
||||||
|
# Phase 7: Auth
|
||||||
|
from app.api.auth import bp as auth_bp
|
||||||
|
api_bp.register_blueprint(auth_bp)
|
||||||
|
|
||||||
|
# Phase A: Reference data
|
||||||
|
from app.api.facilities import bp as facilities_bp
|
||||||
|
from app.api.templates import bp as templates_bp
|
||||||
|
api_bp.register_blueprint(facilities_bp)
|
||||||
|
api_bp.register_blueprint(templates_bp)
|
||||||
|
|
||||||
|
# Phase B: Offline inspection submission
|
||||||
|
from app.api.inspections import bp as inspections_bp
|
||||||
|
from app.api.issues import bp as issues_bp
|
||||||
|
from app.api.photos import bp as photos_bp
|
||||||
|
api_bp.register_blueprint(inspections_bp)
|
||||||
|
api_bp.register_blueprint(issues_bp)
|
||||||
|
api_bp.register_blueprint(photos_bp)
|
||||||
|
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
app/api/issues.py
|
||||||
|
-----------------
|
||||||
|
Mobile API endpoint for submitting issues from the iPad app.
|
||||||
|
|
||||||
|
POST /api/v1/issues
|
||||||
|
Creates a new issue record.
|
||||||
|
Accepts a mobile_local_id for idempotency.
|
||||||
|
Triggers notify_by_matrix() and log_action() identically to the web route.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, request, g, current_app
|
||||||
|
from app import db
|
||||||
|
from app.models.issue import Issue
|
||||||
|
from app.models.facility import Area
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.api.errors import api_ok, api_error
|
||||||
|
from app.api.decorators import jwt_required
|
||||||
|
from app.utils.audit import log_action, ACTION_CREATE
|
||||||
|
from app.utils.notifications import notify_by_matrix
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp = Blueprint('api_issues', __name__)
|
||||||
|
|
||||||
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||||
|
_VALID_SEVERITY = {'low', 'medium', 'high', 'critical'}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Create Issue ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/issues', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def create_issue():
|
||||||
|
"""
|
||||||
|
Create a new issue submitted from the iPad app.
|
||||||
|
|
||||||
|
Idempotency: if mobile_local_id is provided and an issue with that ID
|
||||||
|
already exists, the existing record is returned without duplication.
|
||||||
|
|
||||||
|
Request JSON
|
||||||
|
------------
|
||||||
|
{
|
||||||
|
"inspection_id": 42, // optional
|
||||||
|
"area_id": 12, // required
|
||||||
|
"severity": "high", // "low"|"medium"|"high"|"critical"
|
||||||
|
"description": "...", // required
|
||||||
|
"photo_path": "uploads/...",// optional — already uploaded via /photos/upload
|
||||||
|
"mobile_local_id": "uuid-string" // idempotency key
|
||||||
|
}
|
||||||
|
|
||||||
|
Response 200
|
||||||
|
------------
|
||||||
|
{ "ok": true, "data": { "issue_id": 99, "duplicate": false } }
|
||||||
|
"""
|
||||||
|
user = g.api_user
|
||||||
|
|
||||||
|
if user.role not in _ALLOWED_ROLES:
|
||||||
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
# ── Idempotency check ─────────────────────────────────────────────────
|
||||||
|
mobile_local_id = data.get('mobile_local_id')
|
||||||
|
if mobile_local_id:
|
||||||
|
existing = Issue.query.filter_by(mobile_local_id=mobile_local_id).first()
|
||||||
|
if existing:
|
||||||
|
logger.info('API ISSUES | duplicate | local_id=%s | issue_id=%d | user=%s',
|
||||||
|
mobile_local_id, existing.id, user.username)
|
||||||
|
return api_ok({'issue_id': existing.id, 'duplicate': True})
|
||||||
|
|
||||||
|
# ── Validate ──────────────────────────────────────────────────────────
|
||||||
|
area_id = data.get('area_id')
|
||||||
|
severity = data.get('severity', '').lower()
|
||||||
|
description = (data.get('description') or '').strip()
|
||||||
|
|
||||||
|
if not area_id:
|
||||||
|
return api_error('area_id is required', 400)
|
||||||
|
if severity not in _VALID_SEVERITY:
|
||||||
|
return api_error(f'severity must be one of: {", ".join(sorted(_VALID_SEVERITY))}', 400)
|
||||||
|
if not description:
|
||||||
|
return api_error('description is required', 400)
|
||||||
|
|
||||||
|
area = db.session.get(Area, area_id)
|
||||||
|
if area is None:
|
||||||
|
return api_error('Area not found', 404)
|
||||||
|
|
||||||
|
inspection_id = data.get('inspection_id')
|
||||||
|
if inspection_id:
|
||||||
|
inspection = db.session.get(Inspection, inspection_id)
|
||||||
|
if inspection is None:
|
||||||
|
return api_error('Inspection not found', 404)
|
||||||
|
else:
|
||||||
|
inspection = None
|
||||||
|
|
||||||
|
# ── Create ────────────────────────────────────────────────────────────
|
||||||
|
issue = Issue(
|
||||||
|
inspection_id = inspection_id,
|
||||||
|
area_id = area_id,
|
||||||
|
severity = severity,
|
||||||
|
description = description,
|
||||||
|
photo_path = data.get('photo_path') or None,
|
||||||
|
status = 'open',
|
||||||
|
reported_at = now_eastern(),
|
||||||
|
mobile_local_id = mobile_local_id,
|
||||||
|
)
|
||||||
|
db.session.add(issue)
|
||||||
|
db.session.flush() # get issue.id
|
||||||
|
|
||||||
|
# ── Notifications ─────────────────────────────────────────────────────
|
||||||
|
facility_id = area.facility_id
|
||||||
|
try:
|
||||||
|
from flask import url_for
|
||||||
|
issue_link = url_for('issues.view', issue_id=issue.id, _external=False)
|
||||||
|
except RuntimeError:
|
||||||
|
issue_link = f'/issues/{issue.id}'
|
||||||
|
|
||||||
|
inspection_ref = f'inspection #{inspection_id}' if inspection_id else 'a standalone report'
|
||||||
|
notify_by_matrix(
|
||||||
|
event_type = 'issue_flagged',
|
||||||
|
title = f'New Issue #{issue.id} (Mobile)',
|
||||||
|
body = (
|
||||||
|
f'A new {severity.title()}-severity issue was logged in '
|
||||||
|
f'{area.name} during {inspection_ref}. '
|
||||||
|
f'Description: {description[:120]}'
|
||||||
|
f'{"…" if len(description) > 120 else ""}'
|
||||||
|
),
|
||||||
|
link = issue_link,
|
||||||
|
issue_id = issue.id,
|
||||||
|
facility_id = facility_id,
|
||||||
|
exclude_user_ids = {user.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(ACTION_CREATE, 'Issue', issue.id,
|
||||||
|
f'{severity} issue in {area.name}',
|
||||||
|
f'source=mobile; inspection_id={inspection_id}; '
|
||||||
|
f'local_id={mobile_local_id}')
|
||||||
|
|
||||||
|
logger.info('API ISSUES | created | issue_id=%d | area=%s | severity=%s | user=%s',
|
||||||
|
issue.id, area.name, severity, user.username)
|
||||||
|
|
||||||
|
return api_ok({'issue_id': issue.id, 'duplicate': False})
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
app/api/photos.py
|
||||||
|
-----------------
|
||||||
|
Mobile API endpoint for uploading photos from the iPad app.
|
||||||
|
|
||||||
|
POST /api/v1/photos/upload
|
||||||
|
Accepts a multipart/form-data upload.
|
||||||
|
Saves the file to the server's upload folder.
|
||||||
|
Returns the relative server path used in subsequent inspection/issue submissions.
|
||||||
|
|
||||||
|
The iPad app uploads photos BEFORE submitting the inspection or issue,
|
||||||
|
then includes the returned server_path in the inspection/issue payload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, request, g, current_app
|
||||||
|
from app.api.errors import api_ok, api_error
|
||||||
|
from app.api.decorators import jwt_required
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp = Blueprint('api_photos', __name__)
|
||||||
|
|
||||||
|
_ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||||||
|
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_file(filename: str) -> bool:
|
||||||
|
return (
|
||||||
|
'.' in filename
|
||||||
|
and filename.rsplit('.', 1)[-1].lower() in _ALLOWED_EXTENSIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Upload Photo ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/photos/upload', methods=['POST'])
|
||||||
|
@jwt_required
|
||||||
|
def upload_photo():
|
||||||
|
"""
|
||||||
|
Upload a photo from the iPad app.
|
||||||
|
|
||||||
|
Multipart form fields
|
||||||
|
---------------------
|
||||||
|
file — binary image data (jpg / png / gif)
|
||||||
|
entity_type — "inspection" | "issue" (controls subfolder)
|
||||||
|
|
||||||
|
Response 200
|
||||||
|
------------
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"data": {
|
||||||
|
"server_path": "uploads/inspection_photos/abc123.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
user = g.api_user
|
||||||
|
|
||||||
|
if user.role not in _ALLOWED_ROLES:
|
||||||
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return api_error('No file provided', 400)
|
||||||
|
|
||||||
|
file_obj = request.files['file']
|
||||||
|
entity_type = request.form.get('entity_type', 'inspection')
|
||||||
|
|
||||||
|
if not file_obj or not file_obj.filename:
|
||||||
|
return api_error('Empty file', 400)
|
||||||
|
|
||||||
|
if not _allowed_file(file_obj.filename):
|
||||||
|
return api_error(
|
||||||
|
f'File type not allowed. Accepted: {", ".join(sorted(_ALLOWED_EXTENSIONS))}',
|
||||||
|
400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine destination subfolder
|
||||||
|
if entity_type == 'issue':
|
||||||
|
subfolder = 'issue_photos'
|
||||||
|
else:
|
||||||
|
subfolder = 'inspection_photos'
|
||||||
|
|
||||||
|
ext = file_obj.filename.rsplit('.', 1)[-1].lower()
|
||||||
|
filename = f'{uuid.uuid4().hex}.{ext}'
|
||||||
|
|
||||||
|
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder)
|
||||||
|
os.makedirs(dest_dir, exist_ok=True)
|
||||||
|
|
||||||
|
dest_path = os.path.join(dest_dir, filename)
|
||||||
|
file_obj.save(dest_path)
|
||||||
|
|
||||||
|
server_path = f'uploads/{subfolder}/{filename}'
|
||||||
|
|
||||||
|
logger.info('API PHOTOS | uploaded | entity_type=%s | path=%s | user=%s',
|
||||||
|
entity_type, server_path, user.username)
|
||||||
|
|
||||||
|
return api_ok({'server_path': server_path})
|
||||||
@@ -63,6 +63,7 @@ class Inspection(db.Model):
|
|||||||
notes = db.Column(db.Text) # inspector free-text notes
|
notes = db.Column(db.Text) # inspector free-text notes
|
||||||
form_data = db.Column(db.JSON) # filled form field responses {field_id: value}
|
form_data = db.Column(db.JSON) # filled form field responses {field_id: value}
|
||||||
completed_at = db.Column(db.DateTime)
|
completed_at = db.Column(db.DateTime)
|
||||||
|
mobile_local_id = db.Column(db.String(64), nullable=True, index=True) # idempotency key for mobile submissions
|
||||||
|
|
||||||
# ── Re-inspection / follow-up workflow ────────────────────────────────
|
# ── Re-inspection / follow-up workflow ────────────────────────────────
|
||||||
parent_inspection_id = db.Column(
|
parent_inspection_id = db.Column(
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class Issue(db.Model):
|
|||||||
# Tracks which SLA alert level has already been notified so cron runs
|
# Tracks which SLA alert level has already been notified so cron runs
|
||||||
# don't fire duplicate notifications. Values: None / 'at_risk' / 'breached'
|
# don't fire duplicate notifications. Values: None / 'at_risk' / 'breached'
|
||||||
sla_notified = db.Column(db.String(10), nullable=True, default=None)
|
sla_notified = db.Column(db.String(10), nullable=True, default=None)
|
||||||
|
mobile_local_id = db.Column(db.String(64), nullable=True, index=True) # idempotency key for mobile submissions
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues')
|
assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues')
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Add mobile_local_id to inspections and issues for mobile sync idempotency.
|
||||||
|
|
||||||
|
Phase B — iPad offline inspection submission.
|
||||||
|
|
||||||
|
Each inspection or issue submitted from the iPad app carries a UUID generated
|
||||||
|
on the device (mobile_local_id). The server checks this field before creating
|
||||||
|
a new record so that network retries never produce duplicate rows.
|
||||||
|
|
||||||
|
Revision ID: phase_b_mobile_local_id
|
||||||
|
Revises: phase9_user_full_name
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'phase_b_mobile_local_id'
|
||||||
|
down_revision = 'phase9_user_full_name'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ── inspections.mobile_local_id ───────────────────────────────────────
|
||||||
|
with op.batch_alter_table('inspections') as batch_op:
|
||||||
|
# Check if column already exists (safe re-run)
|
||||||
|
conn = op.get_bind()
|
||||||
|
columns = [row[1] for row in conn.execute(sa.text('PRAGMA table_info(inspections)')).fetchall()] \
|
||||||
|
if conn.dialect.name == 'sqlite' \
|
||||||
|
else [row['COLUMN_NAME'] for row in conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
|
||||||
|
"WHERE TABLE_NAME = 'inspections' AND TABLE_SCHEMA = DATABASE()"
|
||||||
|
)
|
||||||
|
).mappings()]
|
||||||
|
|
||||||
|
if 'mobile_local_id' not in columns:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('mobile_local_id', sa.String(64), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index for fast idempotency lookups
|
||||||
|
op.execute(sa.text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_inspections_mobile_local_id "
|
||||||
|
"ON inspections(mobile_local_id)"
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── issues.mobile_local_id ────────────────────────────────────────────
|
||||||
|
with op.batch_alter_table('issues') as batch_op:
|
||||||
|
conn = op.get_bind()
|
||||||
|
columns = [row[1] for row in conn.execute(sa.text('PRAGMA table_info(issues)')).fetchall()] \
|
||||||
|
if conn.dialect.name == 'sqlite' \
|
||||||
|
else [row['COLUMN_NAME'] for row in conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
|
||||||
|
"WHERE TABLE_NAME = 'issues' AND TABLE_SCHEMA = DATABASE()"
|
||||||
|
)
|
||||||
|
).mappings()]
|
||||||
|
|
||||||
|
if 'mobile_local_id' not in columns:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column('mobile_local_id', sa.String(64), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute(sa.text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_issues_mobile_local_id "
|
||||||
|
"ON issues(mobile_local_id)"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table('inspections') as batch_op:
|
||||||
|
batch_op.drop_column('mobile_local_id')
|
||||||
|
|
||||||
|
with op.batch_alter_table('issues') as batch_op:
|
||||||
|
batch_op.drop_column('mobile_local_id')
|
||||||
Reference in New Issue
Block a user