Initial commit

This commit is contained in:
kamaji
2026-01-26 04:57:20 -06:00
commit bbd720e2a2
8 changed files with 336 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
data/
*.swp
*.hot
*.bak
*.bak2
__pycache__/

10
backend/Dockerfile Normal file
View 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
View 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
View File

@@ -0,0 +1,5 @@
fastapi
uvicorn
python-multipart
openpyxl
portalocker

22
docker-compose.yml Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
technician:$apr1$w4Ix.MV6$WR0HTzCZOkVHJOs/8ndHv/

27
nginx/nginx.conf Normal file
View 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;
}
}
}