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
|
||||
|
||||
# =============================================================================
|
||||
# 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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
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
|
||||
return entry
|
||||
|
||||
@@ -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')
|
||||
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_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()
|
||||
|
||||
@@ -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/<int:contract_id>/download_signed')
|
||||
@@ -204,8 +224,9 @@ def download_signed_contract(contract_id):
|
||||
if not contract.signed_file_path:
|
||||
flash(_('No signed contract available.'), 'danger')
|
||||
return redirect(url_for('users.list_contracts'))
|
||||
return send_file(
|
||||
document_path(contract.signed_file_path),
|
||||
as_attachment=True,
|
||||
download_name=contract.signed_filename,
|
||||
)
|
||||
try:
|
||||
document = open_document(contract.signed_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.signed_filename)
|
||||
|
||||
+19
-1
@@ -530,13 +530,31 @@ document.addEventListener('change', function (event) {
|
||||
* Confirmation before a destructive submit.
|
||||
*
|
||||
* <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
|
||||
* markup where it can be translated.
|
||||
*/
|
||||
document.addEventListener('submit', function (event) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
+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).
|
||||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
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
|
||||
#: deployment sets, to a path outside the releases — alongside them, not
|
||||
#: inside whichever one is current.
|
||||
@@ -45,6 +54,16 @@ BACKUP_DIR_ENV = 'BACKUP_DIR'
|
||||
#: Sub-directory holding uploaded contracts, under the document root.
|
||||
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():
|
||||
"""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)
|
||||
|
||||
|
||||
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):
|
||||
"""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
|
||||
be able to abort the deletion of an account.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
removed = 0
|
||||
for stored_path in stored_paths:
|
||||
if not stored_path:
|
||||
continue
|
||||
target = document_path(stored_path)
|
||||
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
|
||||
except FileNotFoundError:
|
||||
# Already gone. Two contracts sharing a stem, or a previous
|
||||
# attempt: not a problem, and not worth an error line.
|
||||
logger.info('Document already absent: %s', target)
|
||||
except OSError as exc:
|
||||
logger.info('Document already absent: %s', stored_path)
|
||||
except (GoogleDriveStorageError, OSError) as exc:
|
||||
logger.error(
|
||||
'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 '
|
||||
'delete it again.',
|
||||
target,
|
||||
stored_path,
|
||||
exc,
|
||||
)
|
||||
return removed
|
||||
|
||||
@@ -135,7 +135,10 @@
|
||||
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.
|
||||
</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() }}"/>
|
||||
<div class="form-group">
|
||||
<label>Type <strong>WIPE</strong> to confirm:</label>
|
||||
@@ -197,14 +200,14 @@
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
<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() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-warning" title="Restore">
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
</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() }}"/>
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
@@ -262,16 +265,3 @@
|
||||
</div>
|
||||
|
||||
{% 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>
|
||||
</div>
|
||||
<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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -180,7 +180,7 @@
|
||||
<p class="text-muted">Loading availability grid...</p>
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -650,6 +650,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// dispatched by the delegated listener in main.js. This replaces inline
|
||||
// onclick attributes, which no CSP nonce is able to authorise.
|
||||
registerActions({
|
||||
'save-disponibilities': saveDisponibilities,
|
||||
'clear-disponibilities': clearDisponibilities,
|
||||
'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
Reference in New Issue
Block a user