# app.py # OPheno Submission Portal — save submissions into HF Space repo under /submissions/ import gradio as gr import os import shutil import uuid import re from datetime import datetime APP_TITLE = "OPheno Submission Portal" APP_DESC = ( "A collaborative space to contribute weed emergence and phenology observations " "to the OPheno project." ) FORCE_INPUT_TEXT_JS = """ () => { const syncMetadataFields = () => { document.querySelectorAll('[data-sync-field]').forEach((source) => { const target = document.querySelector(`#${source.dataset.syncField} textarea, #${source.dataset.syncField} input`); if (!target || target.value === source.value) { return; } target.value = source.value; target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true })); }); }; const applyInputTextColor = () => { document .querySelectorAll('#submission-form input:not([type="file"]):not([type="checkbox"]):not([type="radio"]), #submission-form textarea, .submission-text-input input, .submission-text-input textarea, .metadata-field__control') .forEach((el) => { el.style.setProperty('color', '#000000', 'important'); el.style.setProperty('caret-color', '#000000', 'important'); el.style.setProperty('-webkit-text-fill-color', '#000000', 'important'); el.style.setProperty('opacity', '1', 'important'); el.style.setProperty('text-shadow', '0 0 0 #000000', 'important'); el.style.setProperty('font-weight', '600', 'important'); }); }; syncMetadataFields(); applyInputTextColor(); window.setTimeout(applyInputTextColor, 250); window.setTimeout(applyInputTextColor, 1000); window.setTimeout(syncMetadataFields, 250); window.setTimeout(syncMetadataFields, 1000); const observer = new MutationObserver(() => { syncMetadataFields(); applyInputTextColor(); }); observer.observe(document.body, { childList: true, subtree: true }); document.addEventListener('input', (event) => { if (event.target.matches('[data-sync-field]')) { syncMetadataFields(); } applyInputTextColor(); }, true); document.addEventListener('change', syncMetadataFields, true); document.addEventListener('click', syncMetadataFields, true); document.addEventListener('input', applyInputTextColor, true); document.addEventListener('focusin', applyInputTextColor, true); } """ CUSTOM_HEAD = """ """ CUSTOM_CSS = """ body, .gradio-container { background: radial-gradient(circle at top, rgba(180, 219, 187, 0.58), transparent 30%), radial-gradient(circle at 80% 10%, rgba(223, 236, 228, 0.85), transparent 26%), linear-gradient(180deg, #f8fbf8 0%, #f3f8f2 36%, #f7faf6 100%); color: #162016; font-family: "Manrope", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .gradio-container h1, .gradio-container h2, .gradio-container h3, .gradio-container h4, .gradio-container h5, .gradio-container h6, .gradio-container p, .gradio-container span, .gradio-container label, .gradio-container strong, .gradio-container li, .gradio-container div, .gradio-container a { color: #162016 !important; opacity: 1 !important; text-shadow: none !important; -webkit-text-fill-color: #162016 !important; } .gradio-container { max-width: 1220px !important; padding: 24px 20px 48px !important; } .app-shell { max-width: 1160px; margin: 0 auto; } .topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 22px; margin-bottom: 30px; background: rgba(255, 255, 255, 0.72); border: 1px solid rgba(255, 255, 255, 0.7); border-radius: 999px; backdrop-filter: blur(20px); box-shadow: 0 18px 48px rgba(24, 36, 26, 0.06); } .topbar__brand { display: flex; align-items: center; gap: 12px; font-size: 1rem; font-weight: 800; letter-spacing: -0.03em; color: #162016 !important; opacity: 1 !important; text-shadow: none !important; -webkit-text-fill-color: #162016 !important; } .topbar__mark { width: 36px; height: 36px; border-radius: 14px; background: linear-gradient(180deg, #f9fdf9, #dfeede); border: 1px solid rgba(47, 93, 59, 0.1); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); } .topbar__link { color: #2f5d3b !important; font-weight: 600; text-decoration: none; -webkit-text-fill-color: #2f5d3b !important; } .hero-panel { position: relative; overflow: hidden; padding: 54px 44px; border-radius: 42px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.7)), linear-gradient(120deg, rgba(228, 241, 228, 0.85), rgba(255, 255, 255, 0.55)); border: 1px solid rgba(255, 255, 255, 0.88); box-shadow: 0 30px 100px rgba(33, 51, 37, 0.1); margin-bottom: 28px; } .hero-panel::before { content: ""; position: absolute; inset: 0; background: radial-gradient(circle at 16% 18%, rgba(95, 147, 104, 0.16), transparent 24%), radial-gradient(circle at 84% 18%, rgba(95, 147, 104, 0.12), transparent 22%); pointer-events: none; } .hero-grid { position: relative; z-index: 1; display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; align-items: center; } .hero-copy { max-width: 720px; } .eyebrow { margin: 0 0 14px; font-size: 0.82rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: #2f5d3b !important; -webkit-text-fill-color: #2f5d3b !important; } .hero-title { margin: 0; font-size: clamp(3.2rem, 6vw, 5.3rem); line-height: 0.94; letter-spacing: -0.07em; color: #162016 !important; text-shadow: none; } .hero-subtitle { margin: 20px 0 0; max-width: 640px; font-size: clamp(1.05rem, 1.8vw, 1.28rem); line-height: 1.65; color: #162016 !important; -webkit-text-fill-color: #162016 !important; } .hero-links { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; } .hero-link { display: inline-flex; align-items: center; justify-content: center; min-height: 50px; padding: 0 18px; border-radius: 999px; font-weight: 700; text-decoration: none; transition: transform 180ms ease, background-color 180ms ease, border-color 180ms ease; } .hero-link:hover { transform: translateY(-2px); } .hero-link--primary { background: #162016; color: #ffffff !important; box-shadow: 0 18px 36px rgba(22, 32, 22, 0.16); -webkit-text-fill-color: #ffffff !important; } .hero-link--primary, .hero-link--primary:link, .hero-link--primary:visited, .hero-link--primary:hover, .hero-link--primary:active { color: #ffffff !important; -webkit-text-fill-color: #ffffff !important; } .hero-link--secondary { background: rgba(255, 255, 255, 0.72); color: #162016 !important; border: 1px solid rgba(34, 62, 40, 0.1); -webkit-text-fill-color: #162016 !important; } .hero-link--secondary, .hero-link--secondary:link, .hero-link--secondary:visited, .hero-link--secondary:hover, .hero-link--secondary:active { color: #162016 !important; -webkit-text-fill-color: #162016 !important; } .form-panel, .info-panel { background: rgba(255, 255, 255, 0.8); border: 1px solid rgba(47, 93, 59, 0.08); border-radius: 34px; box-shadow: 0 24px 80px rgba(33, 51, 37, 0.08); backdrop-filter: blur(18px); } .section-heading { margin: 0 0 24px; } .section-title { margin: 0; font-size: clamp(1.8rem, 3vw, 2.8rem); line-height: 1.04; letter-spacing: -0.05em; color: #162016 !important; opacity: 1 !important; text-shadow: none !important; } .section-text { margin: 14px 0 0; max-width: 760px; color: #162016 !important; line-height: 1.7; font-size: 1rem; opacity: 1 !important; -webkit-text-fill-color: #162016 !important; } .guidance-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 24px; } .guidance-card { padding: 20px; border-radius: 24px; background: #f7fbf6; border: 1px solid rgba(47, 93, 59, 0.08); } .guidance-card strong { display: block; font-size: 1rem; letter-spacing: -0.03em; color: #162016 !important; opacity: 1 !important; } .guidance-card span { display: block; margin-top: 8px; color: #162016 !important; line-height: 1.6; font-size: 0.95rem; opacity: 1 !important; -webkit-text-fill-color: #162016 !important; } .portal-note { margin-top: 24px; padding: 18px 20px; border-radius: 22px; background: rgba(228, 241, 228, 0.65); color: #2f5d3b !important; border: 1px solid rgba(95, 147, 104, 0.12); font-size: 0.96rem; line-height: 1.65; -webkit-text-fill-color: #2f5d3b !important; } .portal-note a { color: #2f5d3b !important; font-weight: 700; -webkit-text-fill-color: #2f5d3b !important; } #submission-form { --body-text-color: #000000; --input-text-color: #000000; --input-placeholder-color: rgba(0, 0, 0, 0.5); --input-background-fill: rgba(255, 255, 255, 0.9); padding: 34px; } #submission-form .gr-group, #submission-form .gr-box, #submission-form .gr-form { background: transparent !important; border: none !important; box-shadow: none !important; } #submission-form .gr-row { gap: 18px !important; } .metadata-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } .metadata-grid--single { grid-template-columns: minmax(0, 1fr); } .metadata-field { background: rgba(228, 232, 234, 0.95); border: 1.5px solid rgba(22, 32, 22, 0.35); border-radius: 26px; padding: 12px; } .metadata-field__label { display: inline-block; max-width: 100%; margin-bottom: 12px; padding: 8px 12px; border-radius: 8px; background: rgba(228, 241, 228, 0.65); color: #000000; font-size: 1rem; font-weight: 800; line-height: 1.35; opacity: 1 !important; -webkit-text-fill-color: #000000 !important; } .metadata-field__control { display: block; width: 100%; min-height: 48px; padding: 12px 14px; border: 1.5px solid rgba(22, 32, 22, 0.12); border-radius: 6px; background: rgba(255, 255, 255, 0.96); color: #000000 !important; caret-color: #000000 !important; font: 600 1rem/1.35 "Manrope", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; opacity: 1 !important; outline: none; resize: vertical; -webkit-text-fill-color: #000000 !important; } .metadata-field__control:focus { border-color: rgba(22, 32, 22, 0.72); box-shadow: 0 0 0 4px rgba(22, 32, 22, 0.08); } .metadata-field__control::placeholder { color: rgba(0, 0, 0, 0.45) !important; opacity: 1 !important; -webkit-text-fill-color: rgba(0, 0, 0, 0.45) !important; } .metadata-sync-field { position: absolute !important; left: -10000px !important; width: 1px !important; height: 1px !important; overflow: hidden !important; opacity: 0 !important; pointer-events: none !important; } #submission-form .gr-textbox, #submission-form .gr-file, #submission-form .gr-markdown { margin-top: 10px; } #submission-form .wrap { border-radius: 24px !important; } #submission-form label, #submission-form .gr-block-label { font-weight: 700 !important; letter-spacing: -0.01em; color: #162016 !important; -webkit-text-fill-color: #162016 !important; } #submission-form .gr-form .gr-markdown, #submission-form .gr-form .gr-markdown *, #submission-form .gr-form .gradio-markdown, #submission-form .gr-form .gradio-markdown *, #submission-form .gr-textbox .gr-description, #submission-form .gr-textbox .gr-description *, #submission-form .gr-textbox .gr-field-info, #submission-form .gr-textbox .gr-field-info *, #submission-form .gr-file .gr-description, #submission-form .gr-file .gr-description *, #submission-form .gr-file .gr-field-info, #submission-form .gr-file .gr-field-info *, #submission-form .gradio-textbox .gradio-textbox-label, #submission-form .gradio-file .gradio-file-label, #submission-form .gradio-textbox label, #submission-form .gradio-file label, #submission-form .gr-textbox label, #submission-form .gr-file label { color: #162016 !important; font-weight: 700 !important; letter-spacing: -0.01em; opacity: 1 !important; -webkit-text-fill-color: #162016 !important; } #submission-form textarea, #submission-form input, #submission-form .wrap, #submission-form .gr-file-upload { background: rgba(255, 255, 255, 0.9) !important; border: 1.5px solid rgba(22, 32, 22, 0.35) !important; box-shadow: none !important; } #submission-form textarea:focus, #submission-form input:focus, #submission-form .wrap:focus-within { border-color: rgba(22, 32, 22, 0.7) !important; box-shadow: 0 0 0 4px rgba(22, 32, 22, 0.08) !important; } #submission-form input, #submission-form textarea, #submission-form input::placeholder, #submission-form textarea::placeholder { color: #000000 !important; opacity: 1 !important; -webkit-text-fill-color: #000000 !important; } #submission-form ::placeholder { color: rgba(0, 0, 0, 0.5) !important; opacity: 1 !important; -webkit-text-fill-color: rgba(0, 0, 0, 0.5) !important; } #submission-form input::placeholder, #submission-form textarea::placeholder, #submission-form .wrap input::placeholder, #submission-form .wrap textarea::placeholder, #submission-form .gr-textbox input::placeholder, #submission-form .gr-textbox textarea::placeholder, #submission-form .gradio-textbox input::placeholder, #submission-form .gradio-textbox textarea::placeholder { color: rgba(0, 0, 0, 0.5) !important; opacity: 1 !important; -webkit-text-fill-color: rgba(0, 0, 0, 0.5) !important; } #submission-form input:-webkit-autofill, #submission-form input:-webkit-autofill:hover, #submission-form input:-webkit-autofill:focus, #submission-form textarea:-webkit-autofill, #submission-form textarea:-webkit-autofill:hover, #submission-form textarea:-webkit-autofill:focus { color: #000000 !important; caret-color: #000000 !important; -webkit-text-fill-color: #000000 !important; -webkit-box-shadow: 0 0 0 1000px rgba(255, 255, 255, 0.9) inset !important; transition: background-color 9999s ease-in-out 0s; } #submission-form input[type="text"], #submission-form input[type="number"], #submission-form input:not([type]), #submission-form input:not([type="file"]):not([type="checkbox"]):not([type="radio"]), #submission-form textarea, #submission-form .wrap input, #submission-form .wrap textarea { color: #000000 !important; caret-color: #000000 !important; -webkit-text-fill-color: #000000 !important; } #submission-form .gr-textbox input, #submission-form .gr-textbox textarea, #submission-form .gradio-textbox input, #submission-form .gradio-textbox textarea, #submission-form [data-testid="textbox"] input, #submission-form [data-testid="textbox"] textarea { color: #000000 !important; caret-color: #000000 !important; opacity: 1 !important; -webkit-text-fill-color: #000000 !important; } .gradio-container [id="submission-form"] textarea, .gradio-container [id="submission-form"] input[type="text"], .gradio-container [id="submission-form"] input[type="number"], .gradio-container [id="submission-form"] .gradio-style textarea, .gradio-container [id="submission-form"] .gradio-style input[type="text"], .gradio-container [id="submission-form"] .gradio-style input[type="number"] { color: #000000 !important; caret-color: #000000 !important; opacity: 1 !important; text-shadow: none !important; -webkit-text-fill-color: #000000 !important; } .submission-text-input, .submission-text-input *, .submission-text-input input, .submission-text-input textarea, .submission-text-input input[type="text"], .submission-text-input input[type="number"] { --body-text-color: #000000 !important; --input-placeholder-color: rgba(0, 0, 0, 0.5) !important; color: #000000 !important; caret-color: #000000 !important; opacity: 1 !important; text-shadow: none !important; -webkit-text-fill-color: #000000 !important; } .submission-text-input input::placeholder, .submission-text-input textarea::placeholder { color: rgba(0, 0, 0, 0.5) !important; opacity: 1 !important; -webkit-text-fill-color: rgba(0, 0, 0, 0.5) !important; } #submission-form .gr-textbox, #submission-form .gr-file, #submission-form .gradio-textbox, #submission-form .gradio-file { border-radius: 26px !important; } #submission-form .gr-file-upload, #submission-form .gr-file-upload *, #submission-form .gr-file-upload-secondary, #submission-form .gr-file-upload-description, #submission-form .gr-file-upload-label, #submission-form .gr-file label, #submission-form .gr-file button, #submission-form .gr-file span { color: #162016 !important; -webkit-text-fill-color: #162016 !important; opacity: 1 !important; } #submission-form .upload-field-copy { margin: 10px 0 10px !important; color: #162016 !important; } #submission-form .upload-field-copy__label { display: inline-flex; align-items: center; width: fit-content; max-width: 100%; min-height: 36px; padding: 9px 14px; border-radius: 8px; background: rgba(228, 241, 228, 0.65); color: #162016 !important; font-size: 0.95rem; font-weight: 700 !important; letter-spacing: -0.01em; line-height: 1.35; -webkit-text-fill-color: #162016 !important; } #submission-form .upload-field-copy__help { margin: 8px 0 0; color: #162016 !important; font-size: 0.95rem; font-weight: 600; line-height: 1.45; opacity: 1 !important; -webkit-text-fill-color: #162016 !important; } #upload-data-file, #upload-data-file * { filter: none !important; opacity: 1 !important; } #upload-data-file .wrap, #upload-data-file .wrap * { color: transparent !important; text-shadow: none !important; -webkit-text-fill-color: transparent !important; } #upload-data-file .icon-wrap, #upload-data-file .icon-wrap *, #upload-data-file svg, #upload-data-file svg *, #upload-data-file path { color: #162016 !important; fill: none !important; stroke: #162016 !important; opacity: 1 !important; -webkit-text-fill-color: #162016 !important; } #submission-form .gr-file .gr-block-label, #submission-form .gr-file > label, #submission-form .gr-file label span, #submission-form .gradio-file .gr-block-label, #submission-form .gradio-file > label, #submission-form .gradio-file label span { color: #162016 !important; font-size: 0.95rem !important; font-weight: 700 !important; letter-spacing: -0.01em; line-height: 1.35 !important; opacity: 1 !important; -webkit-text-fill-color: #162016 !important; } #submission-form .gr-file-upload { background: rgba(255, 255, 255, 0.92) !important; border: 1.5px dashed rgba(22, 32, 22, 0.45) !important; } #submission-form .gr-file-upload:hover { border-color: rgba(22, 32, 22, 0.72) !important; } #submission-form .gr-button-primary, #submission-form button.primary { min-height: 56px !important; border-radius: 999px !important; background: #162016 !important; color: #fff !important; border: none !important; font-weight: 700 !important; box-shadow: 0 18px 36px rgba(22, 32, 22, 0.16) !important; -webkit-text-fill-color: #ffffff !important; } #submission-output, #submission-output p, #submission-output strong, #submission-output span, #submission-output li { color: #162016 !important; opacity: 1 !important; -webkit-text-fill-color: #162016 !important; } #submission-form .gr-button-primary:hover, #submission-form button.primary:hover { background: #223523 !important; } #submission-output { margin-top: 18px; padding: 22px 24px; border-radius: 24px; background: #f7fbf6; border: 1px solid rgba(47, 93, 59, 0.08); } @media (max-width: 960px) { .hero-grid, .guidance-grid { grid-template-columns: 1fr; } .hero-panel { padding: 36px 24px; border-radius: 30px; } } @media (max-width: 760px) { .gradio-container { padding: 14px 12px 36px !important; } .topbar { flex-direction: column; align-items: flex-start; border-radius: 28px; } #submission-form { padding: 24px 18px; } .metadata-grid { grid-template-columns: minmax(0, 1fr); } .hero-links { flex-direction: column; } .hero-link { width: 100%; } } """ def _get_token(): return os.getenv("HF_TOKEN") def _get_repo_id(): return os.getenv("SPACE_ID") or os.getenv("REPO_ID") def ensure_submissions_folder_in_repo(): """ Ensures that a top-level `submissions/` folder exists in the repo. Creates it if missing. """ token = _get_token() repo_id = _get_repo_id() if not token or not repo_id: return "⚠️ Cannot ensure 'submissions/' exists (missing HF_TOKEN or repo id)." from huggingface_hub import HfApi api = HfApi(token=token) try: files = api.list_repo_files(repo_id=repo_id, repo_type="space") if any(f.startswith("submissions/") for f in files): return "'submissions/' already exists in repo." # Create placeholder os.makedirs("submissions", exist_ok=True) keep_path = "submissions/.gitkeep" with open(keep_path, "w") as f: f.write("") api.upload_file( path_or_fileobj=keep_path, path_in_repo="submissions/.gitkeep", repo_id=repo_id, repo_type="space", commit_message="Initialize submissions/ folder" ) return "Created 'submissions/' folder in repo." except Exception as e: return f"⚠️ Failed to ensure submissions/: {e}" def commit_submission_folder(local_folder_path, folder_name): """ Uploads the submission into the HF Space repo: submissions/ """ token = _get_token() repo_id = _get_repo_id() if not token: return "❌ Commit skipped: HF_TOKEN missing (add it in Settings → Repository secrets)." if not repo_id: return "❌ Commit skipped: No SPACE_ID. Add REPO_ID secret manually." from huggingface_hub import HfApi api = HfApi(token=token) try: api.upload_folder( folder_path=local_folder_path, repo_id=repo_id, repo_type="space", path_in_repo=f"submissions/{folder_name}", commit_message=f"Add submission: {folder_name}" ) return "✅ Committed submission to the Space repository." except Exception as e: return f"❌ Commit failed: {e}" # ============================ # NAME SANITIZER + BUILDER # ============================ def sanitize(text: str) -> str: """Make safe for folder names while preserving readability.""" if not text: return "" text = text.strip() # Collapse whitespace to a single underscore text = re.sub(r"\s+", "_", text) # Keep letters, numbers, underscore, dash; remove the rest text = re.sub(r"[^A-Za-z0-9_-]", "", text) # Avoid accidental leading/trailing underscores return text.strip("_") def build_folder_name(unit, weed, crop, year, sender, org): """ Folder pattern (unchanged in intent): UNIT_WEED_[CROP]_YEAR_SENDER_ORG_timestamp_uuid8 """ parts = [sanitize(unit), sanitize(weed)] crop_s = sanitize(crop) if crop_s: parts.append(crop_s) parts.extend([sanitize(year), sanitize(sender), sanitize(org)]) base = "_".join([p for p in parts if p]) timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") short_uuid = str(uuid.uuid4())[:8] return f"{base}_{timestamp}_{short_uuid}" # ============================ # SUBMISSION FUNCTION # ============================ def analyze_outliers(file_path): """ Light non-blocking outlier detection for curator guidance. Tries to read CSV/XLSX files and checks: - Latitude outside [-90, 90] - Longitude outside [-180, 180] - Dates not in YYYY-MM-DD - Negative numeric values where suspicious Returns a list of warning strings. """ warnings = [] # Try to read the file with pandas try: import pandas as pd if file_path.lower().endswith(".csv"): df = pd.read_csv(file_path) elif file_path.lower().endswith(".xlsx") or file_path.lower().endswith(".xls"): df = pd.read_excel(file_path) elif file_path.lower().endswith(".tsv"): df = pd.read_csv(file_path, sep="\t") else: warnings.append("⚠️ Could not parse file for outlier detection (unsupported format).") return warnings # ---- Basic checks ---- # Latitude if "Latitude" in df.columns: bad_lat = df[(df["Latitude"] < -90) | (df["Latitude"] > 90)] for idx, val in bad_lat["Latitude"].items(): warnings.append(f"Row {idx}: Latitude={val} is outside [-90, 90].") # Longitude if "Longitude" in df.columns: bad_lon = df[(df["Longitude"] < -180) | (df["Longitude"] > 180)] for idx, val in bad_lon["Longitude"].items(): warnings.append(f"Row {idx}: Longitude={val} is outside [-180, 180].") # Date if "Date" in df.columns: for idx, val in df["Date"].items(): try: datetime.strptime(str(val), "%Y-%m-%d") except: warnings.append(f"Row {idx}: Date='{val}' is not YYYY-MM-DD.") # Value numeric sanity (optional) if "Value" in df.columns: numeric = pd.to_numeric(df["Value"], errors="coerce") for idx, val in numeric.items(): if pd.isna(val): continue if val < 0: warnings.append(f"Row {idx}: Value={val} is negative (check if expected).") if val > 1e6: warnings.append(f"Row {idx}: Value={val} is extremely high (possible error).") except Exception as e: warnings.append(f"⚠️ Outlier detection crashed: {e}") return warnings def submit_data( sender, org, unit, weed, crop, crop_establishment, tillage_system, trial_type, publication_status, year, doi, extra, uploaded_file, ): # 1. Ensure repo has root folder "submissions" print(ensure_submissions_folder_in_repo()) # 2. Validate errors = [] if not sender: errors.append("• Sender Name is required.") if not unit: errors.append("• Unit is required.") if not weed: errors.append("• WEED is required.") if not year or not re.fullmatch(r"\d{4}", year): errors.append("• Year must be YYYY.") if not uploaded_file: errors.append("• You must upload a file.") if errors: return "❌ Submission not saved:\n" + "\n".join(errors) # 3. Build local path local_root = "submissions" os.makedirs(local_root, exist_ok=True) folder_name = build_folder_name(unit, weed, crop, year, sender, org) submission_dir = os.path.join(local_root, folder_name) os.makedirs(submission_dir, exist_ok=True) # 4. Save uploaded file ext = os.path.splitext(uploaded_file.name)[1] file_dest = os.path.join(submission_dir, f"uploaded_file{ext}") shutil.copy(uploaded_file.name, file_dest) # 5. Save metadata metadata_path = os.path.join(submission_dir, "metadata.txt") with open(metadata_path, "w", encoding="utf-8") as f: f.write(f"Sender: {sender}\n") f.write(f"Organization: {org}\n") f.write(f"Unit: {unit}\n") f.write(f"Weed: {weed}\n") f.write(f"Crop: {crop}\n") f.write(f"Crop establishment method: {crop_establishment}\n") f.write(f"Tillage system: {tillage_system}\n") f.write(f"Trial type: {trial_type}\n") f.write(f"Publication status: {publication_status}\n") f.write(f"Year: {year}\n") f.write(f"DOI: {doi}\n") f.write(f"Extra: {extra}\n") # 5b. New: Run outlier detection + save warnings in folder warnings = analyze_outliers(file_dest) warnings_path = os.path.join(submission_dir, "warnings.txt") with open(warnings_path, "w", encoding="utf-8") as wf: if warnings: wf.write("⚠️ Potential Issues Found:\n\n") for w in warnings: wf.write("• " + w + "\n") else: wf.write("No warnings. All basic checks passed.\n") # 6. Commit to repo commit_msg = commit_submission_folder(submission_dir, folder_name) # 7. Return result return ( "### ✅ Submission successful!\n" f"**Saved locally:** `{submission_dir}`\n\n" f"**Repository status:** {commit_msg}\n\n" "You can view the uploaded file in **Files & Versions → submissions/**.\n" "A `warnings.txt` file has been generated to help curators identify potential issues.\n" ) # ============================ # GRADIO INTERFACE # ============================ theme = gr.themes.Soft( primary_hue="green", secondary_hue="green", neutral_hue="slate", ).set( body_text_color="#162016", body_text_color_dark="#162016", body_text_color_subdued="rgba(22, 32, 22, 0.72)", body_text_color_subdued_dark="rgba(22, 32, 22, 0.72)", body_background_fill="transparent", block_background_fill="rgba(255,255,255,0.8)", block_border_color="rgba(47, 93, 59, 0.08)", block_radius="24px", button_large_radius="999px", input_background_fill="rgba(255,255,255,0.9)", input_background_fill_dark="rgba(255,255,255,0.9)", input_border_color="rgba(47, 93, 59, 0.1)", input_border_color_dark="rgba(47, 93, 59, 0.1)", input_placeholder_color="rgba(22, 32, 22, 0.72)", input_placeholder_color_dark="rgba(22, 32, 22, 0.72)", ) with gr.Blocks(title=APP_TITLE) as demo: gr.HTML( """

Contribute to OPheno

Submission Portal

A collaborative space to contribute weed emergence and phenology observations to the OPheno project.

At the heart of OPheno is collaboration.

This portal helps researchers, practitioners, and collaborators submit datasets in a structured way so maintainers can review, curate, and integrate them into the broader OPheno knowledge base.

Provide Clear Attribution Include your name and organization so contributions can be credited appropriately.
Use Structured Data Upload CSV, Excel, TSV, JSON, or ZIP files that contain the relevant observations.
Help Curators Review Add notes about methods, units, and context so your dataset can be interpreted correctly.
Need weed codes? Use the EPPO Global Database. For project context and datasets, visit the OPheno organization page.
""" ) with gr.Column(elem_id="submission-form", elem_classes=["form-panel"]): gr.HTML( """

Submission Form

Share observations with the OPheno project.

Complete the form below to submit your file and supporting metadata. The underlying validation, upload, and repository commit workflow remain unchanged.

""" ) gr.HTML( """
""" ) sender = gr.Textbox(show_label=False, container=False, elem_id="metadata-sender", elem_classes="metadata-sync-field") org = gr.Textbox(show_label=False, container=False, elem_id="metadata-org", elem_classes="metadata-sync-field") unit = gr.Textbox(show_label=False, container=False, elem_id="metadata-unit", elem_classes="metadata-sync-field") weed = gr.Textbox(show_label=False, container=False, elem_id="metadata-weed", elem_classes="metadata-sync-field") crop = gr.Textbox(show_label=False, container=False, elem_id="metadata-crop", elem_classes="metadata-sync-field") crop_establishment = gr.Textbox(show_label=False, container=False, elem_id="metadata-crop-establishment", elem_classes="metadata-sync-field") tillage_system = gr.Textbox(show_label=False, container=False, elem_id="metadata-tillage-system", elem_classes="metadata-sync-field") trial_type = gr.Textbox(show_label=False, container=False, elem_id="metadata-trial-type", elem_classes="metadata-sync-field") publication_status = gr.Textbox(show_label=False, container=False, elem_id="metadata-publication-status", elem_classes="metadata-sync-field") year = gr.Textbox(show_label=False, container=False, elem_id="metadata-year", elem_classes="metadata-sync-field") doi = gr.Textbox(show_label=False, container=False, elem_id="metadata-doi", elem_classes="metadata-sync-field") extra = gr.Textbox(show_label=False, container=False, lines=4, elem_id="metadata-extra", elem_classes="metadata-sync-field") gr.HTML( """
Upload Data File

Drop a file into the upload area below, or click the box to browse. Accepted formats: CSV, Excel, TSV, JSON, or ZIP.

""" ) uploaded_file = gr.File( label="Upload Data File", show_label=False, interactive=True, elem_id="upload-data-file", elem_classes="upload-file-field", file_count="single", file_types=[".csv", ".xlsx", ".xls", ".tsv", ".json", ".zip"] ) submit_btn = gr.Button("Submit Data", variant="primary") output = gr.Markdown(elem_id="submission-output") submit_btn.click( fn=submit_data, inputs=[ sender, org, unit, weed, crop, crop_establishment, tillage_system, trial_type, publication_status, year, doi, extra, uploaded_file, ], outputs=output ) demo.launch( server_name="0.0.0.0", server_port=7860, css=CUSTOM_CSS, js=FORCE_INPUT_TEXT_JS, head=CUSTOM_HEAD, theme=theme, ssr_mode=False, )