Jun 26 update to the latest codes
This commit is contained in:
@@ -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
@@ -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})
|
||||
|
||||
@@ -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})
|
||||
@@ -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
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user