debut changement vers google drive
CI - Security, Lint & Tests / validate (push) Failing after 1m14s
CI - Security, Lint & Tests / validate (push) Failing after 1m14s
This commit is contained in:
+20
-9
@@ -88,17 +88,28 @@ DISCORD_CLIENT_SECRET=
|
|||||||
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
|
DISCORD_REDIRECT_URI=https://your-domain/auth/discord/callback
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Optional — storage
|
# Contract storage — Google Drive
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Where uploaded contracts live. Empty means `documents/` beside the
|
# New contracts are uploaded to the owner's Google Drive. The OAuth client,
|
||||||
# application. Set it to a path OUTSIDE the deployment directory if you move
|
# refresh token, and folder ID are secrets/configuration: do not commit them.
|
||||||
# to a release-directory layout, or a deployment will take the documents with
|
# Use `local` only for isolated development and legacy-file maintenance.
|
||||||
# it (OPS-011, app/storage.py).
|
DOCUMENT_STORAGE_BACKEND=google_drive
|
||||||
#
|
|
||||||
# IMPORTANT: the backup script reads this same variable. Before wave J it
|
# Target folder in the owner's personal Drive. The application keeps every
|
||||||
# did not, and archived `./documents` regardless — so setting this here and
|
# contract directly in this folder and stores only each Drive file ID in the
|
||||||
# nowhere else produced empty contract backups that still exited 0.
|
# 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=
|
DOCUMENTS_ROOT=
|
||||||
|
|
||||||
# Where the log files go. Empty means `logs/` beside the application. Both
|
# Where the log files go. Empty means `logs/` beside the application. Both
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Audit log for tracking sensitive administrative actions."""
|
"""Audit log for tracking sensitive administrative actions."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(db.Model):
|
class AuditLog(db.Model):
|
||||||
@@ -14,7 +14,7 @@ class AuditLog(db.Model):
|
|||||||
action = db.Column(db.String(100), nullable=False)
|
action = db.Column(db.String(100), nullable=False)
|
||||||
details = db.Column(db.Text, nullable=True)
|
details = db.Column(db.Text, nullable=True)
|
||||||
ip_address = db.Column(db.String(64), 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')
|
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.add(entry)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return entry
|
return entry
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Records of database backups created through the admin panel."""
|
"""Records of database backups created through the admin panel."""
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from datetime import datetime
|
from app.time_utils import utc_now_naive
|
||||||
|
|
||||||
|
|
||||||
class BackupRecord(db.Model):
|
class BackupRecord(db.Model):
|
||||||
@@ -16,6 +16,6 @@ class BackupRecord(db.Model):
|
|||||||
backup_type = db.Column(db.String(20), default='manual') # 'manual' or 'auto'
|
backup_type = db.Column(db.String(20), default='manual') # 'manual' or 'auto'
|
||||||
notes = db.Column(db.Text, nullable=True)
|
notes = db.Column(db.Text, nullable=True)
|
||||||
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), 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')
|
creator = db.relationship('User', foreign_keys=[created_by_id], backref='backups')
|
||||||
|
|||||||
+4
-3
@@ -8,6 +8,7 @@ import uuid
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app.extensions import db, hash_password, csrf
|
from app.extensions import db, hash_password, csrf
|
||||||
|
from app.time_utils import utc_now_naive
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Admin, Manager, Coach, Player, Scout,
|
Admin, Manager, Coach, Player, Scout,
|
||||||
User, USER_TYPES, ESPORT_GAMES,
|
User, USER_TYPES, ESPORT_GAMES,
|
||||||
@@ -625,7 +626,7 @@ def upload_signed_contract(contract_id):
|
|||||||
contract.signed_filename = signed_filename
|
contract.signed_filename = signed_filename
|
||||||
contract.signed_file_path = contract.file_path.replace(contract.stored_filename, signed_filename)
|
contract.signed_file_path = contract.file_path.replace(contract.stored_filename, signed_filename)
|
||||||
contract.status = 'signed'
|
contract.status = 'signed'
|
||||||
contract.signed_at = datetime.utcnow()
|
contract.signed_at = utc_now_naive()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash('Signed contract uploaded successfully!', 'success')
|
flash('Signed contract uploaded successfully!', 'success')
|
||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
@@ -891,7 +892,7 @@ def accept_one_on_one(request_id):
|
|||||||
|
|
||||||
player = request_obj.player
|
player = request_obj.player
|
||||||
request_obj.status = 'approved'
|
request_obj.status = 'approved'
|
||||||
request_obj.responded_at = datetime.utcnow()
|
request_obj.responded_at = utc_now_naive()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Notify player via Discord (same message as if approved through Discord reactions)
|
# 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
|
player = request_obj.player
|
||||||
|
|
||||||
request_obj.status = 'rejected'
|
request_obj.status = 'rejected'
|
||||||
request_obj.responded_at = datetime.utcnow()
|
request_obj.responded_at = utc_now_naive()
|
||||||
if rejection_reason:
|
if rejection_reason:
|
||||||
request_obj.coach_rejection_message = rejection_reason
|
request_obj.coach_rejection_message = rejection_reason
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ from app.routes.users._shared import (
|
|||||||
pdf_upload_error,
|
pdf_upload_error,
|
||||||
)
|
)
|
||||||
from app.routes.users.blueprint import users_bp
|
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.time_utils import utc_now_naive
|
||||||
from app.validators import UploadContractSchema
|
from app.validators import UploadContractSchema
|
||||||
|
|
||||||
@@ -126,9 +132,11 @@ def upload_contract():
|
|||||||
if team:
|
if team:
|
||||||
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
|
relative_path = os.path.join(CONTRACTS_DIR, secure_filename(team.name), stored_filename)
|
||||||
|
|
||||||
absolute_path = document_path(relative_path)
|
try:
|
||||||
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
stored_path = store_uploaded_document(file, relative_path)
|
||||||
file.save(absolute_path)
|
except GoogleDriveStorageError:
|
||||||
|
flash(_('Contract storage is temporarily unavailable. Please try again later.'), 'danger')
|
||||||
|
return redirect(url_for('users.upload_contract'))
|
||||||
|
|
||||||
contract = Contract(
|
contract = Contract(
|
||||||
player_id=player_id,
|
player_id=player_id,
|
||||||
@@ -136,7 +144,7 @@ def upload_contract():
|
|||||||
uploaded_by_id=current_user.id,
|
uploaded_by_id=current_user.id,
|
||||||
original_filename=original_filename,
|
original_filename=original_filename,
|
||||||
stored_filename=stored_filename,
|
stored_filename=stored_filename,
|
||||||
file_path=relative_path,
|
file_path=stored_path,
|
||||||
notes=notes if notes else None,
|
notes=notes if notes else None,
|
||||||
)
|
)
|
||||||
db.session.add(contract)
|
db.session.add(contract)
|
||||||
@@ -166,11 +174,22 @@ def upload_signed_contract(contract_id):
|
|||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
|
|
||||||
signed_filename = f"signed_{contract.stored_filename}"
|
signed_filename = f"signed_{contract.stored_filename}"
|
||||||
signed_path = contract.file_path.replace(contract.stored_filename, signed_filename)
|
# Legacy local records keep their signed copy beside the original. Drive
|
||||||
file.save(document_path(signed_path))
|
# 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_filename = signed_filename
|
||||||
contract.signed_file_path = signed_path
|
contract.signed_file_path = stored_signed_path
|
||||||
contract.status = 'signed'
|
contract.status = 'signed'
|
||||||
contract.signed_at = utc_now_naive()
|
contract.signed_at = utc_now_naive()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -186,11 +205,12 @@ def download_contract(contract_id):
|
|||||||
if not contract.can_view(current_user):
|
if not contract.can_view(current_user):
|
||||||
flash(_('You do not have permission to download this contract.'), 'danger')
|
flash(_('You do not have permission to download this contract.'), 'danger')
|
||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
return send_file(
|
try:
|
||||||
document_path(contract.file_path),
|
document = open_document(contract.file_path)
|
||||||
as_attachment=True,
|
except GoogleDriveStorageError:
|
||||||
download_name=contract.original_filename,
|
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/<int:contract_id>/download_signed')
|
@users_bp.route('/contracts/<int:contract_id>/download_signed')
|
||||||
@@ -204,8 +224,9 @@ def download_signed_contract(contract_id):
|
|||||||
if not contract.signed_file_path:
|
if not contract.signed_file_path:
|
||||||
flash(_('No signed contract available.'), 'danger')
|
flash(_('No signed contract available.'), 'danger')
|
||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
return send_file(
|
try:
|
||||||
document_path(contract.signed_file_path),
|
document = open_document(contract.signed_file_path)
|
||||||
as_attachment=True,
|
except GoogleDriveStorageError:
|
||||||
download_name=contract.signed_filename,
|
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.signed_filename)
|
||||||
|
|||||||
+19
-1
@@ -530,13 +530,31 @@ document.addEventListener('change', function (event) {
|
|||||||
* Confirmation before a destructive submit.
|
* Confirmation before a destructive submit.
|
||||||
*
|
*
|
||||||
* <form data-confirm="Delete this match?">
|
* <form data-confirm="Delete this match?">
|
||||||
|
* <form data-confirm-input="confirm" data-confirm-value="DELETE"
|
||||||
|
* data-confirm-input-message="Type DELETE to continue."
|
||||||
|
* data-confirm="Delete this record?">
|
||||||
*
|
*
|
||||||
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
|
* Replaces onsubmit="return confirm(...)", and keeps the wording in the
|
||||||
* markup where it can be translated.
|
* markup where it can be translated.
|
||||||
*/
|
*/
|
||||||
document.addEventListener('submit', function (event) {
|
document.addEventListener('submit', function (event) {
|
||||||
const form = event.target.closest('[data-confirm]');
|
const form = event.target.closest('[data-confirm]');
|
||||||
if (form && !window.confirm(form.getAttribute('data-confirm'))) {
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputName = form.getAttribute('data-confirm-input');
|
||||||
|
if (inputName) {
|
||||||
|
const input = form.elements.namedItem(inputName);
|
||||||
|
const expectedValue = form.getAttribute('data-confirm-value') || '';
|
||||||
|
if (!input || input.value.trim() !== expectedValue) {
|
||||||
|
window.alert(form.getAttribute('data-confirm-input-message') || 'Confirmation is required.');
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.confirm(form.getAttribute('data-confirm'))) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+76
-7
@@ -28,8 +28,17 @@ absolute path and are returned untouched, so this change needs no data
|
|||||||
migration and can ship before Alembic does (DB-002).
|
migration and can ship before Alembic does (DB-002).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from app.google_drive import (
|
||||||
|
GoogleDriveStorageError,
|
||||||
|
delete_file as delete_google_drive_file,
|
||||||
|
download_file as download_google_drive_file,
|
||||||
|
upload_file as upload_google_drive_file,
|
||||||
|
)
|
||||||
|
|
||||||
#: Environment override for the document root. What a release-directory
|
#: Environment override for the document root. What a release-directory
|
||||||
#: deployment sets, to a path outside the releases — alongside them, not
|
#: deployment sets, to a path outside the releases — alongside them, not
|
||||||
#: inside whichever one is current.
|
#: inside whichever one is current.
|
||||||
@@ -45,6 +54,16 @@ BACKUP_DIR_ENV = 'BACKUP_DIR'
|
|||||||
#: Sub-directory holding uploaded contracts, under the document root.
|
#: Sub-directory holding uploaded contracts, under the document root.
|
||||||
CONTRACTS_DIR = 'contrats signés'
|
CONTRACTS_DIR = 'contrats signés'
|
||||||
|
|
||||||
|
#: Database marker for documents stored remotely. Existing rows continue to
|
||||||
|
#: hold relative or absolute filesystem paths, so switching storage does not
|
||||||
|
#: invalidate contracts already uploaded before the Google Drive move.
|
||||||
|
GOOGLE_DRIVE_PATH_PREFIX = 'gdrive://'
|
||||||
|
|
||||||
|
#: Storage backends deliberately stay explicit. Google Drive is the
|
||||||
|
#: production default; local disk exists only for legacy rows and isolated
|
||||||
|
#: test/development environments.
|
||||||
|
DOCUMENT_STORAGE_BACKEND_ENV = 'DOCUMENT_STORAGE_BACKEND'
|
||||||
|
|
||||||
|
|
||||||
def project_root():
|
def project_root():
|
||||||
"""Absolute path of the project, derived from this file's location.
|
"""Absolute path of the project, derived from this file's location.
|
||||||
@@ -91,6 +110,56 @@ def _rooted(env_name, default_name):
|
|||||||
return os.path.join(project_root(), default_name)
|
return os.path.join(project_root(), default_name)
|
||||||
|
|
||||||
|
|
||||||
|
def document_storage_backend():
|
||||||
|
"""Return the configured backend for new document uploads."""
|
||||||
|
backend = os.getenv(DOCUMENT_STORAGE_BACKEND_ENV, 'google_drive').strip().lower()
|
||||||
|
if backend not in {'google_drive', 'local'}:
|
||||||
|
raise ValueError(
|
||||||
|
f'{DOCUMENT_STORAGE_BACKEND_ENV} must be "google_drive" or "local", not {backend!r}'
|
||||||
|
)
|
||||||
|
return backend
|
||||||
|
|
||||||
|
|
||||||
|
def is_google_drive_path(stored_path):
|
||||||
|
"""Whether a database path references a Google Drive file."""
|
||||||
|
return bool(stored_path and stored_path.startswith(GOOGLE_DRIVE_PATH_PREFIX))
|
||||||
|
|
||||||
|
|
||||||
|
def _google_drive_id(stored_path):
|
||||||
|
file_id = stored_path.removeprefix(GOOGLE_DRIVE_PATH_PREFIX)
|
||||||
|
if not file_id:
|
||||||
|
raise GoogleDriveStorageError('The stored Google Drive file identifier is empty.')
|
||||||
|
return file_id
|
||||||
|
|
||||||
|
|
||||||
|
def store_uploaded_document(file_storage, relative_path):
|
||||||
|
"""Store an uploaded document and return its durable database reference.
|
||||||
|
|
||||||
|
Local storage retains the relative-path format used by existing rows. A
|
||||||
|
Google Drive upload returns an opaque Drive file identifier prefixed with
|
||||||
|
``gdrive://`` so it cannot be mistaken for a filesystem path.
|
||||||
|
"""
|
||||||
|
if document_storage_backend() == 'local':
|
||||||
|
absolute_path = document_path(relative_path)
|
||||||
|
os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
|
||||||
|
file_storage.save(absolute_path)
|
||||||
|
return relative_path
|
||||||
|
|
||||||
|
file_id = upload_google_drive_file(
|
||||||
|
stream=file_storage.stream,
|
||||||
|
filename=os.path.basename(relative_path),
|
||||||
|
mimetype=file_storage.mimetype or 'application/pdf',
|
||||||
|
)
|
||||||
|
return f'{GOOGLE_DRIVE_PATH_PREFIX}{file_id}'
|
||||||
|
|
||||||
|
|
||||||
|
def open_document(stored_path):
|
||||||
|
"""Return a filesystem path or in-memory stream suitable for ``send_file``."""
|
||||||
|
if is_google_drive_path(stored_path):
|
||||||
|
return io.BytesIO(download_google_drive_file(_google_drive_id(stored_path)))
|
||||||
|
return document_path(stored_path)
|
||||||
|
|
||||||
|
|
||||||
def discard_documents(stored_paths):
|
def discard_documents(stored_paths):
|
||||||
"""Remove these documents from disk. Returns how many went (DATA-012).
|
"""Remove these documents from disk. Returns how many went (DATA-012).
|
||||||
|
|
||||||
@@ -107,27 +176,27 @@ def discard_documents(stored_paths):
|
|||||||
A path that cannot be removed is logged and skipped. Nothing here should
|
A path that cannot be removed is logged and skipped. Nothing here should
|
||||||
be able to abort the deletion of an account.
|
be able to abort the deletion of an account.
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
removed = 0
|
removed = 0
|
||||||
for stored_path in stored_paths:
|
for stored_path in stored_paths:
|
||||||
if not stored_path:
|
if not stored_path:
|
||||||
continue
|
continue
|
||||||
target = document_path(stored_path)
|
|
||||||
try:
|
try:
|
||||||
os.remove(target)
|
if is_google_drive_path(stored_path):
|
||||||
|
delete_google_drive_file(_google_drive_id(stored_path))
|
||||||
|
else:
|
||||||
|
os.remove(document_path(stored_path))
|
||||||
removed += 1
|
removed += 1
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# Already gone. Two contracts sharing a stem, or a previous
|
# Already gone. Two contracts sharing a stem, or a previous
|
||||||
# attempt: not a problem, and not worth an error line.
|
# attempt: not a problem, and not worth an error line.
|
||||||
logger.info('Document already absent: %s', target)
|
logger.info('Document already absent: %s', stored_path)
|
||||||
except OSError as exc:
|
except (GoogleDriveStorageError, OSError) as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
'Could not remove %s (%s). It is now an orphan: no database row '
|
'Could not remove %s (%s). It is now an orphan: no database row '
|
||||||
'refers to it, so nothing in the application will ever offer to '
|
'refers to it, so nothing in the application will ever offer to '
|
||||||
'delete it again.',
|
'delete it again.',
|
||||||
target,
|
stored_path,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
return removed
|
return removed
|
||||||
|
|||||||
@@ -135,7 +135,10 @@
|
|||||||
This removes all players from organization teams and deletes all regular-season matches.
|
This removes all players from organization teams and deletes all regular-season matches.
|
||||||
Team structures, coaches, and managers are preserved. A safety backup is created automatically.
|
Team structures, coaches, and managers are preserved. A safety backup is created automatically.
|
||||||
</p>
|
</p>
|
||||||
<form method="POST" action="{{ url_for('admin.wipe_teams') }}" onsubmit="return confirmWipe()">
|
<form method="POST" action="{{ url_for('admin.wipe_teams') }}"
|
||||||
|
data-confirm-input="confirm" data-confirm-value="WIPE"
|
||||||
|
data-confirm-input-message="You must type WIPE to confirm."
|
||||||
|
data-confirm="This will remove ALL players from organization teams and delete ALL regular-season matches. A safety backup will be created automatically. Are you ABSOLUTELY sure?">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Type <strong>WIPE</strong> to confirm:</label>
|
<label>Type <strong>WIPE</strong> to confirm:</label>
|
||||||
@@ -197,14 +200,14 @@
|
|||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action="{{ url_for('admin.restore_backup', backup_id=b.id) }}" class="inline-form"
|
<form method="POST" action="{{ url_for('admin.restore_backup', backup_id=b.id) }}" class="inline-form"
|
||||||
onsubmit="return confirm('Restore backup {{ b.filename }}? This will overwrite all current data. A safety backup will be made first.');">
|
data-confirm="Restore backup {{ b.filename }}? This will overwrite all current data. A safety backup will be made first.">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-sm btn-warning" title="Restore">
|
<button type="submit" class="btn btn-sm btn-warning" title="Restore">
|
||||||
<i class="fas fa-undo"></i>
|
<i class="fas fa-undo"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="{{ url_for('admin.delete_backup', backup_id=b.id) }}" class="inline-form"
|
<form method="POST" action="{{ url_for('admin.delete_backup', backup_id=b.id) }}" class="inline-form"
|
||||||
onsubmit="return confirm('Delete backup {{ b.filename }}?');">
|
data-confirm="Delete backup {{ b.filename }}?">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<button type="submit" class="btn btn-sm btn-danger" title="Delete">
|
<button type="submit" class="btn btn-sm btn-danger" title="Delete">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
@@ -262,16 +265,3 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
function confirmWipe() {
|
|
||||||
var input = document.querySelector('input[name="confirm"]');
|
|
||||||
if (input.value.trim() !== 'WIPE') {
|
|
||||||
alert('You must type WIPE to confirm.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return confirm('This will remove ALL players from organization teams and delete ALL regular-season matches. A safety backup will be created automatically. Are you ABSOLUTELY sure?');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -157,10 +157,10 @@
|
|||||||
<p class="text-muted">Loading...</p>
|
<p class="text-muted">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions mt-3">
|
<div class="form-actions mt-3">
|
||||||
<button type="button" class="btn btn-primary" onclick="saveDisponibilities()">
|
<button type="button" class="btn btn-primary" data-action="save-disponibilities">
|
||||||
<i class="fas fa-save"></i> Save Disponibilities
|
<i class="fas fa-save"></i> Save Disponibilities
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-secondary" onclick="clearDisponibilities()">
|
<button type="button" class="btn btn-secondary" data-action="clear-disponibilities">
|
||||||
<i class="fas fa-trash"></i> Clear All
|
<i class="fas fa-trash"></i> Clear All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
<p class="text-muted">Loading availability grid...</p>
|
<p class="text-muted">Loading availability grid...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions mt-3">
|
<div class="form-actions mt-3">
|
||||||
<button type="button" class="btn btn-secondary" onclick="clearAllAvailability()">
|
<button type="button" class="btn btn-secondary" data-action="clear-availability">
|
||||||
<i class="fas fa-trash"></i> Clear All
|
<i class="fas fa-trash"></i> Clear All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -650,6 +650,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// dispatched by the delegated listener in main.js. This replaces inline
|
// dispatched by the delegated listener in main.js. This replaces inline
|
||||||
// onclick attributes, which no CSP nonce is able to authorise.
|
// onclick attributes, which no CSP nonce is able to authorise.
|
||||||
registerActions({
|
registerActions({
|
||||||
|
'save-disponibilities': saveDisponibilities,
|
||||||
'clear-disponibilities': clearDisponibilities,
|
'clear-disponibilities': clearDisponibilities,
|
||||||
'clear-availability': clearAllAvailability,
|
'clear-availability': clearAllAvailability,
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,28 @@ documents every variable. Copying it verbatim gives a configuration that
|
|||||||
refuses to start until `SECRET_KEY` and `DATABASE_URL` are filled in, rather
|
refuses to start until `SECRET_KEY` and `DATABASE_URL` are filled in, rather
|
||||||
than one that starts and is wide open (OPS-003).
|
than one that starts and is wide open (OPS-003).
|
||||||
|
|
||||||
|
### Google Drive contract storage
|
||||||
|
|
||||||
|
New contracts are stored in the owner's personal Google Drive when
|
||||||
|
`DOCUMENT_STORAGE_BACKEND=google_drive`. Enable the Google Drive API in a
|
||||||
|
Google Cloud project, create an OAuth client for the owner, and authorize it
|
||||||
|
once with the `drive.file` scope. Configure the resulting values outside the
|
||||||
|
repository:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DOCUMENT_STORAGE_BACKEND=google_drive
|
||||||
|
GOOGLE_DRIVE_FOLDER_ID=<owner folder id>
|
||||||
|
GOOGLE_DRIVE_CLIENT_ID=<OAuth client id>
|
||||||
|
GOOGLE_DRIVE_CLIENT_SECRET=<OAuth client secret>
|
||||||
|
GOOGLE_DRIVE_REFRESH_TOKEN=<owner refresh token>
|
||||||
|
```
|
||||||
|
|
||||||
|
The refresh token can create, download, and delete contracts created by this
|
||||||
|
application. Keep all four values out of source control. The database stores
|
||||||
|
only `gdrive://<file-id>` references, so existing local-file rows continue to
|
||||||
|
work after the change. Use `DOCUMENT_STORAGE_BACKEND=local` only for tests,
|
||||||
|
local development, or while migrating legacy files.
|
||||||
|
|
||||||
### Binding and proxy trust — read this before going live (OPS-002)
|
### Binding and proxy trust — read this before going live (OPS-002)
|
||||||
|
|
||||||
Two variables decide whether the rate limiter, the account lockout and the
|
Two variables decide whether the rate limiter, the account lockout and the
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ show_missing = true
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py312"
|
target-version = "py313"
|
||||||
exclude = [".venv", "venv", "migrations", "docs"]
|
exclude = [".venv", "venv", "migrations", "docs"]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ Flask-SQLAlchemy==3.1.1
|
|||||||
Flask-WTF==1.3.0
|
Flask-WTF==1.3.0
|
||||||
frozenlist==1.8.0
|
frozenlist==1.8.0
|
||||||
greenlet==3.5.4
|
greenlet==3.5.4
|
||||||
|
google-api-python-client==2.198.0
|
||||||
|
google-auth==2.56.3
|
||||||
idna==3.18
|
idna==3.18
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ def documents_in_a_throwaway_directory(tmp_path, monkeypatch):
|
|||||||
cannot leave a PDF in someone's working tree.
|
cannot leave a PDF in someone's working tree.
|
||||||
"""
|
"""
|
||||||
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'documents'))
|
monkeypatch.setenv('DOCUMENTS_ROOT', str(tmp_path / 'documents'))
|
||||||
|
monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'local')
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
+14
-17
@@ -1,17 +1,17 @@
|
|||||||
"""Which PostgreSQL driver the application actually asks for — QUA-001.
|
"""Which PostgreSQL driver the application actually asks for — QUA-001.
|
||||||
|
|
||||||
`postgresql://…` is not "whichever driver is installed". SQLAlchemy reads it
|
`postgresql://…` is not "whichever driver is installed". SQLAlchemy reads it
|
||||||
as psycopg2 and imports that module when the engine is created.
|
as psycopg2 unless the URL names a driver. requirements.txt declares psycopg
|
||||||
requirements.txt pins psycopg 3 and no psycopg2, so a clean install against
|
3, so create_app() must explicitly select it.
|
||||||
the URL Render hands out — the same form docs/database-restore.md documents
|
|
||||||
— fails before the first request:
|
|
||||||
|
|
||||||
ModuleNotFoundError: No module named 'psycopg2'
|
The test suite must not assume that psycopg2 is absent: a developer's virtual
|
||||||
|
environment can contain optional packages in addition to the declared
|
||||||
create_app() now names the driver. These tests pin that down, and the last
|
dependencies. These tests instead pin the application's selected driver and
|
||||||
one proves the failure is real rather than theoretical.
|
the dependency contract that production installs use.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
@@ -80,16 +80,13 @@ class TestTheFactory:
|
|||||||
assert app.config['SQLALCHEMY_DATABASE_URI'].startswith('sqlite:///')
|
assert app.config['SQLALCHEMY_DATABASE_URI'].startswith('sqlite:///')
|
||||||
|
|
||||||
|
|
||||||
class TestTheFailureIsReal:
|
class TestDriverContract:
|
||||||
"""Not a hypothetical: this is what the deployed configuration did."""
|
def test_production_dependencies_select_psycopg_3(self):
|
||||||
|
requirements = Path(__file__).resolve().parents[1] / 'requirements.txt'
|
||||||
|
declared = requirements.read_text(encoding='utf-8')
|
||||||
|
|
||||||
def test_psycopg2_is_not_installed(self):
|
assert 'psycopg[binary]==' in declared
|
||||||
with pytest.raises(ImportError):
|
assert 'psycopg2' not in declared
|
||||||
import psycopg2 # noqa: F401
|
|
||||||
|
|
||||||
def test_a_driverless_url_cannot_build_an_engine(self):
|
|
||||||
with pytest.raises(ModuleNotFoundError):
|
|
||||||
create_engine('postgresql://u:p@host/db')
|
|
||||||
|
|
||||||
def test_the_normalised_url_can(self):
|
def test_the_normalised_url_can(self):
|
||||||
engine = create_engine(normalise_database_url('postgresql://u:p@host/db'))
|
engine = create_engine(normalise_database_url('postgresql://u:p@host/db'))
|
||||||
|
|||||||
+23
-1
@@ -19,7 +19,13 @@ import os
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.storage import CONTRACTS_DIR, document_path, documents_root
|
from app.storage import (
|
||||||
|
CONTRACTS_DIR,
|
||||||
|
GOOGLE_DRIVE_PATH_PREFIX,
|
||||||
|
document_path,
|
||||||
|
documents_root,
|
||||||
|
store_uploaded_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentsRoot:
|
class TestDocumentsRoot:
|
||||||
@@ -87,6 +93,22 @@ class TestDocumentPath:
|
|||||||
assert first_old == second_old, 'and must not move the ones already filed'
|
assert first_old == second_old, 'and must not move the ones already filed'
|
||||||
|
|
||||||
|
|
||||||
|
class TestGoogleDriveStorage:
|
||||||
|
def test_new_uploads_store_an_opaque_drive_reference(self, monkeypatch):
|
||||||
|
from werkzeug.datastructures import FileStorage
|
||||||
|
|
||||||
|
monkeypatch.setenv('DOCUMENT_STORAGE_BACKEND', 'google_drive')
|
||||||
|
monkeypatch.setattr(
|
||||||
|
'app.storage.upload_google_drive_file',
|
||||||
|
lambda **_kwargs: 'drive-file-123',
|
||||||
|
)
|
||||||
|
upload = FileStorage(stream=_pdf(), filename='contract.pdf', content_type='application/pdf')
|
||||||
|
|
||||||
|
stored_path = store_uploaded_document(upload, os.path.join(CONTRACTS_DIR, 'new.pdf'))
|
||||||
|
|
||||||
|
assert stored_path == f'{GOOGLE_DRIVE_PATH_PREFIX}drive-file-123'
|
||||||
|
|
||||||
|
|
||||||
class TestThroughTheUploadRoute:
|
class TestThroughTheUploadRoute:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def uploaded(self, app, client, as_role, make_user):
|
def uploaded(self, app, client, as_role, make_user):
|
||||||
|
|||||||
Reference in New Issue
Block a user