Jun 26 update to the latest codes

This commit is contained in:
2026-06-26 12:09:49 -04:00
parent 77678ed724
commit 5d95cdbbfc
15 changed files with 711 additions and 36 deletions
+3
View File
@@ -50,4 +50,7 @@ def register_api(app):
from app.api.comments import bp as comments_bp
api_bp.register_blueprint(comments_bp)
from app.api.devices import bp as devices_bp
api_bp.register_blueprint(devices_bp)
app.register_blueprint(api_bp)
+29 -22
View File
@@ -279,18 +279,20 @@ def me():
@jwt_required
def register_device():
"""
Register or update the APNs device token for the authenticated user.
Register or update device info for the authenticated user.
Called on every app launch after authentication so the server always
has the current token (APNs rotates tokens periodically).
Called on every app launch so the server always has the current
app version and iOS version for the admin Devices page.
apns_token is optional (empty string when APNs push is not configured).
Request JSON
------------
{
"device_id": "<UIDevice.identifierForVendor>",
"apns_token": "<hex_string_from_didRegisterForRemoteNotifications>",
"device_name": "John's iPhone", // optional
"app_version": "1.0.3" // optional
"device_id": "<stable UUID from Keychain>",
"device_name": "Nguyen\'s iPad",
"app_version": "1.0.3",
"ios_version": "18.3.1",
"apns_token": ""
}
Response 200
@@ -302,32 +304,37 @@ def register_device():
apns_token = (data.get('apns_token') or '').strip()[:200]
device_name = (data.get('device_name') or '').strip()[:100] or None
app_version = (data.get('app_version') or '').strip()[:20] or None
ios_version = (data.get('ios_version') or '').strip()[:20] or None
if not device_id or not apns_token:
return api_error('device_id and apns_token are required', 400)
if not device_id:
return api_error('device_id is required', 400)
now = now_eastern()
# Upsert: update existing row or insert new one
existing = DeviceToken.query.filter_by(
user_id=g.api_user.id,
device_id=device_id,
).first()
if existing:
existing.apns_token = apns_token
existing.device_name = device_name
existing.app_version = app_version
existing.registered_at = now_eastern()
existing.apns_token = apns_token or existing.apns_token
existing.device_name = device_name or existing.device_name
existing.app_version = app_version or existing.app_version
existing.ios_version = ios_version or existing.ios_version
existing.last_seen_at = now
else:
db.session.add(DeviceToken(
user_id = g.api_user.id,
device_id = device_id,
apns_token = apns_token,
device_name = device_name,
app_version = app_version,
user_id = g.api_user.id,
device_id = device_id,
apns_token = apns_token,
device_name = device_name,
app_version = app_version,
ios_version = ios_version,
last_seen_at = now,
))
db.session.commit()
logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | apns_token=...%s',
g.api_user.username, device_id, apns_token[-6:])
logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | app=%s | ios=%s',
g.api_user.username, device_id[:8], app_version, ios_version)
return api_ok({'registered': True})
return api_ok({'registered': True})
+95
View File
@@ -0,0 +1,95 @@
"""
app/api/devices.py
------------------
Mobile API endpoint for device registration.
POST /api/v1/devices/register
Upserts a device record for the authenticated user.
Called on every app foreground (active scenePhase) so last_seen_at
stays current and the admin can identify stale / outdated installs.
Request JSON
------------
{
"device_id": "stable-uuid-from-keychain", // required
"device_name": "Nguyen's iPad", // UIDevice.current.name
"app_version": "1.2.0", // CFBundleShortVersionString
"ios_version": "18.3.1" // UIDevice.current.systemVersion
}
Response 200
------------
{ "ok": true, "data": { "registered": true } }
"""
import logging
from flask import Blueprint, request, g
from app import db
from app.models.device_registration import DeviceRegistration
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
from app.utils.audit import log_action, ACTION_UPDATE, ACTION_CREATE
from app.utils.time_utils import now_eastern
logger = logging.getLogger(__name__)
bp = Blueprint('api_devices', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
@bp.route('/devices/register', methods=['POST'])
@jwt_required
def register_device():
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
data = request.get_json(silent=True) or {}
device_id = (data.get('device_id') or '').strip()
device_name = (data.get('device_name') or '').strip()[:255]
app_version = (data.get('app_version') or '').strip()[:32]
ios_version = (data.get('ios_version') or '').strip()[:32]
if not device_id:
return api_error('device_id is required', 400)
if len(device_id) > 64:
return api_error('device_id too long', 400)
now = now_eastern()
existing = DeviceRegistration.query.filter_by(device_id=device_id).first()
if existing:
# Update — always refresh last_seen_at and app/ios version
existing.user_id = user.id # re-bind if different user logs in same device
existing.device_name = device_name or existing.device_name
existing.app_version = app_version or existing.app_version
existing.ios_version = ios_version or existing.ios_version
existing.last_seen_at = now
db.session.commit()
log_action(ACTION_UPDATE, 'DeviceRegistration', existing.id,
f'{device_name} v{app_version}',
f'user={user.username}; ios={ios_version}')
logger.info('API DEVICES | updated | device_id=%s | user=%s | app=%s',
device_id[:8], user.username, app_version)
else:
reg = DeviceRegistration(
device_id = device_id,
user_id = user.id,
device_name = device_name,
app_version = app_version,
ios_version = ios_version,
registered_at = now,
last_seen_at = now,
)
db.session.add(reg)
db.session.commit()
log_action(ACTION_CREATE, 'DeviceRegistration', reg.id,
f'{device_name} v{app_version}',
f'user={user.username}; ios={ios_version}')
logger.info('API DEVICES | registered | device_id=%s | user=%s | app=%s',
device_id[:8], user.username, app_version)
return api_ok({'registered': True})
+61
View File
@@ -433,3 +433,64 @@ def update_issue_photos(issue_id):
issue.id, len(new_photos), user.username)
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})
# ── Attach Resolution Photos (mobile) ─────────────────────────────────────────
@bp.route('/issues/<int:issue_id>/result_photos', methods=['PATCH'])
@jwt_required
def update_issue_result_photos(issue_id):
"""
Attach resolution photos to an issue from the mobile app.
Called when an inspector marks an issue resolved and uploads photos
showing the fix. Stored in Issue.result_photos so they appear under
"Resolution Details" on the web — identical to photos uploaded via the
web update form.
Request JSON
------------
{ "result_photos": ["uploads/issue_result_photos/a.jpg", ...] }
Response 200
------------
{ "ok": true, "data": { "issue_id": 99, "result_photos_count": 2 } }
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
issue = db.session.get(Issue, issue_id)
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector':
fids = get_inspector_scope(user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
return api_error('Access denied', 403)
data = request.get_json(silent=True) or {}
raw = data.get('result_photos')
if not isinstance(raw, list):
return api_error('result_photos must be a list of path strings', 400)
new_photos = [p for p in raw if isinstance(p, str) and p.strip()]
if not new_photos:
return api_error('result_photos must contain at least one valid path', 400)
# Merge idempotently with any existing result_photos
existing = issue.result_photos or []
merged = existing + [p for p in new_photos if p not in existing]
issue.result_photos = merged
db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id,
f'result_photos updated (+{len(new_photos)} photos)',
f'source=mobile; updated_by={user.username}')
logger.info('API ISSUES | result_photos_updated | issue_id=%d | added=%d | user=%s',
issue.id, len(new_photos), user.username)
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})
+3 -1
View File
@@ -46,7 +46,7 @@ def upload_photo():
Multipart form fields
---------------------
file — binary image data (jpg / png / gif)
entity_type — "inspection" | "issue" (controls subfolder)
entity_type — "inspection" | "issue" | "issue_result" (controls subfolder)
Response 200
------------
@@ -80,6 +80,8 @@ def upload_photo():
# Determine destination subfolder
if entity_type == 'issue':
subfolder = 'issue_photos'
elif entity_type == 'issue_result':
subfolder = 'issue_result_photos'
else:
subfolder = 'inspection_photos'