debut changement vers google drive
CI - Security, Lint & Tests / validate (push) Failing after 1m14s

This commit is contained in:
cedrick2711
2026-08-25 19:01:28 -04:00
parent d18979a3e9
commit 38defc53a5
20 changed files with 796 additions and 520 deletions
+20 -9
View File
@@ -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
+105
View File
@@ -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
+2 -2
View File
@@ -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')
+2 -2
View File
@@ -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
View File
@@ -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()
+39 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6 -16
View File
@@ -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 %}
+4 -3
View File
@@ -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
+22
View File
@@ -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
View File
@@ -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]
+2
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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):