Spaces:
Running
Running
from flask import Flask, request, jsonify, render_template_string | |
from google.oauth2.service_account import Credentials | |
from googleapiclient.discovery import build | |
from googleapiclient.http import MediaIoBaseUpload | |
import os | |
import io | |
import datetime | |
import json | |
app = Flask(__name__) | |
SPREADSHEET_ID = '1rcIJflbC1VIc70F6dnASLFvGQ90TXHhCx0iyxsXR7Ww' | |
FOLDER_ID = '1brmLNqOMCvRS0TDY6ECHvIBmlRx8pcPz' | |
# Create service account credentials from environment variable | |
CREDS_JSON = os.environ.get('GOOGLE_CREDENTIALS') | |
if CREDS_JSON: | |
CREDS_INFO = json.loads(CREDS_JSON) | |
CREDENTIALS = Credentials.from_service_account_info( | |
CREDS_INFO, | |
scopes=['https://www.googleapis.com/auth/spreadsheets', | |
'https://www.googleapis.com/auth/drive.file'] | |
) | |
def get_sheet_names(): | |
try: | |
service = build('sheets', 'v4', credentials=CREDENTIALS) | |
spreadsheet = service.spreadsheets().get(spreadsheetId=SPREADSHEET_ID).execute() | |
return [sheet['properties']['title'] for sheet in spreadsheet['sheets']] | |
except Exception as e: | |
print(f"Error getting sheet names: {str(e)}") | |
return ['Sheet1'] | |
def ensure_headers(sheet_name): | |
try: | |
sheets_service = build('sheets', 'v4', credentials=CREDENTIALS) | |
headers = [ | |
['項目 Item', '總金額 Total Cost', '發票檔案 Receipt File', '時間 Timestamp', 'IP位址 IP', '付款明細 Payment Details'] | |
] | |
sheets_service.spreadsheets().values().update( | |
spreadsheetId=SPREADSHEET_ID, | |
range=f'{sheet_name}!A1:F1', | |
valueInputOption='RAW', | |
body={'values': headers} | |
).execute() | |
requests = [{ | |
'repeatCell': { | |
'range': { | |
'sheetId': 0, | |
'startRowIndex': 0, | |
'endRowIndex': 1 | |
}, | |
'cell': { | |
'userEnteredFormat': { | |
'horizontalAlignment': 'CENTER', | |
'textFormat': { | |
'bold': True | |
} | |
} | |
}, | |
'fields': 'userEnteredFormat(horizontalAlignment,textFormat)' | |
} | |
}] | |
sheets_service.spreadsheets().batchUpdate( | |
spreadsheetId=SPREADSHEET_ID, | |
body={'requests': requests} | |
).execute() | |
except Exception as e: | |
print(f"Error setting headers: {str(e)}") | |
HTML_TEMPLATE = ''' | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>Purchase Record Form</title> | |
<meta charset="UTF-8"> | |
<style> | |
body { | |
font-family: Arial, sans-serif; | |
max-width: 800px; | |
margin: 20px auto; | |
padding: 20px; | |
} | |
.form-group { | |
margin-bottom: 20px; | |
} | |
label { | |
display: block; | |
margin-bottom: 8px; | |
font-weight: bold; | |
} | |
input, select { | |
width: 100%; | |
padding: 10px; | |
border: 1px solid #ddd; | |
border-radius: 4px; | |
font-size: 16px; | |
margin-bottom: 10px; | |
} | |
button { | |
background-color: #4CAF50; | |
color: white; | |
padding: 12px 24px; | |
border: none; | |
border-radius: 4px; | |
cursor: pointer; | |
font-size: 16px; | |
} | |
button:hover { | |
background-color: #45a049; | |
} | |
button:disabled { | |
background-color: #cccccc; | |
cursor: not-allowed; | |
} | |
#status { | |
margin-top: 20px; | |
padding: 15px; | |
border-radius: 4px; | |
display: none; | |
} | |
.success { | |
background-color: #dff0d8; | |
color: #3c763d; | |
} | |
.error { | |
background-color: #f2dede; | |
color: #a94442; | |
} | |
.person-entry { | |
border: 1px solid #ddd; | |
padding: 15px; | |
margin-bottom: 15px; | |
border-radius: 4px; | |
} | |
.remove-person { | |
background-color: #dc3545; | |
margin-top: 10px; | |
width: auto; | |
} | |
#add-person { | |
margin-bottom: 20px; | |
background-color: #17a2b8; | |
width: auto; | |
} | |
.total-cost { | |
font-size: 1.2em; | |
font-weight: bold; | |
margin: 20px 0; | |
} | |
</style> | |
</head> | |
<body> | |
<h2>購買記錄表單 Purchase Record Form</h2> | |
<form id="purchaseForm"> | |
<div class="form-group"> | |
<label for="sheetSelect">選擇工作表 Select Sheet:</label> | |
<select id="sheetSelect" name="sheetName" required> | |
{% for sheet in sheet_names %} | |
<option value="{{ sheet }}">{{ sheet }}</option> | |
{% endfor %} | |
</select> | |
</div> | |
<div class="form-group"> | |
<label for="product">購買項目 Item:</label> | |
<input type="text" id="product" name="product" required> | |
</div> | |
<div class="form-group"> | |
<label for="receipt">發票 Receipt:</label> | |
<input type="file" id="receipt" name="receipt" accept="image/*,.pdf" required> | |
</div> | |
<div id="people-container"> | |
<!-- Person entries will be added here --> | |
</div> | |
<button type="button" id="add-person">新增付款人 Add Person</button> | |
<div class="total-cost"> | |
總金額 Total Cost: <span id="total">0</span> | |
</div> | |
<button type="submit" id="submitBtn">提交 Submit</button> | |
</form> | |
<div id="status"></div> | |
<script> | |
let personCounter = 0; | |
const maxPersons = 10; // Maximum number of persons allowed | |
function createPersonEntry() { | |
const container = document.createElement('div'); | |
container.className = 'person-entry'; | |
container.innerHTML = ` | |
<div class="form-group"> | |
<label>姓名 Name:</label> | |
<input type="text" name="names[]" required> | |
</div> | |
<div class="form-group"> | |
<label>學號 Student ID:</label> | |
<input type="text" name="studentIds[]" required> | |
</div> | |
<div class="form-group"> | |
<label>金額 Cost:</label> | |
<input type="number" name="costs[]" required onchange="updateTotal()"> | |
</div> | |
<button type="button" class="remove-person" onclick="removePerson(this)">移除 Remove</button> | |
`; | |
return container; | |
} | |
function updateTotal() { | |
const costs = Array.from(document.getElementsByName('costs[]')) | |
.map(input => Number(input.value) || 0); | |
const total = costs.reduce((sum, cost) => sum + cost, 0); | |
document.getElementById('total').textContent = total; | |
} | |
function removePerson(button) { | |
button.parentElement.remove(); | |
updateTotal(); | |
document.getElementById('add-person').disabled = document.getElementsByClassName('person-entry').length >= maxPersons; | |
} | |
document.getElementById('add-person').addEventListener('click', () => { | |
const container = document.getElementById('people-container'); | |
if (container.children.length < maxPersons) { | |
container.appendChild(createPersonEntry()); | |
personCounter++; | |
document.getElementById('add-person').disabled = container.children.length >= maxPersons; | |
} | |
}); | |
// Add initial person entry | |
document.getElementById('add-person').click(); | |
document.getElementById('purchaseForm').addEventListener('submit', async (e) => { | |
e.preventDefault(); | |
const submitBtn = document.getElementById('submitBtn'); | |
if (submitBtn.disabled) return; | |
if (document.getElementsByName('names[]').length === 0) { | |
alert('請至少新增一個付款人 Please add at least one person'); | |
return; | |
} | |
submitBtn.disabled = true; | |
const status = document.getElementById('status'); | |
status.style.display = 'block'; | |
status.textContent = '提交中... Submitting...'; | |
status.className = ''; | |
const formData = new FormData(e.target); | |
try { | |
const response = await fetch('/submit', { | |
method: 'POST', | |
body: formData | |
}); | |
const result = await response.json(); | |
if (result.success) { | |
status.className = 'success'; | |
status.textContent = '提交成功! ' + result.message; | |
e.target.reset(); | |
document.getElementById('people-container').innerHTML = ''; | |
document.getElementById('add-person').click(); | |
document.getElementById('total').textContent = '0'; | |
} else { | |
status.className = 'error'; | |
status.textContent = '錯誤:' + result.message; | |
} | |
} catch (error) { | |
status.className = 'error'; | |
status.textContent = '提交錯誤 Error submitting form'; | |
} finally { | |
submitBtn.disabled = false; | |
} | |
}); | |
</script> | |
</body> | |
</html> | |
''' | |
def index(): | |
sheet_names = get_sheet_names() | |
return render_template_string(HTML_TEMPLATE, sheet_names=sheet_names) | |
def submit(): | |
try: | |
sheet_name = request.form['sheetName'] | |
product = request.form['product'] | |
file = request.files['receipt'] | |
names = request.form.getlist('names[]') | |
student_ids = request.form.getlist('studentIds[]') | |
costs = request.form.getlist('costs[]') | |
# Calculate total | |
total_cost = sum(float(cost) for cost in costs) | |
# Create payment details string | |
payment_details = [] | |
for name, student_id, cost in zip(names, student_ids, costs): | |
payment_details.append(f"{name}({student_id}): ${cost}") | |
payment_details_str = " | ".join(payment_details) | |
# Initialize Drive service | |
drive_service = build('drive', 'v3', credentials=CREDENTIALS) | |
# Check if subfolder exists, if not create it | |
subfolder_name = sheet_name | |
subfolder_query = f"name = '{subfolder_name}' and '{FOLDER_ID}' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false" | |
results = drive_service.files().list(q=subfolder_query).execute() | |
items = results.get('files', []) | |
if not items: | |
# Create new subfolder | |
folder_metadata = { | |
'name': subfolder_name, | |
'mimeType': 'application/vnd.google-apps.folder', | |
'parents': [FOLDER_ID] | |
} | |
subfolder = drive_service.files().create( | |
body=folder_metadata, | |
fields='id' | |
).execute() | |
subfolder_id = subfolder.get('id') | |
else: | |
subfolder_id = items[0]['id'] | |
# Upload file to the subfolder | |
file_metadata = { | |
'name': f"{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}_{file.filename}", | |
'parents': [subfolder_id] # Use subfolder ID instead of main folder ID | |
} | |
media = MediaIoBaseUpload( | |
io.BytesIO(file.read()), | |
mimetype=file.content_type, | |
resumable=True | |
) | |
uploaded_file = drive_service.files().create( | |
body=file_metadata, | |
media_body=media, | |
fields='id,webViewLink' | |
).execute() | |
file_url = uploaded_file.get('webViewLink', '') | |
# Ensure headers exist | |
ensure_headers(sheet_name) | |
# Update Google Sheet | |
sheets_service = build('sheets', 'v4', credentials=CREDENTIALS) | |
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') | |
ip_address = request.remote_addr | |
# Create single row entry | |
values = [[ | |
product, | |
total_cost, | |
file_url, | |
timestamp, | |
ip_address, | |
payment_details_str | |
]] | |
body = {'values': values} | |
sheets_service.spreadsheets().values().append( | |
spreadsheetId=SPREADSHEET_ID, | |
range=f'{sheet_name}!A:F', | |
valueInputOption='USER_ENTERED', | |
body=body | |
).execute() | |
return jsonify({'success': True, 'message': 'Data submitted successfully!'}) | |
except Exception as e: | |
return jsonify({'success': False, 'message': f'Error: {str(e)}'}) | |
if __name__ == '__main__': | |
app.run(host='0.0.0.0', port=7860) |