Initial commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
data/
|
||||
*.swp
|
||||
*.hot
|
||||
*.bak
|
||||
*.bak2
|
||||
__pycache__/
|
||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
110
backend/main.py
Normal file
110
backend/main.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# main.py
|
||||
from fastapi import FastAPI, Form, UploadFile, File
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from filelock import FileLock
|
||||
import aiofiles
|
||||
import httpx
|
||||
import os
|
||||
from datetime import datetime
|
||||
import openpyxl
|
||||
|
||||
# -------------------------
|
||||
# Configuration
|
||||
# -------------------------
|
||||
NEXTCLOUD_BASE_URL = "https://nextcloud.sdanywhere.com/remote.php/webdav/APs"
|
||||
NEXTCLOUD_USER = "kamaji"
|
||||
NEXTCLOUD_PASSWORD = "DvB0U2Uj3tOJaD"
|
||||
|
||||
EXCEL_FILENAME = "ap_data.xlsx"
|
||||
LOCK_FILENAME = f"{EXCEL_FILENAME}.lock"
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Allow CORS for frontend (if needed)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# -------------------------
|
||||
# Utility: Upload file to Nextcloud
|
||||
# -------------------------
|
||||
async def upload_to_nextcloud(local_path, remote_name):
|
||||
url = f"{NEXTCLOUD_BASE_URL}/{remote_name}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
with open(local_path, "rb") as f:
|
||||
r = await client.put(url, content=f, auth=(NEXTCLOUD_USER, NEXTCLOUD_PASSWORD))
|
||||
r.raise_for_status()
|
||||
|
||||
# -------------------------
|
||||
# Utility: Save Excel safely
|
||||
# -------------------------
|
||||
def append_to_excel(data_row):
|
||||
lock = FileLock(LOCK_FILENAME)
|
||||
with lock:
|
||||
if not os.path.exists(EXCEL_FILENAME):
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(["Timestamp", "Site ID", "AP Location", "Cable Length", "Serial Number"])
|
||||
wb.save(EXCEL_FILENAME)
|
||||
|
||||
wb = openpyxl.load_workbook(EXCEL_FILENAME)
|
||||
ws = wb.active
|
||||
ws.append(data_row)
|
||||
wb.save(EXCEL_FILENAME)
|
||||
|
||||
# -------------------------
|
||||
# Endpoint
|
||||
# -------------------------
|
||||
@app.post("/api/ap/submit")
|
||||
async def submit_ap(
|
||||
site_id: str = Form(...),
|
||||
ap_location: str = Form(...),
|
||||
cable_length: float = Form(...),
|
||||
serial_number: str = Form(...),
|
||||
photo_continuity: UploadFile = File(...),
|
||||
photo_length: UploadFile = File(...),
|
||||
photo_verification: UploadFile = File(...),
|
||||
photo_close: UploadFile = File(...),
|
||||
photo_distance: UploadFile = File(...),
|
||||
):
|
||||
# Capitalize serial number
|
||||
serial_number = serial_number.upper()
|
||||
|
||||
# Create folder locally before upload
|
||||
folder_name = f"AP{ap_location}"
|
||||
os.makedirs(folder_name, exist_ok=True)
|
||||
|
||||
# Save photos locally first
|
||||
photo_mapping = {
|
||||
"continuity": photo_continuity,
|
||||
"length": photo_length,
|
||||
"verification": photo_verification,
|
||||
"close": photo_close,
|
||||
"distance": photo_distance
|
||||
}
|
||||
saved_files = []
|
||||
for key, file in photo_mapping.items():
|
||||
filename = f"AP{ap_location}_{key}.jpg"
|
||||
local_path = os.path.join(folder_name, filename)
|
||||
async with aiofiles.open(local_path, "wb") as out_file:
|
||||
content = await file.read()
|
||||
await out_file.write(content)
|
||||
saved_files.append((local_path, filename)) # store for upload
|
||||
|
||||
# Append data to Excel
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
data_row = [timestamp, site_id, ap_location, cable_length, serial_number]
|
||||
append_to_excel(data_row)
|
||||
|
||||
# Upload Excel to Nextcloud
|
||||
await upload_to_nextcloud(EXCEL_FILENAME, EXCEL_FILENAME)
|
||||
|
||||
# Upload photos to Nextcloud
|
||||
for local_path, remote_name in saved_files:
|
||||
await upload_to_nextcloud(local_path, remote_name)
|
||||
|
||||
return JSONResponse(content={"site_id": site_id, "ap_location": ap_location})
|
||||
5
backend/requirements.txt
Normal file
5
backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
python-multipart
|
||||
openpyxl
|
||||
portalocker
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: ap-backend
|
||||
volumes:
|
||||
- ./data:/data
|
||||
expose:
|
||||
- "8000"
|
||||
|
||||
nginx:
|
||||
image: nginx:stable
|
||||
container_name: ap-nginx
|
||||
ports:
|
||||
- "9090:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/htpasswd:/etc/nginx/htpasswd:ro
|
||||
- ./frontend:/usr/share/nginx/html:ro
|
||||
depends_on:
|
||||
- backend
|
||||
154
frontend/index.html
Normal file
154
frontend/index.html
Normal file
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AP Installation Capture</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 1em; max-width: 700px; margin: auto; }
|
||||
label { display: block; margin-top: 1em; }
|
||||
input, button { width: 100%; padding: 0.5em; margin-top: 0.25em; }
|
||||
button { width: auto; padding: 0.5em 1em; }
|
||||
.row { display: flex; align-items: flex-end; gap: 0.5em; margin-top: 1em; }
|
||||
.row input { flex: 1; }
|
||||
.status { margin-top: 1em; font-weight: bold; }
|
||||
.preview { margin-top: 0.25em; max-width: 100%; width: 500px; height: auto; border: 1px solid #ccc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>AP Installation Capture</h1>
|
||||
|
||||
<form id="apForm">
|
||||
<div class="row">
|
||||
<label for="site_id">Site ID:</label>
|
||||
<input type="text" id="site_id" name="site_id" pattern="\d+" required
|
||||
autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false">
|
||||
<button type="button" id="clearSite">Clear</button>
|
||||
</div>
|
||||
|
||||
<label for="ap_location">AP Location (3 digits):</label>
|
||||
<input type="text" id="ap_location" name="ap_location" pattern="\d{3}" required
|
||||
autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false">
|
||||
|
||||
<label for="length">Cable Length (numeric):</label>
|
||||
<input type="number" id="length" name="cable_length" step="0.01" required
|
||||
autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false">
|
||||
|
||||
<label for="serial_number">Serial Number (XXXX-XXXX-XXXX):</label>
|
||||
<input type="text" id="serial_number" name="serial_number" pattern="[A-Za-z0-9]{4}-[A-Za-z0-9]{4}-[A-Za-z0-9]{4}" required
|
||||
autocapitalize="characters" autocomplete="off" autocorrect="off" spellcheck="false">
|
||||
|
||||
<label for="continuity">Continuity Photo:</label>
|
||||
<input type="file" id="continuity" name="photo_continuity" accept="image/*" required>
|
||||
<img id="preview_continuity" class="preview">
|
||||
|
||||
<label for="length_photo">Length Check Photo:</label>
|
||||
<input type="file" id="length_photo" name="photo_length" accept="image/*" required>
|
||||
<img id="preview_length_photo" class="preview">
|
||||
|
||||
<label for="verification">Verification Photo:</label>
|
||||
<input type="file" id="verification" name="photo_verification" accept="image/*" required>
|
||||
<img id="preview_verification" class="preview">
|
||||
|
||||
<label for="ap_close">AP Close-up Photo:</label>
|
||||
<input type="file" id="ap_close" name="photo_close" accept="image/*" required>
|
||||
<img id="preview_ap_close" class="preview">
|
||||
|
||||
<label for="ap_far">AP Distant Photo:</label>
|
||||
<input type="file" id="ap_far" name="photo_distance" accept="image/*" required>
|
||||
<img id="preview_ap_far" class="preview">
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
|
||||
<script>
|
||||
// Keep Site ID persistent in sessionStorage
|
||||
const siteInput = document.getElementById('site_id');
|
||||
const storedSite = sessionStorage.getItem('site_id');
|
||||
if(storedSite) siteInput.value = storedSite;
|
||||
|
||||
document.getElementById('clearSite').addEventListener('click', () => {
|
||||
siteInput.value = '';
|
||||
sessionStorage.removeItem('site_id');
|
||||
});
|
||||
|
||||
// Preview function
|
||||
function setupPreview(inputId, previewId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const preview = document.getElementById(previewId);
|
||||
input.addEventListener('change', () => {
|
||||
if(input.files && input.files[0]) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => preview.src = e.target.result;
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
} else {
|
||||
preview.src = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupPreview('continuity', 'preview_continuity');
|
||||
setupPreview('length_photo', 'preview_length_photo');
|
||||
setupPreview('verification', 'preview_verification');
|
||||
setupPreview('ap_close', 'preview_ap_close');
|
||||
setupPreview('ap_far', 'preview_ap_far');
|
||||
|
||||
// Form submission
|
||||
document.getElementById('apForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = "Submitting...";
|
||||
|
||||
// Save site ID
|
||||
sessionStorage.setItem('site_id', siteInput.value);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('site_id', siteInput.value);
|
||||
formData.append('ap_location', document.getElementById('ap_location').value);
|
||||
formData.append('cable_length', document.getElementById('length').value);
|
||||
formData.append('serial_number', document.getElementById('serial_number').value);
|
||||
|
||||
formData.append('photo_continuity', document.getElementById('continuity').files[0]);
|
||||
formData.append('photo_length', document.getElementById('length_photo').files[0]);
|
||||
formData.append('photo_verification', document.getElementById('verification').files[0]);
|
||||
formData.append('photo_close', document.getElementById('ap_close').files[0]);
|
||||
formData.append('photo_distance', document.getElementById('ap_far').files[0]);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ap/submit', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
if (typeof errorData.detail === 'string') {
|
||||
status.textContent = "Error: " + errorData.detail;
|
||||
} else if (typeof errorData.detail === 'object') {
|
||||
status.textContent = "Error: " + JSON.stringify(errorData.detail);
|
||||
} else {
|
||||
status.textContent = "Error: " + response.statusText;
|
||||
}
|
||||
} else {
|
||||
const data = await response.json();
|
||||
status.textContent = `Success! Site ${data.site_id}, AP ${data.ap_location} submitted.`;
|
||||
// Optional: reset form except Site ID and previews
|
||||
document.getElementById('ap_location').value = '';
|
||||
document.getElementById('length').value = '';
|
||||
document.getElementById('serial_number').value = '';
|
||||
['continuity','length_photo','verification','ap_close','ap_far'].forEach(id=>{
|
||||
document.getElementById(id).value='';
|
||||
});
|
||||
['preview_continuity','preview_length_photo','preview_verification','preview_ap_close','preview_ap_far'].forEach(pid=>{
|
||||
document.getElementById(pid).src='';
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
status.textContent = "Network error: " + err;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2
nginx/htpasswd
Normal file
2
nginx/htpasswd
Normal file
@@ -0,0 +1,2 @@
|
||||
technician:$apr1$w4Ix.MV6$WR0HTzCZOkVHJOs/8ndHv/
|
||||
|
||||
27
nginx/nginx.conf
Normal file
27
nginx/nginx.conf
Normal file
@@ -0,0 +1,27 @@
|
||||
events {}
|
||||
|
||||
http {
|
||||
# Allow larger file uploads
|
||||
client_max_body_size 50M;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Basic authentication
|
||||
auth_basic "AP Capture";
|
||||
auth_basic_user_file /etc/nginx/htpasswd;
|
||||
|
||||
# Serve static frontend
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user