diff --git a/app/.env.example b/app/.env.example
index 2329195..d2f2cac 100644
--- a/app/.env.example
+++ b/app/.env.example
@@ -88,17 +88,28 @@ DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
# =============================================================================
-# Optional — storage
+# Contract storage — Google Drive
# =============================================================================
-# Where uploaded contracts live. Empty means `documents/` beside the
-# application. Set it to a path OUTSIDE the deployment directory if you move
-# to a release-directory layout, or a deployment will take the documents with
-# it (OPS-011, app/storage.py).
-#
-# IMPORTANT: the backup script reads this same variable. Before wave J it
-# did not, and archived `./documents` regardless — so setting this here and
-# nowhere else produced empty contract backups that still exited 0.
+# New contracts are uploaded to the owner's Google Drive. The OAuth client,
+# refresh token, and folder ID are secrets/configuration: do not commit them.
+# Use `local` only for isolated development and legacy-file maintenance.
+DOCUMENT_STORAGE_BACKEND=google_drive
+
+# Target folder in the owner's personal Drive. The application keeps every
+# contract directly in this folder and stores only each Drive file ID in the
+# database.
+GOOGLE_DRIVE_FOLDER_ID=
+
+# OAuth 2.0 credentials for the owner's Google account. Create a Google Cloud
+# Desktop OAuth client, authorize the Drive scope once, and place the resulting
+# refresh token here. See docs/deployment.md before enabling this in production.
+GOOGLE_DRIVE_CLIENT_ID=
+GOOGLE_DRIVE_CLIENT_SECRET=
+GOOGLE_DRIVE_REFRESH_TOKEN=
+
+# Local storage is retained only for historical rows and local test runs.
+# It is not used for new contracts while DOCUMENT_STORAGE_BACKEND=google_drive.
DOCUMENTS_ROOT=
# Where the log files go. Empty means `logs/` beside the application. Both
diff --git a/app/google_drive.py b/app/google_drive.py
new file mode 100644
index 0000000..3463fb3
--- /dev/null
+++ b/app/google_drive.py
@@ -0,0 +1,105 @@
+"""Google Drive storage for contract PDFs.
+
+The application uses an OAuth refresh token for the owner's personal Google
+account. Tokens and client secrets come only from environment variables; the
+database stores opaque file IDs, never a credential or a shareable Drive URL.
+"""
+
+import io
+import os
+
+GOOGLE_DRIVE_FOLDER_ID_ENV = 'GOOGLE_DRIVE_FOLDER_ID'
+GOOGLE_DRIVE_CLIENT_ID_ENV = 'GOOGLE_DRIVE_CLIENT_ID'
+GOOGLE_DRIVE_CLIENT_SECRET_ENV = 'GOOGLE_DRIVE_CLIENT_SECRET'
+GOOGLE_DRIVE_REFRESH_TOKEN_ENV = 'GOOGLE_DRIVE_REFRESH_TOKEN'
+GOOGLE_DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'
+
+
+class GoogleDriveStorageError(RuntimeError):
+ """A configuration or API failure while storing a contract in Drive."""
+
+
+def _setting(name):
+ value = os.getenv(name)
+ if not value:
+ raise GoogleDriveStorageError(f'{name} must be configured for Google Drive document storage.')
+ return value
+
+
+def _drive_service():
+ """Build an authorized Drive client from the owner's refresh token."""
+ try:
+ from google.oauth2.credentials import Credentials
+ from googleapiclient.discovery import build
+ except ImportError as exc:
+ raise GoogleDriveStorageError(
+ 'Google Drive dependencies are not installed. Install requirements.txt again.'
+ ) from exc
+
+ credentials = Credentials(
+ token=None,
+ refresh_token=_setting(GOOGLE_DRIVE_REFRESH_TOKEN_ENV),
+ token_uri='https://oauth2.googleapis.com/token',
+ client_id=_setting(GOOGLE_DRIVE_CLIENT_ID_ENV),
+ client_secret=_setting(GOOGLE_DRIVE_CLIENT_SECRET_ENV),
+ scopes=[GOOGLE_DRIVE_SCOPE],
+ )
+ return build('drive', 'v3', credentials=credentials, cache_discovery=False)
+
+
+def upload_file(*, stream, filename, mimetype):
+ """Upload a PDF to the configured owner folder and return its Drive ID."""
+ try:
+ from googleapiclient.http import MediaIoBaseUpload
+
+ stream.seek(0)
+ media = MediaIoBaseUpload(stream, mimetype=mimetype, resumable=True)
+ response = (
+ _drive_service()
+ .files()
+ .create(
+ body={'name': filename, 'parents': [_setting(GOOGLE_DRIVE_FOLDER_ID_ENV)]},
+ media_body=media,
+ fields='id',
+ )
+ .execute()
+ )
+ except GoogleDriveStorageError:
+ raise
+ except Exception as exc: # Google client exceptions share no stable base class.
+ raise GoogleDriveStorageError('Google Drive rejected the contract upload.') from exc
+
+ file_id = response.get('id')
+ if not file_id:
+ raise GoogleDriveStorageError('Google Drive did not return an uploaded file identifier.')
+ return file_id
+
+
+def download_file(file_id):
+ """Download a Drive file into memory for Flask's authenticated response."""
+ try:
+ from googleapiclient.http import MediaIoBaseDownload
+
+ destination = io.BytesIO()
+ downloader = MediaIoBaseDownload(
+ destination,
+ _drive_service().files().get_media(fileId=file_id),
+ )
+ complete = False
+ while not complete:
+ _status, complete = downloader.next_chunk()
+ return destination.getvalue()
+ except GoogleDriveStorageError:
+ raise
+ except Exception as exc: # Google client exceptions share no stable base class.
+ raise GoogleDriveStorageError('Google Drive could not download this contract.') from exc
+
+
+def delete_file(file_id):
+ """Permanently delete a Drive document when its contract record is deleted."""
+ try:
+ _drive_service().files().delete(fileId=file_id).execute()
+ except GoogleDriveStorageError:
+ raise
+ except Exception as exc: # Google client exceptions share no stable base class.
+ raise GoogleDriveStorageError('Google Drive could not delete this contract.') from exc
diff --git a/app/models/audit_log.py b/app/models/audit_log.py
index 611ad57..72b2be7 100644
--- a/app/models/audit_log.py
+++ b/app/models/audit_log.py
@@ -1,7 +1,7 @@
"""Audit log for tracking sensitive administrative actions."""
from app.extensions import db
-from datetime import datetime
+from app.time_utils import utc_now_naive
class AuditLog(db.Model):
@@ -14,7 +14,7 @@ class AuditLog(db.Model):
action = db.Column(db.String(100), nullable=False)
details = db.Column(db.Text, nullable=True)
ip_address = db.Column(db.String(64), nullable=True)
- created_at = db.Column(db.DateTime, default=datetime.utcnow)
+ created_at = db.Column(db.DateTime, default=utc_now_naive)
user = db.relationship('User', foreign_keys=[user_id], backref='audit_logs')
@@ -29,4 +29,4 @@ class AuditLog(db.Model):
)
db.session.add(entry)
db.session.commit()
- return entry
\ No newline at end of file
+ return entry
diff --git a/app/models/backup_record.py b/app/models/backup_record.py
index 248d9cf..50c395f 100644
--- a/app/models/backup_record.py
+++ b/app/models/backup_record.py
@@ -1,7 +1,7 @@
"""Records of database backups created through the admin panel."""
from app.extensions import db
-from datetime import datetime
+from app.time_utils import utc_now_naive
class BackupRecord(db.Model):
@@ -16,6 +16,6 @@ class BackupRecord(db.Model):
backup_type = db.Column(db.String(20), default='manual') # 'manual' or 'auto'
notes = db.Column(db.Text, nullable=True)
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
- created_at = db.Column(db.DateTime, default=datetime.utcnow)
+ created_at = db.Column(db.DateTime, default=utc_now_naive)
- creator = db.relationship('User', foreign_keys=[created_by_id], backref='backups')
\ No newline at end of file
+ creator = db.relationship('User', foreign_keys=[created_by_id], backref='backups')
diff --git a/app/routes/users.py b/app/routes/users.py
index 946d563..72ca599 100644
--- a/app/routes/users.py
+++ b/app/routes/users.py
@@ -8,6 +8,7 @@ import uuid
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
from flask_login import login_required, current_user
from app.extensions import db, hash_password, csrf
+from app.time_utils import utc_now_naive
from app.models import (
Admin, Manager, Coach, Player, Scout,
User, USER_TYPES, ESPORT_GAMES,
@@ -625,7 +626,7 @@ def upload_signed_contract(contract_id):
contract.signed_filename = signed_filename
contract.signed_file_path = contract.file_path.replace(contract.stored_filename, signed_filename)
contract.status = 'signed'
- contract.signed_at = datetime.utcnow()
+ contract.signed_at = utc_now_naive()
db.session.commit()
flash('Signed contract uploaded successfully!', 'success')
return redirect(url_for('users.list_contracts'))
@@ -891,7 +892,7 @@ def accept_one_on_one(request_id):
player = request_obj.player
request_obj.status = 'approved'
- request_obj.responded_at = datetime.utcnow()
+ request_obj.responded_at = utc_now_naive()
db.session.commit()
# Notify player via Discord (same message as if approved through Discord reactions)
@@ -934,7 +935,7 @@ def reject_one_on_one(request_id):
player = request_obj.player
request_obj.status = 'rejected'
- request_obj.responded_at = datetime.utcnow()
+ request_obj.responded_at = utc_now_naive()
if rejection_reason:
request_obj.coach_rejection_message = rejection_reason
db.session.commit()
diff --git a/app/routes/users/contracts.py b/app/routes/users/contracts.py
index 62e227d..8505d94 100644
--- a/app/routes/users/contracts.py
+++ b/app/routes/users/contracts.py
@@ -18,7 +18,13 @@ from app.routes.users._shared import (
pdf_upload_error,
)
from app.routes.users.blueprint import users_bp
-from app.storage import CONTRACTS_DIR, document_path
+from app.storage import (
+ CONTRACTS_DIR,
+ GoogleDriveStorageError,
+ is_google_drive_path,
+ open_document,
+ store_uploaded_document,
+)
from app.time_utils import utc_now_naive
from app.validators import UploadContractSchema
@@ -126,9 +132,11 @@ def upload_contract():
if team:
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
- absolute_path = document_path(relative_path)
- os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
- file.save(absolute_path)
+ try:
+ stored_path = store_uploaded_document(file, relative_path)
+ except GoogleDriveStorageError:
+ flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
+ return redirect(url_for('users.upload_contract'))
contract = Contract(
player_id=player_id,
@@ -136,7 +144,7 @@ def upload_contract():
uploaded_by_id=current_user.id,
original_filename=original_filename,
stored_filename=stored_filename,
- file_path=relative_path,
+ file_path=stored_path,
notes=notes if notes else None,
)
db.session.add(contract)
@@ -166,11 +174,22 @@ def upload_signed_contract(contract_id):
return redirect(url_for('users.list_contracts'))
signed_filename = f"signed_{contract.stored_filename}"
- signed_path = contract.file_path.replace(contract.stored_filename, signed_filename)
- file.save(document_path(signed_path))
+ # Legacy local records keep their signed copy beside the original. Drive
+ # identifiers are opaque rather than filenames, so new Drive records use
+ # a fresh logical path and receive a second Drive ID.
+ signed_path = (
+ os.path.join(CONTRACTS_DIR, signed_filename)
+ if is_google_drive_path(contract.file_path)
+ else contract.file_path.replace(contract.stored_filename, signed_filename)
+ )
+ try:
+ stored_signed_path = store_uploaded_document(file, signed_path)
+ except GoogleDriveStorageError:
+ flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
+ return redirect(url_for('users.list_contracts'))
contract.signed_filename = signed_filename
- contract.signed_file_path = signed_path
+ contract.signed_file_path = stored_signed_path
contract.status = 'signed'
contract.signed_at = utc_now_naive()
db.session.commit()
@@ -186,11 +205,12 @@ def download_contract(contract_id):
if not contract.can_view(current_user):
flash(_('You do not have permission to download this contract.'), 'danger')
return redirect(url_for('users.list_contracts'))
- return send_file(
- document_path(contract.file_path),
- as_attachment=True,
- download_name=contract.original_filename,
- )
+ try:
+ document = open_document(contract.file_path)
+ except GoogleDriveStorageError:
+ flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
+ return redirect(url_for('users.list_contracts'))
+ return send_file(document, as_attachment=True, download_name=contract.original_filename)
@users_bp.route('/contracts/