diff --git a/build/cloudbuild.yaml b/build/cloudbuild.yaml index 4247764..141bb66 100644 --- a/build/cloudbuild.yaml +++ b/build/cloudbuild.yaml @@ -9,9 +9,9 @@ steps: - '--build-arg' - 'COCKROACH_DB_CERT_URL=${_COCKROACH_DB_CERT_URL}' - '-t' - - 'africa-south1-docker.pkg.dev/$PROJECT_ID/handees-dev/handees-backend:1.1' + - 'africa-south1-docker.pkg.dev/$PROJECT_ID/handees-dev/handees-backend:1.2' - '-f' - 'docker/Dockerfile' - '.' images: - - 'africa-south1-docker.pkg.dev/$PROJECT_ID/handees-dev/handees-backend:1.1' \ No newline at end of file + - 'africa-south1-docker.pkg.dev/$PROJECT_ID/handees-dev/handees-backend:1.2' \ No newline at end of file diff --git a/core/api/auth/auth_helper.py b/core/api/auth/auth_helper.py index 0e29e85..6133aba 100644 --- a/core/api/auth/auth_helper.py +++ b/core/api/auth/auth_helper.py @@ -186,6 +186,7 @@ def wrapped(*args, **kwargs): token = request.headers['access-token'] uid = verify_token(token) print(uid) + user = User.query.filter_by(user_id=uid).first() logger.debug("user with data: {} still has access".format(uid)) user = User.query.filter_by(user_id=uid).first() except (Exception or Exception in excs or auth.ExpiredIdTokenError) as e: diff --git a/core/api/bookings/events/artisan.py b/core/api/bookings/events/artisan.py index b68c6e9..ef3bdd1 100644 --- a/core/api/bookings/events/artisan.py +++ b/core/api/bookings/events/artisan.py @@ -137,7 +137,7 @@ def on_connect(auth): @socketio.on('disconnect', namespace='/artisan') -def on_disconnect(): +def on_disconnect(reason=None): dropping_sid = request.sid print(f"==== Disconnecting: {dropping_sid} ====") if redis_4.exists(dropping_sid): @@ -159,7 +159,7 @@ def on_disconnect(): @parse_event_data @valid_auth_required def update_location(uid, data): - print("EXECUTING ... for {}".format(uid)) + print("EXECUTING ... for {}".format(uid), flush=True) # add coords to redis redis_5.geoadd( name=data['job_category'], @@ -293,7 +293,7 @@ def accept_offer(uid, data): room = data['booking_id'] data['uid'] = uid lock_key = f"lock:booking:{room}" - with redis_.lock(lock_key, blocking_timeout=1): + with redis_.lock(lock_key, ttl=5000): if redis_.exists(room): # read and remove from queue bk_info = parse_str_data(redis_.get(room)) @@ -317,7 +317,6 @@ def accept_offer(uid, data): only=( 'created_at', 'user_profile', - 'rating', 'job_category', 'jobs_completed', 'hourly_rate' @@ -490,7 +489,7 @@ def handle_location_arrival(uid, data): ) payload = { - 'payload': messages.ARTISAN_ARRIVES, + 'payload': {'message': messages.ARTISAN_ARRIVES}, 'recipient': redis_4.hget( 'booking_id_to_uid', data['booking_id'] diff --git a/core/api/bookings/events/customer.py b/core/api/bookings/events/customer.py index f733e76..008b97e 100644 --- a/core/api/bookings/events/customer.py +++ b/core/api/bookings/events/customer.py @@ -85,7 +85,7 @@ def enter_chat_namespace(uid): @socketio.on('disconnect', namespace='/customer') -def disconnect(): +def disconnect(reason=None): if redis_4.exists(request.sid): redis_4.delete(request.sid) sid_all = redis_4.hgetall("sid_to_user") diff --git a/core/api/bookings/views.py b/core/api/bookings/views.py index 6fd79f6..dac3c8f 100644 --- a/core/api/bookings/views.py +++ b/core/api/bookings/views.py @@ -83,9 +83,6 @@ def create_booking(current_user): to_be_uploaded = [{**_base_img, **img} for img in images['files']] images_schema = BlobSchema(uid=current_user.id, many=True) images = images_schema.load(to_be_uploaded) - for img in images: - img.blob_id = uuid.uuid4().hex - sess.add_all(images) sess.commit() diff --git a/core/api/user/views.py b/core/api/user/views.py index 799822f..4eaa05b 100644 --- a/core/api/user/views.py +++ b/core/api/user/views.py @@ -4,6 +4,7 @@ from loguru import logger import sys + from . import user from core import db from utils import decode_id @@ -16,7 +17,8 @@ from utils import ( gen_response, error_response, - setLogger + setLogger, + paginate ) from schemas.user_schemas import ( AddNewUserSchema, @@ -34,6 +36,7 @@ login_required, permission_required ) +from devices import save_device_hash, check_device_hash logger.remove() setLogger() @@ -57,6 +60,7 @@ def create_new_user(): try: db.session.add(new_user) db.session.commit() + save_device_hash(new_user) return gen_response( 201, data=schema.dump(new_user), @@ -170,6 +174,8 @@ def add_app_token(current_user): @login_required def fetch_user(current_user): """ checks if uid exists """ + user = User.query.filter_by(user_id=current_user.user_id).first() + check_device_hash(user) with db.session() as sess: schema = UserSchema(session=sess) return gen_response( @@ -183,9 +189,15 @@ def fetch_user(current_user): @permission_required(Permission.service_request) def fetch_bookings_for_user(current_user): """ fetch all bookings made by a user """ - bookings = current_user.bookings.order_by( + query = current_user.bookings.order_by( desc(Booking.created_at) - ).all() + ) + + pagination = paginate( + query=query, + page=request.args.get("page", 1, type=int), + per_page=request.args.get("per_page", 10, type=int), + ) msg = 'fetched top recent bookings successfully' schema = BookingSchema( @@ -205,8 +217,15 @@ def fetch_bookings_for_user(current_user): return gen_response( 200, - data=schema.dump(bookings), - message=msg + data={ + "bookings": schema.dump(pagination.items), + "page": pagination.page, + "per_page": pagination.per_page, + "total": pagination.total, + "pages": pagination.pages + }, + message=msg, + ) diff --git a/core/api/views.py b/core/api/views.py index e6dd456..caac3d4 100644 --- a/core/api/views.py +++ b/core/api/views.py @@ -61,12 +61,12 @@ def request_download_urls(current_user): imgs = ImageFileSchema(action='download').load(data) except Exception as e: return error_response(status_code=400, message=str(e)) - + to_be_downloaded = [] for img in imgs['images']: # Fetch the exact blob using the primary key blob = sess.get(Blob, img['blob_id']) - + # Security check: ensure the current user actually owns this blob if not blob or blob.user_id != current_user.user_id: return error_response( diff --git a/devices.py b/devices.py new file mode 100644 index 0000000..52160b6 --- /dev/null +++ b/devices.py @@ -0,0 +1,251 @@ +import datetime +import requests +import hashlib +import hmac +import os +import geoip2.database + +from models.signin_attempt import SignInAttempts +from flask import request +from loguru import logger +from user_agents import parse +from core import db +from add_extensions import ( + redis_, + redis_2, + redis_4, + redis_7 +) + + +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + + +def generate_device_hash(): + try: + user_agent = parse(request.headers.get("User-Agent", "")) + + device_mac = ( + request.headers.get("X-Device-ID", "") + .strip() + .lower() + ) + + print("Device MAC:", device_mac, flush=True) + + device_name = ( + request.headers.get("X-Device-Model", "") + .strip() + .lower() + ) + device_name = device_name if device_name else user_agent.device.family.strip().lower() + print("Device Name:", device_name, flush=True) + + device_os = ( + request.headers.get("X-OS-Family") + or user_agent.os.family + ).strip().lower() + + if device_os.startswith("android"): + device_os = "android" + elif device_os.startswith("ios"): + device_os = "ios" + + print("Device OS:", device_os, flush=True) + + server_secret = os.getenv("DEVICE_HASH_SECRET") + + if not server_secret: + raise RuntimeError("DEVICE_HASH_SECRET is not configured.") + + raw = f"{device_mac}|{device_name}|{device_os}" + + device_hash = hmac.new( + server_secret.encode(), + raw.encode(), + hashlib.sha256 + ).hexdigest() + + device_data = { + "device_uuid": device_mac, + "device_model": device_name, + "device_os": device_os + } + + return device_hash, device_data + + except Exception: + logger.exception("Failed to generate device hash") + raise + + +def check_device_hash(user): + try: + matched = False + + device_hash, device_data = generate_device_hash() + + user_id = user.user_id if hasattr(user, "user_id") else user + + print(user_id, flush=True) + + pattern = f"device_hash:{user_id}:*" + print("Redis key pattern:", pattern, flush=True) + print(redis_.connection_pool.connection_kwargs["db"], flush=True) + print(redis_.connection_pool.connection_kwargs, flush=True) + + keys = redis_.keys() + + print(keys, flush=True) + keys = redis_.keys(pattern) + + print("Generated hash:", device_hash) + print("Redis keys:", keys) + + for key in keys: + print("Checking key:", key, flush=True) + saved_hash = key.split(":")[-1] + + if saved_hash == device_hash: + matched = True + print("Hash matched!") + return True + + print(user.email, flush=True) + + if not matched: + print("Device hash mismatch. Sending verification email.", flush=True) + try: + send_emails( + user.email, + "Unrecognized device detected", + "Please change your password if this wasn't you." + ) + except Exception: + logger.exception("Failed to send verification email") + return { + "message": "Unrecognized device. Verification email sent." + }, 403 + + except Exception: + logger.exception("Device hash check failed") + return False + + +def save_device_hash(new_user): + try: + device_hash, device_data = generate_device_hash() + + redis_key = f"device_hash:{new_user.user_id}:{device_hash}" + + print("Saving device hash in Redis with key:", redis_key, flush=True) + + try: + redis_.set(redis_key, 1) + except Exception: + logger.exception("Failed to save device hash in Redis") + + signin_data = SignInAttempts( + user_id=new_user.user_id, + created_at=datetime.datetime.utcnow(), + device_mac=device_data["device_uuid"], + device_name=device_data["device_model"], + device_os=device_data["device_os"], + estimated_location = get_estimated_location(), + ) + + db.session.add(signin_data) + db.session.flush() + + new_user.last_seen_id = signin_data.id + new_user.device_hash = device_hash + + db.session.commit() + + logger.info( + "Device saved successfully for user_id={}", + new_user.user_id + ) + + return True + + except Exception: + db.session.rollback() + logger.exception( + "Failed to save device for user_id={}", + getattr(new_user, "user_id", None) + ) + return False + + +def get_location(): + # Get IP address + if request.headers.get('X-Forwarded-For'): + ip_address = request.headers.get('X-Forwarded-For').split(',')[0] + else: + ip_address = request.remote_addr + + # Lookup location (path to your local MaxMind database) + try: + with geoip2.database.Reader('GeoLite2-City.mmdb') as reader: + response = reader.city(ip_address) + city = response.city.name + country = response.country.name + return {'ip': ip_address, 'city': city, 'country': country} + except Exception: + return {'ip': ip_address, 'error': 'Location not found'} + + +def get_estimated_location(): + try: + + data = get_location() + + print("Location data:", data, flush=True) + + return { + "ip": data.get("ip"), + "city": data.get("city"), + "country": data.get("country"), + } + + except requests.RequestException as e: + logger.exception( + "Failed to fetch estimated location: %s", + e + ) + return {} + + except Exception as e: + logger.exception( + "Unexpected error while fetching estimated location: %s", + e + ) + return {} + + + +def send_emails(to_email, subject, body): + try: + + sender_email = os.getenv("EMAIL_ADDRESS") + sender_password = os.getenv("EMAIL_PASSWORD") + + message = MIMEMultipart() + + message["From"] = sender_email + message["To"] = to_email + message["Subject"] = subject + + message.attach(MIMEText(body, "plain")) + + with smtplib.SMTP(os.getenv("smtp.gmail.com"), 587) as server: + server.starttls() + server.login(sender_email, sender_password) + server.send_message(message) + + except Exception as e: + logger.exception("Failed to send email: %s", e) + diff --git a/models/signin_attempt.py b/models/signin_attempt.py new file mode 100644 index 0000000..4f08c0e --- /dev/null +++ b/models/signin_attempt.py @@ -0,0 +1,25 @@ +from ctypes.wintypes import INT + +from core import db +from .base import TimestampMixin + + +class SignInAttempts(TimestampMixin, db.Model): + __tablename__ = "signin_attempts" + + id = db.Column(db.Integer, primary_key=True) + + user_id = db.Column( + db.String(50), + db.ForeignKey("user.user_id") + ) + + user = db.relationship( + "User", + back_populates="signin_attempts", + foreign_keys=[user_id] + ) + device_mac = db.Column(db.String(128)) + device_name = db.Column(db.String(128)) + device_os = db.Column(db.String(50)) + estimated_location = db.Column(db.JSON) diff --git a/models/user_models.py b/models/user_models.py index 392d099..59000a6 100644 --- a/models/user_models.py +++ b/models/user_models.py @@ -5,6 +5,7 @@ from flask import current_app from loguru import logger +from models.signin_attempt import SignInAttempts from core import db from models.bookings import Booking, BookingStatusEnum, BookingCategory from models.payments import Payment @@ -135,6 +136,24 @@ class User(TimestampMixin, db.Model): role_id = db.Column(db.Integer, db.ForeignKey('role.id'), nullable=False) cards = db.relationship('CardAuth', backref='user') payments = db.relationship('Payment', backref='user') + device_hash = db.Column(db.String(100)) + + signin_attempts = db.relationship( + "SignInAttempts", + back_populates="user", + foreign_keys="SignInAttempts.user_id" + ) + + last_seen_id = db.Column( + db.Integer, + db.ForeignKey("signin_attempts.id"), + nullable=True + ) + + last_seen = db.relationship( + "SignInAttempts", + foreign_keys=[last_seen_id] + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -203,10 +222,10 @@ def calculate_dynamic_request_ttl(self, c=3.75): def fetch_active_bookings(cls, user_id, session): subq = ( select( - Booking.booking_id, Booking.status, cls.first_name, - cls.last_name, cls.profile_picture, + Booking.booking_id, Booking.status, Booking.created_at, + cls.first_name, cls.last_name, cls.profile_picture, Booking.settlement_type, Artisan.artisan_id, - BookingCategory.name + BookingCategory.name, cls.telephone ) .join(Artisan, Booking.artisan_id == Artisan.artisan_id) .join(cls, Artisan.user_id == cls.user_id) @@ -226,10 +245,12 @@ def fetch_active_bookings(cls, user_id, session): 'name', func.concat_ws(' ', subq.c.first_name, subq.c.last_name), 'settlement_type', subq.c.settlement_type, 'id', subq.c.artisan_id, - 'profile_picture', subq.c.profile_picture + 'profile_picture', subq.c.profile_picture, + 'phone_number', subq.c.telephone ), 'id', subq.c.booking_id, 'status', subq.c.status, - 'category', subq.c.name + 'category', subq.c.name, + 'created_at', subq.c.created_at ) ) ) diff --git a/requirements.txt b/requirements.txt index 0ea69de..92c3218 100644 --- a/requirements.txt +++ b/requirements.txt @@ -240,3 +240,5 @@ zipp # importlib-resources sqlalchemy-cockroachdb passlib[argon2] +user-agents +geoip2 diff --git a/run.sh b/run.sh index b1b5c41..104aa67 100644 --- a/run.sh +++ b/run.sh @@ -1 +1 @@ -docker rm handees-backend && docker run -p 5000:5000 --name handees-backend handees-backend:1.1 \ No newline at end of file +docker stop handees-backend && docker rm handees-backend && docker run -p 5000:5000 --name handees-backend handees-backend:1.1 diff --git a/sample.py b/sample.py index 797a473..ec0c68d 100644 --- a/sample.py +++ b/sample.py @@ -217,7 +217,7 @@ def upload_file_with_presigned_url(presigned_url: str, file_path: str): # and match it when uploading. content_type, _ = mimetypes.guess_type(file_path) file_size = os.path.getsize(file_path) - print("file size is::", file_size) + print("file size is::", file_size, content_type) if content_type is None: content_type = 'application/octet-stream' @@ -252,10 +252,10 @@ def upload_file_with_presigned_url(presigned_url: str, file_path: str): if __name__ == "__main__": - test_presigned_url = "https://storage.googleapis.com/handees_service_request_images_dev/cat.png?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=firebase-adminsdk-fbsvc%40handees-dev.iam.gserviceaccount.com%2F20250708%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20250708T005612Z&X-Goog-Expires=900&X-Goog-SignedHeaders=content-type%3Bhost&X-Goog-Signature=44eb06c9dba5738fab7051745607992b80bbb13ca3a88dce37170f97427e9f1cb732417e84d6e79526b6d98b3d417d568410753f04436f128adf4acafaac19e1e21bb3d915cd308de5dcc24c4df30b52d4ca2fcab624d8617de79b5cb2e45e9050d3765e44d3616ce83efe6e839a79caead86e9276ba67bc83fd84c586056ece38f24b60bfb92ac7ff9dcecae0ba683a312ba60440dc91d7a4d378570e1c73613c6174f5545056b2c64ed2f6fe456cc8f6f3fcc1162689c085e5827448366c40fa07d811de318b73b1e39001b5ab41f60fd1d6478bee2c9691c6fa99e736c91a539035f76588db9cbebff5d5c10ff4091a82f6c0b3f75b16d4b4e8e058df3fdc" + test_presigned_url = "https://storage.googleapis.com/handees_service_request_images_dev/uploads/YVFr9oGC9CPpwBriJ3QbIW1PcO23/cd7c9b6ffe384feeb097b1af5dfbd73b?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=firebase-adminsdk-fbsvc%40handees-dev.iam.gserviceaccount.com%2F20260728%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20260728T151122Z&X-Goog-Expires=900&X-Goog-SignedHeaders=content-type%3Bhost&X-Goog-Signature=65a9fe8e9e41418d60be431db1212c4d78e1d7efe417fb71c90cbe4238a69aa0142f7316fd8ba1fb37ae4cf0a1b9a20bb19729978d691233aab80d89fa7a1c60808754f91a26d1ac5da38343b97808471e0b698538cc57e1a30d9abbe39966dac6521e75d3b43182b4363682208ddbc2ec68159f64b56cfa03fb28bdf19a60c4948b3830243f743bd12661ed5ecefa19bd9ad85b5bf5998277cec5b56731e71f668ce0ef2b47716da36607e654dce143f1d8788cdb342a34b2f8a8a36b38eb38b86a6f3a09dc361c3e24354d4b327bd225d4cee8259639ee06df0b1e05efc968337c16e71fa4bf9db9a920822dc4d8f982bc1f7fa995e7f68528dabed5c35e2f" # 2. Replace with the path to a local file you want to upload - test_file_path = "cat.png" + test_file_path = "frame.png" # Create a dummy file for testing if it doesn't exist if not os.path.exists(test_file_path): diff --git a/schemas/artisan.py b/schemas/artisan.py index 0dedf0f..a130f57 100644 --- a/schemas/artisan.py +++ b/schemas/artisan.py @@ -54,6 +54,7 @@ class Meta: ) reviews = fields.Nested('ReviewSchema', only=('weight', 'comment',)) metrics = fields.Method(serialize='get_metrics') + rating = fields.Method(serialize='get_artisan_rating', dump_only=True) @pre_load def preformat_data(self, data, *args, **kwargs): diff --git a/schemas/generic.py b/schemas/generic.py index 661f47d..c0aa33d 100644 --- a/schemas/generic.py +++ b/schemas/generic.py @@ -52,12 +52,3 @@ def get_url(self, obj): return obj.upload_url return obj.download_url - @pre_load - def add_img_id(self, obj, *args, **kwargs): - if self.uid: - obj['img_id'] = generate_unique_file_id( - user_id=self.uid, - filename=obj['filename'], - blob_type=int(BlobTypes[obj['blob_type'].name].value) - ) - return obj diff --git a/schemas/signin_attempt_schema.py b/schemas/signin_attempt_schema.py new file mode 100644 index 0000000..4105254 --- /dev/null +++ b/schemas/signin_attempt_schema.py @@ -0,0 +1,9 @@ +from models.signin_attempt import SignInAttempts +from .base import BaseSQLAlchemyAutoSchema + +class SignInAttemptSchema(BaseSQLAlchemyAutoSchema): + class Meta: + model = SignInAttempts + include_fk = True + include_relationships = True + load_instance = True \ No newline at end of file diff --git a/schemas/user_schemas.py b/schemas/user_schemas.py index dbac5e9..29e6c8b 100644 --- a/schemas/user_schemas.py +++ b/schemas/user_schemas.py @@ -54,12 +54,14 @@ class Meta: rating = fields.Method(serialize='get_user_rating') active_bookings = fields.Method(serialize='get_active_bookings') - def get_profile_url(self, obj): - blob_id = obj.profile_picture.split('/')[-1] - print(blob_id) + def _get_profile_url(self, blob_id): profile_picture_blob = Blob.get_by_id(blob_id, session=db.session()) return profile_picture_blob.download_url + def get_profile_url(self, obj): + blob_id = obj.profile_picture.split('/')[-1] + return self._get_profile_url(blob_id) + def set_profile_url(self, value): BUCKET_NAME = os.getenv('BUCKET_NAME') FILENAME = value['filename'] @@ -74,7 +76,6 @@ def set_profile_url(self, value): url = f"https://storage.googleapis.com/{BUCKET_NAME}/{new_blob.blob_id}" # save presigned url to schema instance for use in response to api self.upload_url = new_blob.upload_url - print("URL GENERATED!!", self.upload_url, new_blob.upload_url) return url def get_user_rating(self, obj): @@ -86,8 +87,13 @@ def get_user_rating(self, obj): def get_active_bookings(self, obj): uid = obj.user_id - return User.fetch_active_bookings(uid, session=self.session) - + active_bks = User.fetch_active_bookings(uid, session=self.session) + if not active_bks: + return [] + for bk in active_bks: + blob_id = bk['matched_artisan']['profile_picture'].split('/')[-1] + bk['matched_artisan']['profile_picture'] = self._get_profile_url(blob_id) + return active_bks # load_instance = True # transient = True diff --git a/supervisord-dev.conf b/supervisord-dev.conf index 8e00781..3952f15 100644 --- a/supervisord-dev.conf +++ b/supervisord-dev.conf @@ -42,7 +42,7 @@ stdout_logfile_maxbytes=0 stdout_logfile_backups=0 stderr_logfile_maxbytes=0 stderr_logfile_backups=0 -environment=HOME="/home/handeesofficial" +environment=HOME="/home/handeesofficial",PYTHONUNBUFFERED=1 # [program:handees2] diff --git a/utils.py b/utils.py index 8581b9e..9e369b8 100644 --- a/utils.py +++ b/utils.py @@ -8,12 +8,16 @@ import mimetypes import subprocess +from math import ceil +from flask import request +from sqlalchemy import select, func from loguru import logger from flask import jsonify from google.cloud import storage from firebase_admin import messaging from google.oauth2 import service_account from werkzeug.http import HTTP_STATUS_CODES +from firebase_admin._messaging_utils import UnregisteredError def is_serializable(obj): @@ -310,12 +314,17 @@ def decode_file_id(encoded_id: str): def send_notification(data, token, app=None, notification_object=None): print("FCM TOKEN IS::", token) print("Input data", data) - push_notification = messaging.Message( - data=data, - token=token, - notification=messaging.Notification(**notification_object), - ) - response = messaging.send(push_notification, app=app) + try: + push_notification = messaging.Message( + data=data, + token=token, + notification=messaging.Notification(**notification_object), + ) + response = messaging.send(push_notification, app=app) + except UnregisteredError: + logger.warning(f"FCM token stale/unregistered, skipping and removing") + # TODO: mark this token invalid in your DB so you stop sending to it + return return response @@ -386,3 +395,54 @@ def decode_id(val): # response = requests.post(url, data=payload, headers=headers) # print(response.text) + + +def paginate(query, page=1, per_page=10, sort_key=None, session=None): + """ + Paginate a SQLAlchemy query. + + :param query: The query to paginate. + :param page: The page number to retrieve. + :param per_page: The number of items per page. + :return: A paginated query object. + """ + if session: + total = session.scalar(select(func.count()).select_from(query.subquery())) + else: + total = query.count() + + page = max(1, request.args.get("page", page, type=int)) + per_page = max(1, request.args.get("per_page", per_page, type=int)) + + pages = ceil(total / per_page) if total else 1 + + if sort_key: + paginated_query = query.order_by( + sort_key + ).limit(per_page).offset((page - 1) * per_page) + else: + paginated_query = query.limit(per_page).offset((page - 1) * per_page) + return PaginatedQuery(paginated_query, page, per_page, total, pages) + + +class PaginatedQuery: + """ + A paginated SQLAlchemy query object. + """ + + def __init__(self, query, page, per_page, total, pages): + self.query = query + self.page = page + self.per_page = per_page + self.total = total + self.pages = pages + + @property + def items(self): + """ + Get the data for the current page. + """ + return self.query.all() + + def scalars(self, session): + return session.scalars(self.query) \ No newline at end of file