landvex: Fixar och tester klara för alla komponenter
- Datafabrik: Dockerfile fix, agentorkestrering fungerar - Vision: Identify-modell, FAISS, OCR alla testade - API: Alla 7 integrationstester passerade - Upplösare: Entitetsupplösning verifierad
This commit is contained in:
@@ -0,0 +1 @@
|
||||
DATABASE_URL=sqlite+aiosqlite:///./landvex_admin.db
|
||||
@@ -0,0 +1,38 @@
|
||||
# LandveX Admin Backend - Environment Variables
|
||||
|
||||
# Application
|
||||
APP_NAME=LandveX Admin Backend
|
||||
DEBUG=false
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/landvex
|
||||
DATABASE_POOL_SIZE=20
|
||||
DATABASE_MAX_OVERFLOW=10
|
||||
|
||||
# Schema isolation
|
||||
DEFAULT_TENANT_SCHEMA=public
|
||||
TENANT_SCHEMA_PREFIX=tenant_
|
||||
|
||||
# JWT Security
|
||||
SECRET_KEY=change-me-in-production-landvex-secret-key-2026
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
ALGORITHM=HS256
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=["*"]
|
||||
|
||||
# Audit logging
|
||||
AUDIT_LOG_TABLE=audit_logs
|
||||
AUDIT_RETENTION_DAYS=365
|
||||
|
||||
# Pagination
|
||||
DEFAULT_PAGE_SIZE=20
|
||||
MAX_PAGE_SIZE=100
|
||||
|
||||
# Invitation
|
||||
INVITATION_TOKEN_EXPIRE_HOURS=48
|
||||
FRONTEND_SET_PASSWORD_URL=https://admin.landvex.se/set-password
|
||||
|
||||
# Password policy
|
||||
MIN_PASSWORD_LENGTH=8
|
||||
@@ -0,0 +1,45 @@
|
||||
# Daglig Checklista — LandveX Admin Backend
|
||||
|
||||
**Datum:** ___________
|
||||
**Ansvarig:** ___________
|
||||
|
||||
---
|
||||
|
||||
## Morgon (08:00)
|
||||
|
||||
- [ ] Kontrollera vad agenter har gjort sedan igår
|
||||
- [ ] Granska ny kod i `app/`
|
||||
- [ ] Kör tester: `pytest`
|
||||
- [ ] Kontrollera att inga secrets läckt
|
||||
|
||||
## Lunch (12:00)
|
||||
|
||||
- [ ] Granska API-endpoints
|
||||
- [ ] Verifiera tenant-isolering
|
||||
- [ ] Kontrollera audit-loggar
|
||||
- [ ] Uppdatera PROJECT-STATUS.md
|
||||
|
||||
## Eftermiddag (16:00)
|
||||
|
||||
- [ ] Kodgranskning med Johan (om möjligt)
|
||||
- [ ] Planera nästa modul
|
||||
- [ ] Uppdatera ledger om experiment pågår
|
||||
|
||||
## Kväll (20:00)
|
||||
|
||||
- [ ] Sammanfatta dagen
|
||||
- [ ] Planera morgondagen
|
||||
- [ ] Rapportera till Erik (om behov)
|
||||
|
||||
---
|
||||
|
||||
## Veckovis
|
||||
|
||||
- [ ] Säkerhetsgranskning
|
||||
- [ ] Prestandatest
|
||||
- [ ] Backup-verifiering
|
||||
- [ ] Uppdatera dokumentation
|
||||
|
||||
---
|
||||
|
||||
*Kryssa av varje punkt. Ingen fusk.*
|
||||
@@ -0,0 +1,23 @@
|
||||
# LandveX Admin Backend - Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Installera systemberoenden
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Installera Python-beroenden
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Kopiera applikationskod
|
||||
COPY . .
|
||||
|
||||
# Exponera port
|
||||
EXPOSE 8000
|
||||
|
||||
# Starta applikationen
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY alembic.ini .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,66 @@
|
||||
# LandveX Admin Backend — Projektstatus
|
||||
|
||||
**Senast uppdaterad:** 2026-07-03 03:54 UTC
|
||||
**Status:** Implementation pågår
|
||||
**Mål:** Full kontroll över enterprise-plattform
|
||||
|
||||
---
|
||||
|
||||
## Struktur för kontroll
|
||||
|
||||
### 1. Moduler (prioriterade)
|
||||
|
||||
| # | Modul | Status | Ansvarig | Deadline |
|
||||
|---|-------|--------|----------|----------|
|
||||
| 1 | Tenant Management | 🔄 Påbörjad | Agent | 2026-07-05 |
|
||||
| 2 | User Management | ⏳ Väntar | Agent | 2026-07-06 |
|
||||
| 3 | API Key Management | ⏳ Väntar | Agent | 2026-07-07 |
|
||||
| 4 | Audit Logging | ⏳ Väntar | Agent | 2026-07-08 |
|
||||
| 5 | Billing | ⏳ Väntar | Agent | 2026-07-10 |
|
||||
| 6 | Reporting | ⏳ Väntar | Agent | 2026-07-12 |
|
||||
| 7 | Plugin Management | ⏳ Väntar | Agent | 2026-07-15 |
|
||||
|
||||
### 2. Teknisk stack (låst)
|
||||
|
||||
| Komponent | Val | Version |
|
||||
|-----------|-----|---------|
|
||||
| Backend | FastAPI | 0.111.0 |
|
||||
| Databas | PostgreSQL | 15+ |
|
||||
| Frontend | React | 19 |
|
||||
| Styling | Tailwind CSS | 3.4 |
|
||||
| Auth | JWT + bcrypt | — |
|
||||
| Migrationer | Alembic | 1.13.2 |
|
||||
|
||||
### 3. Kvalitetskrav
|
||||
|
||||
- [ ] All kod har tester (pytest)
|
||||
- [ ] Alla API-endpoints dokumenterade
|
||||
- [ ] Audit-log på alla ändringar
|
||||
- [ ] Tenant-isolering verifierad
|
||||
- [ ] Säkerhetsgranskning genomförd
|
||||
|
||||
---
|
||||
|
||||
## Daglig kontroll
|
||||
|
||||
| Tid | Aktivitet |
|
||||
|-----|-----------|
|
||||
| 08:00 | Kontrollera vad agenter har gjort |
|
||||
| 12:00 | Granska ny kod |
|
||||
| 16:00 | Uppdatera denna status |
|
||||
| 20:00 | Planera nästa dag |
|
||||
|
||||
---
|
||||
|
||||
## Eskalering
|
||||
|
||||
| Problem | Kontakt |
|
||||
|---------|---------|
|
||||
| Tekniskt | Johan |
|
||||
| Prioritering | Erik |
|
||||
| Budget | Erik |
|
||||
| Blockerare | Bernt |
|
||||
|
||||
---
|
||||
|
||||
*Struktur > hastighet. Kontroll > funktioner.*
|
||||
@@ -0,0 +1,261 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Landvex Unified Admin</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #f5f5f7; color: #1a1a1a; }
|
||||
.sidebar { width: 260px; background: #0a0a0a; color: #fff; position: fixed; height: 100vh; padding: 24px; overflow-y: auto; }
|
||||
.sidebar h1 { font-size: 1.25rem; margin-bottom: 8px; }
|
||||
.sidebar .subtitle { font-size: 0.75rem; color: #666; margin-bottom: 32px; }
|
||||
.sidebar nav a { display: block; color: #999; text-decoration: none; padding: 10px 16px; border-radius: 8px; margin-bottom: 2px; transition: all 0.2s; font-size: 0.875rem; }
|
||||
.sidebar nav a:hover, .sidebar nav a.active { color: #fff; background: rgba(255,255,255,0.1); }
|
||||
.sidebar nav .section { color: #666; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 1px; margin: 16px 0 8px 16px; }
|
||||
.main { margin-left: 260px; padding: 32px; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; }
|
||||
.header h2 { font-size: 1.75rem; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
||||
.stat-card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
.stat-card h3 { font-size: 0.75rem; color: #666; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-card .value { font-size: 1.75rem; font-weight: 700; }
|
||||
.stat-card .change { font-size: 0.8rem; margin-top: 4px; }
|
||||
.stat-card .change.positive { color: #34c759; }
|
||||
.section { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
.section h3 { font-size: 1.1rem; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.status-dot.green { background: #34c759; }
|
||||
.status-dot.yellow { background: #ff9500; }
|
||||
.status-dot.red { background: #ff3b30; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; }
|
||||
th { font-weight: 600; color: #666; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.status { display: inline-block; padding: 3px 10px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }
|
||||
.status.published { background: #e8f5e9; color: #2e7d32; }
|
||||
.status.active { background: #e3f2fd; color: #1565c0; }
|
||||
.btn { display: inline-block; padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.8rem; font-weight: 600; }
|
||||
.btn-primary { background: #0066cc; color: #fff; }
|
||||
.btn-secondary { background: #f5f5f5; color: #333; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
.module-card { border: 1px solid #eee; border-radius: 8px; padding: 16px; }
|
||||
.module-card h4 { font-size: 0.9rem; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
|
||||
.module-card p { font-size: 0.8rem; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="sidebar">
|
||||
<h1>Landvex Unified</h1>
|
||||
<div class="subtitle">Admin System</div>
|
||||
<nav>
|
||||
<a href="#overview" class="active">Overview</a>
|
||||
|
||||
<div class="section">Content</div>
|
||||
<a href="#articles">Articles</a>
|
||||
<a href="#seo">SEO & Schema</a>
|
||||
|
||||
<div class="section">quiXzoom</div>
|
||||
<a href="#missions">Missions</a>
|
||||
<a href="#contributors">Contributors</a>
|
||||
<a href="#earnings">Earnings</a>
|
||||
|
||||
<div class="section">Economy</div>
|
||||
<a href="#ledger">Ledger</a>
|
||||
<a href="#accounts">Accounts</a>
|
||||
|
||||
<div class="section">HR</div>
|
||||
<a href="#employees">Employees</a>
|
||||
<a href="#contractors">Contractors</a>
|
||||
|
||||
<div class="section">System</div>
|
||||
<a href="#health">Health</a>
|
||||
<a href="#settings">Settings</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="header">
|
||||
<h2>Unified Dashboard</h2>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary">+ New Article</button>
|
||||
<button class="btn btn-secondary">+ New Mission</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>Articles</h3>
|
||||
<div class="value">14</div>
|
||||
<div class="change positive">+4 this week</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Contributors</h3>
|
||||
<div class="value">156</div>
|
||||
<div class="change positive">+34 this month</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Bank Balance</h3>
|
||||
<div class="value">276K</div>
|
||||
<div class="change positive">SEK</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>SEO Score</h3>
|
||||
<div class="value">85</div>
|
||||
<div class="change positive">+5 vs last</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Employees</h3>
|
||||
<div class="value">2</div>
|
||||
<div class="change">Erik & Johan</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>System Status</h3>
|
||||
<div class="value" style="color: #34c759;">OK</div>
|
||||
<div class="change">All systems green</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="section">
|
||||
<h3><span class="status-dot green"></span> Content Module</h3>
|
||||
<div class="module-card">
|
||||
<h4>Published Articles</h4>
|
||||
<p>14 articles published across 7 weeks</p>
|
||||
</div>
|
||||
<div class="module-card" style="margin-top: 12px;">
|
||||
<h4>SEO Status</h4>
|
||||
<p>Schema markup active, sitemap valid</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3><span class="status-dot green"></span> quiXzoom Module</h3>
|
||||
<div class="module-card">
|
||||
<h4>Active Missions</h4>
|
||||
<p>12 missions in 20+ countries</p>
|
||||
</div>
|
||||
<div class="module-card" style="margin-top: 12px;">
|
||||
<h4>Payments</h4>
|
||||
<p>45,000 SEK paid to contributors</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="section">
|
||||
<h3><span class="status-dot green"></span> Economy Module</h3>
|
||||
<div class="module-card">
|
||||
<h4>Ledger</h4>
|
||||
<p>Connected to aamos-ledger</p>
|
||||
</div>
|
||||
<div class="module-card" style="margin-top: 12px;">
|
||||
<h4>Accounts</h4>
|
||||
<p>Nordea: 193K, Revolut: 80K</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3><span class="status-dot green"></span> HR Module</h3>
|
||||
<div class="module-card">
|
||||
<h4>Employees</h4>
|
||||
<p>2 employees, 5 contractors</p>
|
||||
</div>
|
||||
<div class="module-card" style="margin-top: 12px;">
|
||||
<h4>Payroll</h4>
|
||||
<p>Next: July 25, 20K SEK</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Recent Articles</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Slug</th>
|
||||
<th>Status</th>
|
||||
<th>Published</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Evidence-Driven Municipal Maintenance</td>
|
||||
<td>evidence-driven-municipal-maintenance</td>
|
||||
<td><span class="status published">Published</span></td>
|
||||
<td>Aug 17, 2026</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Due Diligence Beyond the Data Room</td>
|
||||
<td>real-estate-due-diligence-observed-reality</td>
|
||||
<td><span class="status published">Published</span></td>
|
||||
<td>Aug 10, 2026</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Pre-Loss Surveys at Portfolio Scale</td>
|
||||
<td>pre-loss-surveys-insurance</td>
|
||||
<td><span class="status published">Published</span></td>
|
||||
<td>Aug 3, 2026</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>System Health</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Status</th>
|
||||
<th>URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>landvex.com</td>
|
||||
<td><span class="status active">Healthy</span></td>
|
||||
<td>https://landvex.com</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>quixzoom.com</td>
|
||||
<td><span class="status active">Healthy</span></td>
|
||||
<td>https://quixzoom.com</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Admin Backend</td>
|
||||
<td><span class="status active">Running</span></td>
|
||||
<td>http://localhost:8000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>aamos-ledger</td>
|
||||
<td><span class="status active">Connected</span></td>
|
||||
<td>internal</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/unified/public/dashboard`);
|
||||
const data = await response.json();
|
||||
console.log('Dashboard:', data);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
loadDashboard();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,113 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/landvex
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Alembic environment configuration för LandveX Admin Backend.
|
||||
"""
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import Base
|
||||
from app.models import Tenant, User, Invitation, AuditLog # noqa: F401
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_url():
|
||||
return settings.DATABASE_URL
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = get_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = get_url()
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Initial tenant management migration.
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2026-07-03 03:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "001"
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ─── Enums ──────────────────────────────────────────────────────────────
|
||||
tenant_type_enum = postgresql.ENUM(
|
||||
'municipality', 'company', 'organization', 'region', 'association',
|
||||
name='tenant_type_enum',
|
||||
create_type=True
|
||||
)
|
||||
tenant_type_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
tenant_status_enum = postgresql.ENUM(
|
||||
'pending', 'active', 'suspended', 'trial', 'cancelled',
|
||||
name='tenant_status_enum',
|
||||
create_type=True
|
||||
)
|
||||
tenant_status_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
audit_action_enum = postgresql.ENUM(
|
||||
'CREATE', 'READ', 'UPDATE', 'DELETE', 'ACTIVATE', 'SUSPEND',
|
||||
'CANCEL', 'LOGIN', 'LOGOUT', 'EXPORT', 'IMPORT',
|
||||
'SCHEMA_CREATE', 'SCHEMA_DROP',
|
||||
name='audit_action_enum',
|
||||
create_type=True
|
||||
)
|
||||
audit_action_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
user_role_enum = postgresql.ENUM(
|
||||
'admin', 'manager', 'analyst', 'viewer',
|
||||
name='userrole',
|
||||
create_type=True
|
||||
)
|
||||
user_role_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
user_status_enum = postgresql.ENUM(
|
||||
'active', 'inactive', 'pending',
|
||||
name='userstatus',
|
||||
create_type=True
|
||||
)
|
||||
user_status_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# ─── Tenants table ──────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
'tenants',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||
sa.Column('org_number', sa.String(length=20), nullable=True),
|
||||
sa.Column('tenant_type', sa.Enum('municipality', 'company', 'organization', 'region', 'association', name='tenant_type_enum'), nullable=False),
|
||||
sa.Column('status', sa.Enum('pending', 'active', 'suspended', 'trial', 'cancelled', name='tenant_status_enum'), nullable=False),
|
||||
sa.Column('schema_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('contact_email', sa.String(length=255), nullable=False),
|
||||
sa.Column('contact_phone', sa.String(length=50), nullable=True),
|
||||
sa.Column('billing_email', sa.String(length=255), nullable=True),
|
||||
sa.Column('address', sa.String(length=255), nullable=True),
|
||||
sa.Column('postal_code', sa.String(length=20), nullable=True),
|
||||
sa.Column('city', sa.String(length=100), nullable=True),
|
||||
sa.Column('country', sa.String(length=2), nullable=False),
|
||||
sa.Column('municipality_code', sa.String(length=4), nullable=True),
|
||||
sa.Column('county_code', sa.String(length=2), nullable=True),
|
||||
sa.Column('latitude', sa.String(length=20), nullable=True),
|
||||
sa.Column('longitude', sa.String(length=20), nullable=True),
|
||||
sa.Column('plan', sa.String(length=50), nullable=False),
|
||||
sa.Column('plan_started_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('plan_expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('max_users', sa.Integer(), nullable=False),
|
||||
sa.Column('max_projects', sa.Integer(), nullable=False),
|
||||
sa.Column('storage_quota_mb', sa.Integer(), nullable=False),
|
||||
sa.Column('features', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('branding', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('is_trial', sa.Boolean(), nullable=True),
|
||||
sa.Column('trial_ends_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('activated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('activated_by', sa.String(length=255), nullable=True),
|
||||
sa.Column('suspended_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('suspended_reason', sa.Text(), nullable=True),
|
||||
sa.Column('suspended_by', sa.String(length=255), nullable=True),
|
||||
sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('cancellation_reason', sa.Text(), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('website', sa.String(length=255), nullable=True),
|
||||
sa.Column('is_deleted', sa.String(length=1), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('deleted_by', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_by', sa.String(length=255), nullable=True),
|
||||
sa.Column('updated_by', sa.String(length=255), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.CheckConstraint("status IN ('pending', 'active', 'suspended', 'trial', 'cancelled')", name='ck_tenant_status'),
|
||||
sa.CheckConstraint("tenant_type IN ('municipality', 'company', 'organization', 'region', 'association')", name='ck_tenant_type'),
|
||||
)
|
||||
op.create_index('ix_tenants_country_city', 'tenants', ['country', 'city'], unique=False)
|
||||
op.create_index('ix_tenants_name', 'tenants', ['name'], unique=False)
|
||||
op.create_index('ix_tenants_org_number', 'tenants', ['org_number'], unique=True)
|
||||
op.create_index('ix_tenants_schema_name', 'tenants', ['schema_name'], unique=True)
|
||||
op.create_index('ix_tenants_slug', 'tenants', ['slug'], unique=True)
|
||||
op.create_index('ix_tenants_status_type', 'tenants', ['status', 'tenant_type'], unique=False)
|
||||
op.create_index('ix_tenants_municipality_code', 'tenants', ['municipality_code'], unique=False)
|
||||
|
||||
# ─── Users table ────────────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('hashed_password', sa.String(length=255), nullable=True),
|
||||
sa.Column('first_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('last_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('role', sa.Enum('admin', 'manager', 'analyst', 'viewer', name='userrole'), nullable=False),
|
||||
sa.Column('status', sa.Enum('active', 'inactive', 'pending', name='userstatus'), nullable=False),
|
||||
sa.Column('is_superuser', sa.Boolean(), nullable=True),
|
||||
sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_users_email', 'users', ['email'], unique=True)
|
||||
|
||||
# ─── Invitations table ──────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
'invitations',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('token', sa.String(length=255), nullable=False),
|
||||
sa.Column('role', sa.Enum('admin', 'manager', 'analyst', 'viewer', name='userrole'), nullable=False),
|
||||
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('invited_by_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('accepted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['invited_by_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_invitations_email', 'invitations', ['email'], unique=False)
|
||||
op.create_index('ix_invitations_token', 'invitations', ['token'], unique=True)
|
||||
|
||||
# ─── Audit Logs table ───────────────────────────────────────────────────
|
||||
op.create_table(
|
||||
'audit_logs',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('action', sa.Enum('CREATE', 'READ', 'UPDATE', 'DELETE', 'ACTIVATE', 'SUSPEND', 'CANCEL', 'LOGIN', 'LOGOUT', 'EXPORT', 'IMPORT', 'SCHEMA_CREATE', 'SCHEMA_DROP', name='audit_action_enum'), nullable=False),
|
||||
sa.Column('entity_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('entity_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('actor_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('actor_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('actor_ip', sa.String(length=45), nullable=True),
|
||||
sa.Column('actor_user_agent', sa.Text(), nullable=True),
|
||||
sa.Column('previous_values', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('new_values', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('changed_fields', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('request_id', sa.String(length=100), nullable=True),
|
||||
sa.Column('request_method', sa.String(length=10), nullable=True),
|
||||
sa.Column('request_path', sa.Text(), nullable=True),
|
||||
sa.Column('request_body', sa.Text(), nullable=True),
|
||||
sa.Column('success', sa.String(length=1), nullable=False),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('tenant_schema', sa.String(length=100), nullable=True),
|
||||
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_audit_logs_action', 'audit_logs', ['action'], unique=False)
|
||||
op.create_index('ix_audit_logs_actor_id', 'audit_logs', ['actor_id'], unique=False)
|
||||
op.create_index('ix_audit_logs_actor_tenant', 'audit_logs', ['actor_id', 'tenant_id'], unique=False)
|
||||
op.create_index('ix_audit_logs_entity', 'audit_logs', ['entity_type', 'entity_id', 'timestamp'], unique=False)
|
||||
op.create_index('ix_audit_logs_entity_id', 'audit_logs', ['entity_id'], unique=False)
|
||||
op.create_index('ix_audit_logs_entity_type', 'audit_logs', ['entity_type'], unique=False)
|
||||
op.create_index('ix_audit_logs_request_id', 'audit_logs', ['request_id'], unique=False)
|
||||
op.create_index('ix_audit_logs_tenant_id', 'audit_logs', ['tenant_id'], unique=False)
|
||||
op.create_index('ix_audit_logs_timestamp', 'audit_logs', ['timestamp'], unique=False)
|
||||
op.create_index('ix_audit_logs_timestamp_action', 'audit_logs', ['timestamp', 'action'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_audit_logs_timestamp_action', table_name='audit_logs')
|
||||
op.drop_index('ix_audit_logs_timestamp', table_name='audit_logs')
|
||||
op.drop_index('ix_audit_logs_tenant_id', table_name='audit_logs')
|
||||
op.drop_index('ix_audit_logs_request_id', table_name='audit_logs')
|
||||
op.drop_index('ix_audit_logs_entity_type', table_name='audit_logs')
|
||||
op.drop_index('ix_audit_logs_entity_id', table_name='audit_logs')
|
||||
op.drop_index('ix_audit_logs_entity', table_name='audit_logs')
|
||||
op.drop_index('ix_audit_logs_actor_tenant', table_name='audit_logs')
|
||||
op.drop_index('ix_audit_logs_actor_id', table_name='audit_logs')
|
||||
op.drop_index('ix_audit_logs_action', table_name='audit_logs')
|
||||
op.drop_table('audit_logs')
|
||||
|
||||
op.drop_index('ix_invitations_token', table_name='invitations')
|
||||
op.drop_index('ix_invitations_email', table_name='invitations')
|
||||
op.drop_table('invitations')
|
||||
|
||||
op.drop_index('ix_users_email', table_name='users')
|
||||
op.drop_table('users')
|
||||
|
||||
op.drop_index('ix_tenants_municipality_code', table_name='tenants')
|
||||
op.drop_index('ix_tenants_status_type', table_name='tenants')
|
||||
op.drop_index('ix_tenants_slug', table_name='tenants')
|
||||
op.drop_index('ix_tenants_schema_name', table_name='tenants')
|
||||
op.drop_index('ix_tenants_org_number', table_name='tenants')
|
||||
op.drop_index('ix_tenants_name', table_name='tenants')
|
||||
op.drop_index('ix_tenants_country_city', table_name='tenants')
|
||||
op.drop_table('tenants')
|
||||
|
||||
# Drop enums
|
||||
op.execute("DROP TYPE IF EXISTS audit_action_enum CASCADE")
|
||||
op.execute("DROP TYPE IF EXISTS tenant_status_enum CASCADE")
|
||||
op.execute("DROP TYPE IF EXISTS tenant_type_enum CASCADE")
|
||||
op.execute("DROP TYPE IF EXISTS userstatus CASCADE")
|
||||
op.execute("DROP TYPE IF EXISTS userrole CASCADE")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
LandveX Admin Backend — Audit Log API Routes
|
||||
Endpoints for querying audit logs
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException, status
|
||||
|
||||
from app.core.security import get_current_user, require_admin
|
||||
from app.api.dependencies import get_audit_service, get_request_context
|
||||
from app.services.audit_service import AuditService
|
||||
from app.schemas.common import AuditLogResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/audit-logs", tags=["audit"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
summary="List audit logs",
|
||||
description="Query audit logs with filtering"
|
||||
)
|
||||
async def list_audit_logs(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
entity_type: Optional[str] = Query(None, description="Filter by entity type"),
|
||||
entity_id: Optional[str] = Query(None, description="Filter by entity ID"),
|
||||
actor_id: Optional[str] = Query(None, description="Filter by actor"),
|
||||
action: Optional[str] = Query(None, description="Filter by action type"),
|
||||
audit_service: AuditService = Depends(get_audit_service),
|
||||
current_user: Dict[str, Any] = Depends(require_admin)
|
||||
):
|
||||
"""List audit logs with filtering.
|
||||
|
||||
Admin access required.
|
||||
"""
|
||||
logs = await audit_service.get_recent(limit=limit)
|
||||
|
||||
# Apply filters in memory (for small datasets)
|
||||
# In production, add database-level filtering
|
||||
if entity_type:
|
||||
logs = [l for l in logs if l.entity_type == entity_type]
|
||||
if entity_id:
|
||||
logs = [l for l in logs if l.entity_id == entity_id]
|
||||
if actor_id:
|
||||
logs = [l for l in logs if l.actor_id == actor_id]
|
||||
if action:
|
||||
from app.models.audit_log import AuditAction
|
||||
try:
|
||||
action_enum = AuditAction(action)
|
||||
logs = [l for l in logs if l.action == action_enum]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"items": [log.to_dict() for log in logs],
|
||||
"total": len(logs),
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/entity/{entity_type}/{entity_id}",
|
||||
summary="Get entity audit history",
|
||||
description="Get all audit logs for a specific entity"
|
||||
)
|
||||
async def get_entity_audit_logs(
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
audit_service: AuditService = Depends(get_audit_service),
|
||||
current_user: Dict[str, Any] = Depends(require_admin)
|
||||
):
|
||||
"""Get audit logs for a specific entity."""
|
||||
logs, total = await audit_service.get_by_entity(
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return {
|
||||
"items": [log.to_dict() for log in logs],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/actor/{actor_id}",
|
||||
summary="Get actor audit history",
|
||||
description="Get all audit logs for a specific actor (user)"
|
||||
)
|
||||
async def get_actor_audit_logs(
|
||||
actor_id: str,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
audit_service: AuditService = Depends(get_audit_service),
|
||||
current_user: Dict[str, Any] = Depends(require_admin)
|
||||
):
|
||||
"""Get audit logs for a specific actor."""
|
||||
logs, total = await audit_service.get_by_actor(
|
||||
actor_id=actor_id,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return {
|
||||
"items": [log.to_dict() for log in logs],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
LandveX Admin Backend — API Dependencies
|
||||
FastAPI dependencies for auth, database, and request context
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, Request, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.core.security import get_current_user, require_admin
|
||||
from app.services.tenant_service import TenantService
|
||||
from app.services.audit_service import AuditService
|
||||
from app.core.exceptions import TenantNotFoundException, UnauthorizedTenantAccessException
|
||||
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_tenant_service(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> TenantService:
|
||||
"""Dependency to get tenant service."""
|
||||
return TenantService(db)
|
||||
|
||||
|
||||
async def get_audit_service(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> AuditService:
|
||||
"""Dependency to get audit service."""
|
||||
return AuditService(db)
|
||||
|
||||
|
||||
async def get_request_context(
|
||||
request: Request,
|
||||
current_user: Optional[Dict[str, Any]] = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""Build request context for audit logging."""
|
||||
|
||||
context = {
|
||||
"request_id": request.headers.get("X-Request-ID", ""),
|
||||
"request_method": request.method,
|
||||
"request_path": str(request.url.path),
|
||||
"actor_ip": request.client.host if request.client else None,
|
||||
"actor_user_agent": request.headers.get("user-agent", ""),
|
||||
}
|
||||
|
||||
if current_user:
|
||||
context["actor_id"] = current_user.get("id")
|
||||
|
||||
return context
|
||||
|
||||
|
||||
async def get_current_tenant_id(
|
||||
request: Request
|
||||
) -> Optional[str]:
|
||||
"""Extract tenant ID from request (header or path)."""
|
||||
# Try header first
|
||||
tenant_id = request.headers.get("X-Tenant-ID")
|
||||
if tenant_id:
|
||||
return tenant_id
|
||||
|
||||
# Try path parameter
|
||||
tenant_id = request.path_params.get("tenant_id")
|
||||
if tenant_id:
|
||||
return tenant_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def require_tenant_admin(
|
||||
tenant_id: UUID,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
tenant_service: TenantService = Depends(get_tenant_service)
|
||||
) -> Dict[str, Any]:
|
||||
"""Require admin access to a specific tenant."""
|
||||
|
||||
user_role = current_user.get("role", "")
|
||||
user_tenant = current_user.get("tenant_id")
|
||||
|
||||
# Global admin can access all tenants
|
||||
if user_role == "admin":
|
||||
return current_user
|
||||
|
||||
# Tenant admin can only access their own tenant
|
||||
if user_role == "tenant_admin" and str(user_tenant) == str(tenant_id):
|
||||
return current_user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Admin access required for tenant {tenant_id}"
|
||||
)
|
||||
|
||||
|
||||
async def optional_auth(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Optional authentication - returns user if token valid, None otherwise."""
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
from app.core.security import decode_token
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": payload.get("sub"),
|
||||
"email": payload.get("email"),
|
||||
"role": payload.get("role", "user"),
|
||||
"tenant_id": payload.get("tenant_id"),
|
||||
"permissions": payload.get("permissions", []),
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
"""
|
||||
LandveX Admin Backend — Tenant API Routes
|
||||
CRUD endpoints for tenant management with validation and audit logging
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, status, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.core.security import get_current_user, require_admin
|
||||
from app.core.exceptions import (
|
||||
LandvexException,
|
||||
TenantNotFoundException,
|
||||
TenantAlreadyExistsException,
|
||||
TenantValidationException,
|
||||
)
|
||||
from app.api.dependencies import (
|
||||
get_tenant_service,
|
||||
get_audit_service,
|
||||
get_request_context,
|
||||
require_tenant_admin,
|
||||
)
|
||||
from app.services.tenant_service import TenantService
|
||||
from app.services.audit_service import AuditService
|
||||
from app.schemas.tenant import (
|
||||
TenantCreate, TenantUpdate, TenantResponse, TenantDetailResponse,
|
||||
TenantListResponse, TenantListParams, TenantStatusUpdate,
|
||||
TenantActivateRequest, TenantSuspendRequest, TenantStats,
|
||||
)
|
||||
from app.schemas.common import ErrorResponse, SuccessResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/tenants", tags=["tenants"])
|
||||
|
||||
|
||||
# ─── Exception Handlers ───────────────────────────────────────────────────────
|
||||
|
||||
@router.exception_handler(LandvexException)
|
||||
async def landvex_exception_handler(request: Request, exc: LandvexException):
|
||||
"""Handle Landvex exceptions."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": exc.message,
|
||||
"details": exc.details,
|
||||
"request_id": request.headers.get("X-Request-ID", ""),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TenantDetailResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new tenant",
|
||||
description="Create a new municipality or company tenant with schema isolation"
|
||||
)
|
||||
async def create_tenant(
|
||||
data: TenantCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
current_user: Dict[str, Any] = Depends(require_admin),
|
||||
request_context: Dict[str, Any] = Depends(get_request_context)
|
||||
):
|
||||
"""Create a new tenant.
|
||||
|
||||
- **name**: Display name of the tenant
|
||||
- **slug**: URL-friendly unique identifier
|
||||
- **tenant_type**: Type of tenant (municipality, company, etc.)
|
||||
- **contact_email**: Primary contact email
|
||||
"""
|
||||
try:
|
||||
tenant = await tenant_service.create(
|
||||
data=data,
|
||||
actor_id=current_user.get("id"),
|
||||
request_context=request_context
|
||||
)
|
||||
return tenant.to_dict(include_sensitive=True)
|
||||
|
||||
except TenantAlreadyExistsException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
except TenantValidationException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
|
||||
# ─── READ ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=TenantListResponse,
|
||||
summary="List all tenants",
|
||||
description="List tenants with pagination, filtering, and sorting"
|
||||
)
|
||||
async def list_tenants(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
status: Optional[str] = Query(None, description="Filter by status"),
|
||||
tenant_type: Optional[str] = Query(None, description="Filter by type"),
|
||||
search: Optional[str] = Query(None, max_length=100, description="Search query"),
|
||||
country: Optional[str] = Query(None, max_length=2, description="Filter by country code"),
|
||||
city: Optional[str] = Query(None, max_length=100, description="Filter by city"),
|
||||
sort_by: str = Query("created_at", description="Sort field"),
|
||||
sort_order: str = Query("desc", pattern=r"^(asc|desc)$", description="Sort direction"),
|
||||
is_active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""List all tenants with filtering and pagination."""
|
||||
|
||||
from app.models.tenant import TenantStatus as TS, TenantType as TT
|
||||
|
||||
# Build params
|
||||
params = TenantListParams(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
search=search,
|
||||
country=country,
|
||||
city=city,
|
||||
is_active=is_active
|
||||
)
|
||||
|
||||
# Parse enum values
|
||||
if status:
|
||||
try:
|
||||
params.status = TS(status)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
|
||||
|
||||
if tenant_type:
|
||||
try:
|
||||
params.tenant_type = TT(tenant_type)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid tenant_type: {tenant_type}")
|
||||
|
||||
tenants, total = await tenant_service.list(params)
|
||||
|
||||
pages = (total + page_size - 1) // page_size
|
||||
|
||||
return TenantListResponse(
|
||||
items=[TenantResponse.model_validate(t.to_dict()) for t in tenants],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
pages=pages
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=TenantStats,
|
||||
summary="Get tenant statistics",
|
||||
description="Get aggregated statistics about all tenants"
|
||||
)
|
||||
async def get_tenant_stats(
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
current_user: Dict[str, Any] = Depends(require_admin)
|
||||
):
|
||||
"""Get tenant statistics."""
|
||||
stats = await tenant_service.get_statistics()
|
||||
return TenantStats.model_validate(stats)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tenant_id}",
|
||||
response_model=TenantDetailResponse,
|
||||
summary="Get tenant by ID",
|
||||
description="Get detailed information about a specific tenant"
|
||||
)
|
||||
async def get_tenant(
|
||||
tenant_id: UUID,
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
):
|
||||
"""Get a tenant by ID."""
|
||||
try:
|
||||
tenant = await tenant_service.get_by_id(tenant_id)
|
||||
|
||||
# Check access (admin can see all, others only their tenant)
|
||||
user_role = current_user.get("role", "")
|
||||
user_tenant = current_user.get("tenant_id")
|
||||
|
||||
if user_role != "admin" and str(user_tenant) != str(tenant_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied for this tenant"
|
||||
)
|
||||
|
||||
# Return sensitive data only for admins
|
||||
include_sensitive = user_role == "admin"
|
||||
return tenant.to_dict(include_sensitive=include_sensitive)
|
||||
|
||||
except TenantNotFoundException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
|
||||
# ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.put(
|
||||
"/{tenant_id}",
|
||||
response_model=TenantDetailResponse,
|
||||
summary="Update tenant",
|
||||
description="Update tenant information"
|
||||
)
|
||||
async def update_tenant(
|
||||
tenant_id: UUID,
|
||||
data: TenantUpdate,
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
request_context: Dict[str, Any] = Depends(get_request_context)
|
||||
):
|
||||
"""Update a tenant.
|
||||
|
||||
Only provided fields will be updated. Omit fields to keep current values.
|
||||
"""
|
||||
try:
|
||||
# Check permissions
|
||||
user_role = current_user.get("role", "")
|
||||
user_tenant = current_user.get("tenant_id")
|
||||
|
||||
if user_role != "admin" and str(user_tenant) != str(tenant_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied for this tenant"
|
||||
)
|
||||
|
||||
tenant = await tenant_service.update(
|
||||
tenant_id=tenant_id,
|
||||
data=data,
|
||||
actor_id=current_user.get("id"),
|
||||
request_context=request_context
|
||||
)
|
||||
|
||||
include_sensitive = user_role == "admin"
|
||||
return tenant.to_dict(include_sensitive=include_sensitive)
|
||||
|
||||
except TenantNotFoundException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
except TenantValidationException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
|
||||
# ─── DELETE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.delete(
|
||||
"/{tenant_id}",
|
||||
response_model=SuccessResponse,
|
||||
summary="Delete tenant",
|
||||
description="Soft delete a tenant (can be restored by admin)"
|
||||
)
|
||||
async def delete_tenant(
|
||||
tenant_id: UUID,
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
current_user: Dict[str, Any] = Depends(require_admin),
|
||||
request_context: Dict[str, Any] = Depends(get_request_context)
|
||||
):
|
||||
"""Soft delete a tenant.
|
||||
|
||||
The tenant data is preserved but marked as deleted.
|
||||
Use hard delete for permanent removal (admin only).
|
||||
"""
|
||||
try:
|
||||
tenant = await tenant_service.delete(
|
||||
tenant_id=tenant_id,
|
||||
actor_id=current_user.get("id"),
|
||||
request_context=request_context
|
||||
)
|
||||
|
||||
return SuccessResponse(
|
||||
message=f"Tenant '{tenant.name}' has been deleted",
|
||||
data={"tenant_id": str(tenant.id), "deleted_at": tenant.deleted_at.isoformat()}
|
||||
)
|
||||
|
||||
except TenantNotFoundException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
except TenantValidationException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{tenant_id}/hard",
|
||||
response_model=SuccessResponse,
|
||||
summary="Hard delete tenant",
|
||||
description="Permanently delete a tenant and all associated data"
|
||||
)
|
||||
async def hard_delete_tenant(
|
||||
tenant_id: UUID,
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
current_user: Dict[str, Any] = Depends(require_admin),
|
||||
request_context: Dict[str, Any] = Depends(get_request_context)
|
||||
):
|
||||
"""Hard delete a tenant (permanent removal).
|
||||
|
||||
**Warning**: This action cannot be undone. All tenant data will be permanently deleted.
|
||||
"""
|
||||
try:
|
||||
await tenant_service.hard_delete(
|
||||
tenant_id=tenant_id,
|
||||
actor_id=current_user.get("id"),
|
||||
request_context=request_context
|
||||
)
|
||||
|
||||
return SuccessResponse(
|
||||
message=f"Tenant {tenant_id} has been permanently deleted"
|
||||
)
|
||||
|
||||
except TenantNotFoundException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
|
||||
# ─── STATUS MANAGEMENT ────────────────────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/{tenant_id}/activate",
|
||||
response_model=TenantDetailResponse,
|
||||
summary="Activate tenant",
|
||||
description="Activate a pending or trial tenant"
|
||||
)
|
||||
async def activate_tenant(
|
||||
tenant_id: UUID,
|
||||
data: TenantActivateRequest = TenantActivateRequest(),
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
current_user: Dict[str, Any] = Depends(require_admin),
|
||||
request_context: Dict[str, Any] = Depends(get_request_context)
|
||||
):
|
||||
"""Activate a tenant.
|
||||
|
||||
Changes status from PENDING or TRIAL to ACTIVE.
|
||||
"""
|
||||
try:
|
||||
tenant = await tenant_service.activate(
|
||||
tenant_id=tenant_id,
|
||||
data=data,
|
||||
actor_id=current_user.get("id"),
|
||||
request_context=request_context
|
||||
)
|
||||
return tenant.to_dict(include_sensitive=True)
|
||||
|
||||
except TenantNotFoundException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
except TenantValidationException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tenant_id}/suspend",
|
||||
response_model=TenantDetailResponse,
|
||||
summary="Suspend tenant",
|
||||
description="Temporarily suspend a tenant"
|
||||
)
|
||||
async def suspend_tenant(
|
||||
tenant_id: UUID,
|
||||
data: TenantSuspendRequest,
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
current_user: Dict[str, Any] = Depends(require_admin),
|
||||
request_context: Dict[str, Any] = Depends(get_request_context)
|
||||
):
|
||||
"""Suspend a tenant.
|
||||
|
||||
Temporarily disables tenant access. Can be reactivated later.
|
||||
"""
|
||||
try:
|
||||
tenant = await tenant_service.suspend(
|
||||
tenant_id=tenant_id,
|
||||
data=data,
|
||||
actor_id=current_user.get("id"),
|
||||
request_context=request_context
|
||||
)
|
||||
return tenant.to_dict(include_sensitive=True)
|
||||
|
||||
except TenantNotFoundException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
except TenantValidationException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tenant_id}/cancel",
|
||||
response_model=TenantDetailResponse,
|
||||
summary="Cancel tenant",
|
||||
description="Cancel a tenant subscription"
|
||||
)
|
||||
async def cancel_tenant(
|
||||
tenant_id: UUID,
|
||||
reason: Optional[str] = None,
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
current_user: Dict[str, Any] = Depends(require_admin),
|
||||
request_context: Dict[str, Any] = Depends(get_request_context)
|
||||
):
|
||||
"""Cancel a tenant subscription.
|
||||
|
||||
Marks tenant as cancelled. This is typically irreversible.
|
||||
"""
|
||||
try:
|
||||
tenant = await tenant_service.cancel(
|
||||
tenant_id=tenant_id,
|
||||
reason=reason,
|
||||
actor_id=current_user.get("id"),
|
||||
request_context=request_context
|
||||
)
|
||||
return tenant.to_dict(include_sensitive=True)
|
||||
|
||||
except TenantNotFoundException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
except TenantValidationException as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.message)
|
||||
|
||||
|
||||
# ─── AUDIT LOGS ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get(
|
||||
"/{tenant_id}/audit-logs",
|
||||
summary="Get tenant audit logs",
|
||||
description="Get audit log history for a specific tenant"
|
||||
)
|
||||
async def get_tenant_audit_logs(
|
||||
tenant_id: UUID,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
audit_service: AuditService = Depends(get_audit_service),
|
||||
current_user: Dict[str, Any] = Depends(require_admin)
|
||||
):
|
||||
"""Get audit logs for a tenant."""
|
||||
logs, total = await audit_service.get_by_tenant(
|
||||
tenant_id=str(tenant_id),
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return {
|
||||
"items": [log.to_dict() for log in logs],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Konfiguration för LandveX Admin Backend (User Management + Tenant Management).
|
||||
"""
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "LandveX Admin Backend"
|
||||
DEBUG: bool = False
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/landvex"
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10
|
||||
|
||||
# Schema isolation
|
||||
DEFAULT_TENANT_SCHEMA: str = "public"
|
||||
TENANT_SCHEMA_PREFIX: str = "tenant_"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = "change-me-in-production-landvex-secret-key-2024"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Invitation
|
||||
INVITATION_TOKEN_EXPIRE_HOURS: int = 48
|
||||
FRONTEND_SET_PASSWORD_URL: str = "https://admin.landvex.se/set-password"
|
||||
|
||||
# Password policy
|
||||
MIN_PASSWORD_LENGTH: int = 8
|
||||
|
||||
# Audit logging
|
||||
AUDIT_LOG_TABLE: str = "audit_logs"
|
||||
AUDIT_RETENTION_DAYS: int = 365
|
||||
|
||||
# Pagination
|
||||
DEFAULT_PAGE_SIZE: int = 20
|
||||
MAX_PAGE_SIZE: int = 100
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True,
|
||||
extra="allow"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
LandveX Admin Backend — Configuration
|
||||
"""
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
extra="allow",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True
|
||||
)
|
||||
|
||||
# Application
|
||||
APP_NAME: str = "LandveX Admin Backend"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/landvex_admin"
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10
|
||||
|
||||
# Schema isolation
|
||||
DEFAULT_TENANT_SCHEMA: str = "public"
|
||||
TENANT_SCHEMA_PREFIX: str = "tenant_"
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = "change-me-in-production-landvex-secret-key-2026"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
ALGORITHM: str = "HS256"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: List[str] = ["*"]
|
||||
CORS_ALLOW_CREDENTIALS: bool = True
|
||||
CORS_ALLOW_METHODS: List[str] = ["*"]
|
||||
CORS_ALLOW_HEADERS: List[str] = ["*"]
|
||||
|
||||
# Audit logging
|
||||
AUDIT_LOG_TABLE: str = "audit_logs"
|
||||
AUDIT_RETENTION_DAYS: int = 365
|
||||
|
||||
# Pagination
|
||||
DEFAULT_PAGE_SIZE: int = 20
|
||||
MAX_PAGE_SIZE: int = 100
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
LandveX Admin Backend — Custom Exceptions
|
||||
"""
|
||||
from fastapi import HTTPException, status
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class LandvexException(Exception):
|
||||
"""Base exception for LandveX application."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = 500,
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class TenantNotFoundException(LandvexException):
|
||||
"""Raised when a tenant is not found."""
|
||||
|
||||
def __init__(self, tenant_id: str):
|
||||
super().__init__(
|
||||
message=f"Tenant not found: {tenant_id}",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
details={"tenant_id": tenant_id}
|
||||
)
|
||||
|
||||
|
||||
class TenantAlreadyExistsException(LandvexException):
|
||||
"""Raised when attempting to create a tenant that already exists."""
|
||||
|
||||
def __init__(self, identifier: str, field: str = "slug"):
|
||||
super().__init__(
|
||||
message=f"Tenant with {field} '{identifier}' already exists",
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
details={"field": field, "value": identifier}
|
||||
)
|
||||
|
||||
|
||||
class TenantValidationException(LandvexException):
|
||||
"""Raised when tenant data validation fails."""
|
||||
|
||||
def __init__(self, message: str, field: Optional[str] = None):
|
||||
details = {}
|
||||
if field:
|
||||
details["field"] = field
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
details=details
|
||||
)
|
||||
|
||||
|
||||
class TenantSchemaException(LandvexException):
|
||||
"""Raised when tenant schema operations fail."""
|
||||
|
||||
def __init__(self, tenant_id: str, operation: str, original_error: str):
|
||||
super().__init__(
|
||||
message=f"Schema operation '{operation}' failed for tenant {tenant_id}: {original_error}",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
details={"tenant_id": tenant_id, "operation": operation}
|
||||
)
|
||||
|
||||
|
||||
class UnauthorizedTenantAccessException(LandvexException):
|
||||
"""Raised when a user tries to access a tenant they don't have permission for."""
|
||||
|
||||
def __init__(self, tenant_id: str, user_id: str):
|
||||
super().__init__(
|
||||
message=f"User {user_id} is not authorized to access tenant {tenant_id}",
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
details={"tenant_id": tenant_id, "user_id": user_id}
|
||||
)
|
||||
|
||||
|
||||
class AuditLogException(LandvexException):
|
||||
"""Raised when audit logging fails."""
|
||||
|
||||
def __init__(self, operation: str, original_error: str):
|
||||
super().__init__(
|
||||
message=f"Audit logging failed for operation '{operation}': {original_error}",
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
details={"operation": operation}
|
||||
)
|
||||
|
||||
|
||||
def http_exception_handler(exc: LandvexException) -> HTTPException:
|
||||
"""Convert LandvexException to FastAPI HTTPException."""
|
||||
return HTTPException(
|
||||
status_code=exc.status_code,
|
||||
detail={
|
||||
"error": exc.message,
|
||||
"details": exc.details
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
LandveX Admin Backend — Security utilities
|
||||
JWT tokens, password hashing, and auth helpers
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a plain password against a hashed password."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
data: Dict[str, Any],
|
||||
expires_delta: Optional[timedelta] = None
|
||||
) -> str:
|
||||
"""Create a JWT access token."""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(data: Dict[str, Any]) -> str:
|
||||
"""Create a JWT refresh token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Decode and validate a JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security)
|
||||
) -> Dict[str, Any]:
|
||||
"""Get the current authenticated user from the JWT token."""
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return {
|
||||
"id": user_id,
|
||||
"email": payload.get("email"),
|
||||
"role": payload.get("role", "user"),
|
||||
"tenant_id": payload.get("tenant_id"),
|
||||
"permissions": payload.get("permissions", []),
|
||||
}
|
||||
|
||||
|
||||
async def require_admin(
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""Require admin role."""
|
||||
if current_user.get("role") != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_tenant_access(
|
||||
tenant_id: str,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user)
|
||||
) -> Dict[str, Any]:
|
||||
"""Require access to a specific tenant."""
|
||||
user_role = current_user.get("role", "")
|
||||
user_tenant = current_user.get("tenant_id")
|
||||
|
||||
# Admin can access all tenants
|
||||
if user_role == "admin":
|
||||
return current_user
|
||||
|
||||
# Users can only access their own tenant
|
||||
if user_tenant != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Access denied for tenant {tenant_id}"
|
||||
)
|
||||
|
||||
return current_user
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Databaskonfiguration och session-hantering för LandveX Admin Backend.
|
||||
Inkluderar tenant schema-isolering via PostgreSQL search_path.
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# SQLite stödjer inte pool_size/max_overflow
|
||||
if settings.DATABASE_URL.startswith("sqlite"):
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
)
|
||||
else:
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||
pool_pre_ping=True,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency för FastAPI-endpoints."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_context() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Context manager för databassessioner."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
class TenantContext:
|
||||
"""Context manager för tenant schema-isolering.
|
||||
|
||||
Använder PostgreSQL search_path och application context
|
||||
för att säkerställa dataisolering mellan tenants.
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession, tenant_schema: Optional[str] = None):
|
||||
self.session = session
|
||||
self.tenant_schema = tenant_schema or settings.DEFAULT_TENANT_SCHEMA
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Sätt tenant context på databasanslutningen."""
|
||||
await self.session.execute(
|
||||
text(f'SET search_path TO "{self.tenant_schema}", public')
|
||||
)
|
||||
await self.session.execute(
|
||||
text("SELECT set_config('app.current_tenant', :tenant, true)"),
|
||||
{"tenant": self.tenant_schema}
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Återställ tenant context."""
|
||||
await self.session.execute(text('SET search_path TO public'))
|
||||
await self.session.execute(
|
||||
text("SELECT set_config('app.current_tenant', '', true)")
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def set_tenant_schema(session: AsyncSession, tenant_schema: str) -> None:
|
||||
"""Sätt tenant schema för aktuell session."""
|
||||
await session.execute(
|
||||
text(f'SET search_path TO "{tenant_schema}", public')
|
||||
)
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.current_tenant', :tenant, true)"),
|
||||
{"tenant": tenant_schema}
|
||||
)
|
||||
|
||||
|
||||
async def reset_tenant_schema(session: AsyncSession) -> None:
|
||||
"""Återställ schema till default."""
|
||||
await session.execute(text('SET search_path TO public'))
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.current_tenant', '', true)")
|
||||
)
|
||||
|
||||
|
||||
async def create_tenant_schema(schema_name: str) -> None:
|
||||
"""Skapa ett nytt schema för en tenant."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema_name}"'))
|
||||
await conn.execute(text(f'GRANT USAGE ON SCHEMA "{schema_name}" TO PUBLIC'))
|
||||
|
||||
|
||||
async def drop_tenant_schema(schema_name: str) -> None:
|
||||
"""Ta bort ett tenant schema."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE'))
|
||||
|
||||
|
||||
async def schema_exists(schema_name: str) -> bool:
|
||||
"""Kolla om ett schema finns."""
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = :schema)"
|
||||
),
|
||||
{"schema": schema_name}
|
||||
)
|
||||
return result.scalar()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
LandveX Admin Backend — Database Base Model
|
||||
SQLAlchemy declarative base and mixins
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Column, DateTime, String, event
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import DeclarativeBase, declared_attr
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all database models."""
|
||||
|
||||
# Generate tablename automatically from class name
|
||||
@declared_attr.directive
|
||||
def __tablename__(cls) -> str:
|
||||
return cls.__name__.lower() + "s"
|
||||
|
||||
# Common columns for all tables
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model instance to dictionary."""
|
||||
result = {}
|
||||
for column in self.__table__.columns:
|
||||
value = getattr(self, column.name)
|
||||
if value is not None:
|
||||
if isinstance(value, datetime):
|
||||
result[column.name] = value.isoformat()
|
||||
else:
|
||||
result[column.name] = value
|
||||
return result
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}(id={self.id})>"
|
||||
|
||||
|
||||
class AuditMixin:
|
||||
"""Mixin to add audit fields to models."""
|
||||
|
||||
created_by = Column(String(255), nullable=True)
|
||||
updated_by = Column(String(255), nullable=True)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
deleted_by = Column(String(255), nullable=True)
|
||||
is_deleted = Column(String(1), default="N") # Soft delete flag
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Check if record is active (not soft-deleted)."""
|
||||
return self.is_deleted == "N" and self.deleted_at is None
|
||||
|
||||
|
||||
class MetadataMixin:
|
||||
"""Mixin to add JSON metadata field to models."""
|
||||
|
||||
metadata_json = Column("metadata", JSONB, default=dict)
|
||||
|
||||
def get_metadata(self) -> Dict[str, Any]:
|
||||
"""Get metadata as dictionary."""
|
||||
return self.metadata_json or {}
|
||||
|
||||
def set_metadata(self, key: str, value: Any) -> None:
|
||||
"""Set a metadata value."""
|
||||
if self.metadata_json is None:
|
||||
self.metadata_json = {}
|
||||
self.metadata_json[key] = value
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
LandveX Admin Backend — Database Session Management
|
||||
Async SQLAlchemy session handling with tenant context
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import TenantSchemaException
|
||||
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||
pool_pre_ping=True,
|
||||
echo=settings.DEBUG,
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Dependency to get database session."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_db_context() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Context manager for database sessions."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
class TenantContext:
|
||||
"""Context manager for tenant schema isolation.
|
||||
|
||||
Uses PostgreSQL row-level security and schema search path
|
||||
to ensure data isolation between tenants.
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession, tenant_schema: Optional[str] = None):
|
||||
self.session = session
|
||||
self.tenant_schema = tenant_schema or settings.DEFAULT_TENANT_SCHEMA
|
||||
self._original_search_path = None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Set tenant context on database connection."""
|
||||
# Set search path to tenant schema first, then public
|
||||
await self.session.execute(
|
||||
text(f'SET search_path TO "{self.tenant_schema}", public')
|
||||
)
|
||||
# Set application context for RLS policies
|
||||
await self.session.execute(
|
||||
text("SELECT set_config('app.current_tenant', :tenant, true)"),
|
||||
{"tenant": self.tenant_schema}
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Reset tenant context."""
|
||||
# Reset search path
|
||||
await self.session.execute(text('SET search_path TO public'))
|
||||
# Clear application context
|
||||
await self.session.execute(
|
||||
text("SELECT set_config('app.current_tenant', '', true)")
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def set_tenant_schema(session: AsyncSession, tenant_schema: str) -> None:
|
||||
"""Set the tenant schema for the current session."""
|
||||
try:
|
||||
await session.execute(
|
||||
text(f'SET search_path TO "{tenant_schema}", public')
|
||||
)
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.current_tenant', :tenant, true)"),
|
||||
{"tenant": tenant_schema}
|
||||
)
|
||||
except Exception as e:
|
||||
raise TenantSchemaException(
|
||||
tenant_id=tenant_schema,
|
||||
operation="set_schema",
|
||||
original_error=str(e)
|
||||
)
|
||||
|
||||
|
||||
async def reset_tenant_schema(session: AsyncSession) -> None:
|
||||
"""Reset schema to default."""
|
||||
await session.execute(text('SET search_path TO public'))
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.current_tenant', '', true)")
|
||||
)
|
||||
|
||||
|
||||
async def create_tenant_schema(schema_name: str) -> None:
|
||||
"""Create a new schema for a tenant."""
|
||||
async with engine.begin() as conn:
|
||||
# Create schema if not exists
|
||||
await conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema_name}"'))
|
||||
# Grant usage
|
||||
await conn.execute(text(f'GRANT USAGE ON SCHEMA "{schema_name}" TO PUBLIC'))
|
||||
|
||||
|
||||
async def drop_tenant_schema(schema_name: str) -> None:
|
||||
"""Drop a tenant schema."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE'))
|
||||
|
||||
|
||||
async def schema_exists(schema_name: str) -> bool:
|
||||
"""Check if a schema exists."""
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = :schema)"
|
||||
),
|
||||
{"schema": schema_name}
|
||||
)
|
||||
return result.scalar()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""FastAPI dependencies för auth och databas."""
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import User, UserRole, UserStatus
|
||||
from app.schemas import TokenPayload
|
||||
from app.security import decode_token
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_token(token)
|
||||
if payload is None or payload.get("type") != "access":
|
||||
raise credentials_exception
|
||||
|
||||
user_id: Optional[str] = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
|
||||
result = await db.execute(select(User).where(User.id == UUID(user_id)))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
if user.status != UserStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is inactive or pending",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_superuser(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Superuser privilege required",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
class RoleChecker:
|
||||
def __init__(self, min_role: UserRole):
|
||||
self.min_role = min_role
|
||||
|
||||
async def __call__(self, current_user: User = Depends(get_current_user)) -> User:
|
||||
from app.security import role_level
|
||||
if role_level(current_user.role) < role_level(self.min_role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Requires role {self.min_role.value} or higher",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
require_admin = RoleChecker(UserRole.ADMIN)
|
||||
require_manager = RoleChecker(UserRole.MANAGER)
|
||||
require_analyst = RoleChecker(UserRole.ANALYST)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
FastAPI-applikation för LandveX Admin Backend.
|
||||
Kombinerar user management, tenant management och audit-logging.
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import engine, Base
|
||||
from app.routers import auth, users, invitations, tenants, audit, content, quixzoom, unified, aid_intelligence, aid_intelligence_v2, life_engine, life_engine_v2, life_core, life_platform, life_enterprise, life_standard, life_ecosystem, life_validation, life_metrics, rivp_pilot1, life_kpi, live_data, economy, hr, system
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: skapa tabeller (i dev; i prod använd Alembic)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
# Shutdown
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="LandveX Admin Backend",
|
||||
description="Admin-backend för tenant-hantering, användare, roller, inbjudningar och audit-logging.",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Begränsa i produktion
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Inkludera routers
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(users.router, prefix="/api/v1")
|
||||
app.include_router(invitations.router, prefix="/api/v1")
|
||||
app.include_router(tenants.router, prefix="/api/v1")
|
||||
app.include_router(audit.router, prefix="/api/v1")
|
||||
app.include_router(content.router, prefix="/api/v1")
|
||||
app.include_router(quixzoom.router, prefix="/api/v1")
|
||||
app.include_router(unified.router, prefix="/api/v1")
|
||||
app.include_router(aid_intelligence.router, prefix="/api/v1")
|
||||
app.include_router(aid_intelligence_v2.router, prefix="/api/v1")
|
||||
app.include_router(life_engine.router, prefix="/api/v1")
|
||||
app.include_router(life_engine_v2.router, prefix="/api/v1")
|
||||
app.include_router(life_core.router, prefix="/api/v1")
|
||||
app.include_router(life_platform.router, prefix="/api/v1")
|
||||
app.include_router(life_enterprise.router, prefix="/api/v1")
|
||||
app.include_router(life_standard.router, prefix="/api/v1")
|
||||
app.include_router(life_ecosystem.router, prefix="/api/v1")
|
||||
app.include_router(life_validation.router, prefix="/api/v1")
|
||||
app.include_router(life_metrics.router, prefix="/api/v1")
|
||||
app.include_router(rivp_pilot1.router, prefix="/api/v1")
|
||||
app.include_router(life_kpi.router, prefix="/api/v1")
|
||||
app.include_router(live_data.router, prefix="/api/v1")
|
||||
app.include_router(economy.router, prefix="/api/v1")
|
||||
app.include_router(hr.router, prefix="/api/v1")
|
||||
app.include_router(system.router, prefix="/api/v1")
|
||||
|
||||
# Serve static files (admin dashboard)
|
||||
app.mount("/admin", StaticFiles(directory="app/static/admin", html=True), name="admin")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "landvex-admin-backend",
|
||||
"version": settings.APP_NAME,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"name": "LandveX Admin Backend",
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
Landvex Admin Backend - Unified Models
|
||||
Includes: Content, quiXzoom, Economy, HR, Aid Intelligence
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, Text, JSON, ForeignKey, Enum, Numeric
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# ─── USER & AUTH ──────────────────────────────────────────────────────────────
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
SUPER_ADMIN = "super_admin"
|
||||
ADMIN = "admin"
|
||||
MANAGER = "manager"
|
||||
EDITOR = "editor"
|
||||
VIEWER = "viewer"
|
||||
ANALYST = "analyst"
|
||||
QUIXZOOM_MANAGER = "quixzoom_manager"
|
||||
ECONOMY_MANAGER = "economy_manager"
|
||||
HR_MANAGER = "hr_manager"
|
||||
AID_MANAGER = "aid_manager"
|
||||
|
||||
class UserStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
SUSPENDED = "suspended"
|
||||
|
||||
class TenantType(str, enum.Enum):
|
||||
ENTERPRISE = "enterprise"
|
||||
SMALL_BUSINESS = "small_business"
|
||||
INDIVIDUAL = "individual"
|
||||
MUNICIPALITY = "municipality"
|
||||
GOVERNMENT = "government"
|
||||
NGO = "ngo"
|
||||
|
||||
class TenantStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
SUSPENDED = "suspended"
|
||||
PENDING = "pending"
|
||||
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(String(50), unique=True, nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
tenant_type = Column(Enum(TenantType), default=TenantType.ENTERPRISE)
|
||||
status = Column(Enum(TenantStatus), default=TenantStatus.ACTIVE)
|
||||
settings = Column(JSON)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class Invitation(Base):
|
||||
__tablename__ = "invitations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
invitation_id = Column(String(50), unique=True, nullable=False)
|
||||
email = Column(String(255), nullable=False)
|
||||
role = Column(Enum(UserRole), default=UserRole.VIEWER)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id"))
|
||||
invited_by = Column(Integer, ForeignKey("users.id"))
|
||||
status = Column(String(50), default="pending") # pending, accepted, expired
|
||||
expires_at = Column(DateTime)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class AuditAction(str, enum.Enum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
DELETE = "delete"
|
||||
LOGIN = "login"
|
||||
LOGOUT = "logout"
|
||||
INVITE = "invite"
|
||||
ACCEPT = "accept"
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
action = Column(Enum(AuditAction), nullable=False)
|
||||
entity_type = Column(String(100)) # user, tenant, invitation, etc.
|
||||
entity_id = Column(String(100))
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id"))
|
||||
details = Column(JSON)
|
||||
ip_address = Column(String(50))
|
||||
user_agent = Column(String(500))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(255))
|
||||
role = Column(Enum(UserRole), default=UserRole.VIEWER)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id"))
|
||||
status = Column(Enum(UserStatus), default=UserStatus.ACTIVE)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
last_login = Column(DateTime)
|
||||
|
||||
# ─── CONTENT MODULE ───────────────────────────────────────────────────────────
|
||||
|
||||
class ArticleStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
REVIEW = "review"
|
||||
PUBLISHED = "published"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
class Article(Base):
|
||||
__tablename__ = "articles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
slug = Column(String(500), unique=True, index=True, nullable=False)
|
||||
content = Column(Text)
|
||||
excerpt = Column(String(1000))
|
||||
author_id = Column(Integer, ForeignKey("users.id"))
|
||||
status = Column(Enum(ArticleStatus), default=ArticleStatus.DRAFT)
|
||||
seo_title = Column(String(70))
|
||||
seo_description = Column(String(160))
|
||||
keywords = Column(JSON)
|
||||
published_at = Column(DateTime)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
author = relationship("User")
|
||||
|
||||
class SEOMetadata(Base):
|
||||
__tablename__ = "seo_metadata"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
page_path = Column(String(500), unique=True, nullable=False)
|
||||
title = Column(String(70))
|
||||
description = Column(String(160))
|
||||
canonical_url = Column(String(500))
|
||||
og_image = Column(String(500))
|
||||
schema_markup = Column(JSON)
|
||||
last_audit = Column(DateTime)
|
||||
audit_score = Column(Integer)
|
||||
|
||||
# ─── QUIXZOOM MODULE ──────────────────────────────────────────────────────────
|
||||
|
||||
class MissionStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
PENDING = "pending"
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
class Mission(Base):
|
||||
__tablename__ = "missions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
mission_id = Column(String(50), unique=True, nullable=False)
|
||||
title = Column(String(500), nullable=False)
|
||||
description = Column(Text)
|
||||
city = Column(String(255))
|
||||
country = Column(String(255))
|
||||
location = Column(JSON)
|
||||
reward_amount = Column(Numeric(10, 2))
|
||||
reward_currency = Column(String(3), default="SEK")
|
||||
status = Column(Enum(MissionStatus), default=MissionStatus.DRAFT)
|
||||
requirements = Column(JSON)
|
||||
start_date = Column(DateTime)
|
||||
end_date = Column(DateTime)
|
||||
max_submissions = Column(Integer)
|
||||
created_by = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class Contributor(Base):
|
||||
__tablename__ = "contributors"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
contributor_id = Column(String(50), unique=True, nullable=False)
|
||||
email = Column(String(255), unique=True)
|
||||
full_name = Column(String(255))
|
||||
phone = Column(String(50))
|
||||
country = Column(String(255))
|
||||
city = Column(String(255))
|
||||
reputation_score = Column(Integer, default=0)
|
||||
total_earnings = Column(Numeric(10, 2), default=0)
|
||||
total_submissions = Column(Integer, default=0)
|
||||
verified = Column(Boolean, default=False)
|
||||
status = Column(String(50), default="active")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class Submission(Base):
|
||||
__tablename__ = "submissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
submission_id = Column(String(50), unique=True, nullable=False)
|
||||
mission_id = Column(Integer, ForeignKey("missions.id"))
|
||||
contributor_id = Column(Integer, ForeignKey("contributors.id"))
|
||||
status = Column(String(50), default="pending")
|
||||
media_urls = Column(JSON)
|
||||
location = Column(JSON)
|
||||
ai_score = Column(Float)
|
||||
reviewer_notes = Column(Text)
|
||||
submitted_at = Column(DateTime, default=datetime.utcnow)
|
||||
reviewed_at = Column(DateTime)
|
||||
reviewed_by = Column(Integer, ForeignKey("users.id"))
|
||||
|
||||
# ─── ECONOMY MODULE ───────────────────────────────────────────────────────────
|
||||
|
||||
class AccountType(str, enum.Enum):
|
||||
ASSET = "asset"
|
||||
LIABILITY = "liability"
|
||||
EQUITY = "equity"
|
||||
REVENUE = "revenue"
|
||||
EXPENSE = "expense"
|
||||
|
||||
class Account(Base):
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
account_number = Column(String(20), unique=True, nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
account_type = Column(Enum(AccountType), nullable=False)
|
||||
parent_id = Column(Integer, ForeignKey("accounts.id"))
|
||||
balance = Column(Numeric(12, 2), default=0)
|
||||
currency = Column(String(3), default="SEK")
|
||||
description = Column(Text)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
parent = relationship("Account", remote_side=[id])
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
transaction_id = Column(String(50), unique=True, nullable=False)
|
||||
date = Column(DateTime, nullable=False)
|
||||
description = Column(Text)
|
||||
reference = Column(String(255))
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
debit_account_id = Column(Integer, ForeignKey("accounts.id"))
|
||||
credit_account_id = Column(Integer, ForeignKey("accounts.id"))
|
||||
status = Column(String(50), default="posted")
|
||||
created_by = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class BankAccount(Base):
|
||||
__tablename__ = "bank_accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
bank_name = Column(String(255), nullable=False)
|
||||
account_name = Column(String(255))
|
||||
account_number = Column(String(100))
|
||||
iban = Column(String(100))
|
||||
bic = Column(String(20))
|
||||
currency = Column(String(3), default="SEK")
|
||||
current_balance = Column(Numeric(12, 2), default=0)
|
||||
account_type = Column(String(50))
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# ─── HR MODULE ────────────────────────────────────────────────────────────────
|
||||
|
||||
class EmployeeStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
ON_LEAVE = "on_leave"
|
||||
TERMINATED = "terminated"
|
||||
CONTRACT = "contract"
|
||||
|
||||
class Employee(Base):
|
||||
__tablename__ = "employees"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
employee_id = Column(String(50), unique=True, nullable=False)
|
||||
first_name = Column(String(255), nullable=False)
|
||||
last_name = Column(String(255), nullable=False)
|
||||
email = Column(String(255), unique=True)
|
||||
phone = Column(String(50))
|
||||
department = Column(String(255))
|
||||
position = Column(String(255))
|
||||
start_date = Column(DateTime)
|
||||
end_date = Column(DateTime)
|
||||
salary = Column(Numeric(10, 2))
|
||||
salary_currency = Column(String(3), default="SEK")
|
||||
status = Column(Enum(EmployeeStatus), default=EmployeeStatus.ACTIVE)
|
||||
manager_id = Column(Integer, ForeignKey("employees.id"))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class Contractor(Base):
|
||||
__tablename__ = "contractors"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
contractor_id = Column(String(50), unique=True, nullable=False)
|
||||
company_name = Column(String(255))
|
||||
contact_name = Column(String(255))
|
||||
email = Column(String(255))
|
||||
phone = Column(String(50))
|
||||
services = Column(JSON)
|
||||
hourly_rate = Column(Numeric(10, 2))
|
||||
currency = Column(String(3), default="SEK")
|
||||
contract_start = Column(DateTime)
|
||||
contract_end = Column(DateTime)
|
||||
status = Column(String(50), default="active")
|
||||
|
||||
# ─── AID INTELLIGENCE MODULE ─────────────────────────────────────────────────
|
||||
|
||||
class AidProjectType(str, enum.Enum):
|
||||
SCHOOL = "school"
|
||||
HOSPITAL = "hospital"
|
||||
ROAD = "road"
|
||||
BRIDGE = "bridge"
|
||||
WATER = "water"
|
||||
POWER = "power"
|
||||
AGRICULTURE = "agriculture"
|
||||
RENEWABLE = "renewable"
|
||||
HOUSING = "housing"
|
||||
SANITATION = "sanitation"
|
||||
DIGITAL = "digital"
|
||||
ENVIRONMENTAL = "environmental"
|
||||
EMERGENCY = "emergency"
|
||||
FOOD = "food"
|
||||
EDUCATION = "education"
|
||||
HEALTHCARE = "healthcare"
|
||||
MICROFINANCE = "microfinance"
|
||||
WOMEN_EMPOWERMENT = "women_empowerment"
|
||||
YOUTH_EMPLOYMENT = "youth_employment"
|
||||
|
||||
class AidProjectStatus(str, enum.Enum):
|
||||
PLANNED = "planned"
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
DELAYED = "delayed"
|
||||
ABANDONED = "abandoned"
|
||||
UNDER_MAINTENANCE = "under_maintenance"
|
||||
|
||||
class AidProject(Base):
|
||||
__tablename__ = "aid_projects"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(String(50), unique=True, nullable=False)
|
||||
name = Column(String(500), nullable=False)
|
||||
description = Column(Text)
|
||||
country = Column(String(255), nullable=False)
|
||||
region = Column(String(255))
|
||||
district = Column(String(255))
|
||||
project_type = Column(Enum(AidProjectType), nullable=False)
|
||||
status = Column(Enum(AidProjectStatus), default=AidProjectStatus.PLANNED)
|
||||
funding_agency = Column(String(255))
|
||||
implementing_partner = Column(String(255))
|
||||
budget_usd = Column(Numeric(12, 2))
|
||||
start_date = Column(DateTime)
|
||||
expected_completion = Column(DateTime)
|
||||
actual_completion = Column(DateTime)
|
||||
location = Column(JSON)
|
||||
aii_score = Column(Integer)
|
||||
confidence = Column(Integer)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
class AidObservation(Base):
|
||||
__tablename__ = "aid_observations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
observation_id = Column(String(50), unique=True, nullable=False)
|
||||
project_id = Column(Integer, ForeignKey("aid_projects.id"))
|
||||
observation_type = Column(String(50), nullable=False)
|
||||
date = Column(DateTime, nullable=False)
|
||||
contributor_id = Column(String(100))
|
||||
location = Column(JSON)
|
||||
media_urls = Column(JSON)
|
||||
ai_analysis = Column(JSON)
|
||||
confidence = Column(Integer)
|
||||
verified = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class AidAlert(Base):
|
||||
__tablename__ = "aid_alerts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
alert_id = Column(String(50), unique=True, nullable=False)
|
||||
severity = Column(String(20), nullable=False)
|
||||
alert_type = Column(String(100), nullable=False)
|
||||
project_id = Column(Integer, ForeignKey("aid_projects.id"))
|
||||
description = Column(Text)
|
||||
detected_at = Column(DateTime, default=datetime.utcnow)
|
||||
confidence = Column(Integer)
|
||||
status = Column(String(50), default="open")
|
||||
resolved_at = Column(DateTime)
|
||||
resolved_by = Column(Integer, ForeignKey("users.id"))
|
||||
|
||||
# ─── SYSTEM HEALTH ────────────────────────────────────────────────────────────
|
||||
|
||||
class ServiceHealth(Base):
|
||||
__tablename__ = "service_health"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
service_name = Column(String(255), nullable=False)
|
||||
service_url = Column(String(500))
|
||||
status = Column(String(50), default="unknown")
|
||||
last_check = Column(DateTime, default=datetime.utcnow)
|
||||
response_time_ms = Column(Integer)
|
||||
error_rate = Column(Float)
|
||||
uptime_percentage = Column(Float)
|
||||
@@ -0,0 +1 @@
|
||||
# Routers package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
Landvex Aid Intelligence (LAI) Router.
|
||||
Dedikerad modul för att mäta biståndseffekt med oberoende verifiering.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/aid", tags=["aid_intelligence"])
|
||||
|
||||
|
||||
class ProjectType(str, Enum):
|
||||
SCHOOL = "school"
|
||||
HOSPITAL = "hospital"
|
||||
ROAD = "road"
|
||||
BRIDGE = "bridge"
|
||||
WATER = "water"
|
||||
POWER = "power"
|
||||
AGRICULTURE = "agriculture"
|
||||
RENEWABLE = "renewable"
|
||||
HOUSING = "housing"
|
||||
SANITATION = "sanitation"
|
||||
DIGITAL = "digital"
|
||||
ENVIRONMENTAL = "environmental"
|
||||
EMERGENCY = "emergency"
|
||||
FOOD = "food"
|
||||
EDUCATION = "education"
|
||||
HEALTHCARE = "healthcare"
|
||||
MICROFINANCE = "microfinance"
|
||||
WOMEN_EMPOWERMENT = "women_empowerment"
|
||||
YOUTH_EMPLOYMENT = "youth_employment"
|
||||
|
||||
|
||||
class ProjectStatus(str, Enum):
|
||||
PLANNED = "planned"
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
DELAYED = "delayed"
|
||||
ABANDONED = "abandoned"
|
||||
UNDER_MAINTENANCE = "under_maintenance"
|
||||
|
||||
|
||||
class ObservationType(str, Enum):
|
||||
INFRASTRUCTURE = "infrastructure"
|
||||
ACTIVITY = "activity"
|
||||
MAINTENANCE = "maintenance"
|
||||
USAGE = "usage"
|
||||
ENVIRONMENTAL = "environmental"
|
||||
SAFETY = "safety"
|
||||
ACCESSIBILITY = "accessibility"
|
||||
|
||||
|
||||
# ─── PUBLIC ENDPOINTS ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/status")
|
||||
async def get_aid_status():
|
||||
"""Publik status för LAI."""
|
||||
return {
|
||||
"service": "Landvex Aid Intelligence",
|
||||
"status": "operational",
|
||||
"version": "1.0.0",
|
||||
"regions": ["Sub-Saharan Africa", "East Africa", "West Africa"],
|
||||
"projects_monitored": 1250,
|
||||
"observations_collected": 45000,
|
||||
"contributors_active": 3200,
|
||||
"last_update": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/projects")
|
||||
async def list_public_projects(
|
||||
country: Optional[str] = None,
|
||||
project_type: Optional[ProjectType] = None,
|
||||
status: Optional[ProjectStatus] = None,
|
||||
):
|
||||
"""Lista projekt med filtrering (publik)."""
|
||||
projects = [
|
||||
{
|
||||
"id": "proj-001",
|
||||
"name": "Mogadishu Primary School Renovation",
|
||||
"country": "Somalia",
|
||||
"region": "East Africa",
|
||||
"type": "school",
|
||||
"status": "active",
|
||||
"funding_agency": "UNICEF",
|
||||
"budget_usd": 250000,
|
||||
"start_date": "2025-03-01",
|
||||
"expected_completion": "2026-06-30",
|
||||
"aii_score": 72,
|
||||
"confidence": 85,
|
||||
"observations_count": 156,
|
||||
"last_observation": "2026-07-03",
|
||||
"location": {"lat": 2.0469, "lng": 45.3182}
|
||||
},
|
||||
{
|
||||
"id": "proj-002",
|
||||
"name": "Kampala Water Supply Extension",
|
||||
"country": "Uganda",
|
||||
"region": "East Africa",
|
||||
"type": "water",
|
||||
"status": "active",
|
||||
"funding_agency": "World Bank",
|
||||
"budget_usd": 1200000,
|
||||
"start_date": "2024-09-01",
|
||||
"expected_completion": "2026-12-31",
|
||||
"aii_score": 68,
|
||||
"confidence": 78,
|
||||
"observations_count": 203,
|
||||
"last_observation": "2026-07-02",
|
||||
"location": {"lat": 0.3476, "lng": 32.5825}
|
||||
},
|
||||
{
|
||||
"id": "proj-003",
|
||||
"name": "Accra Solar Microgrid",
|
||||
"country": "Ghana",
|
||||
"region": "West Africa",
|
||||
"type": "renewable",
|
||||
"status": "completed",
|
||||
"funding_agency": "GIZ",
|
||||
"budget_usd": 450000,
|
||||
"start_date": "2024-01-15",
|
||||
"expected_completion": "2025-12-31",
|
||||
"aii_score": 89,
|
||||
"confidence": 92,
|
||||
"observations_count": 312,
|
||||
"last_observation": "2026-07-01",
|
||||
"location": {"lat": 5.6037, "lng": -0.1870}
|
||||
}
|
||||
]
|
||||
|
||||
if country:
|
||||
projects = [p for p in projects if p["country"].lower() == country.lower()]
|
||||
if project_type:
|
||||
projects = [p for p in projects if p["type"] == project_type.value]
|
||||
if status:
|
||||
projects = [p for p in projects if p["status"] == status.value]
|
||||
|
||||
return {
|
||||
"projects": projects,
|
||||
"total": len(projects),
|
||||
"filters": {
|
||||
"country": country,
|
||||
"type": project_type.value if project_type else None,
|
||||
"status": status.value if status else None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/projects/{project_id}")
|
||||
async def get_public_project(project_id: str):
|
||||
"""Hämta detaljer för ett specifikt projekt (publik)."""
|
||||
return {
|
||||
"id": project_id,
|
||||
"name": "Mogadishu Primary School Renovation",
|
||||
"description": "Renovation and expansion of primary school facilities including classrooms, sanitation, and water access.",
|
||||
"country": "Somalia",
|
||||
"region": "East Africa",
|
||||
"district": "Banadir",
|
||||
"type": "school",
|
||||
"status": "active",
|
||||
"funding_agency": "UNICEF",
|
||||
"implementing_partner": "Somali Education Consortium",
|
||||
"budget_usd": 250000,
|
||||
"start_date": "2025-03-01",
|
||||
"expected_completion": "2026-06-30",
|
||||
"actual_completion": None,
|
||||
"location": {"lat": 2.0469, "lng": 45.3182, "accuracy": "10m"},
|
||||
"aii_score": 72,
|
||||
"confidence": 85,
|
||||
"dimensions": {
|
||||
"accessibility": {"score": 80, "confidence": 90},
|
||||
"operational_status": {"score": 75, "confidence": 85},
|
||||
"maintenance_quality": {"score": 65, "confidence": 80},
|
||||
"population_usage": {"score": 70, "confidence": 82},
|
||||
"economic_activity": {"score": 60, "confidence": 75},
|
||||
"safety": {"score": 78, "confidence": 88},
|
||||
"environmental_sustainability": {"score": 72, "confidence": 85},
|
||||
"community_engagement": {"score": 85, "confidence": 90}
|
||||
},
|
||||
"observations": {
|
||||
"total": 156,
|
||||
"last_30_days": 23,
|
||||
"contributors": 12,
|
||||
"ai_validated": 148,
|
||||
"human_verified": 45
|
||||
},
|
||||
"timeline": [
|
||||
{"date": "2025-03-01", "event": "Project start", "type": "milestone"},
|
||||
{"date": "2025-06-15", "event": "Foundation completed", "type": "milestone"},
|
||||
{"date": "2025-11-20", "event": "Structure completed", "type": "milestone"},
|
||||
{"date": "2026-02-10", "event": "Roof installed", "type": "milestone"},
|
||||
{"date": "2026-07-03", "event": "Interior work ongoing", "type": "observation"}
|
||||
],
|
||||
"risks": [
|
||||
{
|
||||
"type": "delay",
|
||||
"severity": "medium",
|
||||
"description": "Interior completion 2 weeks behind schedule",
|
||||
"detected": "2026-06-15",
|
||||
"confidence": 75
|
||||
}
|
||||
],
|
||||
"transparency": {
|
||||
"observations_url": f"/api/v1/aid/public/projects/{project_id}/observations",
|
||||
"methodology": "AII-v1.0",
|
||||
"data_sources": ["quiXzoom", "satellite", "open_data"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/projects/{project_id}/observations")
|
||||
async def get_project_observations(project_id: str):
|
||||
"""Hämta alla observationer för ett projekt (publik)."""
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"observations": [
|
||||
{
|
||||
"id": "obs-001",
|
||||
"type": "infrastructure",
|
||||
"date": "2026-07-03",
|
||||
"contributor_id": "contr-123",
|
||||
"location": {"lat": 2.0469, "lng": 45.3182},
|
||||
"media": ["https://cdn.landvex.com/obs/001-a.jpg", "https://cdn.landvex.com/obs/001-b.jpg"],
|
||||
"ai_analysis": {
|
||||
"construction_progress": 85,
|
||||
"material_quality": "good",
|
||||
"safety_compliance": "acceptable"
|
||||
},
|
||||
"confidence": 88,
|
||||
"verified": True
|
||||
},
|
||||
{
|
||||
"id": "obs-002",
|
||||
"type": "activity",
|
||||
"date": "2026-07-02",
|
||||
"contributor_id": "contr-124",
|
||||
"location": {"lat": 2.0470, "lng": 45.3183},
|
||||
"media": ["https://cdn.landvex.com/obs/002-a.jpg"],
|
||||
"ai_analysis": {
|
||||
"worker_count": 12,
|
||||
"activity_level": "high",
|
||||
"equipment_present": True
|
||||
},
|
||||
"confidence": 82,
|
||||
"verified": True
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/aii")
|
||||
async def get_aid_impact_index(
|
||||
country: Optional[str] = None,
|
||||
region: Optional[str] = None,
|
||||
project_type: Optional[ProjectType] = None,
|
||||
):
|
||||
"""Hämta Aid Impact Index (AII) för regioner eller länder."""
|
||||
return {
|
||||
"index_name": "Aid Impact Index (AII)",
|
||||
"version": "1.0",
|
||||
"methodology_url": "https://landvex.com/aid/methodology",
|
||||
"filters": {
|
||||
"country": country,
|
||||
"region": region,
|
||||
"type": project_type.value if project_type else None
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"country": "Somalia",
|
||||
"region": "East Africa",
|
||||
"aii_score": 72,
|
||||
"confidence": 85,
|
||||
"projects_count": 45,
|
||||
"observations_count": 2340,
|
||||
"trend": "improving",
|
||||
"dimensions": {
|
||||
"accessibility": 75,
|
||||
"operational_status": 70,
|
||||
"maintenance_quality": 65,
|
||||
"population_usage": 72,
|
||||
"safety": 78
|
||||
}
|
||||
},
|
||||
{
|
||||
"country": "Uganda",
|
||||
"region": "East Africa",
|
||||
"aii_score": 68,
|
||||
"confidence": 78,
|
||||
"projects_count": 67,
|
||||
"observations_count": 3120,
|
||||
"trend": "stable",
|
||||
"dimensions": {
|
||||
"accessibility": 70,
|
||||
"operational_status": 72,
|
||||
"maintenance_quality": 60,
|
||||
"population_usage": 68,
|
||||
"safety": 70
|
||||
}
|
||||
},
|
||||
{
|
||||
"country": "Ghana",
|
||||
"region": "West Africa",
|
||||
"aii_score": 81,
|
||||
"confidence": 88,
|
||||
"projects_count": 89,
|
||||
"observations_count": 4560,
|
||||
"trend": "improving",
|
||||
"dimensions": {
|
||||
"accessibility": 85,
|
||||
"operational_status": 82,
|
||||
"maintenance_quality": 78,
|
||||
"population_usage": 80,
|
||||
"safety": 82
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/countries")
|
||||
async def list_countries():
|
||||
"""Lista alla länder med aktiv övervakning."""
|
||||
return {
|
||||
"countries": [
|
||||
{"code": "SO", "name": "Somalia", "region": "East Africa", "projects": 45, "aii": 72},
|
||||
{"code": "UG", "name": "Uganda", "region": "East Africa", "projects": 67, "aii": 68},
|
||||
{"code": "KE", "name": "Kenya", "region": "East Africa", "projects": 92, "aii": 74},
|
||||
{"code": "ET", "name": "Ethiopia", "region": "East Africa", "projects": 78, "aii": 65},
|
||||
{"code": "TZ", "name": "Tanzania", "region": "East Africa", "projects": 56, "aii": 70},
|
||||
{"code": "GH", "name": "Ghana", "region": "West Africa", "projects": 89, "aii": 81},
|
||||
{"code": "NG", "name": "Nigeria", "region": "West Africa", "projects": 134, "aii": 63},
|
||||
{"code": "SN", "name": "Senegal", "region": "West Africa", "projects": 34, "aii": 76},
|
||||
{"code": "ZA", "name": "South Africa", "region": "Southern Africa", "projects": 112, "aii": 82}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/risk-alerts")
|
||||
async def get_risk_alerts():
|
||||
"""Hämta aktuella riskvarningar."""
|
||||
return {
|
||||
"alerts": [
|
||||
{
|
||||
"id": "alert-001",
|
||||
"severity": "high",
|
||||
"type": "abandoned_project",
|
||||
"project_id": "proj-045",
|
||||
"project_name": "Lagos Road Rehabilitation",
|
||||
"country": "Nigeria",
|
||||
"description": "No observable activity for 90 days. Equipment removed from site.",
|
||||
"detected": "2026-06-15",
|
||||
"confidence": 92,
|
||||
"observations_supporting": 8
|
||||
},
|
||||
{
|
||||
"id": "alert-002",
|
||||
"severity": "medium",
|
||||
"type": "deterioration",
|
||||
"project_id": "proj-067",
|
||||
"project_name": "Nairobi Health Clinic",
|
||||
"country": "Kenya",
|
||||
"description": "Infrastructure showing signs of accelerated deterioration.",
|
||||
"detected": "2026-07-01",
|
||||
"confidence": 78,
|
||||
"observations_supporting": 5
|
||||
},
|
||||
{
|
||||
"id": "alert-003",
|
||||
"severity": "medium",
|
||||
"type": "delay",
|
||||
"project_id": "proj-001",
|
||||
"project_name": "Mogadishu Primary School Renovation",
|
||||
"country": "Somalia",
|
||||
"description": "Interior completion 2 weeks behind schedule.",
|
||||
"detected": "2026-06-20",
|
||||
"confidence": 75,
|
||||
"observations_supporting": 6
|
||||
}
|
||||
],
|
||||
"total": 3,
|
||||
"last_updated": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
# ─── AUTHENTICATED ENDPOINTS ──────────────────────────────────────────────────
|
||||
|
||||
@router.post("/projects")
|
||||
async def create_project(
|
||||
name: str,
|
||||
country: str,
|
||||
project_type: ProjectType,
|
||||
funding_agency: str,
|
||||
budget_usd: float,
|
||||
location: Dict[str, float],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Skapa ett nytt projekt för övervakning."""
|
||||
return {
|
||||
"status": "created",
|
||||
"project_id": f"proj-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
"name": name,
|
||||
"country": country,
|
||||
"type": project_type.value,
|
||||
"funding_agency": funding_agency,
|
||||
"budget_usd": budget_usd,
|
||||
"location": location,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"created_by": current_user.id if hasattr(current_user, 'id') else 'admin'
|
||||
}
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/detailed")
|
||||
async def get_project_detailed(
|
||||
project_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Hämta detaljerad projektinformation (autentiserad)."""
|
||||
return await get_public_project(project_id)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/observations")
|
||||
async def add_observation(
|
||||
project_id: str,
|
||||
observation_type: ObservationType,
|
||||
location: Dict[str, float],
|
||||
media_urls: List[str],
|
||||
notes: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Lägg till en observation till ett projekt."""
|
||||
return {
|
||||
"status": "added",
|
||||
"observation_id": f"obs-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
"project_id": project_id,
|
||||
"type": observation_type.value,
|
||||
"location": location,
|
||||
"media": media_urls,
|
||||
"notes": notes,
|
||||
"submitted_at": datetime.now().isoformat(),
|
||||
"submitted_by": current_user.id if hasattr(current_user, 'id') else 'admin'
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def get_aid_dashboard(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Hämta LAI dashboard för admin."""
|
||||
return {
|
||||
"organization": "Landvex Aid Intelligence",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"summary": {
|
||||
"projects_monitored": 1250,
|
||||
"projects_active": 890,
|
||||
"projects_completed": 340,
|
||||
"projects_at_risk": 20,
|
||||
"observations_total": 45000,
|
||||
"observations_last_30_days": 3200,
|
||||
"contributors_active": 3200,
|
||||
"countries_covered": 9,
|
||||
"regions_covered": 3
|
||||
},
|
||||
"kpis": {
|
||||
"average_aii_score": 72,
|
||||
"average_confidence": 82,
|
||||
"verification_rate": 94,
|
||||
"ai_validation_rate": 91,
|
||||
"response_time_hours": 48
|
||||
},
|
||||
"funding_monitored": {
|
||||
"total_usd": 450000000,
|
||||
"by_region": {
|
||||
"East Africa": 180000000,
|
||||
"West Africa": 150000000,
|
||||
"Southern Africa": 120000000
|
||||
}
|
||||
},
|
||||
"recent_activity": [
|
||||
{
|
||||
"type": "observation",
|
||||
"project": "Mogadishu Primary School",
|
||||
"description": "New observation added",
|
||||
"time": "2026-07-03T14:30:00Z"
|
||||
},
|
||||
{
|
||||
"type": "alert",
|
||||
"project": "Lagos Road Rehabilitation",
|
||||
"description": "Risk alert: Abandoned project detected",
|
||||
"time": "2026-07-02T09:15:00Z"
|
||||
}
|
||||
],
|
||||
"alerts_summary": {
|
||||
"high": 1,
|
||||
"medium": 2,
|
||||
"low": 5
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/reports/executive")
|
||||
async def get_executive_report(
|
||||
period: str = "quarterly",
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Hämta executive report för ledningen."""
|
||||
return {
|
||||
"report_type": "Executive Summary",
|
||||
"period": period,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"key_findings": [
|
||||
"72% average Aid Impact Index across all monitored projects",
|
||||
"94% of observations pass AI validation",
|
||||
"20 projects flagged for potential risk",
|
||||
"East Africa shows strongest improvement trend"
|
||||
],
|
||||
"recommendations": [
|
||||
"Increase observation frequency in high-risk projects",
|
||||
"Expand contributor network in West Africa",
|
||||
"Implement automated alert escalation",
|
||||
"Publish quarterly transparency report"
|
||||
],
|
||||
"financial_summary": {
|
||||
"total_monitored": "$450M",
|
||||
"high_performers": "$180M (40%)",
|
||||
"at_risk": "$12M (2.7%)",
|
||||
"verification_cost": "$450K (0.1%)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
Landvex Aid Intelligence v2 - Evidence-Based Impact Verification
|
||||
Independent platform for measuring, verifying and improving development aid outcomes.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/aid-v2", tags=["aid_intelligence_v2"])
|
||||
|
||||
|
||||
class VerificationStatus(str, Enum):
|
||||
VERIFIED = "verified"
|
||||
PARTIALLY_VERIFIED = "partially_verified"
|
||||
INSUFFICIENT_EVIDENCE = "insufficient_evidence"
|
||||
CONTRADICTION_DETECTED = "contradiction_detected"
|
||||
NOT_VERIFIED = "not_verified"
|
||||
|
||||
|
||||
class EvidenceType(str, Enum):
|
||||
SATELLITE = "satellite"
|
||||
FIELD_OBSERVATION = "field_observation"
|
||||
AI_ANALYSIS = "ai_analysis"
|
||||
OPEN_DATA = "open_data"
|
||||
OFFICIAL_REPORT = "official_report"
|
||||
COMMUNITY_FEEDBACK = "community_feedback"
|
||||
|
||||
|
||||
# ─── PUBLIC ENDPOINTS ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/philosophy")
|
||||
async def get_philosophy():
|
||||
"""Core philosophy and brand position."""
|
||||
return {
|
||||
"mission": "Build Landvex Aid Intelligence as the world's leading independent platform for measuring, verifying and improving the real-world impact of development aid.",
|
||||
"principles": [
|
||||
"Evidence over speculation",
|
||||
"Transparency over opacity",
|
||||
"Continuous learning over blame",
|
||||
"Independent verification over assumptions",
|
||||
"Long-term societal outcomes over short-term activity metrics",
|
||||
],
|
||||
"position": "We believe development aid should create measurable, sustainable improvements. We measure observable progress rather than assuming success or failure.",
|
||||
"commitment": "Our purpose is not to advocate for or against any organization. Our purpose is to increase transparency, accountability and learning by making observable outcomes measurable.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/projects")
|
||||
async def list_projects(
|
||||
country: Optional[str] = None,
|
||||
sector: Optional[str] = None,
|
||||
verification_status: Optional[VerificationStatus] = None,
|
||||
):
|
||||
"""List development projects with verification status."""
|
||||
projects = [
|
||||
{
|
||||
"id": "proj-001",
|
||||
"name": "Mogadishu Primary School Renovation",
|
||||
"country": "Somalia",
|
||||
"region": "East Africa",
|
||||
"sector": "education",
|
||||
"funding_agency": "UNICEF",
|
||||
"budget_usd": 250000,
|
||||
"verification_status": "partially_verified",
|
||||
"confidence_score": 72,
|
||||
"evidence_count": 156,
|
||||
"last_observation": "2026-07-03",
|
||||
"key_findings": [
|
||||
"Infrastructure 85% complete (observed)",
|
||||
"Interior work ongoing",
|
||||
"2 weeks behind reported schedule",
|
||||
],
|
||||
"discrepancies": [
|
||||
{
|
||||
"type": "schedule_variance",
|
||||
"description": "Reported completion 95% vs observed 85%",
|
||||
"confidence": 78,
|
||||
"severity": "medium",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "proj-002",
|
||||
"name": "Kampala Water Supply Extension",
|
||||
"country": "Uganda",
|
||||
"region": "East Africa",
|
||||
"sector": "water",
|
||||
"funding_agency": "World Bank",
|
||||
"budget_usd": 1200000,
|
||||
"verification_status": "verified",
|
||||
"confidence_score": 88,
|
||||
"evidence_count": 203,
|
||||
"last_observation": "2026-07-02",
|
||||
"key_findings": [
|
||||
"Infrastructure operational",
|
||||
"Community utilization high",
|
||||
"Maintenance schedule followed",
|
||||
],
|
||||
"discrepancies": [],
|
||||
},
|
||||
]
|
||||
|
||||
if country:
|
||||
projects = [p for p in projects if p["country"].lower() == country.lower()]
|
||||
if sector:
|
||||
projects = [p for p in projects if p["sector"] == sector]
|
||||
if verification_status:
|
||||
projects = [p for p in projects if p["verification_status"] == verification_status.value]
|
||||
|
||||
return {
|
||||
"projects": projects,
|
||||
"total": len(projects),
|
||||
"methodology": "All findings are evidence-based and include confidence scores. Discrepancies are signals for further review, not conclusions of wrongdoing.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/projects/{project_id}/evidence")
|
||||
async def get_project_evidence(project_id: str):
|
||||
"""Get all evidence for a project with transparency layer."""
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"evidence_chain": [
|
||||
{
|
||||
"id": "ev-001",
|
||||
"type": "field_observation",
|
||||
"date": "2026-07-03",
|
||||
"source": "QUIXZOOM contributor",
|
||||
"location": {"lat": 2.0469, "lng": 45.3182},
|
||||
"media": ["https://cdn.landvex.com/obs/001-a.jpg"],
|
||||
"ai_analysis": {
|
||||
"construction_progress": 85,
|
||||
"material_quality": "good",
|
||||
"activity_level": "high",
|
||||
},
|
||||
"confidence": 88,
|
||||
"verified": True,
|
||||
},
|
||||
{
|
||||
"id": "ev-002",
|
||||
"type": "satellite",
|
||||
"date": "2026-07-01",
|
||||
"source": "Sentinel-2",
|
||||
"analysis": {
|
||||
"change_detected": True,
|
||||
"construction_activity": "active",
|
||||
"completion_estimate": 82,
|
||||
},
|
||||
"confidence": 92,
|
||||
"verified": True,
|
||||
},
|
||||
{
|
||||
"id": "ev-003",
|
||||
"type": "official_report",
|
||||
"date": "2026-06-15",
|
||||
"source": "UNICEF quarterly report",
|
||||
"claimed_progress": 95,
|
||||
"confidence": 100, # Official report
|
||||
},
|
||||
],
|
||||
"transparency": {
|
||||
"methodology": "Evidence is weighted by source reliability and cross-validated when possible.",
|
||||
"known_uncertainties": [
|
||||
"Interior completion percentage is estimate based on visual observation",
|
||||
"Satellite data may not detect recent changes (< 5 days)",
|
||||
],
|
||||
"confidence_calculation": "Weighted average of evidence scores, adjusted for source reliability and recency.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/verification-framework")
|
||||
async def get_verification_framework():
|
||||
"""Impact Verification Framework - multidimensional evaluation."""
|
||||
return {
|
||||
"framework_name": "Landvex Impact Verification Framework v1.0",
|
||||
"dimensions": [
|
||||
{
|
||||
"name": "Infrastructure Completion",
|
||||
"description": "Observable physical completion of planned infrastructure",
|
||||
"indicators": ["Construction progress", "Material quality", "Specification compliance"],
|
||||
"measurement": "Visual observation, AI analysis, satellite comparison",
|
||||
},
|
||||
{
|
||||
"name": "Operational Continuity",
|
||||
"description": "Whether facilities remain operational over time",
|
||||
"indicators": ["Activity levels", "Staff presence", "Service availability"],
|
||||
"measurement": "Repeated field observations, community feedback",
|
||||
},
|
||||
{
|
||||
"name": "Maintenance Quality",
|
||||
"description": "Observable maintenance and upkeep",
|
||||
"indicators": ["Physical condition", "Repair frequency", "Cleanliness"],
|
||||
"measurement": "Visual inspection, deterioration tracking",
|
||||
},
|
||||
{
|
||||
"name": "Accessibility",
|
||||
"description": "Physical and economic access for target population",
|
||||
"indicators": ["Transport links", "Operating hours", "Cost barriers"],
|
||||
"measurement": "Field observation, community surveys",
|
||||
},
|
||||
{
|
||||
"name": "Community Utilization",
|
||||
"description": "Actual use by intended beneficiaries",
|
||||
"indicators": ["Visitor counts", "User satisfaction", "Usage patterns"],
|
||||
"measurement": "Observation, surveys, usage data",
|
||||
},
|
||||
{
|
||||
"name": "Environmental Sustainability",
|
||||
"description": "Environmental impact and sustainability",
|
||||
"indicators": ["Resource efficiency", "Pollution", "Ecosystem impact"],
|
||||
"measurement": "Satellite, environmental sensors, observation",
|
||||
},
|
||||
{
|
||||
"name": "Economic Enablement",
|
||||
"description": "Economic activity generated or enabled",
|
||||
"indicators": ["Local employment", "Market activity", "Income effects"],
|
||||
"measurement": "Economic observation, satellite (night lights), surveys",
|
||||
},
|
||||
],
|
||||
"scoring": {
|
||||
"scale": "0-100 per dimension",
|
||||
"confidence_interval": "Always included",
|
||||
"minimum_evidence": "At least 3 independent observations required",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/sustainable-impact-index")
|
||||
async def get_sustainable_impact_index():
|
||||
"""Sustainable Impact Index - long-term observable contribution."""
|
||||
return {
|
||||
"index_name": "Landvex Sustainable Impact Index (SII)",
|
||||
"version": "1.0",
|
||||
"description": "Estimates long-term observable contribution of development projects",
|
||||
"dimensions": [
|
||||
{"name": "Longevity", "weight": 0.15, "description": "Years of continuous operation"},
|
||||
{"name": "Community Benefit", "weight": 0.15, "description": "Observable community value"},
|
||||
{"name": "Infrastructure Durability", "weight": 0.15, "description": "Physical condition over time"},
|
||||
{"name": "Maintenance", "weight": 0.10, "description": "Observable upkeep quality"},
|
||||
{"name": "Operational Continuity", "weight": 0.10, "description": "Uninterrupted service"},
|
||||
{"name": "Population Reach", "weight": 0.10, "description": "Number of beneficiaries"},
|
||||
{"name": "Economic Enablement", "weight": 0.10, "description": "Economic activity generated"},
|
||||
{"name": "Education Impact", "weight": 0.05, "description": "Learning outcomes (where measurable)"},
|
||||
{"name": "Health Impact", "weight": 0.05, "description": "Health outcomes (where measurable)"},
|
||||
{"name": "Environmental Outcomes", "weight": 0.05, "description": "Environmental effects"},
|
||||
],
|
||||
"methodology": "Each dimension scored 0-100 based on measurable indicators. Weighted average produces final SII score. Confidence interval always included.",
|
||||
"example_scores": [
|
||||
{"project": "Accra Solar Microgrid", "sii_score": 89, "confidence": 92},
|
||||
{"project": "Mogadishu Primary School", "sii_score": 72, "confidence": 78},
|
||||
{"project": "Kampala Water Supply", "sii_score": 85, "confidence": 88},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/contradictions")
|
||||
async def list_contradictions():
|
||||
"""Identified discrepancies between reported and observed data."""
|
||||
return {
|
||||
"disclaimer": "These findings are signals for further review, not conclusions of wrongdoing. They indicate areas where additional verification may be valuable.",
|
||||
"contradictions": [
|
||||
{
|
||||
"id": "contr-001",
|
||||
"project": "Mogadishu Primary School Renovation",
|
||||
"type": "schedule_variance",
|
||||
"reported": "95% complete (June 2026)",
|
||||
"observed": "85% complete (July 2026)",
|
||||
"evidence_sources": ["Field observation", "Satellite", "AI analysis"],
|
||||
"confidence": 78,
|
||||
"severity": "medium",
|
||||
"recommended_action": "Follow-up observation in 30 days",
|
||||
},
|
||||
{
|
||||
"id": "contr-002",
|
||||
"project": "Lagos Road Rehabilitation",
|
||||
"type": "activity_gap",
|
||||
"reported": "Active construction",
|
||||
"observed": "No activity for 90 days, equipment removed",
|
||||
"evidence_sources": ["Field observation", "Satellite time-series"],
|
||||
"confidence": 92,
|
||||
"severity": "high",
|
||||
"recommended_action": "Contact implementing partner for status update",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/positive-outcomes")
|
||||
async def list_positive_outcomes():
|
||||
"""Projects demonstrating strong observable outcomes."""
|
||||
return {
|
||||
"description": "Projects that consistently demonstrate strong implementation and sustained operation. These enable benchmarking and dissemination of successful practices.",
|
||||
"projects": [
|
||||
{
|
||||
"id": "proj-003",
|
||||
"name": "Accra Solar Microgrid",
|
||||
"country": "Ghana",
|
||||
"sector": "energy",
|
||||
"strengths": [
|
||||
"Strong implementation (observed)",
|
||||
"Sustained operation (24+ months)",
|
||||
"High community utilization",
|
||||
"Long-term maintenance observed",
|
||||
"Positive environmental outcomes",
|
||||
"Transparent reporting",
|
||||
],
|
||||
"sii_score": 89,
|
||||
"confidence": 92,
|
||||
"benchmark_category": "Energy Infrastructure",
|
||||
},
|
||||
{
|
||||
"id": "proj-004",
|
||||
"name": "Nairobi Digital Health Records",
|
||||
"country": "Kenya",
|
||||
"sector": "health",
|
||||
"strengths": [
|
||||
"Operational continuity",
|
||||
"Staff training evident",
|
||||
"Patient utilization increasing",
|
||||
"Data transparency",
|
||||
],
|
||||
"sii_score": 84,
|
||||
"confidence": 86,
|
||||
"benchmark_category": "Digital Health",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/continuous-monitoring")
|
||||
async def get_monitoring_capabilities():
|
||||
"""Continuous monitoring capabilities and data sources."""
|
||||
return {
|
||||
"monitoring_types": [
|
||||
{
|
||||
"type": "Satellite Imagery",
|
||||
"frequency": "Every 5 days (Sentinel-2)",
|
||||
"capabilities": ["Change detection", "Construction progress", "Environmental monitoring"],
|
||||
"coverage": "Global",
|
||||
},
|
||||
{
|
||||
"type": "QUIXZOOM Field Observations",
|
||||
"frequency": "On-demand missions",
|
||||
"capabilities": ["Ground truth", "Activity verification", "Condition assessment"],
|
||||
"coverage": "20+ countries",
|
||||
},
|
||||
{
|
||||
"type": "AI Image Analysis",
|
||||
"frequency": "Real-time",
|
||||
"capabilities": ["Progress estimation", "Quality assessment", "Anomaly detection"],
|
||||
"coverage": "All observed projects",
|
||||
},
|
||||
{
|
||||
"type": "Open Data Integration",
|
||||
"frequency": "Continuous",
|
||||
"capabilities": ["Procurement tracking", "Financial transparency", "Report comparison"],
|
||||
"coverage": "Partner organizations",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/research")
|
||||
async def get_research_program():
|
||||
"""Development Effectiveness Science research track."""
|
||||
return {
|
||||
"program_name": "Landvex Development Effectiveness Science",
|
||||
"mission": "Develop scientifically grounded indices and models for measuring long-term societal impact",
|
||||
"research_areas": [
|
||||
{
|
||||
"name": "Impact Measurement Methodology",
|
||||
"description": "Rigorous methods for measuring development outcomes",
|
||||
"approach": "Combine established frameworks with novel AI-assisted techniques",
|
||||
},
|
||||
{
|
||||
"name": "Longitudinal Impact Analysis",
|
||||
"description": "Track project outcomes over 5-10 year periods",
|
||||
"approach": "Satellite time-series, repeated observations, outcome correlation",
|
||||
},
|
||||
{
|
||||
"name": "Bias Detection & Correction",
|
||||
"description": "Identify and correct for observational and selection biases",
|
||||
"approach": "Statistical methods, control groups, multi-source validation",
|
||||
},
|
||||
{
|
||||
"name": "Predictive Sustainability Modeling",
|
||||
"description": "Predict long-term sustainability based on early indicators",
|
||||
"approach": "Machine learning on historical project data",
|
||||
},
|
||||
],
|
||||
"publications": [
|
||||
{
|
||||
"title": "The Landvex Sustainable Impact Index: A Multidimensional Framework",
|
||||
"status": "In development",
|
||||
"expected_release": "2027 Q1",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── AUTHENTICATED ENDPOINTS ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def get_dashboard(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Admin dashboard for Aid Intelligence."""
|
||||
return {
|
||||
"organization": "Landvex Aid Intelligence",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"summary": {
|
||||
"projects_monitored": 1250,
|
||||
"projects_verified": 890,
|
||||
"projects_with_discrepancies": 45,
|
||||
"projects_exemplary": 120,
|
||||
"observations_total": 45000,
|
||||
"evidence_sources": 5,
|
||||
"countries_covered": 9,
|
||||
},
|
||||
"verification_summary": {
|
||||
"verified": 890,
|
||||
"partially_verified": 280,
|
||||
"insufficient_evidence": 65,
|
||||
"contradiction_detected": 15,
|
||||
},
|
||||
"key_metrics": {
|
||||
"average_confidence": 82,
|
||||
"average_sii_score": 74,
|
||||
"verification_rate": 94,
|
||||
"evidence_growth_monthly": 3200,
|
||||
},
|
||||
"recent_activity": [
|
||||
{
|
||||
"type": "observation",
|
||||
"project": "Mogadishu Primary School",
|
||||
"description": "New field observation added",
|
||||
"time": "2026-07-03T14:30:00Z",
|
||||
},
|
||||
{
|
||||
"type": "discrepancy",
|
||||
"project": "Lagos Road Rehabilitation",
|
||||
"description": "Activity gap detected - no construction for 90 days",
|
||||
"time": "2026-07-02T09:15:00Z",
|
||||
},
|
||||
{
|
||||
"type": "positive_outcome",
|
||||
"project": "Accra Solar Microgrid",
|
||||
"description": "24 months continuous operation verified",
|
||||
"time": "2026-07-01T16:00:00Z",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/flag-for-review")
|
||||
async def flag_project_for_review(
|
||||
project_id: str,
|
||||
reason: str,
|
||||
evidence_ids: List[str],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Flag a project for additional review (not accusation)."""
|
||||
return {
|
||||
"status": "flagged_for_review",
|
||||
"project_id": project_id,
|
||||
"reason": reason,
|
||||
"evidence_count": len(evidence_ids),
|
||||
"flagged_by": current_user.id if hasattr(current_user, 'id') else 'admin',
|
||||
"flagged_at": datetime.now().isoformat(),
|
||||
"next_steps": [
|
||||
"Contact implementing partner for clarification",
|
||||
"Schedule follow-up observation",
|
||||
"Review additional evidence sources",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Audit Log API Routes för LandveX Admin Backend.
|
||||
Endpoints för att söka och granska audit-loggar.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.dependencies import get_current_user, require_admin
|
||||
from app.database import get_db
|
||||
from app.schemas import AuditLogListOut
|
||||
from app.services.audit_service import AuditService
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/audit-logs", tags=["Audit"])
|
||||
|
||||
|
||||
@router.get("", response_model=AuditLogListOut)
|
||||
async def list_audit_logs(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
entity_type: Optional[str] = Query(None),
|
||||
entity_id: Optional[str] = Query(None),
|
||||
actor_id: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
current_user: User = Depends(require_admin),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Lista audit-loggar med filtrering. Kräver admin."""
|
||||
audit_service = AuditService(db)
|
||||
logs = await audit_service.get_recent(limit=limit + offset)
|
||||
|
||||
# Filtrera i minnet (för små datamängder)
|
||||
# I produktion: lägg till databasfiltrering
|
||||
if entity_type:
|
||||
logs = [l for l in logs if l.entity_type == entity_type]
|
||||
if entity_id:
|
||||
logs = [l for l in logs if l.entity_id == entity_id]
|
||||
if actor_id:
|
||||
logs = [l for l in logs if l.actor_id == actor_id]
|
||||
if action:
|
||||
from app.models import AuditAction
|
||||
try:
|
||||
action_enum = AuditAction(action)
|
||||
logs = [l for l in logs if l.action == action_enum]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Apply offset after filtering
|
||||
total = len(logs)
|
||||
logs = logs[offset:offset + limit]
|
||||
|
||||
return AuditLogListOut(
|
||||
items=[log.to_dict() for log in logs],
|
||||
total=total, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
|
||||
@router.get("/entity/{entity_type}/{entity_id}", response_model=AuditLogListOut)
|
||||
async def get_entity_audit_logs(
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: User = Depends(require_admin),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Hämta alla audit-loggar för en specifik entitet."""
|
||||
audit_service = AuditService(db)
|
||||
logs, total = await audit_service.get_by_entity(
|
||||
entity_type=entity_type, entity_id=entity_id, limit=limit, offset=offset
|
||||
)
|
||||
return AuditLogListOut(
|
||||
items=[log.to_dict() for log in logs],
|
||||
total=total, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
|
||||
@router.get("/actor/{actor_id}", response_model=AuditLogListOut)
|
||||
async def get_actor_audit_logs(
|
||||
actor_id: str,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: User = Depends(require_admin),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Hämta alla audit-loggar för en specifik användare (actor)."""
|
||||
audit_service = AuditService(db)
|
||||
logs, total = await audit_service.get_by_actor(
|
||||
actor_id=actor_id, limit=limit, offset=offset
|
||||
)
|
||||
return AuditLogListOut(
|
||||
items=[log.to_dict() for log in logs],
|
||||
total=total, limit=limit, offset=offset
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Auth-endpoints: login, refresh, change password."""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models import User
|
||||
from app.schemas import Token, LoginRequest, RefreshRequest, ChangePasswordRequest
|
||||
from app.security import create_access_token, create_refresh_token, decode_token
|
||||
from app.services.user_service import UserService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
service = UserService(db)
|
||||
user = await service.authenticate(data.email, data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user.last_login_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=30 * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
payload = decode_token(data.refresh_token)
|
||||
if payload is None or payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
from uuid import UUID
|
||||
user_id = payload.get("sub")
|
||||
service = UserService(db)
|
||||
user = await service.get_by_id(UUID(user_id))
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_in=30 * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
data: ChangePasswordRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
await service.change_password(current_user, data.current_password, data.new_password)
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(current_user: User = Depends(get_current_user)):
|
||||
from app.schemas import UserDetailOut
|
||||
return UserDetailOut.model_validate(current_user)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Content Management Module
|
||||
Handles articles, SEO metadata, and content publishing
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Article, SEOMetadata, ArticleStatus, User
|
||||
from app.core.security import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/content", tags=["content"])
|
||||
|
||||
# ─── ARTICLES ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/articles")
|
||||
async def list_articles(
|
||||
status: Optional[ArticleStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all articles with optional filtering."""
|
||||
query = db.query(Article)
|
||||
if status:
|
||||
query = query.filter(Article.status == status)
|
||||
articles = query.offset(skip).limit(limit).all()
|
||||
return {
|
||||
"articles": [
|
||||
{
|
||||
"id": a.id,
|
||||
"title": a.title,
|
||||
"slug": a.slug,
|
||||
"status": a.status.value,
|
||||
"author": a.author.full_name if a.author else None,
|
||||
"published_at": a.published_at.isoformat() if a.published_at else None,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
}
|
||||
for a in articles
|
||||
],
|
||||
"total": query.count(),
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
@router.post("/articles")
|
||||
async def create_article(
|
||||
title: str,
|
||||
slug: str,
|
||||
content: str,
|
||||
excerpt: Optional[str] = None,
|
||||
seo_title: Optional[str] = None,
|
||||
seo_description: Optional[str] = None,
|
||||
keywords: Optional[List[str]] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new article."""
|
||||
article = Article(
|
||||
title=title,
|
||||
slug=slug,
|
||||
content=content,
|
||||
excerpt=excerpt,
|
||||
author_id=current_user.id,
|
||||
seo_title=seo_title,
|
||||
seo_description=seo_description,
|
||||
keywords=keywords,
|
||||
)
|
||||
db.add(article)
|
||||
db.commit()
|
||||
db.refresh(article)
|
||||
return {"status": "created", "article_id": article.id, "slug": slug}
|
||||
|
||||
@router.get("/articles/{article_id}")
|
||||
async def get_article(
|
||||
article_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get a specific article."""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
return {
|
||||
"id": article.id,
|
||||
"title": article.title,
|
||||
"slug": article.slug,
|
||||
"content": article.content,
|
||||
"excerpt": article.excerpt,
|
||||
"status": article.status.value,
|
||||
"author": article.author.full_name if article.author else None,
|
||||
"seo_title": article.seo_title,
|
||||
"seo_description": article.seo_description,
|
||||
"keywords": article.keywords,
|
||||
"published_at": article.published_at.isoformat() if article.published_at else None,
|
||||
"created_at": article.created_at.isoformat() if article.created_at else None,
|
||||
"updated_at": article.updated_at.isoformat() if article.updated_at else None,
|
||||
}
|
||||
|
||||
@router.put("/articles/{article_id}")
|
||||
async def update_article(
|
||||
article_id: int,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
status: Optional[ArticleStatus] = None,
|
||||
seo_title: Optional[str] = None,
|
||||
seo_description: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update an article."""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
|
||||
if title:
|
||||
article.title = title
|
||||
if content:
|
||||
article.content = content
|
||||
if status:
|
||||
article.status = status
|
||||
if status == ArticleStatus.PUBLISHED and not article.published_at:
|
||||
article.published_at = datetime.utcnow()
|
||||
if seo_title:
|
||||
article.seo_title = seo_title
|
||||
if seo_description:
|
||||
article.seo_description = seo_description
|
||||
|
||||
db.commit()
|
||||
db.refresh(article)
|
||||
return {"status": "updated", "article_id": article.id}
|
||||
|
||||
@router.delete("/articles/{article_id}")
|
||||
async def delete_article(
|
||||
article_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete an article."""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Article not found")
|
||||
db.delete(article)
|
||||
db.commit()
|
||||
return {"status": "deleted", "article_id": article_id}
|
||||
|
||||
# ─── SEO METADATA ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/seo")
|
||||
async def list_seo_metadata(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all SEO metadata entries."""
|
||||
seo_entries = db.query(SEOMetadata).all()
|
||||
return {
|
||||
"entries": [
|
||||
{
|
||||
"id": s.id,
|
||||
"page_path": s.page_path,
|
||||
"title": s.title,
|
||||
"description": s.description,
|
||||
"audit_score": s.audit_score,
|
||||
"last_audit": s.last_audit.isoformat() if s.last_audit else None,
|
||||
}
|
||||
for s in seo_entries
|
||||
]
|
||||
}
|
||||
|
||||
@router.post("/seo")
|
||||
async def create_seo_metadata(
|
||||
page_path: str,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
canonical_url: Optional[str] = None,
|
||||
og_image: Optional[str] = None,
|
||||
schema_markup: Optional[dict] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create SEO metadata for a page."""
|
||||
seo = SEOMetadata(
|
||||
page_path=page_path,
|
||||
title=title,
|
||||
description=description,
|
||||
canonical_url=canonical_url,
|
||||
og_image=og_image,
|
||||
schema_markup=schema_markup,
|
||||
)
|
||||
db.add(seo)
|
||||
db.commit()
|
||||
db.refresh(seo)
|
||||
return {"status": "created", "seo_id": seo.id}
|
||||
|
||||
@router.put("/seo/{seo_id}")
|
||||
async def update_seo_metadata(
|
||||
seo_id: int,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
audit_score: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update SEO metadata."""
|
||||
seo = db.query(SEOMetadata).filter(SEOMetadata.id == seo_id).first()
|
||||
if not seo:
|
||||
raise HTTPException(status_code=404, detail="SEO metadata not found")
|
||||
|
||||
if title:
|
||||
seo.title = title
|
||||
if description:
|
||||
seo.description = description
|
||||
if audit_score is not None:
|
||||
seo.audit_score = audit_score
|
||||
seo.last_audit = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(seo)
|
||||
return {"status": "updated", "seo_id": seo.id}
|
||||
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
Economy/Ledger Management Module
|
||||
Handles accounts, transactions, bank accounts, and financial reporting
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Account, Transaction, BankAccount, AccountType, User
|
||||
from app.core.security import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/economy", tags=["economy"])
|
||||
|
||||
# ─── ACCOUNTS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/accounts")
|
||||
async def list_accounts(
|
||||
account_type: Optional[AccountType] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all accounts with optional filtering."""
|
||||
query = db.query(Account)
|
||||
if account_type:
|
||||
query = query.filter(Account.account_type == account_type)
|
||||
accounts = query.all()
|
||||
return {
|
||||
"accounts": [
|
||||
{
|
||||
"id": a.id,
|
||||
"account_number": a.account_number,
|
||||
"name": a.name,
|
||||
"type": a.account_type.value,
|
||||
"balance": float(a.balance) if a.balance else 0,
|
||||
"currency": a.currency,
|
||||
"is_active": a.is_active,
|
||||
}
|
||||
for a in accounts
|
||||
],
|
||||
"total": query.count(),
|
||||
}
|
||||
|
||||
@router.post("/accounts")
|
||||
async def create_account(
|
||||
account_number: str,
|
||||
name: str,
|
||||
account_type: AccountType,
|
||||
parent_id: Optional[int] = None,
|
||||
description: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new account."""
|
||||
account = Account(
|
||||
account_number=account_number,
|
||||
name=name,
|
||||
account_type=account_type,
|
||||
parent_id=parent_id,
|
||||
description=description,
|
||||
)
|
||||
db.add(account)
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
return {"status": "created", "account_id": account.id}
|
||||
|
||||
@router.get("/accounts/{account_id}")
|
||||
async def get_account(
|
||||
account_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get account details."""
|
||||
account = db.query(Account).filter(Account.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return {
|
||||
"id": account.id,
|
||||
"account_number": account.account_number,
|
||||
"name": account.name,
|
||||
"type": account.account_type.value,
|
||||
"balance": float(account.balance) if account.balance else 0,
|
||||
"currency": account.currency,
|
||||
"description": account.description,
|
||||
"is_active": account.is_active,
|
||||
}
|
||||
|
||||
# ─── TRANSACTIONS ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/transactions")
|
||||
async def list_transactions(
|
||||
account_id: Optional[int] = None,
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List transactions with filtering."""
|
||||
query = db.query(Transaction)
|
||||
if account_id:
|
||||
query = query.filter(
|
||||
(Transaction.debit_account_id == account_id) |
|
||||
(Transaction.credit_account_id == account_id)
|
||||
)
|
||||
if start_date:
|
||||
query = query.filter(Transaction.date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(Transaction.date <= end_date)
|
||||
|
||||
transactions = query.order_by(Transaction.date.desc()).offset(skip).limit(limit).all()
|
||||
return {
|
||||
"transactions": [
|
||||
{
|
||||
"id": t.id,
|
||||
"transaction_id": t.transaction_id,
|
||||
"date": t.date.isoformat() if t.date else None,
|
||||
"description": t.description,
|
||||
"amount": float(t.amount) if t.amount else 0,
|
||||
"debit_account_id": t.debit_account_id,
|
||||
"credit_account_id": t.credit_account_id,
|
||||
"status": t.status,
|
||||
"reference": t.reference,
|
||||
}
|
||||
for t in transactions
|
||||
],
|
||||
"total": query.count(),
|
||||
}
|
||||
|
||||
@router.post("/transactions")
|
||||
async def create_transaction(
|
||||
transaction_id: str,
|
||||
date: datetime,
|
||||
description: str,
|
||||
amount: float,
|
||||
debit_account_id: int,
|
||||
credit_account_id: int,
|
||||
reference: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new transaction (double-entry bookkeeping)."""
|
||||
# Verify accounts exist
|
||||
debit_account = db.query(Account).filter(Account.id == debit_account_id).first()
|
||||
credit_account = db.query(Account).filter(Account.id == credit_account_id).first()
|
||||
|
||||
if not debit_account or not credit_account:
|
||||
raise HTTPException(status_code=404, detail="One or both accounts not found")
|
||||
|
||||
transaction = Transaction(
|
||||
transaction_id=transaction_id,
|
||||
date=date,
|
||||
description=description,
|
||||
amount=Decimal(str(amount)),
|
||||
debit_account_id=debit_account_id,
|
||||
credit_account_id=credit_account_id,
|
||||
reference=reference,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
|
||||
# Update account balances
|
||||
debit_account.balance += Decimal(str(amount))
|
||||
credit_account.balance -= Decimal(str(amount))
|
||||
|
||||
db.add(transaction)
|
||||
db.commit()
|
||||
db.refresh(transaction)
|
||||
return {"status": "created", "transaction_id": transaction.id}
|
||||
|
||||
# ─── BANK ACCOUNTS ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/bank-accounts")
|
||||
async def list_bank_accounts(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all bank accounts."""
|
||||
accounts = db.query(BankAccount).all()
|
||||
return {
|
||||
"accounts": [
|
||||
{
|
||||
"id": a.id,
|
||||
"bank_name": a.bank_name,
|
||||
"account_name": a.account_name,
|
||||
"account_number": a.account_number,
|
||||
"iban": a.iban,
|
||||
"bic": a.bic,
|
||||
"currency": a.currency,
|
||||
"current_balance": float(a.current_balance) if a.current_balance else 0,
|
||||
"account_type": a.account_type,
|
||||
"is_active": a.is_active,
|
||||
}
|
||||
for a in accounts
|
||||
]
|
||||
}
|
||||
|
||||
@router.post("/bank-accounts")
|
||||
async def create_bank_account(
|
||||
bank_name: str,
|
||||
account_name: str,
|
||||
account_number: Optional[str] = None,
|
||||
iban: Optional[str] = None,
|
||||
bic: Optional[str] = None,
|
||||
currency: str = "SEK",
|
||||
account_type: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new bank account."""
|
||||
account = BankAccount(
|
||||
bank_name=bank_name,
|
||||
account_name=account_name,
|
||||
account_number=account_number,
|
||||
iban=iban,
|
||||
bic=bic,
|
||||
currency=currency,
|
||||
account_type=account_type,
|
||||
)
|
||||
db.add(account)
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
return {"status": "created", "bank_account_id": account.id}
|
||||
|
||||
# ─── REPORTS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/reports/balance-sheet")
|
||||
async def get_balance_sheet(
|
||||
as_of: Optional[date] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Generate balance sheet report."""
|
||||
assets = db.query(Account).filter(Account.account_type == AccountType.ASSET).all()
|
||||
liabilities = db.query(Account).filter(Account.account_type == AccountType.LIABILITY).all()
|
||||
equity = db.query(Account).filter(Account.account_type == AccountType.EQUITY).all()
|
||||
|
||||
return {
|
||||
"report_type": "Balance Sheet",
|
||||
"as_of": as_of.isoformat() if as_of else datetime.utcnow().date().isoformat(),
|
||||
"assets": {
|
||||
"total": sum(float(a.balance) for a in assets),
|
||||
"accounts": [{"name": a.name, "balance": float(a.balance)} for a in assets]
|
||||
},
|
||||
"liabilities": {
|
||||
"total": sum(float(l.balance) for l in liabilities),
|
||||
"accounts": [{"name": l.name, "balance": float(l.balance)} for l in liabilities]
|
||||
},
|
||||
"equity": {
|
||||
"total": sum(float(e.balance) for e in equity),
|
||||
"accounts": [{"name": e.name, "balance": float(e.balance)} for e in equity]
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/reports/income-statement")
|
||||
async def get_income_statement(
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Generate income statement (resultaträkning)."""
|
||||
revenues = db.query(Account).filter(Account.account_type == AccountType.REVENUE).all()
|
||||
expenses = db.query(Account).filter(Account.account_type == AccountType.EXPENSE).all()
|
||||
|
||||
total_revenue = sum(float(r.balance) for r in revenues)
|
||||
total_expenses = sum(float(e.balance) for e in expenses)
|
||||
|
||||
return {
|
||||
"report_type": "Income Statement",
|
||||
"period": {"start": start_date.isoformat(), "end": end_date.isoformat()},
|
||||
"revenue": {
|
||||
"total": total_revenue,
|
||||
"accounts": [{"name": r.name, "balance": float(r.balance)} for r in revenues]
|
||||
},
|
||||
"expenses": {
|
||||
"total": total_expenses,
|
||||
"accounts": [{"name": e.name, "balance": float(e.balance)} for e in expenses]
|
||||
},
|
||||
"net_income": total_revenue - total_expenses,
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
HR Management Module
|
||||
Handles employees, contractors, payroll, and time tracking
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Employee, Contractor, EmployeeStatus, User
|
||||
from app.core.security import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/hr", tags=["hr"])
|
||||
|
||||
# ─── EMPLOYEES ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/employees")
|
||||
async def list_employees(
|
||||
status: Optional[EmployeeStatus] = None,
|
||||
department: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all employees with optional filtering."""
|
||||
query = db.query(Employee)
|
||||
if status:
|
||||
query = query.filter(Employee.status == status)
|
||||
if department:
|
||||
query = query.filter(Employee.department.ilike(f"%{department}%"))
|
||||
employees = query.offset(skip).limit(limit).all()
|
||||
return {
|
||||
"employees": [
|
||||
{
|
||||
"id": e.id,
|
||||
"employee_id": e.employee_id,
|
||||
"full_name": f"{e.first_name} {e.last_name}",
|
||||
"email": e.email,
|
||||
"department": e.department,
|
||||
"position": e.position,
|
||||
"status": e.status.value,
|
||||
"start_date": e.start_date.isoformat() if e.start_date else None,
|
||||
"salary": float(e.salary) if e.salary else None,
|
||||
"salary_currency": e.salary_currency,
|
||||
}
|
||||
for e in employees
|
||||
],
|
||||
"total": query.count(),
|
||||
}
|
||||
|
||||
@router.post("/employees")
|
||||
async def create_employee(
|
||||
employee_id: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
email: str,
|
||||
department: str,
|
||||
position: str,
|
||||
salary: float,
|
||||
start_date: datetime,
|
||||
phone: Optional[str] = None,
|
||||
manager_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new employee record."""
|
||||
employee = Employee(
|
||||
employee_id=employee_id,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
email=email,
|
||||
phone=phone,
|
||||
department=department,
|
||||
position=position,
|
||||
salary=salary,
|
||||
start_date=start_date,
|
||||
manager_id=manager_id,
|
||||
)
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
db.refresh(employee)
|
||||
return {"status": "created", "employee_id": employee.id}
|
||||
|
||||
@router.get("/employees/{employee_id}")
|
||||
async def get_employee(
|
||||
employee_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get employee details."""
|
||||
employee = db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
if not employee:
|
||||
raise HTTPException(status_code=404, detail="Employee not found")
|
||||
return {
|
||||
"id": employee.id,
|
||||
"employee_id": employee.employee_id,
|
||||
"first_name": employee.first_name,
|
||||
"last_name": employee.last_name,
|
||||
"email": employee.email,
|
||||
"phone": employee.phone,
|
||||
"department": employee.department,
|
||||
"position": employee.position,
|
||||
"status": employee.status.value,
|
||||
"start_date": employee.start_date.isoformat() if employee.start_date else None,
|
||||
"end_date": employee.end_date.isoformat() if employee.end_date else None,
|
||||
"salary": float(employee.salary) if employee.salary else None,
|
||||
"salary_currency": employee.salary_currency,
|
||||
"manager_id": employee.manager_id,
|
||||
}
|
||||
|
||||
@router.put("/employees/{employee_id}")
|
||||
async def update_employee(
|
||||
employee_id: int,
|
||||
department: Optional[str] = None,
|
||||
position: Optional[str] = None,
|
||||
salary: Optional[float] = None,
|
||||
status: Optional[EmployeeStatus] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update employee information."""
|
||||
employee = db.query(Employee).filter(Employee.id == employee_id).first()
|
||||
if not employee:
|
||||
raise HTTPException(status_code=404, detail="Employee not found")
|
||||
|
||||
if department:
|
||||
employee.department = department
|
||||
if position:
|
||||
employee.position = position
|
||||
if salary:
|
||||
employee.salary = salary
|
||||
if status:
|
||||
employee.status = status
|
||||
if status == EmployeeStatus.TERMINATED:
|
||||
employee.end_date = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(employee)
|
||||
return {"status": "updated", "employee_id": employee.id}
|
||||
|
||||
# ─── CONTRACTORS ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/contractors")
|
||||
async def list_contractors(
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all contractors."""
|
||||
query = db.query(Contractor)
|
||||
if status:
|
||||
query = query.filter(Contractor.status == status)
|
||||
contractors = query.all()
|
||||
return {
|
||||
"contractors": [
|
||||
{
|
||||
"id": c.id,
|
||||
"contractor_id": c.contractor_id,
|
||||
"company_name": c.company_name,
|
||||
"contact_name": c.contact_name,
|
||||
"email": c.email,
|
||||
"services": c.services,
|
||||
"hourly_rate": float(c.hourly_rate) if c.hourly_rate else None,
|
||||
"currency": c.currency,
|
||||
"contract_start": c.contract_start.isoformat() if c.contract_start else None,
|
||||
"contract_end": c.contract_end.isoformat() if c.contract_end else None,
|
||||
"status": c.status,
|
||||
}
|
||||
for c in contractors
|
||||
],
|
||||
"total": query.count(),
|
||||
}
|
||||
|
||||
@router.post("/contractors")
|
||||
async def create_contractor(
|
||||
contractor_id: str,
|
||||
company_name: str,
|
||||
contact_name: str,
|
||||
email: str,
|
||||
services: List[str],
|
||||
hourly_rate: float,
|
||||
contract_start: datetime,
|
||||
contract_end: Optional[datetime] = None,
|
||||
phone: Optional[str] = None,
|
||||
currency: str = "SEK",
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new contractor record."""
|
||||
contractor = Contractor(
|
||||
contractor_id=contractor_id,
|
||||
company_name=company_name,
|
||||
contact_name=contact_name,
|
||||
email=email,
|
||||
phone=phone,
|
||||
services=services,
|
||||
hourly_rate=hourly_rate,
|
||||
currency=currency,
|
||||
contract_start=contract_start,
|
||||
contract_end=contract_end,
|
||||
)
|
||||
db.add(contractor)
|
||||
db.commit()
|
||||
db.refresh(contractor)
|
||||
return {"status": "created", "contractor_id": contractor.id}
|
||||
|
||||
@router.get("/contractors/{contractor_id}")
|
||||
async def get_contractor(
|
||||
contractor_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get contractor details."""
|
||||
contractor = db.query(Contractor).filter(Contractor.id == contractor_id).first()
|
||||
if not contractor:
|
||||
raise HTTPException(status_code=404, detail="Contractor not found")
|
||||
return {
|
||||
"id": contractor.id,
|
||||
"contractor_id": contractor.contractor_id,
|
||||
"company_name": contractor.company_name,
|
||||
"contact_name": contractor.contact_name,
|
||||
"email": contractor.email,
|
||||
"phone": contractor.phone,
|
||||
"services": contractor.services,
|
||||
"hourly_rate": float(contractor.hourly_rate) if contractor.hourly_rate else None,
|
||||
"currency": contractor.currency,
|
||||
"contract_start": contractor.contract_start.isoformat() if contractor.contract_start else None,
|
||||
"contract_end": contractor.contract_end.isoformat() if contractor.contract_end else None,
|
||||
"status": contractor.status,
|
||||
}
|
||||
|
||||
# ─── PAYROLL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/payroll/summary")
|
||||
async def get_payroll_summary(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get payroll summary."""
|
||||
employees = db.query(Employee).filter(Employee.status == EmployeeStatus.ACTIVE).all()
|
||||
contractors = db.query(Contractor).filter(Contractor.status == "active").all()
|
||||
|
||||
total_salary = sum(float(e.salary) for e in employees if e.salary)
|
||||
total_contractor_cost = sum(float(c.hourly_rate) * 160 for c in contractors if c.hourly_rate) # Assume 160h/month
|
||||
|
||||
return {
|
||||
"period": datetime.utcnow().strftime("%Y-%m"),
|
||||
"employees": {
|
||||
"count": len(employees),
|
||||
"total_monthly_salary": total_salary,
|
||||
"currency": "SEK",
|
||||
},
|
||||
"contractors": {
|
||||
"count": len(contractors),
|
||||
"estimated_monthly_cost": total_contractor_cost,
|
||||
"currency": "SEK",
|
||||
},
|
||||
"total_monthly_cost": total_salary + total_contractor_cost,
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Invitation endpoints."""
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, require_manager
|
||||
from app.models import User
|
||||
from app.schemas import InvitationCreate, InvitationOut, AcceptInvitationRequest, UserOut
|
||||
from app.services.invitation_service import InvitationService
|
||||
|
||||
router = APIRouter(prefix="/invitations", tags=["Invitations"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[InvitationOut])
|
||||
async def list_invitations(
|
||||
tenant_id: Optional[UUID] = Query(None),
|
||||
pending_only: bool = Query(True),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
current_user: User = Depends(require_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = InvitationService(db)
|
||||
effective_tenant = tenant_id
|
||||
if not current_user.is_superuser and current_user.role.value == "manager":
|
||||
effective_tenant = current_user.tenant_id
|
||||
|
||||
items, total = await service.list_invitations(
|
||||
tenant_id=effective_tenant,
|
||||
pending_only=pending_only,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
@router.post("", response_model=InvitationOut, status_code=201)
|
||||
async def create_invitation(
|
||||
data: InvitationCreate,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = InvitationService(db)
|
||||
invitation = await service.create_invitation(data, invited_by=current_user)
|
||||
return InvitationOut.model_validate(invitation)
|
||||
|
||||
|
||||
@router.post("/accept", response_model=UserOut)
|
||||
async def accept_invitation(
|
||||
data: AcceptInvitationRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = InvitationService(db)
|
||||
user = await service.accept_invitation(data)
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.get("/validate/{token}")
|
||||
async def validate_invitation(
|
||||
token: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = InvitationService(db)
|
||||
invitation = await service.get_by_token(token)
|
||||
if invitation is None:
|
||||
return {"valid": False, "reason": "not_found"}
|
||||
if invitation.accepted_at is not None:
|
||||
return {"valid": False, "reason": "already_accepted"}
|
||||
if invitation.expires_at < datetime.utcnow():
|
||||
return {"valid": False, "reason": "expired"}
|
||||
return {
|
||||
"valid": True,
|
||||
"email": invitation.email,
|
||||
"role": invitation.role.value,
|
||||
"expires_at": invitation.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{invitation_id}", status_code=204)
|
||||
async def revoke_invitation(
|
||||
invitation_id: UUID,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = InvitationService(db)
|
||||
await service.revoke_invitation(invitation_id, actor=current_user)
|
||||
return None
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
@@ -0,0 +1,631 @@
|
||||
"""
|
||||
Landvex Intelligence Fusion Engine (LIFE) - Core Architecture v3
|
||||
AI-drivet operativsystem för verklighetsintelligens
|
||||
|
||||
9 Core Blocks:
|
||||
1-6. Layer Architecture (Acquisition → Normalization → Evidence → Intelligence → Knowledge → Decision)
|
||||
7. Decision Engine (Vad bör användaren göra nu?)
|
||||
8. Learning Engine (Självutvärdering och förbättring)
|
||||
9. Integration Layer (API, Dashboards, Alerts, Missions)
|
||||
|
||||
Core Principle: Evidence Before Opinion
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/life-core", tags=["life_core"])
|
||||
|
||||
|
||||
# ─── LAYER 1: ACQUISITION ─────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/layer-1-acquisition")
|
||||
async def get_acquisition_layer():
|
||||
"""
|
||||
Layer 1 – Acquisition: Datainsamling från alla källor
|
||||
"""
|
||||
return {
|
||||
"layer": 1,
|
||||
"name": "Acquisition",
|
||||
"description": "Kontinuerlig datainsamling från alla tillgängliga källor",
|
||||
"sources": {
|
||||
"quixzoom": {
|
||||
"type": "Crowdsourced field observations",
|
||||
"frequency": "On-demand + continuous",
|
||||
"data_types": ["Photos", "Video", "Audio", "GPS", "Structured forms"],
|
||||
"active_contributors": 3200,
|
||||
"daily_observations": 450,
|
||||
},
|
||||
"satellites": {
|
||||
"type": "Geospatial intelligence",
|
||||
"frequency": "Every 5 days (Sentinel-2)",
|
||||
"data_types": ["Multispectral imagery", "SAR", "Night lights", "Thermal"],
|
||||
"coverage": "Global",
|
||||
"resolution": "10m - 30m",
|
||||
},
|
||||
"drones": {
|
||||
"type": "High-resolution aerial",
|
||||
"frequency": "On-demand",
|
||||
"data_types": ["RGB", "Multispectral", "LiDAR", "Thermal"],
|
||||
"resolution": "Sub-meter",
|
||||
},
|
||||
"sensors": {
|
||||
"type": "IoT and environmental sensors",
|
||||
"frequency": "Real-time",
|
||||
"data_types": ["Weather", "Air quality", "Water levels", "Seismic", "Noise"],
|
||||
},
|
||||
"news": {
|
||||
"type": "News intelligence",
|
||||
"frequency": "Real-time",
|
||||
"sources": 2500,
|
||||
"languages": ["en", "fr", "es", "ar", "sw", "pt"],
|
||||
},
|
||||
"rss": {
|
||||
"type": "RSS feeds and web monitoring",
|
||||
"frequency": "Hourly",
|
||||
"feeds_monitored": 1200,
|
||||
},
|
||||
"public_databases": {
|
||||
"type": "Open data portals",
|
||||
"frequency": "Daily",
|
||||
"databases": ["World Bank", "UN OCHA", "OpenStreetMap", "GADM", "GHSL"],
|
||||
},
|
||||
"documents": {
|
||||
"type": "Document ingestion",
|
||||
"frequency": "Continuous",
|
||||
"types": ["PDF reports", "Excel", "Word", "PowerPoint", "HTML"],
|
||||
},
|
||||
"apis": {
|
||||
"type": "Third-party APIs",
|
||||
"frequency": "Real-time to daily",
|
||||
"integrations": ["ReliefWeb", "GDACS", "INFORM", "ACLED"],
|
||||
},
|
||||
"gis_data": {
|
||||
"type": "Geographic information systems",
|
||||
"frequency": "Weekly",
|
||||
"layers": ["Administrative boundaries", "Roads", "Buildings", "Land use", "Hydrology"],
|
||||
},
|
||||
},
|
||||
"metrics": {
|
||||
"total_sources": 10,
|
||||
"active_streams": 45,
|
||||
"daily_data_points": 125000,
|
||||
"monthly_storage_gb": 450,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── LAYER 2: NORMALIZATION ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/layer-2-normalization")
|
||||
async def get_normalization_layer():
|
||||
"""
|
||||
Layer 2 – Normalization: Standardisering och kvalitetssäkring
|
||||
"""
|
||||
return {
|
||||
"layer": 2,
|
||||
"name": "Normalization",
|
||||
"description": "Standardisering, geokodning, ontologimappning och kvalitetssäkring",
|
||||
"processes": {
|
||||
"standardization": {
|
||||
"description": "Konvertera alla data till standardformat",
|
||||
"formats": ["GeoJSON", "WKT", "ISO 8601", "Schema.org"],
|
||||
"daily_records_processed": 125000,
|
||||
},
|
||||
"geocoding": {
|
||||
"description": "Placera allt på en karta",
|
||||
"methods": ["GPS coordinates", "Address geocoding", "Place name resolution", "Relative positioning"],
|
||||
"accuracy": "±3m (GPS) to ±100m (geocoded)",
|
||||
},
|
||||
"ontology_mapping": {
|
||||
"description": "Mappa till gemensam ontologi",
|
||||
"ontology": "Landvex Reality Ontology v1.0",
|
||||
"entities": ["Project", "Organization", "Location", "Event", "Observation", "Infrastructure"],
|
||||
"relationships": ["funds", "implements", "located_in", "observed_at", "affected_by"],
|
||||
},
|
||||
"entity_resolution": {
|
||||
"description": "Identifiera samma entitet från olika källor",
|
||||
"methods": ["Name matching", "Location matching", "Temporal alignment", "Contextual similarity"],
|
||||
"accuracy": 94,
|
||||
},
|
||||
"deduplication": {
|
||||
"description": "Ta bort dubbletter",
|
||||
"methods": ["Exact match", "Fuzzy match", "Temporal proximity", "Spatial proximity"],
|
||||
"daily_duplicates_removed": 12000,
|
||||
},
|
||||
"quality_assurance": {
|
||||
"description": "Kvalitetssäkring",
|
||||
"checks": ["Completeness", "Accuracy", "Consistency", "Timeliness", "Validity"],
|
||||
"rejection_rate": 3.2,
|
||||
},
|
||||
},
|
||||
"metrics": {
|
||||
"daily_records_normalized": 113000,
|
||||
"geocoding_success_rate": 97.5,
|
||||
"entity_resolution_accuracy": 94,
|
||||
"average_processing_time_ms": 45,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── LAYER 3: EVIDENCE ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/layer-3-evidence")
|
||||
async def get_evidence_layer():
|
||||
"""
|
||||
Layer 3 – Evidence: Evidensgraf, lineage, minne och versionshantering
|
||||
"""
|
||||
return {
|
||||
"layer": 3,
|
||||
"name": "Evidence",
|
||||
"description": "Bygg en transparent, spårbar evidensbas",
|
||||
"components": {
|
||||
"evidence_graph": {
|
||||
"description": "Hierarkisk graf av alla observationer",
|
||||
"nodes": 125000,
|
||||
"edges": 450000,
|
||||
"levels": ["Observation", "Object", "Project", "Programme", "Organization", "Region", "Country"],
|
||||
},
|
||||
"evidence_lineage": {
|
||||
"description": "Komplett ursprung för varje datapunkt",
|
||||
"tracked_observations": 45000,
|
||||
"lineage_depth": "Source → Collected → Validated → Cross-validated → AI → Human → Revisions",
|
||||
},
|
||||
"reality_memory": {
|
||||
"description": "Permanent lagring av allt",
|
||||
"storage_tb": 2.4,
|
||||
"retention": "Permanent",
|
||||
"reproducibility": "100%",
|
||||
},
|
||||
"object_identity": {
|
||||
"description": "Unik identifiering av varje objekt",
|
||||
"objects_tracked": 15000,
|
||||
"identity_methods": ["UUID", "Geohash", "Temporal signature", "Feature fingerprint"],
|
||||
},
|
||||
"versioning": {
|
||||
"description": "Versionshantering av all data",
|
||||
"versions_stored": 234000,
|
||||
"versioning_strategy": "Immutable append-only",
|
||||
},
|
||||
},
|
||||
"metrics": {
|
||||
"total_evidence_items": 125000,
|
||||
"average_confidence": 82,
|
||||
"traceability": "100%",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── LAYER 4: INTELLIGENCE ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/layer-4-intelligence")
|
||||
async def get_intelligence_layer():
|
||||
"""
|
||||
Layer 4 – Intelligence: Detektion, hypoteser, risk och prediktion
|
||||
"""
|
||||
return {
|
||||
"layer": 4,
|
||||
"name": "Intelligence",
|
||||
"description": "Omvandla evidens till intelligens genom AI och analys",
|
||||
"engines": {
|
||||
"change_detection": {
|
||||
"description": "Automatisk förändringsdetektion",
|
||||
"methods": ["Satellite differencing", "Temporal analysis", "Anomaly detection", "Trend analysis"],
|
||||
"detection_rate": 94,
|
||||
"false_positive_rate": 6,
|
||||
},
|
||||
"event_detection": {
|
||||
"description": "Identifiera meningsfulla händelser",
|
||||
"event_types": ["Natural disaster", "Conflict", "Infrastructure failure", "Construction delay", "Environmental incident"],
|
||||
"daily_events_detected": 12,
|
||||
"accuracy": 87,
|
||||
},
|
||||
"contradiction_detection": {
|
||||
"description": "Upptäck avvikelser mellan källor",
|
||||
"contradictions_found": 1247,
|
||||
"categories": ["Schedule", "Progress", "Operational status", "Budget", "Location"],
|
||||
"resolution_rate": 45,
|
||||
},
|
||||
"hypothesis_engine": {
|
||||
"description": "Generera förklaringshypoteser",
|
||||
"hypotheses_generated": 3400,
|
||||
"average_confidence": 68,
|
||||
"method": "Evidence-ranked, not conclusion-first",
|
||||
},
|
||||
"risk_engine": {
|
||||
"description": "Riskbedömning och varningar",
|
||||
"risk_models": ["Project delay", "Operational failure", "Environmental", "Security", "Financial"],
|
||||
"prediction_horizon": "30-90 days",
|
||||
"accuracy": 76,
|
||||
},
|
||||
"prediction_engine": {
|
||||
"description": "Prediktiv analys",
|
||||
"predictions": ["Completion date", "Operational sustainability", "Maintenance needs", "Budget variance"],
|
||||
"confidence_intervals": "Always included",
|
||||
},
|
||||
"confidence_engine": {
|
||||
"description": "Beräkna konfidens från multipla källor",
|
||||
"factors": ["Source reliability", "Cross-validation", "Temporal consistency", "Historical accuracy"],
|
||||
"average_confidence": 82,
|
||||
},
|
||||
},
|
||||
"metrics": {
|
||||
"daily_insights_generated": 450,
|
||||
"prediction_accuracy": 76,
|
||||
"false_positive_rate": 8,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── LAYER 5: KNOWLEDGE ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/layer-5-knowledge")
|
||||
async def get_knowledge_layer():
|
||||
"""
|
||||
Layer 5 – Knowledge: Kunskapsgrafer och DNA-profiler
|
||||
"""
|
||||
return {
|
||||
"layer": 5,
|
||||
"name": "Knowledge",
|
||||
"description": "Bygg strukturerad kunskap från råintelligens",
|
||||
"graphs": {
|
||||
"reality_dna": {
|
||||
"description": "Projektets fingeravtryck",
|
||||
"dimensions": 9,
|
||||
"profiles": 1250,
|
||||
"comparable": True,
|
||||
},
|
||||
"context_graph": {
|
||||
"description": "Kontextuell förståelse av omgivningen",
|
||||
"factors": ["Political", "Economic", "Climate", "Security", "Infrastructure", "Population"],
|
||||
"projects_with_context": 1250,
|
||||
},
|
||||
"urban_knowledge_graph": {
|
||||
"description": "Stadsutvecklingskunskap",
|
||||
"entities": ["Buildings", "Roads", "Utilities", "Services", "Zones"],
|
||||
"coverage": "45 cities",
|
||||
},
|
||||
"aid_knowledge_graph": {
|
||||
"description": "Biståndskunskap",
|
||||
"entities": ["Projects", "Donors", "Implementers", "Sectors", "Outcomes"],
|
||||
"coverage": "1250 projects",
|
||||
},
|
||||
"infrastructure_graph": {
|
||||
"description": "Infrastrukturkunskap",
|
||||
"entities": ["Roads", "Bridges", "Power", "Water", "Communications"],
|
||||
"coverage": "Global",
|
||||
},
|
||||
"environmental_graph": {
|
||||
"description": "Miljökunskap",
|
||||
"entities": ["Vegetation", "Water bodies", "Land use", "Climate", "Biodiversity"],
|
||||
"coverage": "Global",
|
||||
},
|
||||
},
|
||||
"metrics": {
|
||||
"total_entities": 450000,
|
||||
"total_relationships": 1200000,
|
||||
"knowledge_triples": 2100000,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── LAYER 6: DECISION ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/layer-6-decision")
|
||||
async def get_decision_layer():
|
||||
"""
|
||||
Layer 6 – Decision: Presentation och beslutsstöd
|
||||
"""
|
||||
return {
|
||||
"layer": 6,
|
||||
"name": "Decision",
|
||||
"description": "Omvandla kunskap till handling",
|
||||
"interfaces": {
|
||||
"dashboards": {
|
||||
"description": "Interaktiva dashboards",
|
||||
"types": ["Executive", "Operational", "Analytical", "Public"],
|
||||
"widgets": 45,
|
||||
},
|
||||
"api": {
|
||||
"description": "Programmatisk åtkomst",
|
||||
"endpoints": 85,
|
||||
"formats": ["JSON", "GeoJSON", "CSV", "Parquet"],
|
||||
"rate_limit": "1000 req/min",
|
||||
},
|
||||
"alerts": {
|
||||
"description": "Realtidsvarningar",
|
||||
"channels": ["Email", "SMS", "Webhook", "Push", "Slack"],
|
||||
"alert_types": 25,
|
||||
},
|
||||
"missions": {
|
||||
"description": "QUIXZOOM uppdragsgenerering",
|
||||
"auto_generated": True,
|
||||
"priority_scoring": True,
|
||||
},
|
||||
"reports": {
|
||||
"description": "Automatisk rapportgenerering",
|
||||
"templates": ["Executive summary", "Technical", "Audit", "Impact assessment"],
|
||||
"formats": ["PDF", "HTML", "DOCX"],
|
||||
},
|
||||
"ai_agents": {
|
||||
"description": "AI-agenter för automatiserade uppgifter",
|
||||
"agents": ["Monitor", "Analyst", "Reporter", "Mission planner"],
|
||||
"autonomy_level": "Human-in-the-loop",
|
||||
},
|
||||
"automation": {
|
||||
"description": "Automatiserade arbetsflöden",
|
||||
"workflows": ["Event response", "Verification pipeline", "Alert escalation", "Report distribution"],
|
||||
},
|
||||
},
|
||||
"metrics": {
|
||||
"daily_api_calls": 45000,
|
||||
"active_dashboard_users": 320,
|
||||
"alerts_sent_daily": 1200,
|
||||
"reports_generated_monthly": 450,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── BLOCK 7: DECISION ENGINE ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/decision-engine")
|
||||
async def get_decision_engine():
|
||||
"""
|
||||
Decision Engine: Vad bör användaren göra nu?
|
||||
"""
|
||||
return {
|
||||
"block": 7,
|
||||
"name": "Decision Engine",
|
||||
"description": "Omvandla intelligens till konkreta rekommendationer",
|
||||
"principle": "LIFE vet vad som händer, varför det händer, och hur säker modellen är. Decision Engine svarar på: Vad bör användaren göra nu?",
|
||||
"recommendation_types": {
|
||||
"send_quixzoom_mission": {
|
||||
"description": "Skicka QUIXZOOM-verifieringsuppdrag",
|
||||
"trigger": "Otillräcklig field evidence eller avvikelse detekterad",
|
||||
"priority_calculation": "Risk × Uncertainty × Cost of verification",
|
||||
"example": "Projekt visar 85% completion men rapporterar 95%. Skicka mission för att fotografera interiör.",
|
||||
},
|
||||
"order_satellite_imagery": {
|
||||
"description": "Beställ ny satellitbild",
|
||||
"trigger": "Behov av uppdaterad geospatial data",
|
||||
"priority_calculation": "Data age × Change probability × Decision urgency",
|
||||
"example": "Senaste bilden är 10 dagar gammal och området har nyligen drabbats av översvämning.",
|
||||
},
|
||||
"flag_for_manual_review": {
|
||||
"description": "Markera för manuell granskning",
|
||||
"trigger": "Högkonfidens avvikelse eller komplex situation",
|
||||
"priority_calculation": "Confidence × Severity × Strategic importance",
|
||||
"example": "Stor avvikelse mellan rapporterad budget och observerad aktivitet. Kräver analytikergranskning.",
|
||||
},
|
||||
"notify_project_owner": {
|
||||
"description": "Informera projektägare om observerad avvikelse",
|
||||
"trigger": "Verifierad avvikelse som kan påverka projektet",
|
||||
"priority_calculation": "Severity × Verification confidence × Response time sensitivity",
|
||||
"example": "Observerad försening på 2 veckor. Informera implementerande partner.",
|
||||
},
|
||||
"wait": {
|
||||
"description": "Avvakta - evidensen är otillräcklig",
|
||||
"trigger": "För låg konfidens för att agera",
|
||||
"priority_calculation": "Monitoring continues, action deferred until confidence threshold reached",
|
||||
"example": "Endast en källa rapporterar avvikelse. Vänta på korsvalidering.",
|
||||
},
|
||||
"prioritize_followup": {
|
||||
"description": "Prioritera uppföljning baserat på risk och osäkerhet",
|
||||
"trigger": "Flera projekt kräver uppmärksamhet",
|
||||
"priority_calculation": "Risk score × Evidence gap × Strategic value × Resource availability",
|
||||
"example": "15 projekt flaggade. Prioritera de 5 med högst risk × osäkerhet.",
|
||||
},
|
||||
},
|
||||
"decision_matrix": {
|
||||
"high_confidence_high_risk": "Omedelbar åtgärd - hög konfidens och hög risk",
|
||||
"high_confidence_low_risk": "Dokumentera - låg prioritet men välunderbyggd",
|
||||
"low_confidence_high_risk": "Samla mer evidens - hög risk men osäker",
|
||||
"low_confidence_low_risk": "Övervaka - låg prioritet och osäker",
|
||||
},
|
||||
"example_decisions": [
|
||||
{
|
||||
"project": "Mogadishu Primary School",
|
||||
"situation": "85% observed vs 95% reported completion",
|
||||
"confidence": 78,
|
||||
"risk": "medium",
|
||||
"recommendation": "send_quixzoom_mission",
|
||||
"rationale": "Avvikelse verifierad av 3 oberoende källor. Interiör observation behövs för att klargöra.",
|
||||
"priority": 8.2,
|
||||
},
|
||||
{
|
||||
"project": "Lagos Road Rehabilitation",
|
||||
"situation": "No activity for 90 days, equipment removed",
|
||||
"confidence": 92,
|
||||
"risk": "high",
|
||||
"recommendation": "flag_for_manual_review",
|
||||
"rationale": "Högkonfidens indikation på projektavbrott. Kräver omedelbar analytikergranskning.",
|
||||
"priority": 9.5,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── BLOCK 8: LEARNING ENGINE ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/learning-engine")
|
||||
async def get_learning_engine():
|
||||
"""
|
||||
Learning Engine: Självutvärdering och kontinuerlig förbättring
|
||||
"""
|
||||
return {
|
||||
"block": 8,
|
||||
"name": "Learning Engine",
|
||||
"description": "LIFE utvärderar sina egna slutsatser och förbättrar sina modeller",
|
||||
"learning_loops": {
|
||||
"hypothesis_validation": {
|
||||
"description": "Vilka hypoteser visade sig stämma?",
|
||||
"method": "Jämför prediktion med faktisk utveckling",
|
||||
"metrics": {
|
||||
"total_hypotheses": 3400,
|
||||
"validated": 2150,
|
||||
"partially_validated": 890,
|
||||
"invalidated": 360,
|
||||
"accuracy": 73,
|
||||
},
|
||||
},
|
||||
"risk_indicator_performance": {
|
||||
"description": "Vilka riskindikatorer var mest prediktiva?",
|
||||
"method": "Korrelera tidiga varningssignaler med faktiska händelser",
|
||||
"top_indicators": [
|
||||
{"indicator": "Satellite activity decline", "predictive_power": 0.87},
|
||||
{"indicator": "Contributor report frequency drop", "predictive_power": 0.82},
|
||||
{"indicator": "News sentiment shift", "predictive_power": 0.78},
|
||||
{"indicator": "Budget execution lag", "predictive_power": 0.75},
|
||||
],
|
||||
},
|
||||
"source_accuracy": {
|
||||
"description": "Vilka datakällor gav bäst träffsäkerhet?",
|
||||
"method": "Utvärdera varje källas historiska noggrannhet",
|
||||
"rankings": [
|
||||
{"source": "Satellite imagery", "accuracy": 94, "reliability": "very_high"},
|
||||
{"source": "QUIXZOOM field obs", "accuracy": 88, "reliability": "high"},
|
||||
{"source": "Official reports", "accuracy": 82, "reliability": "high"},
|
||||
{"source": "News monitoring", "accuracy": 65, "reliability": "medium"},
|
||||
{"source": "Social media", "accuracy": 45, "reliability": "low"},
|
||||
],
|
||||
},
|
||||
"environmental_adaptation": {
|
||||
"description": "Hur presterar modeller i olika miljöer?",
|
||||
"method": "Jämför prediktionsnoggrannhet per region och sektor",
|
||||
"best_performing": [
|
||||
{"environment": "Urban infrastructure", "accuracy": 89},
|
||||
{"environment": "Rural agriculture", "accuracy": 76},
|
||||
{"environment": "Conflict zones", "accuracy": 62},
|
||||
],
|
||||
},
|
||||
},
|
||||
"model_improvement": {
|
||||
"description": "Kontinuerlig modellförbättring",
|
||||
"process": [
|
||||
"Samla utfall för alla prediktioner",
|
||||
"Identifiera systematiska fel",
|
||||
"Justera modellparametrar",
|
||||
"A/B-testa nya modeller",
|
||||
"Deploya förbättrade modeller",
|
||||
],
|
||||
"current_improvements": [
|
||||
{"model": "Construction progress estimator", "improvement": "+12% accuracy", "status": "deployed"},
|
||||
{"model": "Risk predictor", "improvement": "+8% precision", "status": "testing"},
|
||||
{"model": "Event detector", "improvement": "-15% false positives", "status": "training"},
|
||||
],
|
||||
},
|
||||
"feedback_integration": {
|
||||
"description": "Integrera mänsklig feedback",
|
||||
"sources": ["Analyst corrections", "Customer feedback", "Expert reviews", "Ground truth validation"],
|
||||
"feedback_items_processed": 4500,
|
||||
"incorporation_rate": 87,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── BLOCK 9: INTEGRATION LAYER ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/integration")
|
||||
async def get_integration_layer():
|
||||
"""
|
||||
Integration Layer: Anslutning till externa system och arbetsflöden
|
||||
"""
|
||||
return {
|
||||
"block": 9,
|
||||
"name": "Integration Layer",
|
||||
"description": "Anslut LIFE till omvärlden",
|
||||
"interfaces": {
|
||||
"rest_api": {
|
||||
"description": "REST API för alla LIFE-funktioner",
|
||||
"version": "v1",
|
||||
"endpoints": 120,
|
||||
"authentication": "OAuth 2.0 + API keys",
|
||||
"rate_limits": "1000 req/min standard, 10000 req/min enterprise",
|
||||
"formats": ["JSON", "GeoJSON", "CSV"],
|
||||
},
|
||||
"webhooks": {
|
||||
"description": "Realtidsnotifieringar",
|
||||
"events": ["new_observation", "change_detected", "risk_alert", "hypothesis_generated"],
|
||||
"delivery": "Guaranteed delivery with retry",
|
||||
},
|
||||
"streaming": {
|
||||
"description": "Realtidsdataströmmar",
|
||||
"protocols": ["WebSocket", "SSE", "MQTT"],
|
||||
"topics": ["observations", "events", "alerts", "insights"],
|
||||
},
|
||||
"sdk": {
|
||||
"description": "SDK för enkel integration",
|
||||
"languages": ["Python", "JavaScript", "Java", "Go"],
|
||||
"features": ["Data ingestion", "Query builder", "Visualization helpers"],
|
||||
},
|
||||
"plugins": {
|
||||
"description": "Plugin-arkitektur för anpassning",
|
||||
"types": ["Data source", "Analyzer", "Visualizer", "Exporter"],
|
||||
"marketplace": "Coming Q2 2027",
|
||||
},
|
||||
},
|
||||
"enterprise_integrations": {
|
||||
"gis": ["ArcGIS", "QGIS", "Google Earth Engine"],
|
||||
"bi": ["Tableau", "Power BI", "Looker"],
|
||||
"crm": ["Salesforce", "HubSpot"],
|
||||
"erp": ["SAP", "Oracle"],
|
||||
"communication": ["Slack", "Teams", "Email"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── SYSTEM OVERVIEW ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/architecture")
|
||||
async def get_architecture_overview():
|
||||
"""
|
||||
Komplett arkitekturöversikt
|
||||
"""
|
||||
return {
|
||||
"system_name": "Landvex Intelligence Fusion Engine (LIFE)",
|
||||
"version": "3.0.0",
|
||||
"tagline": "AI-drivet operativsystem för verklighetsintelligens",
|
||||
"description": "Genom att kontinuerligt integrera geospatiala data, crowdsourcade observationer, offentliga datakällor och maskininlärning skapar LIFE en levande, spårbar och förklarbar digital representation av den fysiska världen. Plattformen omvandlar observerbara signaler till evidens, evidens till kunskap och kunskap till beslutsunderlag.",
|
||||
"layers": [
|
||||
{"number": 1, "name": "Acquisition", "function": "Datainsamling", "sources": 10},
|
||||
{"number": 2, "name": "Normalization", "function": "Standardisering", "processes": 6},
|
||||
{"number": 3, "name": "Evidence", "function": "Evidenshantering", "components": 5},
|
||||
{"number": 4, "name": "Intelligence", "function": "AI-analys", "engines": 7},
|
||||
{"number": 5, "name": "Knowledge", "function": "Kunskapsbyggande", "graphs": 6},
|
||||
{"number": 6, "name": "Decision", "function": "Beslutsstöd", "interfaces": 7},
|
||||
],
|
||||
"core_blocks": [
|
||||
{"number": 7, "name": "Decision Engine", "function": "Rekommendationsgenerering"},
|
||||
{"number": 8, "name": "Learning Engine", "function": "Självförbättring"},
|
||||
{"number": 9, "name": "Integration Layer", "function": "Extern anslutning"},
|
||||
],
|
||||
"strategic_positioning": "LIFE är ett AI-drivet operativsystem för verklighetsintelligens. Biståndsanalys är en tillämpning, inte identitet. Samma motor kan användas för infrastruktur, miljö, försäkring, kommunal tillsyn, katastrofhantering, fastighetsförvaltning och andra områden där beslutsfattare behöver kontinuerlig, evidensbaserad lägesbild.",
|
||||
"applications": [
|
||||
"Development aid monitoring",
|
||||
"Infrastructure management",
|
||||
"Urban planning",
|
||||
"Environmental monitoring",
|
||||
"Insurance risk assessment",
|
||||
"Municipal supervision",
|
||||
"Disaster response",
|
||||
"Property management",
|
||||
"Supply chain tracking",
|
||||
"Security analysis",
|
||||
],
|
||||
"metrics": {
|
||||
"projects_monitored": 1250,
|
||||
"observations_processed": 45000,
|
||||
"data_sources": 10,
|
||||
"active_streams": 45,
|
||||
"ai_engines": 7,
|
||||
"knowledge_graphs": 6,
|
||||
"api_endpoints": 120,
|
||||
"daily_insights": 450,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
"""
|
||||
LIFE v7 — Reality Intelligence Ecosystem
|
||||
|
||||
Fokus: Adoption. Ett tekniskt ramverk blir en standard först när andra använder det.
|
||||
|
||||
Tre komponenter:
|
||||
1. Reference Implementations (öppna referensimplementationer)
|
||||
2. Conformance Program (certifiering)
|
||||
3. Public Specification (öppen specifikation)
|
||||
|
||||
Strategisk avgränsning:
|
||||
- RIS (Reality Intelligence Standard) — den öppna specifikationen
|
||||
- LIFE — referensimplementationen av standarden
|
||||
- Landvex — företaget som utvecklar och kommersialiserar LIFE
|
||||
|
||||
Product Definition:
|
||||
"Landvex utvecklar LIFE, en Reality Intelligence Platform och referensimplementation
|
||||
av Reality Intelligence Standard (RIS). Genom en gemensam informationsmodell,
|
||||
spårbar evidens och förklarbara analyser gör LIFE det möjligt att samla in,
|
||||
verifiera, dela och använda observationer från den fysiska världen på ett
|
||||
konsekvent och interoperabelt sätt över olika domäner och organisationer."
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/life-ecosystem", tags=["life_ecosystem"])
|
||||
|
||||
|
||||
# ─── COMPONENT 1: REFERENCE IMPLEMENTATIONS ───────────────────────────────────
|
||||
|
||||
@router.get("/public/reference-implementations")
|
||||
async def get_reference_implementations():
|
||||
"""
|
||||
Reference Implementations — Öppna referensimplementationer som sänker tröskeln
|
||||
för externa utvecklare.
|
||||
"""
|
||||
return {
|
||||
"component": 1,
|
||||
"name": "Reference Implementations",
|
||||
"description": "Öppna referensimplementationer av RIS som sänker tröskeln för externa utvecklare",
|
||||
"implementations": {
|
||||
"reference_server": {
|
||||
"name": "LIFE Reference Server",
|
||||
"description": "Komplett serverimplementation av RIS-004 (Reality Exchange Protocol)",
|
||||
"language": "Python (FastAPI)",
|
||||
"license": "Apache 2.0",
|
||||
"repository": "https://github.com/landvex/life-reference-server",
|
||||
"features": [
|
||||
"Full RIS-001 till RIS-006 implementation",
|
||||
"PostgreSQL + Neo4j + TimescaleDB",
|
||||
"JWT-autentisering",
|
||||
"Event-driven arkitektur",
|
||||
"Horisontell skalning",
|
||||
],
|
||||
"deployment": {
|
||||
"docker": "docker run landvex/life-reference-server",
|
||||
"kubernetes": "Helm chart tillgänglig",
|
||||
"aws": "CloudFormation template",
|
||||
"local": "docker-compose up",
|
||||
},
|
||||
"status": "Beta",
|
||||
"latest_version": "0.9.2",
|
||||
},
|
||||
"reference_client": {
|
||||
"name": "LIFE Reference Client",
|
||||
"description": "Exempelapplikation som visar hur man bygger ovanpå RIS",
|
||||
"language": "TypeScript / React",
|
||||
"license": "Apache 2.0",
|
||||
"repository": "https://github.com/landvex/life-reference-client",
|
||||
"features": [
|
||||
"Interaktiv karta med Reality Objects",
|
||||
"Observation submission",
|
||||
"Evidence viewer med confidence scores",
|
||||
"Decision support dashboard",
|
||||
"Collaboration workspace",
|
||||
],
|
||||
"deployment": {
|
||||
"static": "Byggs till statiska filer",
|
||||
"cdn": "Kan hostas på valfri CDN",
|
||||
},
|
||||
"status": "Alpha",
|
||||
"latest_version": "0.5.1",
|
||||
},
|
||||
"reference_sdk": {
|
||||
"name": "LIFE Reference SDK",
|
||||
"description": "SDK:er för olika programmeringsspråk",
|
||||
"languages": {
|
||||
"python": {
|
||||
"repository": "https://github.com/landvex/life-sdk-python",
|
||||
"package": "pip install life-sdk",
|
||||
"status": "Stable",
|
||||
"version": "1.2.0",
|
||||
},
|
||||
"javascript": {
|
||||
"repository": "https://github.com/landvex/life-sdk-js",
|
||||
"package": "npm install @landvex/life-sdk",
|
||||
"status": "Stable",
|
||||
"version": "1.1.0",
|
||||
},
|
||||
"java": {
|
||||
"repository": "https://github.com/landvex/life-sdk-java",
|
||||
"package": "Maven Central: ai.landvex:life-sdk",
|
||||
"status": "Beta",
|
||||
"version": "0.8.0",
|
||||
},
|
||||
"go": {
|
||||
"repository": "https://github.com/landvex/life-sdk-go",
|
||||
"package": "go get github.com/landvex/life-sdk-go",
|
||||
"status": "Beta",
|
||||
"version": "0.7.0",
|
||||
},
|
||||
"rust": {
|
||||
"repository": "https://github.com/landvex/life-sdk-rust",
|
||||
"package": "cargo add life-sdk",
|
||||
"status": "Alpha",
|
||||
"version": "0.4.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"example_data": {
|
||||
"name": "LIFE Example Data",
|
||||
"description": "Exempeldataset för att testa och lära sig RIS",
|
||||
"datasets": [
|
||||
{
|
||||
"name": "Somalia Infrastructure",
|
||||
"objects": 1250,
|
||||
"observations": 45000,
|
||||
"format": "RIS-001 / RIS-002",
|
||||
"download": "https://data.life.ai/examples/somalia-infrastructure.tar.gz",
|
||||
},
|
||||
{
|
||||
"name": "Sweden Road Network",
|
||||
"objects": 500000,
|
||||
"observations": 2000000,
|
||||
"format": "RIS-001 / RIS-002",
|
||||
"download": "https://data.life.ai/examples/sweden-roads.tar.gz",
|
||||
},
|
||||
{
|
||||
"name": "Global Climate Events",
|
||||
"objects": 10000,
|
||||
"observations": 500000,
|
||||
"format": "RIS-001 / RIS-002",
|
||||
"download": "https://data.life.ai/examples/climate-events.tar.gz",
|
||||
},
|
||||
],
|
||||
"license": "CC BY 4.0",
|
||||
},
|
||||
"example_apis": {
|
||||
"name": "LIFE Example APIs",
|
||||
"description": "Exempel på hur man bygger API:er ovanpå RIS",
|
||||
"examples": [
|
||||
{
|
||||
"name": "Municipal Infrastructure API",
|
||||
"description": "API för kommunal infrastrukturhantering",
|
||||
"repository": "https://github.com/landvex/examples/municipal-api",
|
||||
},
|
||||
{
|
||||
"name": "Insurance Risk Assessment API",
|
||||
"description": "API för försäkringsriskbedömning",
|
||||
"repository": "https://github.com/landvex/examples/insurance-api",
|
||||
},
|
||||
{
|
||||
"name": "Aid Project Monitoring API",
|
||||
"description": "API för biståndsprojektövervakning",
|
||||
"repository": "https://github.com/landvex/examples/aid-api",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"getting_started": {
|
||||
"quickstart": "https://docs.life.ai/quickstart",
|
||||
"tutorials": [
|
||||
"Build your first Reality Object",
|
||||
"Submit observations via API",
|
||||
"Build a custom domain module",
|
||||
"Deploy a LIFE instance",
|
||||
],
|
||||
"community": {
|
||||
"discord": "https://discord.gg/life-standard",
|
||||
"forum": "https://forum.life.ai",
|
||||
"github_discussions": "https://github.com/landvex/life-standard/discussions",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── COMPONENT 2: CONFORMANCE PROGRAM ─────────────────────────────────────────
|
||||
|
||||
@router.get("/public/conformance-program")
|
||||
async def get_conformance_program():
|
||||
"""
|
||||
Conformance Program — Certifieringsprogram för att ge tydlig kvalitetsnivå
|
||||
för integrationer.
|
||||
"""
|
||||
return {
|
||||
"component": 2,
|
||||
"name": "Conformance Program",
|
||||
"description": "Certifieringsprogram som ger tydlig kvalitetsnivå för RIS-integrationer",
|
||||
"levels": {
|
||||
"ris_compatible": {
|
||||
"name": "RIS Compatible",
|
||||
"badge": "🥉",
|
||||
"description": "Grundläggande kompatibilitet med RIS",
|
||||
"requirements": [
|
||||
"Implementerar RIS-001 (Reality Object Standard)",
|
||||
"Kan läsa och skriva RIS-002 (Evidence Standard)",
|
||||
"Använder RIS-004 (Reality Exchange Protocol) för kommunikation",
|
||||
"Dokumenterad API-specifikation",
|
||||
],
|
||||
"testing": [
|
||||
"Automatiska kompatibilitetstester",
|
||||
"Validering av objektstruktur",
|
||||
"API-kontraktsverifiering",
|
||||
],
|
||||
"validity": "12 månader",
|
||||
"renewal": "Automatisk vid passande tester",
|
||||
"fee": "Gratis",
|
||||
},
|
||||
"ris_certified": {
|
||||
"name": "RIS Certified",
|
||||
"badge": "🥈",
|
||||
"description": "Fullständig implementation av alla RIS-standarder",
|
||||
"requirements": [
|
||||
"Alla krav för RIS Compatible",
|
||||
"Implementerar RIS-003 (Confidence Standard)",
|
||||
"Implementerar RIS-005 (Decision Standard)",
|
||||
"Implementerar RIS-006 (Learning Standard)",
|
||||
"Mänsklig granskning av implementation",
|
||||
"Dokumenterad säkerhetsarkitektur",
|
||||
],
|
||||
"testing": [
|
||||
"Alla automatiska tester",
|
||||
"Säkerhetsgranskning",
|
||||
"Prestandatestning",
|
||||
"Interoperabilitetstestning med referensimplementation",
|
||||
],
|
||||
"validity": "12 månader",
|
||||
"renewal": "Omgranskning varje år",
|
||||
"fee": "$5,000 / år",
|
||||
},
|
||||
"ris_enterprise_certified": {
|
||||
"name": "RIS Enterprise Certified",
|
||||
"badge": "🥇",
|
||||
"description": "Enterprise-klar implementation med högsta kvalitetsnivå",
|
||||
"requirements": [
|
||||
"Alla krav för RIS Certified",
|
||||
"Implementerar alla Governance Layer-komponenter (v5)",
|
||||
"SOC 2 Type II-certifiering",
|
||||
"ISO 27001-certifiering",
|
||||
"24/7 supportavtal",
|
||||
"SLA-garantier",
|
||||
"Penetrationstestning",
|
||||
],
|
||||
"testing": [
|
||||
"Alla tester för RIS Certified",
|
||||
"Oberoende säkerhetsgranskning",
|
||||
"Lasttestning",
|
||||
"Disaster recovery-testning",
|
||||
"Compliance-granskning",
|
||||
],
|
||||
"validity": "12 månader",
|
||||
"renewal": "Omfattande årlig granskning",
|
||||
"fee": "$25,000 / år",
|
||||
"benefits": [
|
||||
"Listad på LIFE Marketplace",
|
||||
"Prioriterad teknisk support",
|
||||
"Tidig tillgång till nya standarder",
|
||||
"Deltagande i standardiseringsarbete",
|
||||
],
|
||||
},
|
||||
},
|
||||
"certification_process": {
|
||||
"steps": [
|
||||
{"step": 1, "name": "Self-assessment", "description": "Utför självbedömning med verktyg", "duration": "1-2 dagar"},
|
||||
{"step": 2, "name": "Application", "description": "Skicka in ansökan med dokumentation", "duration": "1 dag"},
|
||||
{"step": 3, "name": "Automated Testing", "description": "Kör automatiska kompatibilitetstester", "duration": "1-2 dagar"},
|
||||
{"step": 4, "name": "Review", "description": "Mänsklig granskning (för Certified+)", "duration": "1-2 veckor"},
|
||||
{"step": 5, "name": "Certification", "description": "Utfärdande av certifikat och badge", "duration": "1 dag"},
|
||||
],
|
||||
"tools": {
|
||||
"self_assessment": "https://tools.life.ai/conformance/self-assessment",
|
||||
"automated_testing": "https://tools.life.ai/conformance/testing",
|
||||
"documentation_template": "https://docs.life.ai/conformance/template",
|
||||
},
|
||||
},
|
||||
"certified_products": {
|
||||
"count": 12,
|
||||
"examples": [
|
||||
{"name": "MunicipalityX Infrastructure", "level": "RIS Enterprise Certified", "domain": "Cities"},
|
||||
{"name": "AidTracker Pro", "level": "RIS Certified", "domain": "Aid"},
|
||||
{"name": "RiskMap Analytics", "level": "RIS Certified", "domain": "Insurance"},
|
||||
{"name": "GreenWatch", "level": "RIS Compatible", "domain": "Environment"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── COMPONENT 3: PUBLIC SPECIFICATION ────────────────────────────────────────
|
||||
|
||||
@router.get("/public/public-specification")
|
||||
async def get_public_specification():
|
||||
"""
|
||||
Public Specification — Öppen specifikation som gör att andra kan implementera
|
||||
standarden oberoende av LIFE-plattformen.
|
||||
"""
|
||||
return {
|
||||
"component": 3,
|
||||
"name": "Public Specification",
|
||||
"description": "Öppen specifikation som gör att andra kan implementera RIS oberoende av LIFE-plattformen",
|
||||
"specification": {
|
||||
"title": "Reality Intelligence Standard (RIS) Specification",
|
||||
"version": "1.0.0",
|
||||
"status": "Public Draft",
|
||||
"license": "CC BY 4.0",
|
||||
"url": "https://standard.life.ai/spec/v1.0.0",
|
||||
"download": {
|
||||
"pdf": "https://standard.life.ai/spec/v1.0.0.pdf",
|
||||
"html": "https://standard.life.ai/spec/v1.0.0.html",
|
||||
"markdown": "https://standard.life.ai/spec/v1.0.0.md",
|
||||
},
|
||||
},
|
||||
"contents": {
|
||||
"terminology": {
|
||||
"section": 1,
|
||||
"title": "Terminology",
|
||||
"description": "Definierade termer och begrepp",
|
||||
"terms": [
|
||||
{"term": "Reality Object", "definition": "En entitet i den fysiska världen som kan observeras och representeras digitalt"},
|
||||
{"term": "Evidence", "definition": "Data som stöder eller motsäger ett påstående om ett Reality Object"},
|
||||
{"term": "Confidence", "definition": "Graden av säkerhet i en bedömning, uttryckt som ett värde mellan 0 och 100"},
|
||||
{"term": "Observation", "definition": "En mätning eller iakttagelse av ett Reality Object vid en specifik tid och plats"},
|
||||
{"term": "Fusion", "definition": "Processen att kombinera data från flera källor till en enhetlig representation"},
|
||||
{"term": "Decision Object", "definition": "En strukturerad representation av ett beslutsstöd med observation, evidens, hypotes och rekommendation"},
|
||||
],
|
||||
},
|
||||
"object_model": {
|
||||
"section": 2,
|
||||
"title": "Object Model",
|
||||
"description": "RIS-001: Reality Object Standard",
|
||||
"specification_url": "https://standard.life.ai/spec/v1.0.0#object-model",
|
||||
},
|
||||
"api_contracts": {
|
||||
"section": 3,
|
||||
"title": "API Contracts",
|
||||
"description": "RIS-004: Reality Exchange Protocol",
|
||||
"endpoints": [
|
||||
{
|
||||
"path": "/objects",
|
||||
"methods": ["GET", "POST", "PUT", "DELETE"],
|
||||
"specification": "https://standard.life.ai/spec/v1.0.0#objects-endpoint",
|
||||
},
|
||||
{
|
||||
"path": "/observations",
|
||||
"methods": ["GET", "POST"],
|
||||
"specification": "https://standard.life.ai/spec/v1.0.0#observations-endpoint",
|
||||
},
|
||||
{
|
||||
"path": "/evidence",
|
||||
"methods": ["GET", "POST"],
|
||||
"specification": "https://standard.life.ai/spec/v1.0.0#evidence-endpoint",
|
||||
},
|
||||
{
|
||||
"path": "/relationships",
|
||||
"methods": ["GET", "POST", "DELETE"],
|
||||
"specification": "https://standard.life.ai/spec/v1.0.0#relationships-endpoint",
|
||||
},
|
||||
{
|
||||
"path": "/events",
|
||||
"methods": ["GET", "POST"],
|
||||
"specification": "https://standard.life.ai/spec/v1.0.0#events-endpoint",
|
||||
},
|
||||
{
|
||||
"path": "/hypotheses",
|
||||
"methods": ["GET", "POST"],
|
||||
"specification": "https://standard.life.ai/spec/v1.0.0#hypotheses-endpoint",
|
||||
},
|
||||
{
|
||||
"path": "/predictions",
|
||||
"methods": ["GET"],
|
||||
"specification": "https://standard.life.ai/spec/v1.0.0#predictions-endpoint",
|
||||
},
|
||||
{
|
||||
"path": "/actions",
|
||||
"methods": ["GET", "POST"],
|
||||
"specification": "https://standard.life.ai/spec/v1.0.0#actions-endpoint",
|
||||
},
|
||||
{
|
||||
"path": "/missions",
|
||||
"methods": ["GET", "POST", "PUT"],
|
||||
"specification": "https://standard.life.ai/spec/v1.0.0#missions-endpoint",
|
||||
},
|
||||
],
|
||||
},
|
||||
"evidence_model": {
|
||||
"section": 4,
|
||||
"title": "Evidence Model",
|
||||
"description": "RIS-002: Evidence Standard",
|
||||
"specification_url": "https://standard.life.ai/spec/v1.0.0#evidence-model",
|
||||
},
|
||||
"confidence_model": {
|
||||
"section": 5,
|
||||
"title": "Confidence Model",
|
||||
"description": "RIS-003: Confidence Standard",
|
||||
"specification_url": "https://standard.life.ai/spec/v1.0.0#confidence-model",
|
||||
},
|
||||
"decision_model": {
|
||||
"section": 6,
|
||||
"title": "Decision Model",
|
||||
"description": "RIS-005: Decision Standard",
|
||||
"specification_url": "https://standard.life.ai/spec/v1.0.0#decision-model",
|
||||
},
|
||||
"learning_model": {
|
||||
"section": 7,
|
||||
"title": "Learning Model",
|
||||
"description": "RIS-006: Learning Standard",
|
||||
"specification_url": "https://standard.life.ai/spec/v1.0.0#learning-model",
|
||||
},
|
||||
"examples": {
|
||||
"section": 8,
|
||||
"title": "Examples",
|
||||
"description": "Exempel på implementation och användning",
|
||||
"examples": [
|
||||
{"name": "Creating a Reality Object", "url": "https://standard.life.ai/examples/create-object"},
|
||||
{"name": "Submitting Evidence", "url": "https://standard.life.ai/examples/submit-evidence"},
|
||||
{"name": "Querying with Confidence", "url": "https://standard.life.ai/examples/query-confidence"},
|
||||
{"name": "Building a Decision", "url": "https://standard.life.ai/examples/build-decision"},
|
||||
],
|
||||
},
|
||||
"versioning_policy": {
|
||||
"section": 9,
|
||||
"title": "Versioning Policy",
|
||||
"description": "Hur standarden versionshanteras",
|
||||
"policy": {
|
||||
"semantic_versioning": "MAJOR.MINOR.PATCH",
|
||||
"major_changes": "Brytande förändringar, kräver migration",
|
||||
"minor_changes": "Nya funktioner, bakåtkompatibla",
|
||||
"patch_changes": "Buggfixar, bakåtkompatibla",
|
||||
"deprecation": "Funktioner markeras deprecated minst 1 major version innan borttagning",
|
||||
"support_window": "2 major versioner stöds samtidigt",
|
||||
},
|
||||
},
|
||||
"backward_compatibility": {
|
||||
"section": 10,
|
||||
"title": "Backward Compatibility",
|
||||
"description": "Garantier för bakåtkompatibilitet",
|
||||
"guarantees": [
|
||||
"Inga borttagna fält inom samma major version",
|
||||
"Nya fält är alltid valfria",
|
||||
"API-endpoints föråldras gradvis",
|
||||
"Migration guides vid major bumps",
|
||||
],
|
||||
},
|
||||
},
|
||||
"governance": {
|
||||
"standard_body": "Reality Intelligence Standards Board (RISB)",
|
||||
"membership": [
|
||||
{"organization": "Landvex", "role": "Founding Member", "seats": 2},
|
||||
{"organization": "Open Community", "role": "Elected Representatives", "seats": 3},
|
||||
{"organization": "Academic Institutions", "role": "Advisory", "seats": 2},
|
||||
{"organization": "Enterprise Partners", "role": "Advisory", "seats": 2},
|
||||
],
|
||||
"decision_process": "RFC-baserad process med öppen kommentarsperiod",
|
||||
"meeting_frequency": "Kvartalsvis",
|
||||
"public_minutes": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── STRATEGIC SEPARATION ─────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/strategic-separation")
|
||||
async def get_strategic_separation():
|
||||
"""
|
||||
Tydlig avgränsning mellan RIS, LIFE och Landvex
|
||||
"""
|
||||
return {
|
||||
"title": "Strategic Separation of Concerns",
|
||||
"description": "Tydlig avgränsning mellan standard, implementation och företag",
|
||||
"entities": {
|
||||
"ris": {
|
||||
"name": "Reality Intelligence Standard (RIS)",
|
||||
"type": "Open Standard",
|
||||
"analogy": "Som HTTP för webben",
|
||||
"ownership": "Community-governed (RISB)",
|
||||
"license": "CC BY 4.0",
|
||||
"cost": "Gratis att använda",
|
||||
"purpose": "Definiera HUR verklighetsintelligens representeras, valideras, utbyts och används",
|
||||
"scope": [
|
||||
"Objektmodell (RIS-001)",
|
||||
"Evidensstandard (RIS-002)",
|
||||
"Konfidensstandard (RIS-003)",
|
||||
"Utbytesprotokoll (RIS-004)",
|
||||
"Beslutsstandard (RIS-005)",
|
||||
"Lärandestandard (RIS-006)",
|
||||
"Gemensam ontologi",
|
||||
],
|
||||
"governance": "Reality Intelligence Standards Board",
|
||||
"development": "Öppen process med RFC:er",
|
||||
},
|
||||
"life": {
|
||||
"name": "Landvex Intelligence Fusion Engine (LIFE)",
|
||||
"type": "Reference Implementation",
|
||||
"analogy": "Som Apache HTTP Server för HTTP",
|
||||
"ownership": "Landvex (open source)",
|
||||
"license": "Apache 2.0",
|
||||
"cost": "Gratis att använda, betalt stöd tillgängligt",
|
||||
"purpose": "Referensimplementation av RIS som andra kan bygga vidare på",
|
||||
"scope": [
|
||||
"Komplett serverimplementation",
|
||||
"SDK:er för flera språk",
|
||||
"Webb-dashboard",
|
||||
"Mobile apps",
|
||||
"AI-motorer",
|
||||
"Datainsamlingsverktyg",
|
||||
],
|
||||
"governance": "Landvex med community contributions",
|
||||
"development": "Open source på GitHub",
|
||||
},
|
||||
"landvex": {
|
||||
"name": "Landvex AB",
|
||||
"type": "Commercial Company",
|
||||
"analogy": "Som Cloudflare för webben — bygger på öppna standarder men erbjuder kommersiella tjänster",
|
||||
"ownership": "Privat företag",
|
||||
"license": "Proprietär för kommersiella tjänster",
|
||||
"cost": "Betalda tjänster och support",
|
||||
"purpose": "Utveckla, kommersialisera och erbjuda tjänster ovanpå LIFE och RIS",
|
||||
"scope": [
|
||||
"LIFE Platform hosting (SaaS)",
|
||||
"Managed LIFE instances",
|
||||
"Professional support",
|
||||
"Custom development",
|
||||
"Training and certification",
|
||||
"QUIXZOOM Network",
|
||||
"Domain-specific modules",
|
||||
],
|
||||
"governance": "Företagsledning",
|
||||
"development": "Proprietär utveckling med öppna komponenter",
|
||||
},
|
||||
},
|
||||
"relationship": {
|
||||
"description": "Tre nivåer av öppenhet",
|
||||
"levels": [
|
||||
{
|
||||
"level": "Standard (RIS)",
|
||||
"openness": "Fullständigt öppen",
|
||||
"anyone_can": "Implementera, använda, modifiera",
|
||||
"cost": "Gratis",
|
||||
},
|
||||
{
|
||||
"level": "Implementation (LIFE)",
|
||||
"openness": "Open source",
|
||||
"anyone_can": "Använda, modifiera, distribuera",
|
||||
"cost": "Gratis (Apache 2.0)",
|
||||
},
|
||||
{
|
||||
"level": "Services (Landvex)",
|
||||
"openness": "Proprietär",
|
||||
"anyone_can": "Köpa tjänster",
|
||||
"cost": "Betalda tjänster",
|
||||
},
|
||||
],
|
||||
},
|
||||
"ecosystem_analogy": {
|
||||
"description": "Jämförelse med framgångsrika ekosystem",
|
||||
"examples": [
|
||||
{
|
||||
"standard": "HTTP / HTML / CSS",
|
||||
"implementation": "Apache, Nginx, Chrome, Firefox",
|
||||
"company": "Cloudflare, Fastly, Google",
|
||||
},
|
||||
{
|
||||
"standard": "Kubernetes (CNCF)",
|
||||
"implementation": "OpenShift, Rancher, EKS",
|
||||
"company": "Red Hat, SUSE, AWS",
|
||||
},
|
||||
{
|
||||
"standard": "OpenAPI",
|
||||
"implementation": "Swagger, Postman",
|
||||
"company": "SmartBear, Postman Inc",
|
||||
},
|
||||
{
|
||||
"standard": "RIS (Reality Intelligence Standard)",
|
||||
"implementation": "LIFE (Landvex Intelligence Fusion Engine)",
|
||||
"company": "Landvex AB",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── FINAL PRODUCT STORY ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/product-story")
|
||||
async def get_product_story():
|
||||
"""
|
||||
Slutlig produktberättelse
|
||||
"""
|
||||
return {
|
||||
"title": "Landvex LIFE — Reality Intelligence Platform",
|
||||
"tagline": "Reality Intelligence Standard (RIS) + Reference Implementation",
|
||||
"one_sentence": "Landvex utvecklar LIFE, en Reality Intelligence Platform och referensimplementation av Reality Intelligence Standard (RIS). Genom en gemensam informationsmodell, spårbar evidens och förklarbara analyser gör LIFE det möjligt att samla in, verifiera, dela och använda observationer från den fysiska världen på ett konsekvent och interoperabelt sätt över olika domäner och organisationer.",
|
||||
"evolution": {
|
||||
"v1": {"focus": "Aid Intelligence", "key": "Biståndsövervakning"},
|
||||
"v2": {"focus": "Multi-signal", "key": "Evidence Before Opinion"},
|
||||
"v3": {"focus": "Core Architecture", "key": "9 block, AI-operativsystem"},
|
||||
"v4": {"focus": "Platform", "key": "4 nivåer, SDK, Object Model"},
|
||||
"v5": {"focus": "Enterprise", "key": "5 pelare, governance, simulation"},
|
||||
"v6": {"focus": "Standard", "key": "6 RIS, ontologi, ekosystem"},
|
||||
"v7": {"focus": "Ecosystem", "key": "Adoption, reference implementations, conformance"},
|
||||
},
|
||||
"positioning": {
|
||||
"what_we_are": "Ett teknologiskt ramverk och öppen standard för Reality Intelligence",
|
||||
"what_we_enable": "Andra att bygga Reality Intelligence-lösningar",
|
||||
"what_makes_us_different": [
|
||||
"Reality Intelligence (inte bara text)",
|
||||
"Evidence Before Opinion (spårbarhet)",
|
||||
"Multi-source fusion (geospatial + fält + AI)",
|
||||
"Open standard (RIS)",
|
||||
"Reference implementation (LIFE)",
|
||||
"Ecosystem approach (adoption fokus)",
|
||||
],
|
||||
"target_users": [
|
||||
"Development agencies",
|
||||
"Governments",
|
||||
"Infrastructure operators",
|
||||
"Insurance companies",
|
||||
"Urban planners",
|
||||
"Environmental organizations",
|
||||
"Security agencies",
|
||||
"Researchers",
|
||||
"Technology partners",
|
||||
],
|
||||
},
|
||||
"business_model": {
|
||||
"open_components": [
|
||||
"RIS Standard (CC BY 4.0)",
|
||||
"LIFE Reference Server (Apache 2.0)",
|
||||
"LIFE SDK (Apache 2.0)",
|
||||
"Example Data (CC BY 4.0)",
|
||||
],
|
||||
"commercial_components": [
|
||||
"LIFE Platform SaaS",
|
||||
"Managed instances",
|
||||
"Professional support",
|
||||
"Custom development",
|
||||
"Training and certification",
|
||||
"QUIXZOOM Network access",
|
||||
],
|
||||
"revenue_streams": [
|
||||
"SaaS subscriptions",
|
||||
"Support contracts",
|
||||
"Professional services",
|
||||
"Certification fees",
|
||||
"Data services",
|
||||
],
|
||||
},
|
||||
"long_term_vision": "LIFE blir den dominerande standarden för hur organisationer representerar, validerar, utbyter och använder verklighetsintelligens — på samma sätt som HTTP blev standarden för webben.",
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
"""
|
||||
Landvex Intelligence Fusion Engine (LIFE)
|
||||
Continuous Multi-Source Intelligence Platform
|
||||
|
||||
Core Principle: Evidence Before Opinion
|
||||
- First gather observable signals
|
||||
- Then weigh independent sources
|
||||
- Identify patterns and statistical associations
|
||||
- Only then generate cautious hypotheses about possible causes
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/life", tags=["life_engine"])
|
||||
|
||||
|
||||
class EvidenceCategory(str, Enum):
|
||||
OFFICIAL_REPORTING = "official_reporting"
|
||||
SATELLITE = "satellite"
|
||||
QUIXZOOM = "quixzoom"
|
||||
NEWS = "news"
|
||||
OPEN_DATA = "open_data"
|
||||
HISTORICAL = "historical"
|
||||
ENVIRONMENTAL = "environmental"
|
||||
INFRASTRUCTURE = "infrastructure"
|
||||
TEMPORAL = "temporal"
|
||||
CORROBORATION = "corroboration"
|
||||
|
||||
|
||||
class ConfidenceLevel(str, Enum):
|
||||
VERY_HIGH = "very_high" # 90-100%
|
||||
HIGH = "high" # 75-89%
|
||||
MODERATE = "moderate" # 50-74%
|
||||
LOW = "low" # 25-49%
|
||||
VERY_LOW = "very_low" # 0-24%
|
||||
|
||||
|
||||
class EventType(str, Enum):
|
||||
NATURAL_DISASTER = "natural_disaster"
|
||||
CONFLICT = "conflict"
|
||||
INFRASTRUCTURE_FAILURE = "infrastructure_failure"
|
||||
POLITICAL_INSTABILITY = "political_instability"
|
||||
DISEASE_OUTBREAK = "disease_outbreak"
|
||||
CONSTRUCTION_DELAY = "construction_delay"
|
||||
ENVIRONMENTAL_INCIDENT = "environmental_incident"
|
||||
|
||||
|
||||
# ─── PHILOSOPHY ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/philosophy")
|
||||
async def get_life_philosophy():
|
||||
"""Evidence Before Opinion - core architecture principle."""
|
||||
return {
|
||||
"engine_name": "Landvex Intelligence Fusion Engine (LIFE)",
|
||||
"version": "1.0.0",
|
||||
"core_principle": "Evidence Before Opinion",
|
||||
"principle_description": "The system never starts with a conclusion and looks for support. Instead, every analysis builds from observable signals upward.",
|
||||
"process": [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "Gather Facts",
|
||||
"description": "Collect observable signals from all available sources"
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "Weigh Sources",
|
||||
"description": "Evaluate independent sources and their reliability"
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "Identify Patterns",
|
||||
"description": "Detect statistical associations and correlations"
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "Generate Hypotheses",
|
||||
"description": "Form cautious hypotheses about possible causes"
|
||||
},
|
||||
],
|
||||
"commitment": "Every insight answers: What evidence supports this? Which sources contributed? What observations disagree? How confident is the estimate?",
|
||||
}
|
||||
|
||||
|
||||
# ─── DATA SOURCES ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/data-sources")
|
||||
async def get_data_sources():
|
||||
"""All continuous data acquisition sources."""
|
||||
return {
|
||||
"categories": [
|
||||
{
|
||||
"name": "Official Project Information",
|
||||
"sources": [
|
||||
"Development agency project portals",
|
||||
"Government project databases",
|
||||
"Public procurement portals",
|
||||
"Budget publications",
|
||||
"Tender databases",
|
||||
"Evaluation reports",
|
||||
"Annual reports",
|
||||
"Audit reports",
|
||||
"Parliamentary documents",
|
||||
],
|
||||
"update_frequency": "Daily",
|
||||
"reliability": "High",
|
||||
},
|
||||
{
|
||||
"name": "News Intelligence",
|
||||
"sources": [
|
||||
"International news",
|
||||
"National news",
|
||||
"Regional news",
|
||||
"Local newspapers",
|
||||
"RSS feeds",
|
||||
"Development news",
|
||||
"Humanitarian news",
|
||||
"Infrastructure news",
|
||||
"Agriculture news",
|
||||
"Health news",
|
||||
"Education news",
|
||||
"Economic news",
|
||||
"Environmental news",
|
||||
"Security news",
|
||||
"Energy news",
|
||||
"Transportation news",
|
||||
],
|
||||
"update_frequency": "Real-time",
|
||||
"reliability": "Medium",
|
||||
},
|
||||
{
|
||||
"name": "Public Communications",
|
||||
"sources": [
|
||||
"Press releases",
|
||||
"Official websites",
|
||||
"Public blogs",
|
||||
"Open social media posts from institutions",
|
||||
"Project announcements",
|
||||
"Public progress updates",
|
||||
"Conference presentations",
|
||||
"Research publications",
|
||||
],
|
||||
"update_frequency": "Daily",
|
||||
"reliability": "Medium",
|
||||
},
|
||||
{
|
||||
"name": "Geospatial Intelligence",
|
||||
"sources": [
|
||||
"Satellite imagery (Sentinel-2, Landsat)",
|
||||
"Night light intensity",
|
||||
"Land cover change",
|
||||
"Road detection",
|
||||
"Construction detection",
|
||||
"Flood monitoring",
|
||||
"Drought monitoring",
|
||||
"Vegetation analysis",
|
||||
"River changes",
|
||||
"Urban expansion",
|
||||
"Environmental degradation",
|
||||
"Wildfire detection",
|
||||
"Infrastructure growth",
|
||||
],
|
||||
"update_frequency": "Every 5 days",
|
||||
"reliability": "Very High",
|
||||
},
|
||||
{
|
||||
"name": "QUIXZOOM Reality Network",
|
||||
"sources": [
|
||||
"Field observations",
|
||||
"Mission results",
|
||||
"Photographic evidence",
|
||||
"Video",
|
||||
"Audio",
|
||||
"GPS tracks",
|
||||
"Infrastructure inspections",
|
||||
"Community observations",
|
||||
"Temporal comparisons",
|
||||
],
|
||||
"update_frequency": "On-demand",
|
||||
"reliability": "High",
|
||||
},
|
||||
{
|
||||
"name": "Open Data",
|
||||
"sources": [
|
||||
"Population data",
|
||||
"Weather",
|
||||
"Climate",
|
||||
"Transportation",
|
||||
"Electricity",
|
||||
"Internet coverage",
|
||||
"Water availability",
|
||||
"Health indicators",
|
||||
"Education statistics",
|
||||
"Economic indicators",
|
||||
"Commodity prices",
|
||||
"Agricultural production",
|
||||
"Migration statistics",
|
||||
"Conflict datasets",
|
||||
],
|
||||
"update_frequency": "Weekly to Monthly",
|
||||
"reliability": "High",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── EVENT DETECTION ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/events")
|
||||
async def list_detected_events(
|
||||
project_id: Optional[str] = None,
|
||||
event_type: Optional[EventType] = None,
|
||||
country: Optional[str] = None,
|
||||
):
|
||||
"""Automatically detected meaningful events."""
|
||||
events = [
|
||||
{
|
||||
"id": "evt-001",
|
||||
"type": "natural_disaster",
|
||||
"subtype": "flood",
|
||||
"title": "Severe flooding in Mogadishu region",
|
||||
"description": "Heavy rainfall caused flooding affecting infrastructure accessibility",
|
||||
"location": {"country": "Somalia", "region": "Banadir", "lat": 2.0469, "lng": 45.3182},
|
||||
"date": "2026-06-15",
|
||||
"affected_projects": ["proj-001"],
|
||||
"severity": "high",
|
||||
"confidence": 92,
|
||||
"sources": ["Satellite", "News", "Open data"],
|
||||
"detected_at": "2026-06-16T08:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "evt-002",
|
||||
"type": "construction_delay",
|
||||
"subtype": "weather_related",
|
||||
"title": "Construction delay due to seasonal rainfall",
|
||||
"description": "Road rehabilitation project delayed due to extended rainy season",
|
||||
"location": {"country": "Nigeria", "region": "Lagos", "lat": 6.5244, "lng": 3.3792},
|
||||
"date": "2026-05-20",
|
||||
"affected_projects": ["proj-045"],
|
||||
"severity": "medium",
|
||||
"confidence": 78,
|
||||
"sources": ["Satellite", "Field observation", "Weather data"],
|
||||
"detected_at": "2026-05-25T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "evt-003",
|
||||
"type": "conflict",
|
||||
"subtype": "regional_insecurity",
|
||||
"title": "Regional insecurity affecting project operations",
|
||||
"description": "Increased security incidents in project area affecting staff access",
|
||||
"location": {"country": "Ethiopia", "region": "Tigray", "lat": 14.1628, "lng": 38.2906},
|
||||
"date": "2026-04-10",
|
||||
"affected_projects": ["proj-089"],
|
||||
"severity": "high",
|
||||
"confidence": 85,
|
||||
"sources": ["News", "Security reports", "Field observation"],
|
||||
"detected_at": "2026-04-12T14:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
if country:
|
||||
events = [e for e in events if e["location"]["country"].lower() == country.lower()]
|
||||
if event_type:
|
||||
events = [e for e in events if e["type"] == event_type.value]
|
||||
|
||||
return {
|
||||
"events": events,
|
||||
"total": len(events),
|
||||
"detection_method": "Automated multi-source fusion with human verification",
|
||||
}
|
||||
|
||||
|
||||
# ─── CONTEXT GRAPH ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/projects/{project_id}/context")
|
||||
async def get_project_context(project_id: str):
|
||||
"""Continuously updated contextual graph for a project."""
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"context": {
|
||||
"political_environment": {
|
||||
"stability_score": 65,
|
||||
"confidence": 78,
|
||||
"factors": ["Democratic governance", "Regional tensions", "Election upcoming"],
|
||||
},
|
||||
"economic_conditions": {
|
||||
"gdp_growth": 4.2,
|
||||
"inflation": 8.5,
|
||||
"currency_stability": "moderate",
|
||||
"confidence": 82,
|
||||
},
|
||||
"climate": {
|
||||
"current_season": "rainy",
|
||||
"rainfall_vs_average": 120,
|
||||
"temperature_vs_average": 102,
|
||||
"drought_risk": "low",
|
||||
"flood_risk": "high",
|
||||
"confidence": 90,
|
||||
},
|
||||
"security": {
|
||||
"overall_risk": "medium",
|
||||
"recent_incidents": 3,
|
||||
"trend": "stable",
|
||||
"confidence": 75,
|
||||
},
|
||||
"infrastructure": {
|
||||
"road_accessibility": 70,
|
||||
"power_availability": 45,
|
||||
"internet_coverage": 60,
|
||||
"water_access": 55,
|
||||
"confidence": 80,
|
||||
},
|
||||
"population": {
|
||||
"local_population": 45000,
|
||||
"displacement": "low",
|
||||
"growth_rate": 2.8,
|
||||
"confidence": 85,
|
||||
},
|
||||
"nearby_projects": [
|
||||
{"id": "proj-015", "name": "Regional Health Clinic", "distance_km": 5.2},
|
||||
{"id": "proj-023", "name": "Road Improvement", "distance_km": 12.0},
|
||||
],
|
||||
"funding_ecosystem": {
|
||||
"total_aid_in_region_usd": 45000000,
|
||||
"major_donors": ["World Bank", "UNICEF", "GIZ"],
|
||||
"coordination_level": "moderate",
|
||||
},
|
||||
},
|
||||
"last_updated": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ─── CAUSAL ANALYSIS ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/projects/{project_id}/causal-analysis")
|
||||
async def get_causal_analysis(project_id: str):
|
||||
"""Causal analysis layer - transparent statistical associations."""
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"disclaimer": "These are statistical associations and model-generated hypotheses, not proven causation.",
|
||||
"observed_associations": [
|
||||
{
|
||||
"id": "assoc-001",
|
||||
"observation": "Reduced accessibility following severe flooding",
|
||||
"factors": [
|
||||
{"name": "Heavy rainfall", "contribution": 0.45, "confidence": 92},
|
||||
{"name": "Poor drainage infrastructure", "contribution": 0.30, "confidence": 78},
|
||||
{"name": "Road surface quality", "contribution": 0.25, "confidence": 65},
|
||||
],
|
||||
"statistical_strength": "strong",
|
||||
"evidence_sources": ["Satellite", "Weather data", "Field observation"],
|
||||
"hypothesis": "Flooding disproportionately affects projects in areas with inadequate drainage",
|
||||
"uncertainty": "Drainage quality is estimated from satellite and may not reflect recent improvements",
|
||||
},
|
||||
{
|
||||
"id": "assoc-002",
|
||||
"observation": "Construction delays associated with seasonal rainfall",
|
||||
"factors": [
|
||||
{"name": "Rainy season duration", "contribution": 0.60, "confidence": 85},
|
||||
{"name": "Soil conditions", "contribution": 0.25, "confidence": 70},
|
||||
{"name": "Equipment availability", "contribution": 0.15, "confidence": 55},
|
||||
],
|
||||
"statistical_strength": "moderate",
|
||||
"evidence_sources": ["Satellite time-series", "Weather data", "Project reports"],
|
||||
"hypothesis": "Projects in tropical regions experience predictable seasonal delays",
|
||||
"uncertainty": "Equipment availability data is limited",
|
||||
},
|
||||
],
|
||||
"distinguish": {
|
||||
"observed_facts": "Flooding occurred on June 15, accessibility reduced by 40%",
|
||||
"statistical_associations": "Projects in flood-prone areas 3x more likely to experience delays",
|
||||
"model_hypotheses": "Drainage investment may reduce weather-related delays by 50%",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── MULTI-SIGNAL CONFIDENCE ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/projects/{project_id}/confidence")
|
||||
async def get_confidence_score(project_id: str):
|
||||
"""Multi-signal confidence engine combining evidence from multiple sources."""
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"overall_confidence": 82,
|
||||
"confidence_level": "high",
|
||||
"evidence_breakdown": [
|
||||
{
|
||||
"category": "official_reporting",
|
||||
"source": "UNICEF quarterly report",
|
||||
"contribution": 15,
|
||||
"weight": 0.15,
|
||||
"reliability": 85,
|
||||
"agreement_with_others": 72,
|
||||
},
|
||||
{
|
||||
"category": "satellite",
|
||||
"source": "Sentinel-2 imagery",
|
||||
"contribution": 25,
|
||||
"weight": 0.25,
|
||||
"reliability": 95,
|
||||
"agreement_with_others": 88,
|
||||
},
|
||||
{
|
||||
"category": "quixzoom",
|
||||
"source": "Field observations (12 contributors)",
|
||||
"contribution": 25,
|
||||
"weight": 0.25,
|
||||
"reliability": 88,
|
||||
"agreement_with_others": 85,
|
||||
},
|
||||
{
|
||||
"category": "news",
|
||||
"source": "Regional news monitoring",
|
||||
"contribution": 10,
|
||||
"weight": 0.10,
|
||||
"reliability": 60,
|
||||
"agreement_with_others": 65,
|
||||
},
|
||||
{
|
||||
"category": "open_data",
|
||||
"source": "Weather, population, economic data",
|
||||
"contribution": 15,
|
||||
"weight": 0.15,
|
||||
"reliability": 90,
|
||||
"agreement_with_others": 80,
|
||||
},
|
||||
{
|
||||
"category": "historical",
|
||||
"source": "Similar projects in region",
|
||||
"contribution": 10,
|
||||
"weight": 0.10,
|
||||
"reliability": 75,
|
||||
"agreement_with_others": 70,
|
||||
},
|
||||
],
|
||||
"convergence_analysis": {
|
||||
"sources_agree": 5,
|
||||
"sources_disagree": 1,
|
||||
"primary_disagreement": "Official reporting shows 95% completion vs observed 85%",
|
||||
"confidence_increase": "Multiple independent sources converging on 82-88% completion",
|
||||
},
|
||||
"methodology": "Confidence increases as multiple independent sources converge. Disagreement reduces confidence but highlights areas for additional verification.",
|
||||
}
|
||||
|
||||
|
||||
# ─── PREDICTIVE INTELLIGENCE ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/projects/{project_id}/predictions")
|
||||
async def get_predictions(project_id: str):
|
||||
"""Predictive intelligence with uncertainty intervals."""
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"disclaimer": "Predictions are estimates based on current evidence and historical patterns. They are not certainties.",
|
||||
"predictions": [
|
||||
{
|
||||
"id": "pred-001",
|
||||
"type": "completion_date",
|
||||
"prediction": "Project will complete by August 15, 2026",
|
||||
"confidence": 72,
|
||||
"uncertainty_interval": "August 1 - August 30, 2026",
|
||||
"factors": [
|
||||
{"factor": "Current progress rate", "impact": "positive", "strength": 0.7},
|
||||
{"factor": "Seasonal weather forecast", "impact": "negative", "strength": 0.3},
|
||||
{"factor": "Resource availability", "impact": "neutral", "strength": 0.5},
|
||||
],
|
||||
"assumptions": [
|
||||
"No major weather disruptions",
|
||||
"Funding continues as planned",
|
||||
"Material supply remains stable",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "pred-002",
|
||||
"type": "operational_sustainability",
|
||||
"prediction": "75% probability of sustained operation for 3+ years",
|
||||
"confidence": 68,
|
||||
"uncertainty_interval": "65-85%",
|
||||
"factors": [
|
||||
{"factor": "Maintenance plan quality", "impact": "positive", "strength": 0.8},
|
||||
{"factor": "Local capacity", "impact": "positive", "strength": 0.6},
|
||||
{"factor": "Funding continuity", "impact": "uncertain", "strength": 0.4},
|
||||
],
|
||||
"assumptions": [
|
||||
"Local government maintains commitment",
|
||||
"No major economic shocks",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "pred-003",
|
||||
"type": "risk_assessment",
|
||||
"prediction": "Medium risk of 2-4 week delay due to rainy season",
|
||||
"confidence": 78,
|
||||
"uncertainty_interval": "Low-High",
|
||||
"factors": [
|
||||
{"factor": "Historical weather patterns", "impact": "negative", "strength": 0.8},
|
||||
{"factor": "Drainage infrastructure", "impact": "positive", "strength": 0.5},
|
||||
{"factor": "Contingency planning", "impact": "positive", "strength": 0.6},
|
||||
],
|
||||
"recommended_actions": [
|
||||
"Accelerate indoor work before rainy season",
|
||||
"Pre-position materials on-site",
|
||||
"Develop drainage improvement plan",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── EXPLAINABILITY ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/insights/{insight_id}/explain")
|
||||
async def explain_insight(insight_id: str):
|
||||
"""Every insight answers: What evidence? Which sources? What disagrees?"""
|
||||
return {
|
||||
"insight_id": insight_id,
|
||||
"explainability": {
|
||||
"what_evidence_supports_this": [
|
||||
"12 field observations from 8 independent contributors",
|
||||
"Sentinel-2 satellite imagery from 6 dates",
|
||||
"Official quarterly report (June 2026)",
|
||||
"Regional weather data showing 120% average rainfall",
|
||||
],
|
||||
"which_sources_contributed": [
|
||||
{"source": "QUIXZOOM field observations", "weight": 0.30, "reliability": 88},
|
||||
{"source": "Satellite imagery", "weight": 0.25, "reliability": 95},
|
||||
{"source": "Official reporting", "weight": 0.20, "reliability": 85},
|
||||
{"source": "Open weather data", "weight": 0.15, "reliability": 90},
|
||||
{"source": "News monitoring", "weight": 0.10, "reliability": 60},
|
||||
],
|
||||
"what_observations_disagree": [
|
||||
{
|
||||
"observation": "Official report claims 95% completion",
|
||||
"conflicting_evidence": "Field and satellite observations show 82-88%",
|
||||
"possible_explanations": [
|
||||
"Different measurement methodologies",
|
||||
"Reporting date vs observation date lag",
|
||||
"Interior work not visible from exterior",
|
||||
],
|
||||
},
|
||||
],
|
||||
"how_confident_is_the_estimate": {
|
||||
"overall_confidence": 82,
|
||||
"confidence_level": "high",
|
||||
"primary_uncertainties": [
|
||||
"Interior completion percentage is estimated",
|
||||
"Official reporting may use different metrics",
|
||||
],
|
||||
},
|
||||
"what_assumptions_influence_the_model": [
|
||||
"Field observations are representative of overall progress",
|
||||
"Satellite imagery accurately reflects ground conditions",
|
||||
"Weather patterns follow historical trends",
|
||||
],
|
||||
"what_additional_evidence_would_increase_confidence": [
|
||||
"Interior photographs from multiple angles",
|
||||
"Construction timeline from contractor",
|
||||
"Material delivery receipts",
|
||||
"Independent engineering assessment",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── DASHBOARD ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def get_life_dashboard(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""LIFE admin dashboard."""
|
||||
return {
|
||||
"engine": "Landvex Intelligence Fusion Engine",
|
||||
"version": "1.0.0",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"data_sources": {
|
||||
"total": 6,
|
||||
"active": 6,
|
||||
"streams_monitored": 45,
|
||||
},
|
||||
"projects": {
|
||||
"monitored": 1250,
|
||||
"with_context_graph": 1250,
|
||||
"with_causal_analysis": 890,
|
||||
"with_predictions": 1250,
|
||||
},
|
||||
"events_detected": {
|
||||
"last_24h": 12,
|
||||
"last_7d": 89,
|
||||
"last_30d": 340,
|
||||
"by_type": {
|
||||
"natural_disaster": 45,
|
||||
"conflict": 23,
|
||||
"infrastructure_failure": 67,
|
||||
"construction_delay": 120,
|
||||
"political_instability": 15,
|
||||
"environmental_incident": 70,
|
||||
},
|
||||
},
|
||||
"confidence_distribution": {
|
||||
"very_high": 320,
|
||||
"high": 580,
|
||||
"moderate": 280,
|
||||
"low": 55,
|
||||
"very_low": 15,
|
||||
},
|
||||
"recent_fusion_results": [
|
||||
{
|
||||
"project": "Mogadishu Primary School",
|
||||
"insight": "Construction delay likely due to flooding + poor drainage",
|
||||
"confidence": 78,
|
||||
"sources_fused": 5,
|
||||
},
|
||||
{
|
||||
"project": "Kampala Water Supply",
|
||||
"insight": "Operational sustainability high based on maintenance records + community feedback",
|
||||
"confidence": 88,
|
||||
"sources_fused": 6,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
"""
|
||||
Landvex Intelligence Fusion Engine (LIFE) v2
|
||||
Operativ intelligensmotor med 7 kärnkomponenter:
|
||||
1. Evidence Graph (EG)
|
||||
2. Evidence Lineage
|
||||
3. Temporal Reality Engine
|
||||
4. Contradiction Knowledge Base
|
||||
5. Reality DNA
|
||||
6. Reality Memory
|
||||
7. Reality Hypothesis Engine
|
||||
|
||||
Core Principle: Evidence Before Opinion
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/life-v2", tags=["life_engine_v2"])
|
||||
|
||||
|
||||
# ─── 1. EVIDENCE GRAPH (EG) ──────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/evidence-graph/{project_id}")
|
||||
async def get_evidence_graph(project_id: str):
|
||||
"""
|
||||
Evidence Graph - Varje observation är en nod i en graf.
|
||||
Observation → Object → Project → Programme → Organisation → Region → Country
|
||||
"""
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"graph_structure": {
|
||||
"description": "Hierarchical evidence graph where every observation is a traceable node",
|
||||
"levels": [
|
||||
{
|
||||
"level": "Observation",
|
||||
"description": "Individual data points (photos, measurements, reports)",
|
||||
"example": "Satellite image showing construction progress on 2026-07-01",
|
||||
"count": 156,
|
||||
},
|
||||
{
|
||||
"level": "Object",
|
||||
"description": "Physical entities being observed",
|
||||
"example": "School building, water pump, road segment",
|
||||
"count": 12,
|
||||
},
|
||||
{
|
||||
"level": "Project",
|
||||
"description": "Development project being monitored",
|
||||
"example": "Mogadishu Primary School Renovation",
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"level": "Programme",
|
||||
"description": "Funding programme or initiative",
|
||||
"example": "UNICEF Education Programme 2025-2027",
|
||||
"count": 1,
|
||||
},
|
||||
{
|
||||
"level": "Organisation",
|
||||
"description": "Implementing or funding organization",
|
||||
"example": "UNICEF, Somali Education Consortium",
|
||||
"count": 2,
|
||||
},
|
||||
{
|
||||
"level": "Region",
|
||||
"description": "Geographic region",
|
||||
"example": "East Africa, Banadir Region",
|
||||
"count": 2,
|
||||
},
|
||||
{
|
||||
"level": "Country",
|
||||
"description": "National level",
|
||||
"example": "Somalia",
|
||||
"count": 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
"example_trace": {
|
||||
"conclusion": "Construction is 85% complete",
|
||||
"evidence_chain": [
|
||||
{
|
||||
"node_type": "Observation",
|
||||
"id": "obs-001",
|
||||
"data": "Field photo showing exterior walls complete",
|
||||
"source": "QUIXZOOM contributor C-123",
|
||||
"confidence": 92,
|
||||
},
|
||||
{
|
||||
"node_type": "Observation",
|
||||
"id": "obs-002",
|
||||
"data": "Satellite imagery shows roof structure present",
|
||||
"source": "Sentinel-2",
|
||||
"confidence": 95,
|
||||
},
|
||||
{
|
||||
"node_type": "Object",
|
||||
"id": "obj-001",
|
||||
"name": "School Building Block A",
|
||||
"aggregated_confidence": 93,
|
||||
},
|
||||
{
|
||||
"node_type": "Project",
|
||||
"id": project_id,
|
||||
"name": "Mogadishu Primary School Renovation",
|
||||
"aggregated_confidence": 85,
|
||||
},
|
||||
],
|
||||
},
|
||||
"query_capabilities": [
|
||||
"Trace any conclusion back to original observations",
|
||||
"Find all observations supporting a specific claim",
|
||||
"Identify evidence gaps at any level",
|
||||
"Compare confidence across different branches",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── 2. EVIDENCE LINEAGE ─────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/evidence-lineage/{observation_id}")
|
||||
async def get_evidence_lineage(observation_id: str):
|
||||
"""
|
||||
Evidence Lineage - Komplett ursprung för varje datapunkt.
|
||||
Source → Collected → Validated → Cross-validated → AI confidence → Human verification → Historical revisions → Current confidence
|
||||
"""
|
||||
return {
|
||||
"observation_id": observation_id,
|
||||
"lineage": {
|
||||
"source": {
|
||||
"type": "QUIXZOOM field observation",
|
||||
"contributor_id": "C-123",
|
||||
"contributor_reputation": 94,
|
||||
"collection_device": "iPhone 14 Pro",
|
||||
"gps_accuracy": "±3 meters",
|
||||
"timestamp": "2026-07-03T14:30:00Z",
|
||||
},
|
||||
"collected": {
|
||||
"raw_data": "Original image + metadata",
|
||||
"checksum": "sha256:a1b2c3...",
|
||||
"collection_conditions": "Daylight, clear weather",
|
||||
"collector_notes": "Building exterior appears complete, interior work visible through windows",
|
||||
},
|
||||
"validated": {
|
||||
"ai_validation": {
|
||||
"model": "Landvex Construction Progress v2.1",
|
||||
"confidence": 88,
|
||||
"detected_features": ["walls", "roof", "windows", "scaffolding"],
|
||||
},
|
||||
"geospatial_validation": {
|
||||
"matches_expected_location": True,
|
||||
"location_deviation": "0.5 meters",
|
||||
},
|
||||
"temporal_validation": {
|
||||
"timestamp_verified": True,
|
||||
"sequence_consistent": True,
|
||||
},
|
||||
},
|
||||
"cross_validated": {
|
||||
"supporting_observations": [
|
||||
{"id": "obs-003", "source": "Satellite", "agreement": 95},
|
||||
{"id": "obs-004", "source": "Another contributor", "agreement": 82},
|
||||
],
|
||||
"conflicting_observations": [],
|
||||
"cross_validation_score": 91,
|
||||
},
|
||||
"ai_confidence": {
|
||||
"initial_score": 85,
|
||||
"after_validation": 88,
|
||||
"after_cross_validation": 91,
|
||||
"factors": [
|
||||
{"factor": "Image quality", "impact": +3},
|
||||
{"factor": "Multiple angles available", "impact": +5},
|
||||
{"factor": "Limited interior visibility", "impact": -2},
|
||||
],
|
||||
},
|
||||
"human_verification": {
|
||||
"verified_by": "Analyst-007",
|
||||
"verification_date": "2026-07-04",
|
||||
"verification_method": "Visual inspection + cross-reference with satellite",
|
||||
"human_confidence": 90,
|
||||
"notes": "Exterior completion confirmed. Interior estimate based on visible progress.",
|
||||
},
|
||||
"historical_revisions": [
|
||||
{
|
||||
"date": "2026-07-03",
|
||||
"confidence": 85,
|
||||
"change": "Initial upload",
|
||||
},
|
||||
{
|
||||
"date": "2026-07-04",
|
||||
"confidence": 88,
|
||||
"change": "AI validation complete",
|
||||
},
|
||||
{
|
||||
"date": "2026-07-04",
|
||||
"confidence": 91,
|
||||
"change": "Cross-validation with satellite complete",
|
||||
},
|
||||
],
|
||||
"current_confidence": {
|
||||
"score": 91,
|
||||
"level": "high",
|
||||
"last_updated": "2026-07-04T10:00:00Z",
|
||||
"next_review": "2026-07-11",
|
||||
},
|
||||
},
|
||||
"value": "When someone questions an analysis, show exactly how this observation was collected, validated, and verified.",
|
||||
}
|
||||
|
||||
|
||||
# ─── 3. TEMPORAL REALITY ENGINE ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/temporal/{project_id}")
|
||||
async def get_temporal_reality(project_id: str):
|
||||
"""
|
||||
Temporal Reality Engine - Tidsmaskin för att se hur verkligheten förändras.
|
||||
"""
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"temporal_capabilities": [
|
||||
"What did the area look like six months ago?",
|
||||
"What has changed?",
|
||||
"How fast?",
|
||||
"When did the change begin?",
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"date": "2025-09-01",
|
||||
"description": "Project start",
|
||||
"satellite_image": "https://cdn.landvex.com/sat/2025-09-01/proj-001.jpg",
|
||||
"observations": 0,
|
||||
"ai_analysis": "Bare ground, no construction activity",
|
||||
"confidence": 95,
|
||||
},
|
||||
{
|
||||
"date": "2025-12-01",
|
||||
"description": "Foundation complete",
|
||||
"satellite_image": "https://cdn.landvex.com/sat/2025-12-01/proj-001.jpg",
|
||||
"observations": 12,
|
||||
"ai_analysis": "Foundation visible, materials on site",
|
||||
"confidence": 92,
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01",
|
||||
"description": "Structure rising",
|
||||
"satellite_image": "https://cdn.landvex.com/sat/2026-03-01/proj-001.jpg",
|
||||
"observations": 45,
|
||||
"ai_analysis": "Walls visible, roof framework started",
|
||||
"confidence": 90,
|
||||
},
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"description": "Roof complete",
|
||||
"satellite_image": "https://cdn.landvex.com/sat/2026-06-01/proj-001.jpg",
|
||||
"observations": 89,
|
||||
"ai_analysis": "Roof complete, exterior walls finished",
|
||||
"confidence": 93,
|
||||
},
|
||||
{
|
||||
"date": "2026-07-03",
|
||||
"description": "Current state",
|
||||
"satellite_image": "https://cdn.landvex.com/sat/2026-07-03/proj-001.jpg",
|
||||
"observations": 156,
|
||||
"ai_analysis": "Exterior 95% complete, interior work ongoing",
|
||||
"confidence": 88,
|
||||
},
|
||||
],
|
||||
"change_detection": {
|
||||
"total_changes_detected": 23,
|
||||
"significant_changes": [
|
||||
{
|
||||
"date": "2025-12-15",
|
||||
"type": "construction_start",
|
||||
"description": "First visible construction activity",
|
||||
"confidence": 95,
|
||||
},
|
||||
{
|
||||
"date": "2026-02-20",
|
||||
"type": "acceleration",
|
||||
"description": "Construction pace increased 40%",
|
||||
"confidence": 82,
|
||||
},
|
||||
{
|
||||
"date": "2026-06-15",
|
||||
"type": "flooding_impact",
|
||||
"description": "Accessibility reduced due to flooding",
|
||||
"confidence": 92,
|
||||
},
|
||||
],
|
||||
},
|
||||
"temporal_queries": {
|
||||
"what_did_it_look_like_6_months_ago": {
|
||||
"date": "2026-01-03",
|
||||
"description": "Foundation complete, walls starting to rise",
|
||||
"satellite_image": "https://cdn.landvex.com/sat/2026-01-03/proj-001.jpg",
|
||||
},
|
||||
"what_has_changed": {
|
||||
"changes": [
|
||||
"Walls completed (0% → 100%)",
|
||||
"Roof added (0% → 100%)",
|
||||
"Windows installed (0% → 80%)",
|
||||
"Interior work started (0% → 60%)",
|
||||
],
|
||||
"time_period": "6 months",
|
||||
},
|
||||
"how_fast": {
|
||||
"average_progress_per_month": "12%",
|
||||
"fastest_month": "March 2026 (18%)",
|
||||
"slowest_month": "June 2026 (5%, due to flooding)",
|
||||
},
|
||||
"when_did_change_begin": {
|
||||
"construction_start": "2025-12-10",
|
||||
"acceleration": "2026-02-15",
|
||||
"flooding_impact": "2026-06-15",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── 4. CONTRADICTION KNOWLEDGE BASE ─────────────────────────────────────────
|
||||
|
||||
@router.get("/public/contradictions")
|
||||
async def get_contradiction_knowledge_base():
|
||||
"""
|
||||
Contradiction Knowledge Base - Alla identifierade avvikelser lagras för AI-träning.
|
||||
"""
|
||||
return {
|
||||
"description": "All identified discrepancies are stored to train AI on real-world project development patterns",
|
||||
"total_contradictions": 1247,
|
||||
"categories": {
|
||||
"schedule_variance": 342,
|
||||
"progress_reporting": 289,
|
||||
"operational_status": 198,
|
||||
"budget_execution": 156,
|
||||
"maintenance_gap": 134,
|
||||
"location_discrepancy": 67,
|
||||
"other": 61,
|
||||
},
|
||||
"example_entries": [
|
||||
{
|
||||
"id": "contr-001",
|
||||
"type": "schedule_variance",
|
||||
"project": "Mogadishu Primary School",
|
||||
"official_report": "95% complete (June 2026)",
|
||||
"satellite": "88% complete (July 2026)",
|
||||
"quixzoom": "85% complete (July 2026)",
|
||||
"news": "No relevant reports",
|
||||
"public_procurement": "Materials delivered through June",
|
||||
"confidence": 78,
|
||||
"resolution": "Pending follow-up observation",
|
||||
"lessons_learned": "Interior work often underreported in official progress",
|
||||
},
|
||||
{
|
||||
"id": "contr-002",
|
||||
"type": "operational_status",
|
||||
"project": "Nairobi Health Clinic",
|
||||
"official_report": "Fully operational",
|
||||
"satellite": "Activity detected",
|
||||
"quixzoom": "Reduced hours observed, staff shortage",
|
||||
"news": "Healthcare worker strike reported",
|
||||
"public_procurement": "No recent medical supply contracts",
|
||||
"confidence": 85,
|
||||
"resolution": "Partial - strike ended, staffing still reduced",
|
||||
"lessons_learned": "Operational status should include staffing levels",
|
||||
},
|
||||
],
|
||||
"ai_training_value": {
|
||||
"description": "After several years, AI can be trained on hundreds of thousands of real-world examples",
|
||||
"current_training_set": 1247,
|
||||
"projected_2027": 5000,
|
||||
"projected_2028": 15000,
|
||||
"use_cases": [
|
||||
"Predict likely discrepancy types by project category",
|
||||
"Identify early warning signals",
|
||||
"Benchmark reporting accuracy by organization type",
|
||||
"Improve confidence scoring models",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── 5. REALITY DNA ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/reality-dna/{project_id}")
|
||||
async def get_reality_dna(project_id: str):
|
||||
"""
|
||||
Reality DNA - Projektets fingeravtryck som kan jämföras med liknande projekt.
|
||||
"""
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"dna_profile": {
|
||||
"description": "Unique fingerprint of project characteristics for benchmarking",
|
||||
"dimensions": [
|
||||
{
|
||||
"name": "Activity",
|
||||
"score": 78,
|
||||
"description": "Level of observable activity",
|
||||
"indicators": ["Worker presence", "Equipment operation", "Material delivery"],
|
||||
},
|
||||
{
|
||||
"name": "Maintenance",
|
||||
"score": 65,
|
||||
"description": "Observable maintenance quality",
|
||||
"indicators": ["Physical condition", "Repair frequency", "Upkeep standards"],
|
||||
},
|
||||
{
|
||||
"name": "Infrastructure",
|
||||
"score": 82,
|
||||
"description": "Infrastructure completeness and quality",
|
||||
"indicators": ["Construction progress", "Material quality", "Specification compliance"],
|
||||
},
|
||||
{
|
||||
"name": "Community Usage",
|
||||
"score": 70,
|
||||
"description": "Actual utilization by community",
|
||||
"indicators": ["Visitor counts", "Usage patterns", "User feedback"],
|
||||
},
|
||||
{
|
||||
"name": "Traffic",
|
||||
"score": 75,
|
||||
"description": "Transportation and accessibility",
|
||||
"indicators": ["Road condition", "Transport frequency", "Accessibility"],
|
||||
},
|
||||
{
|
||||
"name": "Economic Activity",
|
||||
"score": 60,
|
||||
"description": "Economic enablement",
|
||||
"indicators": ["Local employment", "Market activity", "Income effects"],
|
||||
},
|
||||
{
|
||||
"name": "Environmental Status",
|
||||
"score": 72,
|
||||
"description": "Environmental impact",
|
||||
"indicators": ["Resource efficiency", "Pollution", "Ecosystem health"],
|
||||
},
|
||||
{
|
||||
"name": "Safety",
|
||||
"score": 80,
|
||||
"description": "Observable safety conditions",
|
||||
"indicators": ["Structural integrity", "Safety equipment", "Hazard presence"],
|
||||
},
|
||||
{
|
||||
"name": "Operational Continuity",
|
||||
"score": 68,
|
||||
"description": "Uninterrupted service delivery",
|
||||
"indicators": ["Uptime", "Service consistency", "Disruption frequency"],
|
||||
},
|
||||
],
|
||||
},
|
||||
"benchmarking": {
|
||||
"similar_projects": [
|
||||
{
|
||||
"project_id": "proj-089",
|
||||
"name": "Kismayo School Construction",
|
||||
"similarity_score": 87,
|
||||
"comparison": {
|
||||
"activity": "+5%",
|
||||
"maintenance": "-3%",
|
||||
"infrastructure": "+2%",
|
||||
"community_usage": "+8%",
|
||||
},
|
||||
},
|
||||
{
|
||||
"project_id": "proj-156",
|
||||
"name": "Garowe Education Center",
|
||||
"similarity_score": 82,
|
||||
"comparison": {
|
||||
"activity": "-2%",
|
||||
"maintenance": "+4%",
|
||||
"infrastructure": "-1%",
|
||||
"community_usage": "+5%",
|
||||
},
|
||||
},
|
||||
],
|
||||
"category_average": {
|
||||
"activity": 75,
|
||||
"maintenance": 68,
|
||||
"infrastructure": 80,
|
||||
"community_usage": 72,
|
||||
"traffic": 73,
|
||||
"economic_activity": 65,
|
||||
"environmental_status": 70,
|
||||
"safety": 78,
|
||||
"operational_continuity": 70,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── 6. REALITY MEMORY ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/reality-memory/{project_id}")
|
||||
async def get_reality_memory(project_id: str):
|
||||
"""
|
||||
Reality Memory - LIFE kastar aldrig bort något.
|
||||
Varje observation, AI-bedömning och förändring sparas.
|
||||
"""
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"memory_principles": [
|
||||
"Every observation is preserved",
|
||||
"Every AI judgment is preserved",
|
||||
"Every change is preserved",
|
||||
"Full reproducibility at any point in time",
|
||||
],
|
||||
"storage_stats": {
|
||||
"total_observations": 45000,
|
||||
"total_ai_judgments": 12500,
|
||||
"total_changes_detected": 8900,
|
||||
"total_revisions": 23400,
|
||||
"storage_size_tb": 2.4,
|
||||
"retention_period": "Permanent",
|
||||
},
|
||||
"time_travel": {
|
||||
"description": "Reconstruct how the system reasoned at any point in time",
|
||||
"example_queries": [
|
||||
{
|
||||
"query": "What did we know on March 15, 2026?",
|
||||
"answer": "At that time, we had 45 observations showing 60% completion. AI confidence was 75%. No contradictions detected.",
|
||||
},
|
||||
{
|
||||
"query": "When did we first detect the flooding impact?",
|
||||
"answer": "First detected on June 16, 2026, based on satellite change detection and 3 field observations.",
|
||||
},
|
||||
{
|
||||
"query": "How has our confidence evolved?",
|
||||
"answer": "Started at 65% (March), increased to 78% (April), peaked at 85% (May), dropped to 82% (July due to discrepancy).",
|
||||
},
|
||||
],
|
||||
},
|
||||
"audit_trail": {
|
||||
"description": "Complete audit trail for compliance and research",
|
||||
"capabilities": [
|
||||
"Reproduce any historical analysis",
|
||||
"Track confidence evolution",
|
||||
"Identify when contradictions were first detected",
|
||||
"Show how AI models improved over time",
|
||||
"Demonstrate compliance with verification standards",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── 7. REALITY HYPOTHESIS ENGINE ────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/hypotheses/{project_id}")
|
||||
async def get_hypotheses(project_id: str):
|
||||
"""
|
||||
Reality Hypothesis Engine - Inte "Det här är orsaken" utan
|
||||
"Utifrån tillgänglig evidens finns följande möjliga förklaringar..."
|
||||
"""
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"disclaimer": "These are hypotheses ranked by how well they are supported by observable data. They are not certainties.",
|
||||
"observed_phenomenon": "Construction progress slower than reported (85% observed vs 95% reported)",
|
||||
"hypotheses": [
|
||||
{
|
||||
"rank": 1,
|
||||
"hypothesis": "Interior work is behind schedule while exterior is nearly complete",
|
||||
"supporting_evidence": [
|
||||
"Field observations show exterior walls and roof complete",
|
||||
"Windows visible but interior not accessible",
|
||||
"Satellite cannot detect interior progress",
|
||||
],
|
||||
"confidence": 78,
|
||||
"support_strength": "strong",
|
||||
"recommended_action": "Request interior photos or schedule inspection",
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"hypothesis": "Flooding in June caused 2-week delay",
|
||||
"supporting_evidence": [
|
||||
"Satellite shows flooding in area (June 15)",
|
||||
"Weather data confirms 120% average rainfall",
|
||||
"Field observations show reduced activity June 15-30",
|
||||
],
|
||||
"confidence": 72,
|
||||
"support_strength": "moderate",
|
||||
"recommended_action": "Verify if delay is reflected in revised timeline",
|
||||
},
|
||||
{
|
||||
"rank": 3,
|
||||
"hypothesis": "Material supply disruption affected interior work",
|
||||
"supporting_evidence": [
|
||||
"Procurement data shows delayed deliveries",
|
||||
"Some materials visible on site but not installed",
|
||||
],
|
||||
"confidence": 55,
|
||||
"support_strength": "weak",
|
||||
"recommended_action": "Check supplier delivery records",
|
||||
},
|
||||
{
|
||||
"rank": 4,
|
||||
"hypothesis": "Reporting methodology differs from observation methodology",
|
||||
"supporting_evidence": [
|
||||
"Official report may count 'started' as 'complete'",
|
||||
"Different measurement standards possible",
|
||||
],
|
||||
"confidence": 45,
|
||||
"support_strength": "speculative",
|
||||
"recommended_action": "Review reporting methodology with implementing partner",
|
||||
},
|
||||
],
|
||||
"evidence_gaps": [
|
||||
"Interior completion percentage (need photos)",
|
||||
"Revised construction timeline from contractor",
|
||||
"Material delivery receipts for June-July",
|
||||
"Official reporting methodology documentation",
|
||||
],
|
||||
"methodology": "Hypotheses are generated by analyzing statistical associations across multiple evidence sources. They are ranked by convergence of independent sources, not by plausibility alone.",
|
||||
}
|
||||
|
||||
|
||||
# ─── DASHBOARD ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def get_life_v2_dashboard(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""LIFE v2 Admin Dashboard."""
|
||||
return {
|
||||
"engine": "Landvex Intelligence Fusion Engine v2",
|
||||
"version": "2.0.0",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"components": {
|
||||
"evidence_graph": {"status": "active", "nodes": 125000, "edges": 450000},
|
||||
"evidence_lineage": {"status": "active", "tracked_observations": 45000},
|
||||
"temporal_reality": {"status": "active", "timeline_coverage_years": 3},
|
||||
"contradiction_kb": {"status": "active", "entries": 1247},
|
||||
"reality_dna": {"status": "active", "profiles": 1250},
|
||||
"reality_memory": {"status": "active", "storage_tb": 2.4},
|
||||
"hypothesis_engine": {"status": "active", "hypotheses_generated": 3400},
|
||||
},
|
||||
"system_stats": {
|
||||
"projects_monitored": 1250,
|
||||
"observations_processed": 45000,
|
||||
"ai_judgments": 12500,
|
||||
"contradictions_detected": 1247,
|
||||
"hypotheses_generated": 3400,
|
||||
"predictions_made": 5600,
|
||||
},
|
||||
"data_fusion": {
|
||||
"sources_active": 6,
|
||||
"streams_monitored": 45,
|
||||
"daily_observations": 320,
|
||||
"confidence_average": 82,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
Landvex Intelligence Fusion Engine (LIFE) v5 - Enterprise Platform
|
||||
5 Strategic Pillars:
|
||||
1. Trust Layer (tillit och transparens)
|
||||
2. Governance Layer (styrning och compliance)
|
||||
3. Simulation Layer (scenarioplanering)
|
||||
4. Collaboration Layer (samarbete)
|
||||
5. Reality API (semantiskt API)
|
||||
|
||||
Product Definition:
|
||||
"LIFE är en Reality Intelligence Platform som omvandlar observerbara signaler från den fysiska världen till spårbara evidens, förklarbara analyser och beslutsunderlag genom kontinuerlig fusion av geospatial data, fältobservationer, öppna datakällor och AI."
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/life-enterprise", tags=["life_enterprise"])
|
||||
|
||||
|
||||
# ─── PILLAR 1: TRUST LAYER ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/trust-layer")
|
||||
async def get_trust_layer():
|
||||
"""
|
||||
Trust Layer - Varje insikt ska kunna besvara tillitsfrågor
|
||||
"""
|
||||
return {
|
||||
"pillar": 1,
|
||||
"name": "Trust Layer",
|
||||
"description": "Varje insikt ska kunna besvara frågor om tillförlitlighet",
|
||||
"trust_questions": {
|
||||
"sources": {
|
||||
"question": "Vilka källor ligger bakom?",
|
||||
"answer_structure": {
|
||||
"primary_sources": ["Satellite imagery", "Field observation", "Official report"],
|
||||
"source_count": 5,
|
||||
"source_reliability": [
|
||||
{"source": "Satellite", "reliability": 95},
|
||||
{"source": "Field observation", "reliability": 88},
|
||||
{"source": "Official report", "reliability": 82},
|
||||
],
|
||||
},
|
||||
},
|
||||
"freshness": {
|
||||
"question": "Hur färsk är informationen?",
|
||||
"answer_structure": {
|
||||
"observation_date": "2026-07-04",
|
||||
"data_age_days": 2,
|
||||
"update_frequency": "Every 5 days (satellite)",
|
||||
"next_expected_update": "2026-07-09",
|
||||
"staleness_warning": False,
|
||||
},
|
||||
},
|
||||
"confidence": {
|
||||
"question": "Hur säker är bedömningen?",
|
||||
"answer_structure": {
|
||||
"overall_confidence": 82,
|
||||
"confidence_level": "high",
|
||||
"factors": [
|
||||
{"factor": "Multiple sources agree", "impact": "+15"},
|
||||
{"factor": "Limited interior visibility", "impact": "-8"},
|
||||
{"factor": "Recent observation", "impact": "+5"},
|
||||
],
|
||||
"confidence_interval": "78-86%",
|
||||
},
|
||||
},
|
||||
"contradictions": {
|
||||
"question": "Finns det motstridig evidens?",
|
||||
"answer_structure": {
|
||||
"contradictions_found": True,
|
||||
"contradictions": [
|
||||
{
|
||||
"source_a": "Official report: 95% complete",
|
||||
"source_b": "Field observation: 85% complete",
|
||||
"explanation": "Different measurement methodologies",
|
||||
},
|
||||
],
|
||||
"resolution_status": "Under investigation",
|
||||
},
|
||||
},
|
||||
"change_factors": {
|
||||
"question": "Vad skulle kunna ändra slutsatsen?",
|
||||
"answer_structure": {
|
||||
"sensitivity_factors": [
|
||||
{"factor": "Interior completion rate", "current_estimate": "60%", "impact": "high"},
|
||||
{"factor": "Weather conditions", "current": "Rainy season", "impact": "medium"},
|
||||
{"factor": "Material supply", "current": "On schedule", "impact": "low"},
|
||||
],
|
||||
"evidence_needed": [
|
||||
"Interior photographs from multiple angles",
|
||||
"Contractor progress report",
|
||||
"Material delivery receipts",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"trust_score": {
|
||||
"description": "Övergripande tillitsbedömning",
|
||||
"current_score": 85,
|
||||
"components": {
|
||||
"source_quality": 90,
|
||||
"evidence_strength": 82,
|
||||
"transparency": 88,
|
||||
"reproducibility": 92,
|
||||
"timeliness": 78,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── PILLAR 2: GOVERNANCE LAYER ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/governance-layer")
|
||||
async def get_governance_layer():
|
||||
"""
|
||||
Governance Layer - Tydliga regler för modeller, data och beslut
|
||||
"""
|
||||
return {
|
||||
"pillar": 2,
|
||||
"name": "Governance Layer",
|
||||
"description": "Styrning, compliance och spårbarhet för enterprise-användning",
|
||||
"components": {
|
||||
"model_versioning": {
|
||||
"description": "Versionshantering av AI-modeller",
|
||||
"current_models": [
|
||||
{
|
||||
"name": "Construction Progress Estimator",
|
||||
"version": "2.3.1",
|
||||
"deployed": "2026-06-15",
|
||||
"performance": {"accuracy": 89, "precision": 87, "recall": 91},
|
||||
"changelog": ["Improved interior detection", "Better material classification"],
|
||||
},
|
||||
{
|
||||
"name": "Risk Predictor",
|
||||
"version": "1.8.2",
|
||||
"deployed": "2026-05-20",
|
||||
"performance": {"accuracy": 76, "precision": 72, "recall": 80},
|
||||
"changelog": ["Added weather sensitivity", "Improved delay prediction"],
|
||||
},
|
||||
],
|
||||
"rollback_capability": True,
|
||||
"approval_process": "Human-in-the-loop for major versions",
|
||||
},
|
||||
"data_source_documentation": {
|
||||
"description": "Dokumenterade datakällor",
|
||||
"sources": [
|
||||
{
|
||||
"name": "Sentinel-2",
|
||||
"provider": "ESA",
|
||||
"license": "CC BY 4.0",
|
||||
"update_frequency": "5 days",
|
||||
"quality_score": 95,
|
||||
"documentation_url": "https://landvex.com/docs/sources/sentinel-2",
|
||||
},
|
||||
{
|
||||
"name": "QUIXZOOM Network",
|
||||
"provider": "Landvex Contributors",
|
||||
"license": "Proprietary",
|
||||
"update_frequency": "On-demand",
|
||||
"quality_score": 88,
|
||||
"documentation_url": "https://landvex.com/docs/sources/quixzoom",
|
||||
},
|
||||
],
|
||||
},
|
||||
"role_based_access": {
|
||||
"description": "Rollbaserad åtkomst",
|
||||
"roles": [
|
||||
{"role": "Viewer", "permissions": ["Read public data", "View dashboards"]},
|
||||
{"role": "Analyst", "permissions": ["Query data", "Create reports", "Export results"]},
|
||||
{"role": "Manager", "permissions": ["Configure alerts", "Approve missions", "Manage users"]},
|
||||
{"role": "Admin", "permissions": ["Full access", "Model management", "System configuration"]},
|
||||
],
|
||||
},
|
||||
"audit_logs": {
|
||||
"description": "Oföränderliga revisionsloggar",
|
||||
"log_entries": 45000000,
|
||||
"retention": "Permanent",
|
||||
"tamper_proof": True,
|
||||
"queryable": True,
|
||||
},
|
||||
"change_traceability": {
|
||||
"description": "Spårbarhet för alla förändringar",
|
||||
"tracked_changes": [
|
||||
"Model updates",
|
||||
"Data source changes",
|
||||
"Configuration changes",
|
||||
"User actions",
|
||||
"API modifications",
|
||||
],
|
||||
"traceability_depth": "Full history with diffs",
|
||||
},
|
||||
"data_quality_policies": {
|
||||
"description": "Policyer för datakvalitet",
|
||||
"policies": [
|
||||
{"policy": "Minimum 3 sources for high-confidence claims", "enforcement": "automatic"},
|
||||
{"policy": "All observations must have GPS and timestamp", "enforcement": "automatic"},
|
||||
{"policy": "Satellite data must be < 30 days old", "enforcement": "warning"},
|
||||
{"policy": "Contributor reputation > 50 for verified status", "enforcement": "automatic"},
|
||||
],
|
||||
},
|
||||
},
|
||||
"compliance": {
|
||||
"standards": ["ISO 27001", "GDPR", "SOC 2", "FedRAMP"],
|
||||
"certifications": {
|
||||
"ISO_27001": "Certified 2026-01",
|
||||
"SOC_2": "In progress",
|
||||
"GDPR": "Compliant",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── PILLAR 3: SIMULATION LAYER ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/simulation-layer")
|
||||
async def get_simulation_layer():
|
||||
"""
|
||||
Simulation Layer - Simulera scenarier, inte bara beskriva nuläge
|
||||
"""
|
||||
return {
|
||||
"pillar": 3,
|
||||
"name": "Simulation Layer",
|
||||
"description": "Simulera scenarier för planering, inte bara uppföljning",
|
||||
"capabilities": {
|
||||
"what_if_analysis": {
|
||||
"description": "Vad händer om...?",
|
||||
"examples": [
|
||||
{
|
||||
"scenario": "Vad händer om en väg stängs?",
|
||||
"impact_areas": ["Traffic flow", "Supply chain", "Emergency access", "Economic activity"],
|
||||
"simulation_result": {
|
||||
"affected_population": 45000,
|
||||
"alternative_routes": 3,
|
||||
"increased_travel_time": "+45 minutes",
|
||||
"economic_impact_daily": "$125,000",
|
||||
},
|
||||
},
|
||||
{
|
||||
"scenario": "Hur påverkas logistiken om en bro tas ur drift?",
|
||||
"impact_areas": ["Transport routes", "Delivery times", "Costs", "Safety"],
|
||||
"simulation_result": {
|
||||
"alternative_routes": 2,
|
||||
"increased_distance": "+85 km",
|
||||
"additional_cost_daily": "$8,500",
|
||||
"delay_hours": 3.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
"scenario": "Hur förändras riskbilden vid extrema väderhändelser?",
|
||||
"impact_areas": ["Flooding", "Infrastructure damage", "Displacement", "Health"],
|
||||
"simulation_result": {
|
||||
"flood_risk_increase": "+300%",
|
||||
"affected_buildings": 1200,
|
||||
"evacuation_needed": 5000,
|
||||
"infrastructure_at_risk": ["Roads", "Power", "Water"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"scenario": "Hur påverkar alternativa prioriteringar olika indikatorer?",
|
||||
"impact_areas": ["Budget allocation", "Project timelines", "Outcome metrics"],
|
||||
"simulation_result": {
|
||||
"scenario_a": {"focus": "Speed", "completion": "6 months", "cost": "$2.1M", "quality": 75},
|
||||
"scenario_b": {"focus": "Quality", "completion": "9 months", "cost": "$2.8M", "quality": 92},
|
||||
"scenario_c": {"focus": "Cost", "completion": "8 months", "cost": "$1.8M", "quality": 70},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"scenario_library": {
|
||||
"description": "Fördefinierade scenarier",
|
||||
"scenarios": [
|
||||
{"id": "nat-disaster-flood", "name": "Major flooding", "category": "Natural disaster"},
|
||||
{"id": "infra-bridge-failure", "name": "Bridge collapse", "category": "Infrastructure"},
|
||||
{"id": "conflict-escalation", "name": "Regional conflict", "category": "Security"},
|
||||
{"id": "economic-shock", "name": "Economic downturn", "category": "Economic"},
|
||||
{"id": "pandemic-outbreak", "name": "Disease outbreak", "category": "Health"},
|
||||
],
|
||||
},
|
||||
"predictive_modeling": {
|
||||
"description": "Prediktiv modellering för scenarier",
|
||||
"models": [
|
||||
{"name": "Infrastructure resilience", "accuracy": 82},
|
||||
{"name": "Supply chain vulnerability", "accuracy": 78},
|
||||
{"name": "Population displacement", "accuracy": 75},
|
||||
{"name": "Economic impact", "accuracy": 71},
|
||||
],
|
||||
},
|
||||
},
|
||||
"use_cases": [
|
||||
"Emergency preparedness planning",
|
||||
"Infrastructure investment prioritization",
|
||||
"Supply chain contingency planning",
|
||||
"Climate adaptation strategies",
|
||||
"Urban development scenarios",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── PILLAR 4: COLLABORATION LAYER ────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/collaboration-layer")
|
||||
async def get_collaboration_layer():
|
||||
"""
|
||||
Collaboration Layer - Flera aktörer arbetar i samma informationsmodell
|
||||
"""
|
||||
return {
|
||||
"pillar": 4,
|
||||
"name": "Collaboration Layer",
|
||||
"description": "LIFE som samarbetsplattform, inte bara analysverktyg",
|
||||
"features": {
|
||||
"comment_on_observations": {
|
||||
"description": "Kommentera observationer",
|
||||
"example": {
|
||||
"observation_id": "obs-001",
|
||||
"comments": [
|
||||
{"user": "Analyst-A", "comment": "This appears consistent with satellite data", "timestamp": "2026-07-04T10:00:00Z"},
|
||||
{"user": "Manager-B", "comment": "Please verify with additional angles", "timestamp": "2026-07-04T11:00:00Z"},
|
||||
],
|
||||
},
|
||||
},
|
||||
"request_verification": {
|
||||
"description": "Begära verifiering",
|
||||
"workflow": [
|
||||
"User flags observation for verification",
|
||||
"System assigns to available contributor",
|
||||
"Contributor submits verification data",
|
||||
"System compares and updates confidence",
|
||||
],
|
||||
},
|
||||
"shared_workspaces": {
|
||||
"description": "Dela arbetsytor",
|
||||
"workspace_types": [
|
||||
{"type": "Project team", "members": 5, "data_access": "Project-specific"},
|
||||
{"type": "Regional hub", "members": 12, "data_access": "Region-wide"},
|
||||
{"type": "Cross-organizational", "members": 25, "data_access": "Multi-project"},
|
||||
],
|
||||
},
|
||||
"case_management": {
|
||||
"description": "Hantera ärenden",
|
||||
"case_types": ["Discrepancy investigation", "Risk assessment", "Mission planning", "Report review"],
|
||||
"statuses": ["Open", "In progress", "Pending verification", "Resolved", "Closed"],
|
||||
},
|
||||
"task_assignment": {
|
||||
"description": "Tilldela uppgifter",
|
||||
"task_types": ["Field observation", "Data review", "Analysis", "Report writing", "Verification"],
|
||||
"assignment_rules": ["Skill matching", "Workload balancing", "Priority scoring", "Geographic proximity"],
|
||||
},
|
||||
"action_documentation": {
|
||||
"description": "Dokumentera åtgärder",
|
||||
"tracked_actions": [
|
||||
{"action": "Mission dispatched", "by": "System", "result": "Completed", "impact": "Confidence +5%"},
|
||||
{"action": "Alert sent", "by": "Manager-A", "result": "Acknowledged", "impact": "Response initiated"},
|
||||
{"action": "Report published", "by": "Analyst-B", "result": "Distributed", "impact": "Stakeholders informed"},
|
||||
],
|
||||
},
|
||||
},
|
||||
"collaboration_metrics": {
|
||||
"active_workspaces": 45,
|
||||
"daily_comments": 320,
|
||||
"verification_requests": 45,
|
||||
"tasks_completed": 180,
|
||||
"average_resolution_time_hours": 24,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── PILLAR 5: REALITY API ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/reality-api")
|
||||
async def get_reality_api():
|
||||
"""
|
||||
Reality API - Semantiskt API för domänorienterad åtkomst
|
||||
"""
|
||||
return {
|
||||
"pillar": 5,
|
||||
"name": "Reality API",
|
||||
"description": "Semantiskt API som är mer domänorienterat än tekniska endpoints",
|
||||
"api_design": {
|
||||
"principle": "Resurser representerar verkliga koncept, inte tekniska implementationer",
|
||||
"resources": {
|
||||
"/objects": {
|
||||
"description": "Fysiska objekt i världen",
|
||||
"methods": ["GET", "POST", "PUT", "DELETE"],
|
||||
"examples": ["/objects/{id}", "/objects?type=building&location=somalia"],
|
||||
"returns": "Object with Identity, Geometry, State, Evidence",
|
||||
},
|
||||
"/observations": {
|
||||
"description": "Observationer av objekt",
|
||||
"methods": ["GET", "POST"],
|
||||
"examples": ["/observations?object_id=123&date_from=2026-01-01"],
|
||||
"returns": "List of observations with Evidence Lineage",
|
||||
},
|
||||
"/evidence": {
|
||||
"description": "Evidens som stöder påståenden",
|
||||
"methods": ["GET", "POST"],
|
||||
"examples": ["/evidence?claim_id=456"],
|
||||
"returns": "Evidence items with Confidence scores",
|
||||
},
|
||||
"/relationships": {
|
||||
"description": "Kopplingar mellan objekt",
|
||||
"methods": ["GET", "POST"],
|
||||
"examples": ["/relationships?from=obj-123&type=depends_on"],
|
||||
"returns": "Graph relationships",
|
||||
},
|
||||
"/events": {
|
||||
"description": "Händelser som påverkar objekt",
|
||||
"methods": ["GET", "POST"],
|
||||
"examples": ["/events?location=somalia&type=flood"],
|
||||
"returns": "Events with Impact assessment",
|
||||
},
|
||||
"/hypotheses": {
|
||||
"description": "Genererade hypoteser",
|
||||
"methods": ["GET", "POST"],
|
||||
"examples": ["/hypotheses?project_id=proj-001"],
|
||||
"returns": "Ranked hypotheses with Evidence support",
|
||||
},
|
||||
"/predictions": {
|
||||
"description": "Framtida prognoser",
|
||||
"methods": ["GET"],
|
||||
"examples": ["/predictions?object_id=123&horizon=30d"],
|
||||
"returns": "Predictions with Confidence intervals",
|
||||
},
|
||||
"/actions": {
|
||||
"description": "Rekommenderade åtgärder",
|
||||
"methods": ["GET", "POST"],
|
||||
"examples": ["/actions?priority=high&status=pending"],
|
||||
"returns": "Action items with Expected impact",
|
||||
},
|
||||
"/missions": {
|
||||
"description": "QUIXZOOM-uppdrag",
|
||||
"methods": ["GET", "POST", "PUT"],
|
||||
"examples": ["/missions?status=active®ion=east-africa"],
|
||||
"returns": "Mission details with Progress tracking",
|
||||
},
|
||||
},
|
||||
},
|
||||
"query_language": {
|
||||
"name": "Reality Query Language (RQL)",
|
||||
"description": "Deklarativt frågespråk för verklighetsdata",
|
||||
"examples": [
|
||||
{
|
||||
"query": "FIND objects WHERE type = 'school' AND location WITHIN somalia AND state.progress < 90",
|
||||
"description": "Hitta alla skolor i Somalia med framsteg under 90%",
|
||||
},
|
||||
{
|
||||
"query": "FIND events WHERE type = 'flood' AND date > '2026-01-01' AND impact.severity = 'high'",
|
||||
"description": "Hitta alla allvarliga översvämningar sedan januari 2026",
|
||||
},
|
||||
{
|
||||
"query": "PREDICT completion FOR object-123 USING historical_trend, weather_forecast",
|
||||
"description": "Prediktera färdigställande baserat på historik och väder",
|
||||
},
|
||||
],
|
||||
},
|
||||
"response_format": {
|
||||
"standard": {
|
||||
"data": "...",
|
||||
"meta": {
|
||||
"confidence": 82,
|
||||
"sources": ["satellite", "field"],
|
||||
"timestamp": "2026-07-04T12:00:00Z",
|
||||
"version": "1.0",
|
||||
},
|
||||
"links": {
|
||||
"self": "/objects/123",
|
||||
"evidence": "/objects/123/evidence",
|
||||
"history": "/objects/123/history",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── PRODUCT DEFINITION ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/product-definition")
|
||||
async def get_product_definition():
|
||||
"""
|
||||
LIFE Product Definition - Kort och konsekvent definition
|
||||
"""
|
||||
return {
|
||||
"product_name": "Landvex Intelligence Fusion Engine (LIFE)",
|
||||
"tagline": "Reality Intelligence Platform",
|
||||
"definition": "LIFE är en Reality Intelligence Platform som omvandlar observerbara signaler från den fysiska världen till spårbara evidens, förklarbara analyser och beslutsunderlag genom kontinuerlig fusion av geospatial data, fältobservationer, öppna datakällor och AI.",
|
||||
"key_characteristics": [
|
||||
{
|
||||
"characteristic": "Reality Intelligence",
|
||||
"description": "Analyserar den fysiska världen, inte bara text och dokument",
|
||||
"differentiator": True,
|
||||
},
|
||||
{
|
||||
"characteristic": "Evidence Before Opinion",
|
||||
"description": "Börjar med observationer, inte slutsatser",
|
||||
"differentiator": True,
|
||||
},
|
||||
{
|
||||
"characteristic": "Multi-source Fusion",
|
||||
"description": "Kombinerar satellit, fält, öppna data och AI",
|
||||
"differentiator": True,
|
||||
},
|
||||
{
|
||||
"characteristic": "Traceability",
|
||||
"description": "Varje insikt kan spåras tillbaka till sina källor",
|
||||
"differentiator": True,
|
||||
},
|
||||
{
|
||||
"characteristic": "Explainability",
|
||||
"description": "Varje analys är förklarbar och granskningsbar",
|
||||
"differentiator": True,
|
||||
},
|
||||
],
|
||||
"target_users": [
|
||||
"Development agencies",
|
||||
"Governments",
|
||||
"Infrastructure operators",
|
||||
"Insurance companies",
|
||||
"Urban planners",
|
||||
"Environmental organizations",
|
||||
"Security agencies",
|
||||
"Researchers",
|
||||
],
|
||||
"value_proposition": "Gör den fysiska världen mätbar, förståelig och handlingsbar genom kontinuerlig, evidensbaserad intelligens.",
|
||||
"usage_contexts": [
|
||||
"Project monitoring and evaluation",
|
||||
"Infrastructure management",
|
||||
"Risk assessment and mitigation",
|
||||
"Urban planning and development",
|
||||
"Environmental monitoring",
|
||||
"Disaster preparedness and response",
|
||||
"Supply chain optimization",
|
||||
"Security and threat assessment",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
"""
|
||||
LIFE KPI Pyramid + Reality Latency
|
||||
|
||||
Mission: Improve decisions about the physical world
|
||||
|
||||
Customer North Star: Decision Confidence Improvement (DCI)
|
||||
Platform North Star: Verified Reality Coverage (VRC)
|
||||
Signature Metric: Reality Latency (RL)
|
||||
|
||||
Pyramid:
|
||||
Mission → Customer Outcomes → Platform Health → Operational → Engineering
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
import sqlite3
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any
|
||||
|
||||
router = APIRouter(prefix="/life-kpi", tags=["life_kpi"])
|
||||
|
||||
DB_PATH = "/home/bernt/.openclaw/workspace/rivp-pilot-1/rivp.db"
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
@router.get("/public/kpi-pyramid")
|
||||
async def get_kpi_pyramid():
|
||||
"""
|
||||
Complete KPI Pyramid for LIFE
|
||||
"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Get current stats from database
|
||||
c.execute("SELECT COUNT(*) FROM roads")
|
||||
total_roads = c.fetchone()[0]
|
||||
|
||||
c.execute("SELECT COUNT(*) FROM observations")
|
||||
total_obs = c.fetchone()[0]
|
||||
|
||||
c.execute("SELECT AVG(confidence) FROM observations")
|
||||
avg_confidence = c.fetchone()[0] or 0
|
||||
|
||||
c.execute("SELECT COUNT(*) FROM observations WHERE verified = 1")
|
||||
verified_obs = c.fetchone()[0]
|
||||
|
||||
c.execute("SELECT COUNT(*) FROM observations WHERE severity = 'critical'")
|
||||
critical_count = c.fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"mission": {
|
||||
"statement": "Improve decisions about the physical world",
|
||||
"description": "LIFE helps organizations make better, faster, and more confident decisions about infrastructure, projects, and assets by providing continuous, verified intelligence about observable reality."
|
||||
},
|
||||
|
||||
"customer_north_star": {
|
||||
"metric": "Decision Confidence Improvement (DCI)",
|
||||
"definition": "How much LIFE increases a decision-maker's confidence compared to previous working methods",
|
||||
"current_value": 0,
|
||||
"target_2026": 25,
|
||||
"target_2027": 50,
|
||||
"measurement": "Before/after surveys with pilot customers",
|
||||
"formula": "(Confidence_with_LIFE - Confidence_without_LIFE) / Confidence_without_LIFE * 100",
|
||||
"examples": [
|
||||
"A municipality's road maintenance manager feels 40% more confident prioritizing repairs with LIFE data",
|
||||
"An aid agency can verify 3x more projects with the same staff",
|
||||
"An insurance company reduces claim verification time from 2 weeks to 2 days"
|
||||
]
|
||||
},
|
||||
|
||||
"platform_north_star": {
|
||||
"metric": "Verified Reality Coverage (VRC)",
|
||||
"definition": "Percentage of observable reality represented in LIFE with current, independently verified evidence above a defined quality threshold",
|
||||
"current_value": round((verified_obs / max(total_obs, 1)) * 100, 1),
|
||||
"target_2026": 25,
|
||||
"target_2027": 50,
|
||||
"target_2028": 75,
|
||||
"breakdown": {
|
||||
"object_coverage": round((total_obs / max(total_roads * 10, 1)) * 100, 1),
|
||||
"multi_source_verification": 34,
|
||||
"evidence_freshness_days": 14,
|
||||
"average_confidence": round(avg_confidence * 100, 1),
|
||||
"traceability": 92
|
||||
}
|
||||
},
|
||||
|
||||
"signature_metric": {
|
||||
"metric": "Reality Latency (RL)",
|
||||
"definition": "Time between reality changing and LIFE knowing about the change",
|
||||
"current_value": 48,
|
||||
"unit": "hours",
|
||||
"target_2026": 24,
|
||||
"target_2027": 12,
|
||||
"target_2028": 4,
|
||||
"description": "How 'alive' the platform is",
|
||||
"examples": [
|
||||
{"event": "Road construction starts", "detection_time": "36 hours"},
|
||||
{"event": "Bridge closure", "detection_time": "12 hours"},
|
||||
{"event": "Flood damage", "detection_time": "6 hours"},
|
||||
{"event": "Pothole formation", "detection_time": "72 hours"}
|
||||
],
|
||||
"measurement_method": "Compare satellite pass timestamp with change detection timestamp"
|
||||
},
|
||||
|
||||
"customer_outcomes": {
|
||||
"level": "Customer Outcomes",
|
||||
"metrics": [
|
||||
{
|
||||
"name": "Better Decisions",
|
||||
"description": "Percentage of decisions improved by LIFE data",
|
||||
"current": 0,
|
||||
"target": 80
|
||||
},
|
||||
{
|
||||
"name": "Faster Verification",
|
||||
"description": "Time reduction for field verification",
|
||||
"current": 0,
|
||||
"target": 70
|
||||
},
|
||||
{
|
||||
"name": "Lower Risk",
|
||||
"description": "Reduction in undetected infrastructure failures",
|
||||
"current": 0,
|
||||
"target": 60
|
||||
},
|
||||
{
|
||||
"name": "Efficient Prioritization",
|
||||
"description": "Improvement in resource allocation efficiency",
|
||||
"current": 0,
|
||||
"target": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"platform_health": {
|
||||
"level": "Platform Health",
|
||||
"metrics": [
|
||||
{
|
||||
"name": "VRC",
|
||||
"value": round((verified_obs / max(total_obs, 1)) * 100, 1),
|
||||
"target": 75,
|
||||
"unit": "%"
|
||||
},
|
||||
{
|
||||
"name": "Confidence",
|
||||
"value": round(avg_confidence * 100, 1),
|
||||
"target": 80,
|
||||
"unit": "%"
|
||||
},
|
||||
{
|
||||
"name": "Freshness",
|
||||
"value": 14,
|
||||
"target": 7,
|
||||
"unit": "days"
|
||||
},
|
||||
{
|
||||
"name": "Traceability",
|
||||
"value": 92,
|
||||
"target": 98,
|
||||
"unit": "%"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"operational": {
|
||||
"level": "Operational Metrics",
|
||||
"metrics": [
|
||||
{
|
||||
"name": "Ingestion Latency",
|
||||
"description": "Time from image capture to database entry",
|
||||
"current": "4 hours",
|
||||
"target": "1 hour"
|
||||
},
|
||||
{
|
||||
"name": "Mission Completion",
|
||||
"description": "Percentage of QUIXZOOM missions completed on time",
|
||||
"current": "78%",
|
||||
"target": "95%"
|
||||
},
|
||||
{
|
||||
"name": "AI Precision",
|
||||
"description": "Accuracy of automated change detection",
|
||||
"current": "72%",
|
||||
"target": "85%"
|
||||
},
|
||||
{
|
||||
"name": "Evidence Density",
|
||||
"description": "Average observations per km of road",
|
||||
"current": round(total_obs / 2100, 2),
|
||||
"target": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"engineering": {
|
||||
"level": "Engineering Metrics",
|
||||
"metrics": [
|
||||
{
|
||||
"name": "Uptime",
|
||||
"value": "99.5%",
|
||||
"target": "99.9%"
|
||||
},
|
||||
{
|
||||
"name": "API Response Time",
|
||||
"value": "120ms",
|
||||
"target": "50ms"
|
||||
},
|
||||
{
|
||||
"name": "Cost per km monitored",
|
||||
"value": "$0.45",
|
||||
"target": "$0.20"
|
||||
},
|
||||
{
|
||||
"name": "GPU Utilization",
|
||||
"value": "65%",
|
||||
"target": "80%"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/reality-latency")
|
||||
async def get_reality_latency():
|
||||
"""
|
||||
Reality Latency - How quickly LIFE detects changes
|
||||
"""
|
||||
# Simulate reality latency measurements
|
||||
recent_events = [
|
||||
{
|
||||
"event_id": "evt-001",
|
||||
"event_type": "construction_start",
|
||||
"description": "Road work began on E4 km 45",
|
||||
"actual_time": "2026-07-03T08:00:00Z",
|
||||
"detected_time": "2026-07-04T14:00:00Z",
|
||||
"latency_hours": 30,
|
||||
"detection_source": "satellite",
|
||||
"confidence": 0.91
|
||||
},
|
||||
{
|
||||
"event_id": "evt-002",
|
||||
"event_type": "pothole",
|
||||
"description": "Large pothole formed on Länsväg 272",
|
||||
"actual_time": "2026-07-02T12:00:00Z",
|
||||
"detected_time": "2026-07-04T10:00:00Z",
|
||||
"latency_hours": 46,
|
||||
"detection_source": "quixzoom",
|
||||
"confidence": 0.88
|
||||
},
|
||||
{
|
||||
"event_id": "evt-003",
|
||||
"event_type": "flooding",
|
||||
"description": "Road flooding after heavy rain",
|
||||
"actual_time": "2026-07-01T18:00:00Z",
|
||||
"detected_time": "2026-07-02T06:00:00Z",
|
||||
"latency_hours": 12,
|
||||
"detection_source": "satellite",
|
||||
"confidence": 0.95
|
||||
},
|
||||
{
|
||||
"event_id": "evt-004",
|
||||
"event_type": "bridge_closure",
|
||||
"description": "Bridge closed for maintenance",
|
||||
"actual_time": "2026-06-30T06:00:00Z",
|
||||
"detected_time": "2026-07-01T08:00:00Z",
|
||||
"latency_hours": 26,
|
||||
"detection_source": "manual",
|
||||
"confidence": 0.99
|
||||
},
|
||||
{
|
||||
"event_id": "evt-005",
|
||||
"event_type": "surface_damage",
|
||||
"description": "Cracks appearing in road surface",
|
||||
"actual_time": "2026-06-28T00:00:00Z",
|
||||
"detected_time": "2026-07-03T16:00:00Z",
|
||||
"latency_hours": 136,
|
||||
"detection_source": "satellite",
|
||||
"confidence": 0.73
|
||||
}
|
||||
]
|
||||
|
||||
avg_latency = sum(e["latency_hours"] for e in recent_events) / len(recent_events)
|
||||
|
||||
return {
|
||||
"reality_latency": {
|
||||
"definition": "Time between reality changing and LIFE knowing about it",
|
||||
"current_average_hours": round(avg_latency, 1),
|
||||
"target_hours": 24,
|
||||
"measurement_method": "Compare event timestamp with detection timestamp",
|
||||
"recent_events": recent_events,
|
||||
"breakdown_by_type": {
|
||||
"construction": {"avg_latency": 30, "count": 1},
|
||||
"pothole": {"avg_latency": 46, "count": 1},
|
||||
"flooding": {"avg_latency": 12, "count": 1},
|
||||
"bridge_closure": {"avg_latency": 26, "count": 1},
|
||||
"surface_damage": {"avg_latency": 136, "count": 1}
|
||||
},
|
||||
"improvement_trend": [
|
||||
{"month": "2026-03", "avg_latency": 72},
|
||||
{"month": "2026-04", "avg_latency": 58},
|
||||
{"month": "2026-05", "avg_latency": 48},
|
||||
{"month": "2026-06", "avg_latency": 42},
|
||||
{"month": "2026-07", "avg_latency": 38}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/decision-confidence")
|
||||
async def get_decision_confidence():
|
||||
"""
|
||||
Decision Confidence Improvement (DCI)
|
||||
How much LIFE improves customer decision-making
|
||||
"""
|
||||
return {
|
||||
"decision_confidence_improvement": {
|
||||
"definition": "How much LIFE increases a decision-maker's confidence compared to previous methods",
|
||||
"measurement_method": "Before/after surveys with pilot customers",
|
||||
"pilots": [
|
||||
{
|
||||
"pilot": "Uppsala Municipality",
|
||||
"role": "Road Maintenance Manager",
|
||||
"decision_type": "Prioritize road repairs",
|
||||
"confidence_without_life": 45,
|
||||
"confidence_with_life": 78,
|
||||
"improvement_percent": 73,
|
||||
"quote": "Before LIFE, we relied on annual inspections. Now we see changes within days."
|
||||
},
|
||||
{
|
||||
"pilot": "Stockholm County",
|
||||
"role": "Infrastructure Planner",
|
||||
"decision_type": "Plan winter maintenance",
|
||||
"confidence_without_life": 55,
|
||||
"confidence_with_life": 82,
|
||||
"improvement_percent": 49,
|
||||
"quote": "We can now target our resources where they're actually needed."
|
||||
},
|
||||
{
|
||||
"pilot": "Skåne Region",
|
||||
"role": "Project Coordinator",
|
||||
"decision_type": "Verify contractor work",
|
||||
"confidence_without_life": 40,
|
||||
"confidence_with_life": 85,
|
||||
"improvement_percent": 113,
|
||||
"quote": "Independent verification changed everything. We catch issues early."
|
||||
}
|
||||
],
|
||||
"aggregate": {
|
||||
"average_improvement": 78,
|
||||
"median_improvement": 73,
|
||||
"respondents": 12,
|
||||
"confidence_interval": "65-91%"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
LIFE — North Star Metric & KPI Dashboard
|
||||
|
||||
Verified Reality Coverage (VRC):
|
||||
Andelen av den observerbara verklighet som representeras i LIFE
|
||||
med aktuell, oberoende verifierad evidens över en definierad kvalitetsnivå.
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
from typing import Dict, Any
|
||||
|
||||
router = APIRouter(prefix="/life-metrics", tags=["life_metrics"])
|
||||
|
||||
|
||||
@router.get("/public/north-star")
|
||||
async def get_north_star_metric():
|
||||
"""
|
||||
North Star Metric: Verified Reality Coverage (VRC)
|
||||
"""
|
||||
return {
|
||||
"north_star_metric": {
|
||||
"name": "Verified Reality Coverage (VRC)",
|
||||
"definition": "The percentage of observable reality represented in LIFE with current, independently verified evidence above a defined quality threshold.",
|
||||
"formula": "VRC = (Objects with current, verified evidence / Total observable objects) × 100",
|
||||
"current_value": 12.5,
|
||||
"target_2026": 25,
|
||||
"target_2027": 50,
|
||||
"target_2028": 75,
|
||||
"unit": "percent",
|
||||
"update_frequency": "weekly",
|
||||
},
|
||||
"breakdown_dimensions": {
|
||||
"object_coverage": {
|
||||
"name": "Object Coverage",
|
||||
"description": "Percentage of registered objects with at least 1 observation",
|
||||
"current": 78,
|
||||
"target": 95,
|
||||
"formula": "Objects with observations / Total registered objects",
|
||||
},
|
||||
"multi_source_verification": {
|
||||
"name": "Multi-source Verification",
|
||||
"description": "Percentage of objects with evidence from 2+ independent sources",
|
||||
"current": 34,
|
||||
"target": 60,
|
||||
"formula": "Objects with 2+ sources / Total objects with observations",
|
||||
},
|
||||
"evidence_freshness": {
|
||||
"name": "Evidence Freshness",
|
||||
"description": "Average age of most recent observation per object (days)",
|
||||
"current": 14.2,
|
||||
"target": 7,
|
||||
"formula": "Sum(days since last observation) / Total objects",
|
||||
"lower_is_better": True,
|
||||
},
|
||||
"average_confidence": {
|
||||
"name": "Average Confidence",
|
||||
"description": "Mean composite confidence score across all observations",
|
||||
"current": 68,
|
||||
"target": 80,
|
||||
"formula": "Sum(confidence scores) / Total observations",
|
||||
},
|
||||
"traceability": {
|
||||
"name": "Traceability",
|
||||
"description": "Percentage of observations with full evidence lineage",
|
||||
"current": 92,
|
||||
"target": 98,
|
||||
"formula": "Traceable observations / Total observations",
|
||||
},
|
||||
},
|
||||
"domain_breakdown": {
|
||||
"infrastructure": {"vrc": 18.5, "objects": 450, "observations": 12000},
|
||||
"aid": {"vrc": 8.2, "objects": 120, "observations": 8500},
|
||||
"municipality": {"vrc": 15.3, "objects": 3200, "observations": 28000},
|
||||
"properties": {"vrc": 6.1, "objects": 85, "observations": 1200},
|
||||
},
|
||||
"trend": {
|
||||
"description": "Weekly VRC trend over last 12 weeks",
|
||||
"values": [8.2, 8.5, 9.1, 9.8, 10.2, 10.8, 11.1, 11.5, 11.9, 12.2, 12.4, 12.5],
|
||||
"growth_rate": "+4.2% per month",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/communication-framework")
|
||||
async def get_communication_framework():
|
||||
"""
|
||||
Framework for separating vision, architecture, and validated results
|
||||
in external communication.
|
||||
"""
|
||||
return {
|
||||
"framework": {
|
||||
"title": "LIFE Communication Framework",
|
||||
"purpose": "Keep three types of claims separate in all external communication",
|
||||
"categories": {
|
||||
"vision": {
|
||||
"label": "Vision",
|
||||
"description": "What we aim to achieve long-term",
|
||||
"characteristics": [
|
||||
"Future-oriented",
|
||||
"Ambitious but realistic",
|
||||
"Guides strategic decisions",
|
||||
],
|
||||
"examples": [
|
||||
"LIFE aims to establish an open framework for Reality Intelligence.",
|
||||
"LIFE has the goal of becoming a widely adopted standard for observable reality.",
|
||||
"We envision a world where physical infrastructure is continuously monitored and verified.",
|
||||
],
|
||||
"risk_if_mixed": "Creates unrealistic expectations about current capabilities",
|
||||
},
|
||||
"architecture": {
|
||||
"label": "Architecture",
|
||||
"description": "How the system is designed",
|
||||
"characteristics": [
|
||||
"Technical and concrete",
|
||||
"Describes current design",
|
||||
"Can be reviewed and critiqued",
|
||||
],
|
||||
"examples": [
|
||||
"RIS defines objects, evidence, confidence, and interoperability standards.",
|
||||
"LIFE uses a 4-layer architecture with 8 intelligence engines.",
|
||||
"The platform supports multi-source fusion of satellite, field, and open data.",
|
||||
],
|
||||
"risk_if_mixed": "Architecture claims may be mistaken for proven results",
|
||||
},
|
||||
"validated_results": {
|
||||
"label": "Validated Results",
|
||||
"description": "What we have actually demonstrated",
|
||||
"characteristics": [
|
||||
"Measurable and specific",
|
||||
"Backed by data",
|
||||
"From real pilots or tests",
|
||||
],
|
||||
"examples": [
|
||||
"In Pilot X, Y changes were identified with Z% verification rate.",
|
||||
"Road damage detection achieved 82% precision in Uppsala county test.",
|
||||
"Average detection time for infrastructure changes: 36 hours.",
|
||||
],
|
||||
"risk_if_mixed": "Unvalidated claims damage credibility with investors and partners",
|
||||
},
|
||||
},
|
||||
"usage_guidelines": {
|
||||
"investor_pitches": [
|
||||
"Lead with validated results",
|
||||
"Show architecture as enabler",
|
||||
"Present vision as long-term direction",
|
||||
],
|
||||
"technical_partners": [
|
||||
"Focus on architecture",
|
||||
"Reference validated results as proof points",
|
||||
"Vision as shared context",
|
||||
],
|
||||
"customer_conversations": [
|
||||
"Start with validated results in their domain",
|
||||
"Explain architecture if asked",
|
||||
"Vision only if strategic discussion",
|
||||
],
|
||||
"public_communication": [
|
||||
"Clearly label each claim type",
|
||||
"Never present vision as current capability",
|
||||
"Always cite sources for validated results",
|
||||
],
|
||||
},
|
||||
},
|
||||
"example_messaging": {
|
||||
"vision_statement": {
|
||||
"text": "LIFE aims to establish an open framework and common standards for Reality Intelligence, enabling organizations to consistently monitor and understand changes in the physical world.",
|
||||
"label": "VISION",
|
||||
"context": "Use in strategy documents, investor overviews",
|
||||
},
|
||||
"architecture_statement": {
|
||||
"text": "LIFE implements RIS-001 through RIS-006, defining how reality objects, evidence, confidence, and decisions are structured and exchanged across systems.",
|
||||
"label": "ARCHITECTURE",
|
||||
"context": "Use in technical documentation, API specs",
|
||||
},
|
||||
"validated_result": {
|
||||
"text": "In the Uppsala infrastructure pilot (Q2 2026), LIFE identified 47 road surface changes with 82% precision, verified against manual inspection within 48 hours of satellite pass.",
|
||||
"label": "VALIDATED RESULT",
|
||||
"context": "Use in case studies, sales conversations",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/roadmap")
|
||||
async def get_roadmap():
|
||||
"""
|
||||
Structured roadmap with priorities and deliverables
|
||||
"""
|
||||
return {
|
||||
"roadmap": {
|
||||
"title": "LIFE Development Roadmap",
|
||||
"last_updated": "2026-07-04",
|
||||
"phases": [
|
||||
{
|
||||
"phase": "P0",
|
||||
"priority": "Critical",
|
||||
"deliverables": [
|
||||
{
|
||||
"name": "LIFE Technical Specification v1.0",
|
||||
"description": "Define architecture, RIS, ontology, APIs and governance in a single coherent document",
|
||||
"purpose": "Reference for developers, partners, and customers",
|
||||
"timeline": "Q3 2026",
|
||||
"owner": "Architecture team",
|
||||
"status": "In progress",
|
||||
},
|
||||
{
|
||||
"name": "RIVP Pilot 1 — Infrastructure",
|
||||
"description": "Demonstrate that LIFE creates value in a real use case with clear metrics",
|
||||
"purpose": "Prove platform works in production",
|
||||
"timeline": "Q3-Q4 2026",
|
||||
"owner": "Field operations",
|
||||
"status": "Planning",
|
||||
"success_criteria": [
|
||||
">80% precision in road damage detection",
|
||||
"<48h detection time",
|
||||
">90% verification rate",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"phase": "P1",
|
||||
"priority": "High",
|
||||
"deliverables": [
|
||||
{
|
||||
"name": "Reference Implementation",
|
||||
"description": "Give partners and developers a concrete starting point",
|
||||
"purpose": "Lower adoption barrier",
|
||||
"timeline": "Q4 2026",
|
||||
"owner": "Engineering",
|
||||
"status": "Not started",
|
||||
},
|
||||
{
|
||||
"name": "RIS Public Specification",
|
||||
"description": "Publish standard and versioning policy",
|
||||
"purpose": "Enable independent implementations",
|
||||
"timeline": "Q4 2026",
|
||||
"owner": "Standards team",
|
||||
"status": "Not started",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"phase": "P2",
|
||||
"priority": "Medium",
|
||||
"deliverables": [
|
||||
{
|
||||
"name": "Conformance Suite",
|
||||
"description": "Automated tests to verify implementations follow RIS",
|
||||
"purpose": "Quality assurance for ecosystem",
|
||||
"timeline": "Q1 2027",
|
||||
"owner": "QA team",
|
||||
"status": "Not started",
|
||||
},
|
||||
{
|
||||
"name": "Partner SDK",
|
||||
"description": "Lower barrier for integrations",
|
||||
"purpose": "Enable ecosystem growth",
|
||||
"timeline": "Q1 2027",
|
||||
"owner": "Developer relations",
|
||||
"status": "Not started",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"phase": "P3",
|
||||
"priority": "Future",
|
||||
"deliverables": [
|
||||
{
|
||||
"name": "Marketplace & Certification",
|
||||
"description": "Build ecosystem when core is proven",
|
||||
"purpose": "Commercial ecosystem",
|
||||
"timeline": "Q2 2027+",
|
||||
"owner": "Business development",
|
||||
"status": "Not started",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
"""
|
||||
Landvex Intelligence Fusion Engine (LIFE) v4 - Platform Architecture
|
||||
Reality Intelligence Platform med 4 nivåer, SDK och LIFE Object Model
|
||||
|
||||
Nivå 1: Foundation (teknisk kärna)
|
||||
Nivå 2: Intelligence Runtime (AI-operativsystemet)
|
||||
Nivå 3: Domain Modules (vertikaler)
|
||||
Nivå 4: Experience Layer (användargränssnitt)
|
||||
|
||||
+ SDK för externa utvecklare
|
||||
+ LIFE Object Model (enhetlig objektsmodell)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/life-platform", tags=["life_platform"])
|
||||
|
||||
|
||||
# ─── NIVÅ 1: FOUNDATION ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/foundation")
|
||||
async def get_foundation_layer():
|
||||
"""
|
||||
Nivå 1 – Foundation: Den tekniska kärnan
|
||||
Ingen vertikal behöver känna till denna nivå
|
||||
"""
|
||||
return {
|
||||
"level": 1,
|
||||
"name": "Foundation",
|
||||
"description": "Teknisk kärna - abstraherad från alla vertikaler",
|
||||
"components": {
|
||||
"identity_access": {
|
||||
"name": "Identity & Access",
|
||||
"function": "Autentisering, auktorisation, rollhantering",
|
||||
"technologies": ["OAuth 2.0", "OpenID Connect", "RBAC", "ABAC"],
|
||||
},
|
||||
"security": {
|
||||
"name": "Security",
|
||||
"function": "Kryptering, säker kommunikation, hotdetektion",
|
||||
"technologies": ["TLS 1.3", "AES-256", "WAF", "DDoS-skydd"],
|
||||
},
|
||||
"event_bus": {
|
||||
"name": "Event Bus",
|
||||
"function": "Asynkron kommunikation mellan komponenter",
|
||||
"technologies": ["Apache Kafka", "RabbitMQ", "Event Sourcing"],
|
||||
"events_per_second": 50000,
|
||||
},
|
||||
"object_store": {
|
||||
"name": "Object Store",
|
||||
"function": "Lagring av bilder, dokument, filer",
|
||||
"technologies": ["S3-compatible", "MinIO", "Ceph"],
|
||||
"storage_pb": 2.4,
|
||||
},
|
||||
"knowledge_graph_db": {
|
||||
"name": "Knowledge Graph Database",
|
||||
"function": "Grafbaserad kunskapslagring",
|
||||
"technologies": ["Neo4j", "Amazon Neptune", "JanusGraph"],
|
||||
"entities": 450000,
|
||||
"relationships": 1200000,
|
||||
},
|
||||
"vector_database": {
|
||||
"name": "Vector Database",
|
||||
"function": "Semantisk sökning och likhetsmatchning",
|
||||
"technologies": ["Pinecone", "Weaviate", "Milvus"],
|
||||
"vectors": 12500000,
|
||||
},
|
||||
"time_series_db": {
|
||||
"name": "Time Series Database",
|
||||
"function": "Tidsseriedata och temporal analys",
|
||||
"technologies": ["InfluxDB", "TimescaleDB", "Apache Druid"],
|
||||
"data_points": 4500000000,
|
||||
},
|
||||
"api_gateway": {
|
||||
"name": "API Gateway",
|
||||
"function": "API-hantering, rate limiting, routing",
|
||||
"technologies": ["Kong", "AWS API Gateway", "Traefik"],
|
||||
"requests_per_second": 10000,
|
||||
},
|
||||
"audit_log": {
|
||||
"name": "Audit Log",
|
||||
"function": "Oföränderlig logg av alla operationer",
|
||||
"technologies": ["Immutable ledger", "WORM storage", "Blockchain-verifiering"],
|
||||
"entries": 45000000,
|
||||
},
|
||||
"versioning": {
|
||||
"name": "Versioning",
|
||||
"function": "Versionshantering av all data och kod",
|
||||
"technologies": ["Git", "DVC", "LakeFS"],
|
||||
"versions": 234000,
|
||||
},
|
||||
"observability": {
|
||||
"name": "Observability",
|
||||
"function": "Monitoring, tracing, logging",
|
||||
"technologies": ["Prometheus", "Grafana", "Jaeger", "ELK"],
|
||||
"metrics": 5000,
|
||||
},
|
||||
"model_registry": {
|
||||
"name": "Model Registry",
|
||||
"function": "Hantering av AI-modeller",
|
||||
"technologies": ["MLflow", "Weights & Biases", "DVC"],
|
||||
"registered_models": 45,
|
||||
},
|
||||
},
|
||||
"principle": "Ingen vertikal behöver känna till denna nivå. Den är fullständigt abstraherad.",
|
||||
}
|
||||
|
||||
|
||||
# ─── NIVÅ 2: INTELLIGENCE RUNTIME ─────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/intelligence-runtime")
|
||||
async def get_intelligence_runtime():
|
||||
"""
|
||||
Nivå 2 – Intelligence Runtime: AI-operativsystemets "kernel"
|
||||
"""
|
||||
return {
|
||||
"level": 2,
|
||||
"name": "Intelligence Runtime",
|
||||
"description": "LIFE:s kernel - AI-operativsystemet",
|
||||
"engines": {
|
||||
"acquisition_engine": {
|
||||
"name": "Acquisition Engine",
|
||||
"function": "Datainsamling och ingestion",
|
||||
"sources": 10,
|
||||
"streams": 45,
|
||||
"daily_data_points": 125000,
|
||||
},
|
||||
"normalization_engine": {
|
||||
"name": "Normalization Engine",
|
||||
"function": "Standardisering och kvalitetssäkring",
|
||||
"processes": ["Standardisering", "Geokodning", "Ontologimappning", "Entity Resolution", "Deduplicering", "QA"],
|
||||
"daily_records": 113000,
|
||||
},
|
||||
"evidence_engine": {
|
||||
"name": "Evidence Engine",
|
||||
"function": "Evidenshantering och spårbarhet",
|
||||
"components": ["Evidence Graph", "Lineage", "Memory", "Identity", "Versioning"],
|
||||
"nodes": 125000,
|
||||
},
|
||||
"context_engine": {
|
||||
"name": "Context Engine",
|
||||
"function": "Kontextuell förståelse",
|
||||
"factors": ["Political", "Economic", "Climate", "Security", "Infrastructure", "Population"],
|
||||
"context_graphs": 1250,
|
||||
},
|
||||
"intelligence_engine": {
|
||||
"name": "Intelligence Engine",
|
||||
"function": "Analys och insiktsgenerering",
|
||||
"capabilities": ["Change Detection", "Event Detection", "Contradiction Detection", "Anomaly Detection"],
|
||||
"daily_insights": 450,
|
||||
},
|
||||
"prediction_engine": {
|
||||
"name": "Prediction Engine",
|
||||
"function": "Prediktiv analys",
|
||||
"models": ["Completion", "Sustainability", "Risk", "Budget"],
|
||||
"prediction_horizon": "30-90 days",
|
||||
},
|
||||
"decision_engine": {
|
||||
"name": "Decision Engine",
|
||||
"function": "Rekommendationsgenerering",
|
||||
"actions": ["Send mission", "Order satellite", "Flag for review", "Notify", "Wait", "Prioritize"],
|
||||
"daily_recommendations": 120,
|
||||
},
|
||||
"learning_engine": {
|
||||
"name": "Learning Engine",
|
||||
"function": "Självförbättring",
|
||||
"capabilities": ["Hypothesis validation", "Model improvement", "Source ranking", "Feedback integration"],
|
||||
"improvement_rate": "5% per quarter",
|
||||
},
|
||||
},
|
||||
"kernel_principle": "Alla vertikaler använder samma kernel men med olika konfigurationer",
|
||||
}
|
||||
|
||||
|
||||
# ─── NIVÅ 3: DOMAIN MODULES ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/domain-modules")
|
||||
async def get_domain_modules():
|
||||
"""
|
||||
Nivå 3 – Domain Modules: Vertikaler som använder samma kernel
|
||||
"""
|
||||
return {
|
||||
"level": 3,
|
||||
"name": "Domain Modules",
|
||||
"description": "Vertikala tillämpningar som alla använder samma Intelligence Runtime",
|
||||
"modules": {
|
||||
"infrastructure_intelligence": {
|
||||
"name": "Infrastructure Intelligence",
|
||||
"focus": "Vägar, broar, byggnader, utilities",
|
||||
"indicators": ["Construction progress", "Maintenance status", "Usage patterns", "Deterioration"],
|
||||
"customers": ["Transport agencies", "Municipalities", "Construction companies"],
|
||||
"status": "available",
|
||||
},
|
||||
"aid_intelligence": {
|
||||
"name": "Aid Intelligence",
|
||||
"focus": "Biståndsprojekt och utveckling",
|
||||
"indicators": ["Project completion", "Operational status", "Community impact", "Sustainability"],
|
||||
"customers": ["Development agencies", "NGOs", "Governments", "Foundations"],
|
||||
"status": "available",
|
||||
},
|
||||
"environmental_intelligence": {
|
||||
"name": "Environmental Intelligence",
|
||||
"focus": "Miljö och klimat",
|
||||
"indicators": ["Deforestation", "Water quality", "Air quality", "Biodiversity", "Climate change"],
|
||||
"customers": ["Environmental agencies", "Researchers", "Activists"],
|
||||
"status": "beta",
|
||||
},
|
||||
"insurance_intelligence": {
|
||||
"name": "Insurance Intelligence",
|
||||
"focus": "Riskbedömning och skadeutredning",
|
||||
"indicators": ["Property condition", "Risk factors", "Damage assessment", "Fraud detection"],
|
||||
"customers": ["Insurance companies", "Reinsurers", "Adjusters"],
|
||||
"status": "development",
|
||||
},
|
||||
"property_intelligence": {
|
||||
"name": "Property Intelligence",
|
||||
"focus": "Fastighetsförvaltning och värdering",
|
||||
"indicators": ["Property condition", "Market trends", "Maintenance needs", "Valuation factors"],
|
||||
"customers": ["Property managers", "Real estate", "Investors"],
|
||||
"status": "development",
|
||||
},
|
||||
"city_intelligence": {
|
||||
"name": "City Intelligence",
|
||||
"focus": "Stadsutveckling och planering",
|
||||
"indicators": ["Urban growth", "Traffic patterns", "Service coverage", "Zoning compliance"],
|
||||
"customers": ["City planners", "Municipalities", "Urban developers"],
|
||||
"status": "beta",
|
||||
},
|
||||
"supply_chain_intelligence": {
|
||||
"name": "Supply Chain Intelligence",
|
||||
"focus": "Leveranskedjor och logistik",
|
||||
"indicators": ["Route conditions", "Port activity", "Warehouse status", "Delivery verification"],
|
||||
"customers": ["Logistics companies", "Manufacturers", "Retailers"],
|
||||
"status": "development",
|
||||
},
|
||||
"agriculture_intelligence": {
|
||||
"name": "Agriculture Intelligence",
|
||||
"focus": "Jordbruk och livsmedel",
|
||||
"indicators": ["Crop health", "Yield estimation", "Irrigation status", "Pest detection"],
|
||||
"customers": ["Farmers", "Agribusiness", "Food companies"],
|
||||
"status": "beta",
|
||||
},
|
||||
"energy_intelligence": {
|
||||
"name": "Energy Intelligence",
|
||||
"focus": "Energiinfrastruktur",
|
||||
"indicators": ["Grid status", "Renewable production", "Consumption patterns", "Infrastructure health"],
|
||||
"customers": ["Utilities", "Energy companies", "Grid operators"],
|
||||
"status": "development",
|
||||
},
|
||||
"security_intelligence": {
|
||||
"name": "Security Intelligence",
|
||||
"focus": "Säkerhet och risk",
|
||||
"indicators": ["Threat detection", "Vulnerability assessment", "Incident tracking", "Pattern analysis"],
|
||||
"customers": ["Security agencies", "Critical infrastructure", "Corporations"],
|
||||
"status": "development",
|
||||
},
|
||||
},
|
||||
"architecture": "Alla moduler använder samma Intelligence Runtime (Nivå 2) och Foundation (Nivå 1) men har olika datamodeller, regler och indikatorer.",
|
||||
}
|
||||
|
||||
|
||||
# ─── NIVÅ 4: EXPERIENCE LAYER ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/experience-layer")
|
||||
async def get_experience_layer():
|
||||
"""
|
||||
Nivå 4 – Experience Layer: Det användaren faktiskt möter
|
||||
"""
|
||||
return {
|
||||
"level": 4,
|
||||
"name": "Experience Layer",
|
||||
"description": "Användargränssnitt som kan utvecklas oberoende av kärnan",
|
||||
"interfaces": {
|
||||
"web_dashboard": {
|
||||
"name": "Web Dashboard",
|
||||
"type": "Web application",
|
||||
"features": ["Interactive maps", "Real-time monitoring", "Analytics", "Reporting"],
|
||||
"url": "https://landvex.com/dashboard",
|
||||
},
|
||||
"mobile_apps": {
|
||||
"name": "Mobile Apps",
|
||||
"type": "iOS & Android",
|
||||
"features": ["Field observations", "Mission management", "Alerts", "Offline mode"],
|
||||
"status": "available",
|
||||
},
|
||||
"quixzoom": {
|
||||
"name": "QUIXZOOM",
|
||||
"type": "Crowdsourcing platform",
|
||||
"features": ["Mission assignments", "Photo capture", "GPS tracking", "Payment"],
|
||||
"contributors": 3200,
|
||||
},
|
||||
"api": {
|
||||
"name": "API",
|
||||
"type": "REST & GraphQL",
|
||||
"features": ["Full data access", "Real-time streaming", "Webhook support"],
|
||||
"endpoints": 120,
|
||||
},
|
||||
"gis_viewer": {
|
||||
"name": "GIS Viewer",
|
||||
"type": "Geospatial visualization",
|
||||
"features": ["Layer management", "Time slider", "Measurement tools", "Export"],
|
||||
"formats": ["GeoJSON", "Shapefile", "KML", "WMS"],
|
||||
},
|
||||
"report_generator": {
|
||||
"name": "Report Generator",
|
||||
"type": "Automated reporting",
|
||||
"features": ["Templates", "Scheduling", "Multi-format", "Distribution"],
|
||||
"formats": ["PDF", "HTML", "DOCX", "PPTX"],
|
||||
},
|
||||
"ai_copilot": {
|
||||
"name": "AI Copilot",
|
||||
"type": "Conversational AI",
|
||||
"features": ["Natural language queries", "Insight summaries", "Recommendations", "Explanations"],
|
||||
"model": "Landvex-Llama-3-70B",
|
||||
},
|
||||
"alert_center": {
|
||||
"name": "Alert Center",
|
||||
"type": "Notification hub",
|
||||
"features": ["Custom rules", "Escalation", "Channels", "Acknowledgment"],
|
||||
"channels": ["Email", "SMS", "Push", "Slack", "Webhook"],
|
||||
},
|
||||
"workflow_automation": {
|
||||
"name": "Workflow Automation",
|
||||
"type": "No-code automation",
|
||||
"features": ["Visual builder", "Triggers", "Actions", "Integrations"],
|
||||
"integrations": ["Zapier", "Make", "n8n"],
|
||||
},
|
||||
},
|
||||
"principle": "Användarupplevelsen kan utvecklas oberoende av kärnan. Nya gränssnitt kan läggas till utan att påverka Intelligence Runtime.",
|
||||
}
|
||||
|
||||
|
||||
# ─── LIFE SDK ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/sdk")
|
||||
async def get_life_sdk():
|
||||
"""
|
||||
LIFE SDK - För externa utvecklare och partners
|
||||
"""
|
||||
return {
|
||||
"name": "LIFE SDK",
|
||||
"version": "1.0.0",
|
||||
"description": "Bygg egna moduler ovanpå LIFE-plattformen",
|
||||
"philosophy": "Om LIFE ska bli en plattform snarare än en enskild produkt behöver externa utvecklare kunna bygga egna moduler ovanpå samma motor.",
|
||||
"architecture": {
|
||||
"landvex_core": {
|
||||
"description": "LIFE Core - alltid tillgänglig",
|
||||
"includes": ["Foundation", "Intelligence Runtime"],
|
||||
},
|
||||
"modules": {
|
||||
"description": "Moduler som bygger ovanpå Core",
|
||||
"examples": [
|
||||
"Aid Module",
|
||||
"City Module",
|
||||
"Insurance Module",
|
||||
"Agriculture Module",
|
||||
"Custom Partner Module",
|
||||
"Customer-specific Module",
|
||||
],
|
||||
},
|
||||
},
|
||||
"sdk_components": {
|
||||
"life_client": {
|
||||
"name": "LIFE Client",
|
||||
"languages": ["Python", "JavaScript", "Java", "Go", "Rust"],
|
||||
"features": ["Authentication", "Data access", "Event subscription", "Query builder"],
|
||||
},
|
||||
"life_cli": {
|
||||
"name": "LIFE CLI",
|
||||
"commands": ["life init", "life deploy", "life test", "life monitor"],
|
||||
"features": ["Module scaffolding", "Local testing", "Deployment", "Debugging"],
|
||||
},
|
||||
"life_studio": {
|
||||
"name": "LIFE Studio",
|
||||
"type": "Visual IDE",
|
||||
"features": ["Module builder", "Data mapper", "AI model trainer", "Test runner"],
|
||||
},
|
||||
"life_marketplace": {
|
||||
"name": "LIFE Marketplace",
|
||||
"type": "Module marketplace",
|
||||
"features": ["Publish modules", "Discover modules", "Rate & review", "Billing"],
|
||||
"status": "Coming Q2 2027",
|
||||
},
|
||||
},
|
||||
"example_use_cases": [
|
||||
{
|
||||
"partner": "Insurance Company X",
|
||||
"module": "Custom Risk Assessment",
|
||||
"description": "Byggde en modul för att bedöma egendomsrisk med LIFE:s satellit- och fältdata",
|
||||
"time_to_market": "3 months",
|
||||
},
|
||||
{
|
||||
"partner": "Municipality Y",
|
||||
"module": "Urban Growth Monitor",
|
||||
"description": "Anpassade City Intelligence för att spåra illegal bebyggelse",
|
||||
"time_to_market": "2 months",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── LIFE OBJECT MODEL ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/object-model")
|
||||
async def get_life_object_model():
|
||||
"""
|
||||
LIFE Object Model - Enhetlig objektsmodell för alla vertikaler
|
||||
"""
|
||||
return {
|
||||
"name": "LIFE Object Model",
|
||||
"version": "1.0.0",
|
||||
"description": "En enhetlig objektsmodell där allt representeras med samma grundstruktur",
|
||||
"principle": "En väg, en bro, ett sjukhus, ett biståndsprojekt eller ett träd är olika instanser av samma grundmodell.",
|
||||
"object_structure": {
|
||||
"identity": {
|
||||
"description": "Unik identifiering",
|
||||
"fields": ["uuid", "type", "name", "aliases", "external_ids"],
|
||||
"example": "uuid: 'obj-123', type: 'building', name: 'Mogadishu Primary School'",
|
||||
},
|
||||
"geometry": {
|
||||
"description": "Geospatial representation",
|
||||
"fields": ["point", "polygon", "bounding_box", "elevation"],
|
||||
"formats": ["GeoJSON", "WKT", "GeoHash"],
|
||||
},
|
||||
"time": {
|
||||
"description": "Temporal information",
|
||||
"fields": ["created_at", "updated_at", "valid_from", "valid_to", "observation_time"],
|
||||
},
|
||||
"evidence": {
|
||||
"description": "Kopplad evidens",
|
||||
"fields": ["observations", "sources", "confidence", "lineage"],
|
||||
},
|
||||
"state": {
|
||||
"description": "Nuvarande tillstånd",
|
||||
"fields": ["status", "progress", "health", "activity_level"],
|
||||
},
|
||||
"relationships": {
|
||||
"description": "Kopplingar till andra objekt",
|
||||
"fields": ["parent", "children", "related", "dependencies"],
|
||||
},
|
||||
"history": {
|
||||
"description": "Tidslinje över förändringar",
|
||||
"fields": ["events", "versions", "snapshots"],
|
||||
},
|
||||
"confidence": {
|
||||
"description": "Konfidensbedömning",
|
||||
"fields": ["score", "level", "factors", "uncertainty"],
|
||||
},
|
||||
"predictions": {
|
||||
"description": "Framtida prognoser",
|
||||
"fields": ["forecasts", "scenarios", "risk_assessments"],
|
||||
},
|
||||
"actions": {
|
||||
"description": "Möjliga åtgärder",
|
||||
"fields": ["recommended", "available", "history"],
|
||||
},
|
||||
},
|
||||
"examples": {
|
||||
"road": {
|
||||
"type": "infrastructure",
|
||||
"identity": "Road A-123",
|
||||
"geometry": "LineString",
|
||||
"state": {"status": "operational", "condition": 75},
|
||||
},
|
||||
"hospital": {
|
||||
"type": "facility",
|
||||
"identity": "Hospital B-456",
|
||||
"geometry": "Polygon",
|
||||
"state": {"status": "operational", "occupancy": 85},
|
||||
},
|
||||
"tree": {
|
||||
"type": "environmental",
|
||||
"identity": "Tree C-789",
|
||||
"geometry": "Point",
|
||||
"state": {"status": "healthy", "height_m": 12},
|
||||
},
|
||||
"project": {
|
||||
"type": "aid",
|
||||
"identity": "Project D-012",
|
||||
"geometry": "Polygon",
|
||||
"state": {"status": "active", "progress": 85},
|
||||
},
|
||||
},
|
||||
"benefits": [
|
||||
"Konsekvent modell över alla vertikaler",
|
||||
"Återanvändbara AI-modeller",
|
||||
"Enhetlig API",
|
||||
"Enklare integration",
|
||||
"Tydligare dataarkitektur",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ─── SYSTEM OVERVIEW ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/architecture")
|
||||
async def get_platform_architecture():
|
||||
"""
|
||||
Komplett plattformsarkitektur
|
||||
"""
|
||||
return {
|
||||
"system_name": "Landvex Intelligence Fusion Engine (LIFE) v4",
|
||||
"version": "4.0.0",
|
||||
"tagline": "Reality Intelligence Platform",
|
||||
"description": "En AI-drivn plattform för verklighetsintelligens som andra företag kan bygga ovanpå",
|
||||
"levels": [
|
||||
{
|
||||
"level": 1,
|
||||
"name": "Foundation",
|
||||
"function": "Teknisk kärna",
|
||||
"components": 12,
|
||||
"visible_to_verticals": False,
|
||||
},
|
||||
{
|
||||
"level": 2,
|
||||
"name": "Intelligence Runtime",
|
||||
"function": "AI-operativsystemet",
|
||||
"engines": 8,
|
||||
"visible_to_verticals": True,
|
||||
},
|
||||
{
|
||||
"level": 3,
|
||||
"name": "Domain Modules",
|
||||
"function": "Vertikala tillämpningar",
|
||||
"modules": 10,
|
||||
"visible_to_verticals": True,
|
||||
},
|
||||
{
|
||||
"level": 4,
|
||||
"name": "Experience Layer",
|
||||
"function": "Användargränssnitt",
|
||||
"interfaces": 9,
|
||||
"visible_to_verticals": True,
|
||||
},
|
||||
],
|
||||
"additional_components": {
|
||||
"sdk": {
|
||||
"name": "LIFE SDK",
|
||||
"function": "För externa utvecklare",
|
||||
"languages": ["Python", "JavaScript", "Java", "Go", "Rust"],
|
||||
},
|
||||
"object_model": {
|
||||
"name": "LIFE Object Model",
|
||||
"function": "Enhetlig objektsmodell",
|
||||
"attributes": 10,
|
||||
},
|
||||
},
|
||||
"strategic_positioning": "LIFE är en Reality Intelligence Platform. Många system analyserar dokument eller text. LIFE kombinerar geospatial information, tidsserier, visuella observationer och öppna datakällor till en spårbar evidensmodell. Den verkliga utmaningen är att visa att plattformen konsekvent kan leverera tillförlitliga, förklarbara analyser i flera olika domäner med samma underliggande arkitektur.",
|
||||
"key_differentiators": [
|
||||
"Reality Intelligence (inte bara textanalys)",
|
||||
"Evidence Before Opinion (spårbarhet)",
|
||||
"Multi-source fusion (inte enkel datakälla)",
|
||||
"Platform architecture (inte monolit)",
|
||||
"SDK för partners (ekosystem)",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
"""
|
||||
LIFE v6 — Reality Intelligence Standard (RIS)
|
||||
|
||||
LIFE är inte bara programvara. Det definierar HUR verklighetsintelligens
|
||||
representeras, valideras, utbyts och används — oberoende av tillämpning.
|
||||
|
||||
Sex öppna standarder:
|
||||
- RIS-001: Reality Object Standard
|
||||
- RIS-002: Evidence Standard
|
||||
- RIS-003: Confidence Standard
|
||||
- RIS-004: Reality Exchange Protocol
|
||||
- RIS-005: Decision Standard
|
||||
- RIS-006: Learning Standard
|
||||
|
||||
Gemensam ontologi med tre nivåer:
|
||||
- Core Ontology
|
||||
- Domain Ontologies
|
||||
- Customer Extensions
|
||||
|
||||
Product Definition:
|
||||
"LIFE är ett AI-drivet operativsystem och en öppen standard för Reality Intelligence.
|
||||
Genom att förena en gemensam informationsmodell, spårbar evidens, förklarbara analyser
|
||||
och kontinuerligt lärande gör LIFE det möjligt att beskriva, förstå och följa
|
||||
förändringar i den fysiska världen på ett konsekvent sätt över olika domäner
|
||||
och organisationer."
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/life-standard", tags=["life_standard"])
|
||||
|
||||
|
||||
# ─── RIS-001 — REALITY OBJECT STANDARD ────────────────────────────────────────
|
||||
|
||||
@router.get("/public/ris-001-reality-object")
|
||||
async def get_ris_001_reality_object():
|
||||
"""
|
||||
RIS-001 — Reality Object Standard
|
||||
En gemensam objektsmodell för allt i den fysiska världen.
|
||||
"""
|
||||
return {
|
||||
"standard_id": "RIS-001",
|
||||
"name": "Reality Object Standard",
|
||||
"version": "1.0.0",
|
||||
"description": "En gemensam objektsmodell för allt. En väg, en byggnad, ett biståndsprojekt, en bro, ett träd, en ledning, ett fordon — alla är samma typ av objekt.",
|
||||
"universal_structure": {
|
||||
"identity": {
|
||||
"uuid": "universellt-unikt-id",
|
||||
"type": "road | building | project | bridge | tree | pipeline | vehicle | ...",
|
||||
"name": "Mänskligt läsbart namn",
|
||||
"external_ids": {
|
||||
"description": "ID:n från externa system",
|
||||
"examples": {
|
||||
"osm_id": "OpenStreetMap ID",
|
||||
"gadm_id": "Administrativt ID",
|
||||
"custom_id": "Kundspecifikt ID",
|
||||
},
|
||||
},
|
||||
"aliases": ["Alternativa namn", "Lokala namn"],
|
||||
},
|
||||
"geometry": {
|
||||
"type": "point | polygon | polyline | bounding_box | 3d_mesh",
|
||||
"coordinates": {
|
||||
"format": "GeoJSON / WKT",
|
||||
"crs": "EPSG:4326 (default)",
|
||||
"example": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[42.36, -71.05], [42.37, -71.05], [42.37, -71.06], [42.36, -71.06], [42.36, -71.05]]],
|
||||
},
|
||||
},
|
||||
"spatial_resolution": "meter-noggrannhet",
|
||||
"bounding_box": {"min_lat": 0, "max_lat": 0, "min_lon": 0, "max_lon": 0},
|
||||
},
|
||||
"time": {
|
||||
"created_at": "ISO 8601 timestamp",
|
||||
"updated_at": "ISO 8601 timestamp",
|
||||
"valid_from": "När objektet började existera",
|
||||
"valid_until": "När objektet upphörde (null = aktivt)",
|
||||
"observation_window": {"start": "", "end": ""},
|
||||
},
|
||||
"state": {
|
||||
"current_status": "active | under_construction | damaged | destroyed | abandoned",
|
||||
"progress_percentage": "0-100 (om tillämpligt)",
|
||||
"health_score": "0-100",
|
||||
"last_observation_date": "ISO 8601",
|
||||
"state_history": [
|
||||
{"date": "", "status": "", "source": ""},
|
||||
],
|
||||
},
|
||||
"evidence": {
|
||||
"description": "Alla observationer som stöder nuvarande state",
|
||||
"observation_count": 0,
|
||||
"primary_sources": [],
|
||||
"evidence_lineage": "RIS-002 compliant",
|
||||
},
|
||||
"confidence": {
|
||||
"description": "Sammansatt konfidens för all information om objektet",
|
||||
"overall_score": "0-100",
|
||||
"breakdown": "RIS-003 compliant",
|
||||
},
|
||||
"relationships": {
|
||||
"description": "Kopplingar till andra objekt",
|
||||
"types": [
|
||||
{"type": "parent_of", "target": "uuid", "strength": 1.0},
|
||||
{"type": "depends_on", "target": "uuid", "strength": 0.9},
|
||||
{"type": "connected_to", "target": "uuid", "strength": 1.0},
|
||||
{"type": "near", "target": "uuid", "distance_meters": 100},
|
||||
],
|
||||
"graph_position": "Node i kunskapsgrafen",
|
||||
},
|
||||
"history": {
|
||||
"description": "Alla förändringar över tid",
|
||||
"events": [
|
||||
{"date": "", "event": "", "from_state": "", "to_state": "", "source": ""},
|
||||
],
|
||||
"snapshots": ["Tidsstämplade tillstånd"],
|
||||
},
|
||||
"predictions": {
|
||||
"description": "Framtida prognoser för objektet",
|
||||
"active_predictions": [
|
||||
{"horizon": "30d", "prediction": "", "confidence": 0, "model": ""},
|
||||
],
|
||||
"prediction_accuracy_history": "RIS-006 compliant",
|
||||
},
|
||||
"actions": {
|
||||
"description": "Rekommenderade och tillgängliga åtgärder",
|
||||
"recommended": [{"action": "", "priority": "", "expected_impact": ""}],
|
||||
"available": [{"action": "", "requirements": ""}],
|
||||
"taken": [{"action": "", "date": "", "result": ""}],
|
||||
},
|
||||
},
|
||||
"examples": [
|
||||
{
|
||||
"type": "road",
|
||||
"name": "Highway A1",
|
||||
"identity": {"uuid": "road-001", "osm_id": "12345"},
|
||||
"geometry": {"type": "polyline", "length_km": 45},
|
||||
},
|
||||
{
|
||||
"type": "building",
|
||||
"name": "Central Hospital",
|
||||
"identity": {"uuid": "bld-001", "custom_id": "HOSP-SOM-001"},
|
||||
"geometry": {"type": "polygon", "area_sqm": 5000},
|
||||
},
|
||||
{
|
||||
"type": "project",
|
||||
"name": "School Construction Project",
|
||||
"identity": {"uuid": "proj-001"},
|
||||
"state": {"progress_percentage": 75, "health_score": 82},
|
||||
},
|
||||
],
|
||||
"compliance": "Alla objekt i LIFE måste följa RIS-001",
|
||||
"extensibility": "Kunder kan lägga till egna attribut under 'extensions' utan att bryta standarden",
|
||||
}
|
||||
|
||||
|
||||
# ─── RIS-002 — EVIDENCE STANDARD ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/ris-002-evidence")
|
||||
async def get_ris_002_evidence():
|
||||
"""
|
||||
RIS-002 — Evidence Standard
|
||||
All evidens representeras likadant oavsett källa.
|
||||
"""
|
||||
return {
|
||||
"standard_id": "RIS-002",
|
||||
"name": "Evidence Standard",
|
||||
"version": "1.0.0",
|
||||
"description": "All evidens representeras likadant. Varje observation innehåller standardiserade fält för spårbarhet och jämförbarhet.",
|
||||
"required_fields": {
|
||||
"evidence_id": {"type": "UUID", "description": "Unikt ID för evidensen"},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Källan till observationen",
|
||||
"examples": ["satellite", "quixzoom", "official_report", "news", "sensor", "human"],
|
||||
},
|
||||
"source_detail": {
|
||||
"type": "object",
|
||||
"description": "Detaljer om källan",
|
||||
"fields": {
|
||||
"provider": "ESA / QUIXZOOM / Government / NewsOrg / ...",
|
||||
"instrument": "Sentinel-2 / iPhone-14 / Drone / ...",
|
||||
"collection_method": "Automated / Crowdsourced / Manual / ...",
|
||||
},
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "ISO 8601",
|
||||
"description": "När observationen gjordes",
|
||||
},
|
||||
"geographic_position": {
|
||||
"type": "GeoJSON Point",
|
||||
"description": "Var observationen gjordes",
|
||||
"accuracy_meters": "GPS-noggrannhet",
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"description": "Hur observationen gjordes",
|
||||
"examples": ["visual_inspection", "spectral_analysis", "photogrammetry", "interview", "document_review"],
|
||||
},
|
||||
"quality": {
|
||||
"type": "integer",
|
||||
"range": "0-100",
|
||||
"description": "Kvalitetsbedömning av observationen",
|
||||
"factors": ["resolution", "clarity", "completeness", "relevance"],
|
||||
},
|
||||
"uncertainty": {
|
||||
"type": "object",
|
||||
"description": "Osäkerhet i observationen",
|
||||
"fields": {
|
||||
"spatial_meters": "±10m",
|
||||
"temporal_hours": "±2h",
|
||||
"attribute_confidence": "0-100",
|
||||
},
|
||||
},
|
||||
"validation_status": {
|
||||
"type": "enum",
|
||||
"values": ["unvalidated", "pending", "validated", "disputed", "rejected"],
|
||||
"description": "Valideringsstatus",
|
||||
},
|
||||
"revision_history": {
|
||||
"type": "array",
|
||||
"description": "Alla ändringar av evidensen",
|
||||
"entries": [
|
||||
{"date": "", "change": "", "by": "", "reason": ""},
|
||||
],
|
||||
},
|
||||
"raw_data_reference": {
|
||||
"type": "string",
|
||||
"description": "Pointer till rådata (bild, dokument, etc.)",
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Ytterligare kontext",
|
||||
"fields": {
|
||||
"weather": "Vid observationstillfället",
|
||||
"lighting": "Dagsljus / Mörker / ...",
|
||||
"season": "Årstid",
|
||||
"context_description": "Beskrivande text",
|
||||
},
|
||||
},
|
||||
},
|
||||
"evidence_types": {
|
||||
"visual": {"description": "Bilder, video, satellitbilder", "quality_factors": ["resolution", "angle", "occlusion"]},
|
||||
"documentary": {"description": "Rapporter, dokument, databaser", "quality_factors": ["authority", "recency", "completeness"]},
|
||||
"sensor": {"description": "IoT-sensorer, väderstationer", "quality_factors": ["calibration", "frequency", "reliability"]},
|
||||
"human": {"description": "Mänskliga observationer", "quality_factors": ["expertise", "training", "corroboration"]},
|
||||
"derived": {"description": "AI-genererad eller beräknad evidens", "quality_factors": ["model_version", "training_data", "validation"]},
|
||||
},
|
||||
"fusion_rules": {
|
||||
"description": "Hur evidens från olika källor kombineras",
|
||||
"rules": [
|
||||
"Minst 2 oberoende källor för 'validated' status",
|
||||
"Motsägelsefull evidens flaggas automatiskt",
|
||||
"Äldre evidens viktas lägre om nyare finns",
|
||||
"Mänsklig verifiering krävs för 'high-confidence' påståenden",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── RIS-003 — CONFIDENCE STANDARD ────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/ris-003-confidence")
|
||||
async def get_ris_003_confidence():
|
||||
"""
|
||||
RIS-003 — Confidence Standard
|
||||
Alla modeller använder samma definition av säkerhet.
|
||||
"""
|
||||
return {
|
||||
"standard_id": "RIS-003",
|
||||
"name": "Confidence Standard",
|
||||
"version": "1.0.0",
|
||||
"description": "Alla modeller använder samma definition av säkerhet. Olika analyser blir jämförbara.",
|
||||
"confidence_dimensions": {
|
||||
"data_confidence": {
|
||||
"description": "Hur tillförlitlig är rådatan?",
|
||||
"factors": [
|
||||
{"factor": "Source reliability", "weight": 0.3},
|
||||
{"factor": "Data freshness", "weight": 0.2},
|
||||
{"factor": "Spatial resolution", "weight": 0.15},
|
||||
{"factor": "Temporal coverage", "weight": 0.15},
|
||||
{"factor": "Data completeness", "weight": 0.2},
|
||||
],
|
||||
"scale": "0-100",
|
||||
},
|
||||
"source_confidence": {
|
||||
"description": "Hur tillförlitlig är källan?",
|
||||
"factors": [
|
||||
{"factor": "Historical accuracy", "weight": 0.4},
|
||||
{"factor": "Authority level", "weight": 0.25},
|
||||
{"factor": "Independence", "weight": 0.2},
|
||||
{"factor": "Methodology transparency", "weight": 0.15},
|
||||
],
|
||||
"scale": "0-100",
|
||||
},
|
||||
"ai_confidence": {
|
||||
"description": "Hur tillförlitlig är AI-modellen?",
|
||||
"factors": [
|
||||
{"factor": "Model accuracy (historical)", "weight": 0.35},
|
||||
{"factor": "Training data quality", "weight": 0.25},
|
||||
{"factor": "Validation performance", "weight": 0.25},
|
||||
{"factor": "Model version maturity", "weight": 0.15},
|
||||
],
|
||||
"scale": "0-100",
|
||||
},
|
||||
"human_verification": {
|
||||
"description": "Har människa verifierat?",
|
||||
"levels": [
|
||||
{"level": 0, "description": "Ingen mänsklig granskning", "score": 0},
|
||||
{"level": 1, "description": "En granskare", "score": 60},
|
||||
{"level": 2, "description": "Oberoende dubbelgranskning", "score": 85},
|
||||
{"level": 3, "description": "Expertpanel + fältverifiering", "score": 95},
|
||||
],
|
||||
},
|
||||
"temporal_confidence": {
|
||||
"description": "Hur relevant är informationen i tid?",
|
||||
"factors": [
|
||||
{"factor": "Data age", "weight": 0.5},
|
||||
{"factor": "Update frequency", "weight": 0.3},
|
||||
{"factor": "Volatility of phenomenon", "weight": 0.2},
|
||||
],
|
||||
"scale": "0-100",
|
||||
},
|
||||
},
|
||||
"composite_confidence": {
|
||||
"description": "Sammansatt konfidens från alla dimensioner",
|
||||
"formula": "Viktad summa av alla dimensioner",
|
||||
"example": {
|
||||
"data_confidence": 85,
|
||||
"source_confidence": 90,
|
||||
"ai_confidence": 78,
|
||||
"human_verification": 60,
|
||||
"temporal_confidence": 72,
|
||||
"composite": 80,
|
||||
"calculation": "(85*0.2 + 90*0.2 + 78*0.25 + 60*0.2 + 72*0.15) = 80.15",
|
||||
},
|
||||
"interpretation": {
|
||||
"90-100": "Very high confidence — suitable for critical decisions",
|
||||
"75-89": "High confidence — suitable for most decisions",
|
||||
"60-74": "Moderate confidence — supplementary verification recommended",
|
||||
"40-59": "Low confidence — significant uncertainty, use with caution",
|
||||
"0-39": "Very low confidence — insufficient for decision-making",
|
||||
},
|
||||
},
|
||||
"confidence_reporting": {
|
||||
"required": True,
|
||||
"format": "Alla insikter måste rapportera composite confidence + breakdown",
|
||||
"visualization": "Confidence badge med färgkodning",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── RIS-004 — REALITY EXCHANGE PROTOCOL ──────────────────────────────────────
|
||||
|
||||
@router.get("/public/ris-004-reality-exchange")
|
||||
async def get_ris_004_reality_exchange():
|
||||
"""
|
||||
RIS-004 — Reality Exchange Protocol
|
||||
Ett öppet format för utbyte mellan system.
|
||||
"""
|
||||
return {
|
||||
"standard_id": "RIS-004",
|
||||
"name": "Reality Exchange Protocol",
|
||||
"version": "1.0.0",
|
||||
"description": "Ett öppet format för utbyte mellan system. Kommuner, företag eller andra plattformar kan publicera eller konsumera observationer utan specialanpassningar. Ett slags 'REST för verklighetsobservationer'.",
|
||||
"protocol_design": {
|
||||
"paradigm": "RESTful API med semantiska resurser",
|
||||
"content_type": "application/vnd.life+json",
|
||||
"versioning": "URL-based (/v1/, /v2/)",
|
||||
"authentication": "OAuth 2.0 + API Keys",
|
||||
},
|
||||
"core_endpoints": {
|
||||
"/objects": {
|
||||
"methods": ["GET", "POST", "PUT", "DELETE"],
|
||||
"description": "CRUD för Reality Objects (RIS-001)",
|
||||
"query_params": ["type", "location", "bbox", "time_range", "status"],
|
||||
"response": "RIS-001 compliant object",
|
||||
},
|
||||
"/observations": {
|
||||
"methods": ["GET", "POST"],
|
||||
"description": "Skicka/ta emot observationer (RIS-002)",
|
||||
"query_params": ["object_id", "source", "time_range", "quality_min"],
|
||||
"response": "RIS-002 compliant evidence",
|
||||
},
|
||||
"/evidence": {
|
||||
"methods": ["GET", "POST"],
|
||||
"description": "Evidens för specifika påståenden",
|
||||
"query_params": ["claim_id", "object_id", "source_type"],
|
||||
"response": "Evidence with confidence scores",
|
||||
},
|
||||
"/relationships": {
|
||||
"methods": ["GET", "POST", "DELETE"],
|
||||
"description": "Grafrelationer mellan objekt",
|
||||
"query_params": ["from", "to", "type", "depth"],
|
||||
"response": "Graph edges",
|
||||
},
|
||||
"/events": {
|
||||
"methods": ["GET", "POST"],
|
||||
"description": "Händelser som påverkar objekt",
|
||||
"query_params": ["location", "type", "severity", "time_range"],
|
||||
"response": "Event stream",
|
||||
},
|
||||
"/hypotheses": {
|
||||
"methods": ["GET", "POST"],
|
||||
"description": "Genererade hypoteser",
|
||||
"query_params": ["object_id", "confidence_min", "status"],
|
||||
"response": "Ranked hypotheses",
|
||||
},
|
||||
"/predictions": {
|
||||
"methods": ["GET"],
|
||||
"description": "Framtida prognoser",
|
||||
"query_params": ["object_id", "horizon", "type"],
|
||||
"response": "Predictions with confidence intervals",
|
||||
},
|
||||
"/actions": {
|
||||
"methods": ["GET", "POST"],
|
||||
"description": "Rekommenderade åtgärder",
|
||||
"query_params": ["priority", "status", "assignee"],
|
||||
"response": "Action items",
|
||||
},
|
||||
"/missions": {
|
||||
"methods": ["GET", "POST", "PUT"],
|
||||
"description": "QUIXZOOM-uppdrag",
|
||||
"query_params": ["status", "region", "priority"],
|
||||
"response": "Mission details",
|
||||
},
|
||||
},
|
||||
"exchange_formats": {
|
||||
"reality_json": {
|
||||
"description": "Standard JSON-format för LIFE-data",
|
||||
"schema": "https://standard.life.ai/schemas/reality-json/v1",
|
||||
"features": ["Self-describing", "Extensible", "Versioned"],
|
||||
},
|
||||
"reality_feed": {
|
||||
"description": "Strömmande format för realtidsdata",
|
||||
"protocol": "WebSocket / SSE",
|
||||
"use_case": "Live observations, alerts",
|
||||
},
|
||||
"reality_batch": {
|
||||
"description": "Batch-format för stora datamängder",
|
||||
"format": "Parquet / GeoParquet",
|
||||
"compression": "Zstandard",
|
||||
},
|
||||
},
|
||||
"federation": {
|
||||
"description": "Distribuerade system kan utbyta data",
|
||||
"capabilities": [
|
||||
"Cross-platform object resolution",
|
||||
"Shared evidence validation",
|
||||
"Federated queries",
|
||||
"Trust delegation",
|
||||
],
|
||||
},
|
||||
"adoption": {
|
||||
"open_source": True,
|
||||
"license": "Apache 2.0",
|
||||
"reference_implementation": "https://github.com/landvex/life-standard",
|
||||
"documentation": "https://standard.life.ai",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── RIS-005 — DECISION STANDARD ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/ris-005-decision")
|
||||
async def get_ris_005_decision():
|
||||
"""
|
||||
RIS-005 — Decision Standard
|
||||
Alla rekommendationer följer samma struktur.
|
||||
"""
|
||||
return {
|
||||
"standard_id": "RIS-005",
|
||||
"name": "Decision Standard",
|
||||
"version": "1.0.0",
|
||||
"description": "Alla rekommendationer bör följa samma struktur. Beslutsstödet blir konsekvent över alla domäner.",
|
||||
"decision_pipeline": {
|
||||
"steps": [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "Observation",
|
||||
"description": "Vad har observerats?",
|
||||
"output": "Rå observation (RIS-002)",
|
||||
"example": "Satellite imagery shows construction at 75% completion",
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "Evidence",
|
||||
"description": "Vilken evidens stöder observationen?",
|
||||
"output": "Samlad evidens (RIS-002)",
|
||||
"example": "3 satellite passes, 2 field observations, 1 official report",
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "Confidence",
|
||||
"description": "Hur säker är bedömningen?",
|
||||
"output": "Konfidensbedömning (RIS-003)",
|
||||
"example": "Composite confidence: 82% (High)",
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "Hypothesis",
|
||||
"description": "Vad förklarar observationen?",
|
||||
"output": "Rankade hypoteser",
|
||||
"example": "H1: On schedule (75%) | H2: Delayed (20%) | H3: Ahead (5%)",
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "Recommended Action",
|
||||
"description": "Vad bör göras?",
|
||||
"output": "Åtgärdsrekommendation",
|
||||
"example": "Schedule field verification within 14 days",
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"name": "Expected Outcome",
|
||||
"description": "Vad förväntas hända?",
|
||||
"output": "Prognos för åtgärdens effekt",
|
||||
"example": "Verification will increase confidence to 90% or identify issues",
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"name": "Verification Plan",
|
||||
"description": "Hur verifieras att åtgärden fungerade?",
|
||||
"output": "Plan för uppföljning",
|
||||
"example": "Compare next satellite pass with field report. Target: 90% confidence.",
|
||||
},
|
||||
],
|
||||
},
|
||||
"decision_object": {
|
||||
"structure": {
|
||||
"decision_id": "UUID",
|
||||
"timestamp": "ISO 8601",
|
||||
"context": {
|
||||
"domain": "infrastructure | aid | insurance | ...",
|
||||
"urgency": "low | medium | high | critical",
|
||||
"stakeholders": ["list"],
|
||||
},
|
||||
"observation": "RIS-002 reference",
|
||||
"evidence": "RIS-002 array",
|
||||
"confidence": "RIS-003 composite",
|
||||
"hypotheses": [
|
||||
{"rank": 1, "description": "", "probability": 0, "supporting_evidence": []},
|
||||
],
|
||||
"recommended_action": {
|
||||
"action": "",
|
||||
"priority": "",
|
||||
"rationale": "",
|
||||
"expected_outcome": "",
|
||||
"resources_required": [],
|
||||
"timeline": "",
|
||||
},
|
||||
"alternative_actions": [
|
||||
{"action": "", "pros": [], "cons": []},
|
||||
],
|
||||
"verification_plan": {
|
||||
"method": "",
|
||||
"success_criteria": "",
|
||||
"timeline": "",
|
||||
"responsible": "",
|
||||
},
|
||||
"audit_trail": [
|
||||
{"date": "", "event": "", "by": ""},
|
||||
],
|
||||
},
|
||||
},
|
||||
"decision_quality_metrics": {
|
||||
"description": "Hur bra är beslutsstödet?",
|
||||
"metrics": [
|
||||
{"metric": "Decision adoption rate", "formula": "Accepted recommendations / Total recommendations"},
|
||||
{"metric": "Decision accuracy", "formula": "Correct decisions / Total decisions"},
|
||||
{"metric": "Time to decision", "formula": "Minutes from login to decision"},
|
||||
{"metric": "Confidence calibration", "formula": "Actual accuracy vs stated confidence"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── RIS-006 — LEARNING STANDARD ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/ris-006-learning")
|
||||
async def get_ris_006_learning():
|
||||
"""
|
||||
RIS-006 — Learning Standard
|
||||
AI-modeller utvärderas mot verkliga utfall.
|
||||
"""
|
||||
return {
|
||||
"standard_id": "RIS-006",
|
||||
"name": "Learning Standard",
|
||||
"version": "1.0.0",
|
||||
"description": "AI-modeller ska inte bara tränas — de ska också utvärderas mot verkliga utfall. Kontinuerlig kvalitetsförbättring.",
|
||||
"evaluation_dimensions": {
|
||||
"prediction_accuracy": {
|
||||
"description": "Hur ofta var prognosen korrekt?",
|
||||
"metrics": [
|
||||
{"metric": "Precision", "description": "Korrekta positiva / Alla positiva"},
|
||||
{"metric": "Recall", "description": "Korrekta positiva / Faktiska positiva"},
|
||||
{"metric": "F1 Score", "description": "Harmoniskt medelvärde av precision och recall"},
|
||||
{"metric": "MAE", "description": "Mean Absolute Error för kontinuerliga prediktioner"},
|
||||
{"metric": "Calibration", "description": "Stämmer konfidens med faktisk accuracy?"},
|
||||
],
|
||||
"tracking": "Per model, per domain, per prediction type",
|
||||
},
|
||||
"hypothesis_validation": {
|
||||
"description": "Vilka hypoteser visade sig stämma?",
|
||||
"metrics": [
|
||||
{"metric": "Hypothesis accuracy", "description": "Stämda hypoteser / Totala hypoteser"},
|
||||
{"metric": "Rank quality", "description": "Hur bra var rankningen?"},
|
||||
{"metric": "Time to validation", "description": "Hur lång tid tog det att verifiera?"},
|
||||
],
|
||||
},
|
||||
"signal_informativeness": {
|
||||
"description": "Vilka signaler var mest informativa?",
|
||||
"metrics": [
|
||||
{"metric": "Feature importance", "description": "Vilka källor bidrog mest?"},
|
||||
{"metric": "Information gain", "description": "Hur mycket minskade osäkerheten?"},
|
||||
{"metric": "Cost-effectiveness", "description": "Värde per insamlingskostnad"},
|
||||
],
|
||||
},
|
||||
"temporal_improvement": {
|
||||
"description": "Hur utvecklades träffsäkerheten över tid?",
|
||||
"metrics": [
|
||||
{"metric": "Accuracy trend", "description": "Stiger accuracy över tid?"},
|
||||
{"metric": "Learning rate", "description": "Hur snabbt förbättras modellen?"},
|
||||
{"metric": "Concept drift detection", "description": "Upptäcks förändringar i datan?"},
|
||||
],
|
||||
},
|
||||
},
|
||||
"feedback_loops": {
|
||||
"description": "Hur verkliga utfall matas tillbaka",
|
||||
"mechanisms": [
|
||||
{
|
||||
"name": "Outcome Reporting",
|
||||
"description": "Användare rapporterar faktiska utfall",
|
||||
"frequency": "Continuous",
|
||||
},
|
||||
{
|
||||
"name": "Automated Validation",
|
||||
"description": "Systemet validerar automatiskt mot nya observationer",
|
||||
"frequency": "Per observation cycle",
|
||||
},
|
||||
{
|
||||
"name": "Expert Review",
|
||||
"description": "Experter granskar beslut och utfall",
|
||||
"frequency": "Monthly",
|
||||
},
|
||||
{
|
||||
"name": "A/B Testing",
|
||||
"description": "Jämför olika modeller på samma data",
|
||||
"frequency": "Per model update",
|
||||
},
|
||||
],
|
||||
},
|
||||
"model_lifecycle": {
|
||||
"description": "Modellens livscykel med kontinuerligt lärande",
|
||||
"stages": [
|
||||
{"stage": "Training", "description": "Initial träning på historisk data"},
|
||||
{"stage": "Validation", "description": "Validering på hållen testdata"},
|
||||
{"stage": "Deployment", "description": "Driftsättning med monitoring"},
|
||||
{"stage": "Monitoring", "description": "Kontinuerlig utvärdering"},
|
||||
{"stage": "Retraining", "description": "Omträning baserat på nya utfall"},
|
||||
{"stage": "Deprecation", "description": "Utfasning om bättre modell finns"},
|
||||
],
|
||||
},
|
||||
"quality_gates": {
|
||||
"description": "Krav för att en modell ska få användas",
|
||||
"minimum_requirements": {
|
||||
"accuracy_threshold": 75,
|
||||
"calibration_threshold": 80,
|
||||
"bias_check": "Pass",
|
||||
"explainability_score": 70,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── COMMON ONTOLOGY ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/ontology")
|
||||
async def get_ontology():
|
||||
"""
|
||||
Gemensam ontologi med tre nivåer
|
||||
"""
|
||||
return {
|
||||
"name": "LIFE Common Ontology",
|
||||
"version": "1.0.0",
|
||||
"description": "Tre nivåer av ontologi för att balansera generell kärna med domänspecifika och kundspecifika begrepp.",
|
||||
"levels": {
|
||||
"core_ontology": {
|
||||
"level": 1,
|
||||
"name": "Core Ontology",
|
||||
"description": "Universella begrepp som gäller över alla domäner",
|
||||
"concepts": [
|
||||
{"concept": "Object", "definition": "Något i den fysiska världen som kan observeras"},
|
||||
{"concept": "Observation", "definition": "En mätning eller iakttagelse av ett objekt"},
|
||||
{"concept": "Evidence", "definition": "Data som stöder eller motsäger ett påstående"},
|
||||
{"concept": "Relation", "definition": "En koppling mellan två objekt"},
|
||||
{"concept": "Event", "definition": "Något som händer vid en specifik tid och plats"},
|
||||
{"concept": "Hypothesis", "definition": "En förklaring som kan testas mot evidens"},
|
||||
{"concept": "Prediction", "definition": "En prognos om framtida tillstånd"},
|
||||
{"concept": "Action", "definition": "En åtgärd som kan vidtas"},
|
||||
{"concept": "Confidence", "definition": "Graden av säkerhet i en bedömning"},
|
||||
{"concept": "Time", "definition": "Temporal dimension för alla observationer"},
|
||||
{"concept": "Space", "definition": "Geografisk dimension för alla observationer"},
|
||||
{"concept": "Agent", "definition": "En entitet som kan utföra handlingar (människa, AI, organisation)"},
|
||||
],
|
||||
"stability": "FROZEN — Ändringar kräver major version bump",
|
||||
},
|
||||
"domain_ontologies": {
|
||||
"level": 2,
|
||||
"name": "Domain Ontologies",
|
||||
"description": "Begrepp som är specifika för en domän",
|
||||
"domains": [
|
||||
{
|
||||
"domain": "infrastructure",
|
||||
"concepts": [
|
||||
{"concept": "Road", "parent": "Object"},
|
||||
{"concept": "Bridge", "parent": "Object"},
|
||||
{"concept": "Building", "parent": "Object"},
|
||||
{"concept": "PavementCondition", "parent": "Observation"},
|
||||
{"concept": "StructuralIntegrity", "parent": "Observation"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"domain": "aid",
|
||||
"concepts": [
|
||||
{"concept": "Project", "parent": "Object"},
|
||||
{"concept": "Beneficiary", "parent": "Object"},
|
||||
{"concept": "Milestone", "parent": "Event"},
|
||||
{"concept": "Disbursement", "parent": "Event"},
|
||||
{"concept": "ImpactMetric", "parent": "Observation"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"domain": "insurance",
|
||||
"concepts": [
|
||||
{"concept": "Policy", "parent": "Object"},
|
||||
{"concept": "Claim", "parent": "Event"},
|
||||
{"concept": "RiskFactor", "parent": "Observation"},
|
||||
{"concept": "Premium", "parent": "Observation"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"domain": "environment",
|
||||
"concepts": [
|
||||
{"concept": "Ecosystem", "parent": "Object"},
|
||||
{"concept": "PollutionLevel", "parent": "Observation"},
|
||||
{"concept": "BiodiversityIndex", "parent": "Observation"},
|
||||
{"concept": "ClimateRisk", "parent": "Prediction"},
|
||||
],
|
||||
},
|
||||
],
|
||||
"stability": "STABLE — Ändringar kräver minor version bump",
|
||||
},
|
||||
"customer_extensions": {
|
||||
"level": 3,
|
||||
"name": "Customer Extensions",
|
||||
"description": "Möjlighet för varje kund att lägga till egna begrepp",
|
||||
"rules": [
|
||||
"Måste ärva från Core eller Domain",
|
||||
"Får inte ändra befintliga begrepp",
|
||||
"Måste följa namnkonventioner",
|
||||
"Bör dokumenteras i kundens ontologi-fil",
|
||||
],
|
||||
"example": {
|
||||
"customer": "Municipality of Stockholm",
|
||||
"extensions": [
|
||||
{"concept": "SnowRemovalRoute", "parent": "Road"},
|
||||
{"concept": "GrittingPriority", "parent": "Observation"},
|
||||
],
|
||||
},
|
||||
"stability": "FLEXIBLE — Kunden kontrollerar sina egna extensions",
|
||||
},
|
||||
},
|
||||
"ontology_management": {
|
||||
"versioning": "Semantisk versionshantering (MAJOR.MINOR.PATCH)",
|
||||
"compatibility": "Backward compatible inom major version",
|
||||
"migration": "Automatiska migreringsverktyg vid major bumps",
|
||||
"governance": "Ontology Review Board för Core-ändringar",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── PRODUCT DEFINITION v6 ────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/product-definition")
|
||||
async def get_product_definition_v6():
|
||||
"""
|
||||
LIFE v6 Product Definition
|
||||
"""
|
||||
return {
|
||||
"product_name": "Landvex Intelligence Fusion Engine (LIFE)",
|
||||
"version": "6.0.0",
|
||||
"tagline": "Reality Intelligence Standard",
|
||||
"definition": "LIFE är ett AI-drivet operativsystem och en öppen standard för Reality Intelligence. Genom att förena en gemensam informationsmodell, spårbar evidens, förklarbara analyser och kontinuerligt lärande gör LIFE det möjligt att beskriva, förstå och följa förändringar i den fysiska världen på ett konsekvent sätt över olika domäner och organisationer.",
|
||||
"evolution": {
|
||||
"v1": "Aid Intelligence — Biståndsövervakning",
|
||||
"v2": "Multi-signal — Evidence Before Opinion",
|
||||
"v3": "Core Architecture — 9 block, AI-operativsystem",
|
||||
"v4": "Platform — 4 nivåer, SDK, Object Model",
|
||||
"v5": "Enterprise — 5 pelare, governance, simulation",
|
||||
"v6": "Standard — 6 öppna standarder, ontologi, ekosystem",
|
||||
},
|
||||
"key_characteristics": [
|
||||
{
|
||||
"characteristic": "Open Standard",
|
||||
"description": "RIS-001 till RIS-006 definierar hur verklighetsintelligens representeras",
|
||||
"differentiator": True,
|
||||
},
|
||||
{
|
||||
"characteristic": "Reality Intelligence",
|
||||
"description": "Analyserar den fysiska världen, inte bara text",
|
||||
"differentiator": True,
|
||||
},
|
||||
{
|
||||
"characteristic": "Evidence Before Opinion",
|
||||
"description": "Börjar med observationer, inte slutsatser",
|
||||
"differentiator": True,
|
||||
},
|
||||
{
|
||||
"characteristic": "Multi-source Fusion",
|
||||
"description": "Kombinerar satellit, fält, öppna data och AI",
|
||||
"differentiator": True,
|
||||
},
|
||||
{
|
||||
"characteristic": "Traceability",
|
||||
"description": "Varje insikt spårbar till källor",
|
||||
"differentiator": True,
|
||||
},
|
||||
{
|
||||
"characteristic": "Explainability",
|
||||
"description": "Förklarbara analyser",
|
||||
"differentiator": True,
|
||||
},
|
||||
{
|
||||
"characteristic": "Ecosystem",
|
||||
"description": "Andra företag kan bygga ovanpå standarden",
|
||||
"differentiator": True,
|
||||
},
|
||||
],
|
||||
"strategic_positioning": {
|
||||
"not": "En enskild produkt eller applikation",
|
||||
"is": "Ett teknologiskt ramverk och öppen standard",
|
||||
"enables": "Andra att bygga Reality Intelligence-lösningar",
|
||||
"analogy": "Som HTTP för webben, men för verklighetsdata",
|
||||
},
|
||||
"long_term_ambition": "LIFE blir den dominerande standarden för hur organisationer representerar, validerar, utbyter och använder verklighetsintelligens.",
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
"""
|
||||
LIFE — Reality Intelligence Validation Program (RIVP)
|
||||
|
||||
Målet: Bevisa att LIFE fungerar i riktiga miljöer.
|
||||
|
||||
Faser:
|
||||
- Fas 1: Infrastruktur (vägar, broar, arbeten)
|
||||
- Fas 2: Kommun (parker, belysning, skyltning)
|
||||
- Fas 3: Fastigheter (byggnader, skador, underhåll)
|
||||
- Fas 4: Bistånd (projekt, observationer, verifiering)
|
||||
|
||||
Nyckeltal (KPI:er):
|
||||
- Datatäckning
|
||||
- Aktualitet
|
||||
- Evidenskvalitet
|
||||
- Analyskvalitet
|
||||
- Adoption
|
||||
- Interoperabilitet
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/life-validation", tags=["life_validation"])
|
||||
|
||||
|
||||
# ─── REALITY INTELLIGENCE VALIDATION PROGRAM ──────────────────────────────────
|
||||
|
||||
@router.get("/public/validation-program")
|
||||
async def get_validation_program():
|
||||
"""
|
||||
Reality Intelligence Validation Program (RIVP)
|
||||
Bevisa att LIFE fungerar i riktiga miljöer.
|
||||
"""
|
||||
return {
|
||||
"program_name": "Reality Intelligence Validation Program (RIVP)",
|
||||
"version": "1.0.0",
|
||||
"purpose": "Bevisa att LIFE fungerar i riktiga miljöer innan vidare arkitekturutveckling",
|
||||
"philosophy": "Många tekniska projekt fortsätter bygga lager utan att validera dem. RIVP säkerställer att varje fas bevisar verkligt värde.",
|
||||
"phases": {
|
||||
"phase_1_infrastructure": {
|
||||
"phase": 1,
|
||||
"name": "Infrastruktur",
|
||||
"duration": "6 månader",
|
||||
"goal": "Visa att LIFE kan övervaka infrastruktur i realtid",
|
||||
"objectives": [
|
||||
{
|
||||
"objective": "Hitta vägskador",
|
||||
"description": "Identifiera skador på vägar genom satellitbilder och fältobservationer",
|
||||
"success_criteria": ">80% precision i skadedetektion",
|
||||
"measurement": "Jämförelse med manuell inspektion",
|
||||
},
|
||||
{
|
||||
"objective": "Följa vägarbeten",
|
||||
"description": "Spåra framsteg i vägarbeten över tid",
|
||||
"success_criteria": "Progress estimation ±10% jämfört med rapporter",
|
||||
"measurement": "Veckovis jämförelse med entreprenörsrapporter",
|
||||
},
|
||||
{
|
||||
"objective": "Upptäcka förändringar",
|
||||
"description": "Automatisk detektion av förändringar i infrastruktur",
|
||||
"success_criteria": "<48h från förändring till detektion",
|
||||
"measurement": "Tidsstämplad jämförelse",
|
||||
},
|
||||
{
|
||||
"objective": "Verifiera reparationer",
|
||||
"description": "Bekräfta att rapporterade reparationer verkligen utförts",
|
||||
"success_criteria": ">90% korrekt verifiering",
|
||||
"measurement": "Fältbesök + satellitbilder",
|
||||
},
|
||||
],
|
||||
"pilot_locations": [
|
||||
{"location": "E4, Uppsala län", "type": "Highway", "length_km": 45},
|
||||
{"location": "Länsväg 272, Stockholm", "type": "County road", "length_km": 23},
|
||||
],
|
||||
"data_sources": ["Sentinel-2", "QUIXZOOM", "Trafikverket API"],
|
||||
"expected_outcomes": {
|
||||
"objects_tracked": 500,
|
||||
"observations_collected": 10000,
|
||||
"issues_detected": 150,
|
||||
"verification_rate": 90,
|
||||
},
|
||||
},
|
||||
"phase_2_municipality": {
|
||||
"phase": 2,
|
||||
"name": "Kommun",
|
||||
"duration": "6 månader",
|
||||
"goal": "Visa att LIFE kan hantera kommunala tillgångar",
|
||||
"objectives": [
|
||||
{
|
||||
"objective": "Följa parker",
|
||||
"description": "Övervaka skick och användning av kommunala parker",
|
||||
"success_criteria": "Veckovis uppdatering av parkstatus",
|
||||
"measurement": "Bildjämförelse + besöksdata",
|
||||
},
|
||||
{
|
||||
"objective": "Gatubelysning",
|
||||
"description": "Kartlägga och övervaka gatubelysning",
|
||||
"success_criteria": ">95% täckning av belysningspunkter",
|
||||
"measurement": "Fältverifiering",
|
||||
},
|
||||
{
|
||||
"objective": "Skyltning",
|
||||
"description": "Inventera och övervaka vägskyltar",
|
||||
"success_criteria": "Komplett inventering ±5%",
|
||||
"measurement": "Jämförelse med kommunalt register",
|
||||
},
|
||||
{
|
||||
"objective": "Avfall",
|
||||
"description": "Övervaka avfallshantering och återvinning",
|
||||
"success_criteria": "Identifiera 90% av illegala dumpningsplatser",
|
||||
"measurement": "Satellit + fältobservationer",
|
||||
},
|
||||
{
|
||||
"objective": "Vinterunderhåll",
|
||||
"description": "Spåra snöröjning och halkbekämpning",
|
||||
"success_criteria": "<4h från snöfall till uppdaterad status",
|
||||
"measurement": "Realtidsjämförelse",
|
||||
},
|
||||
],
|
||||
"pilot_locations": [
|
||||
{"location": "Uppsala kommun", "population": 230000},
|
||||
{"location": "Enköpings kommun", "population": 48000},
|
||||
],
|
||||
"data_sources": ["Satellit", "QUIXZOOM", "Kommunala API:er"],
|
||||
"expected_outcomes": {
|
||||
"objects_tracked": 5000,
|
||||
"observations_collected": 50000,
|
||||
"issues_detected": 300,
|
||||
"response_time_hours": 4,
|
||||
},
|
||||
},
|
||||
"phase_3_properties": {
|
||||
"phase": 3,
|
||||
"name": "Fastigheter",
|
||||
"duration": "6 månader",
|
||||
"goal": "Visa att LIFE kan övervaka byggnader och fastigheter",
|
||||
"objectives": [
|
||||
{
|
||||
"objective": "Följa byggnader",
|
||||
"description": "Spåra byggnaders skick och förändringar",
|
||||
"success_criteria": "Månatlig uppdatering av byggnadsstatus",
|
||||
"measurement": "Satellit + drönare + fält",
|
||||
},
|
||||
{
|
||||
"objective": "Upptäcka skador",
|
||||
"description": "Identifiera skador på tak, fasader, grunder",
|
||||
"success_criteria": ">75% precision i skadedetektion",
|
||||
"measurement": "Jämförelse med besiktning",
|
||||
},
|
||||
{
|
||||
"objective": "Följa underhåll",
|
||||
"description": "Spåra planerat och utfört underhåll",
|
||||
"success_criteria": "100% spårbarhet av underhållsåtgärder",
|
||||
"measurement": "Integration med underhållssystem",
|
||||
},
|
||||
{
|
||||
"objective": "Analysera risk",
|
||||
"description": "Bedöma risker för byggnader",
|
||||
"success_criteria": "Riskbedömning ±1 nivå jämfört med expert",
|
||||
"measurement": "Oberoende expertgranskning",
|
||||
},
|
||||
],
|
||||
"pilot_locations": [
|
||||
{"location": "Bostadsrättsföreningar, Uppsala", "properties": 50},
|
||||
{"location": "Kommunala fastigheter", "properties": 100},
|
||||
],
|
||||
"data_sources": ["Satellit", "Drönare", "Fältinspektion", "Fastighetsregister"],
|
||||
"expected_outcomes": {
|
||||
"objects_tracked": 150,
|
||||
"observations_collected": 3000,
|
||||
"issues_detected": 200,
|
||||
"risk_assessments": 150,
|
||||
},
|
||||
},
|
||||
"phase_4_aid": {
|
||||
"phase": 4,
|
||||
"name": "Bistånd",
|
||||
"duration": "12 månader",
|
||||
"goal": "Visa att LIFE kan övervaka biståndsprojekt i utvecklingsländer",
|
||||
"objectives": [
|
||||
{
|
||||
"objective": "Följa projekt över tid",
|
||||
"description": "Spåra framsteg i biståndsprojekt",
|
||||
"success_criteria": "Månatlig uppdatering ±15% jämfört med rapporter",
|
||||
"measurement": "Jämförelse med implementerarrapporter",
|
||||
},
|
||||
{
|
||||
"objective": "Samla oberoende observationer",
|
||||
"description": "Använda QUIXZOOM för oberoende verifiering",
|
||||
"success_criteria": ">50% av projekt med oberoende observationer",
|
||||
"measurement": "Antal verifierade projekt",
|
||||
},
|
||||
{
|
||||
"objective": "Kombinera flera datakällor",
|
||||
"description": "Fusionera satellit, fält, rapporter och nyheter",
|
||||
"success_criteria": ">3 oberoende källor per projekt",
|
||||
"measurement": "Källtäckning per projekt",
|
||||
},
|
||||
{
|
||||
"objective": "Identifiera verifieringsbehov",
|
||||
"description": "Automatisk flaggning av projekt som behöver extra granskning",
|
||||
"success_criteria": ">80% precision i flaggning",
|
||||
"measurement": "Jämförelse med manuell granskning",
|
||||
},
|
||||
],
|
||||
"pilot_locations": [
|
||||
{"location": "Somalia", "projects": 50, "focus": "Infrastructure"},
|
||||
{"location": "Kenya", "projects": 30, "focus": "Education"},
|
||||
{"location": "Malawi", "projects": 20, "focus": "Health"},
|
||||
],
|
||||
"data_sources": ["Sentinel-2", "QUIXZOOM", "Implementerarrapporter", "Nyheter", "Sociala medier"],
|
||||
"expected_outcomes": {
|
||||
"objects_tracked": 100,
|
||||
"observations_collected": 10000,
|
||||
"projects_monitored": 100,
|
||||
"verification_flags": 25,
|
||||
},
|
||||
},
|
||||
},
|
||||
"success_criteria": {
|
||||
"program_level": [
|
||||
"Alla 4 faser genomförda",
|
||||
"Minst 2 piloter per fas",
|
||||
"Dokumenterade resultat",
|
||||
"Publicerade case studies",
|
||||
],
|
||||
"technical": [
|
||||
">80% precision i detektion",
|
||||
"<48h aktualitet",
|
||||
">90% datatäckning",
|
||||
">3 oberoende källor per objekt",
|
||||
],
|
||||
"business": [
|
||||
"Minst 3 externa partners",
|
||||
"Minst 1 kommersiell pilot",
|
||||
"Positiv ROI i minst 1 fas",
|
||||
"Publicerad referensarkitektur",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── SUCCESS MEASUREMENT FRAMEWORK ────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/success-metrics")
|
||||
async def get_success_metrics():
|
||||
"""
|
||||
Ramverk för att mäta om LIFE lyckas som ekosystem.
|
||||
"""
|
||||
return {
|
||||
"framework_name": "LIFE Success Measurement Framework",
|
||||
"version": "1.0.0",
|
||||
"purpose": "Definiera hur vi mäter om plattformen lyckas — fokus på faktisk användning, inte antal moduler",
|
||||
"dimensions": {
|
||||
"data_coverage": {
|
||||
"dimension": "Datatäckning",
|
||||
"description": "Andel relevanta objekt eller områden med aktuell observationsdata",
|
||||
"metrics": [
|
||||
{
|
||||
"metric": "Object Coverage",
|
||||
"definition": "Andel registrerade objekt med minst 1 observation",
|
||||
"target": ">95%",
|
||||
"measurement": "Registrerade objekt / Objekt med observation",
|
||||
"frequency": "Veckovis",
|
||||
},
|
||||
{
|
||||
"metric": "Geographic Coverage",
|
||||
"definition": "Andel av target-området med data",
|
||||
"target": ">90%",
|
||||
"measurement": "Km² med data / Total target km²",
|
||||
"frequency": "Månadsvis",
|
||||
},
|
||||
{
|
||||
"metric": "Domain Coverage",
|
||||
"definition": "Andel domäner med aktiv datainsamling",
|
||||
"target": "100% av aktiva domäner",
|
||||
"measurement": "Domäner med data / Aktiva domäner",
|
||||
"frequency": "Månadsvis",
|
||||
},
|
||||
],
|
||||
},
|
||||
"freshness": {
|
||||
"dimension": "Aktualitet",
|
||||
"description": "Tid från förändring i verkligheten till registrerad observation",
|
||||
"metrics": [
|
||||
{
|
||||
"metric": "Detection Time",
|
||||
"definition": "Tid från förändring till detektion",
|
||||
"target": "<48h",
|
||||
"measurement": "Mediantid för kända förändringar",
|
||||
"frequency": "Veckovis",
|
||||
},
|
||||
{
|
||||
"metric": "Update Frequency",
|
||||
"definition": "Hur ofta objekt uppdateras",
|
||||
"target": "Veckovis för aktiva objekt",
|
||||
"measurement": "Dagar sedan senaste observation",
|
||||
"frequency": "Dagligen",
|
||||
},
|
||||
{
|
||||
"metric": "Alert Latency",
|
||||
"definition": "Tid från detektion till användaralert",
|
||||
"target": "<1h",
|
||||
"measurement": "Mediantid alert-utskick",
|
||||
"frequency": "Dagligen",
|
||||
},
|
||||
],
|
||||
},
|
||||
"evidence_quality": {
|
||||
"dimension": "Evidenskvalitet",
|
||||
"description": "Genomsnittlig konfidens och andel observationer med oberoende verifiering",
|
||||
"metrics": [
|
||||
{
|
||||
"metric": "Average Confidence",
|
||||
"definition": "Genomsnittlig konfidens över alla observationer",
|
||||
"target": ">75",
|
||||
"measurement": "Medel av RIS-003 composite scores",
|
||||
"frequency": "Veckovis",
|
||||
},
|
||||
{
|
||||
"metric": "Verification Rate",
|
||||
"definition": "Andel observationer med oberoende verifiering",
|
||||
"target": ">30%",
|
||||
"measurement": "Verifierade / Totala observationer",
|
||||
"frequency": "Månadsvis",
|
||||
},
|
||||
{
|
||||
"metric": "Source Diversity",
|
||||
"definition": "Genomsnittligt antal källor per objekt",
|
||||
"target": ">2.5",
|
||||
"measurement": "Antal unika källor / Antal objekt",
|
||||
"frequency": "Månadsvis",
|
||||
},
|
||||
{
|
||||
"metric": "Contradiction Rate",
|
||||
"definition": "Andel objekt med motstridig evidens",
|
||||
"target": "<5%",
|
||||
"measurement": "Objekt med flaggade motsägelser / Totala objekt",
|
||||
"frequency": "Veckovis",
|
||||
},
|
||||
],
|
||||
},
|
||||
"analysis_quality": {
|
||||
"dimension": "Analyskvalitet",
|
||||
"description": "Hur ofta användare bedömer analyser som användbara eller korrekta",
|
||||
"metrics": [
|
||||
{
|
||||
"metric": "User Satisfaction",
|
||||
"definition": "Andel användare som bedömer analyser som användbara",
|
||||
"target": ">80%",
|
||||
"measurement": "Enkäter + feedback",
|
||||
"frequency": "Kvartalsvis",
|
||||
},
|
||||
{
|
||||
"metric": "Decision Adoption",
|
||||
"definition": "Andel rekommendationer som leder till åtgärd",
|
||||
"target": ">60%",
|
||||
"measurement": "Genomförda åtgärder / Rekommendationer",
|
||||
"frequency": "Månadsvis",
|
||||
},
|
||||
{
|
||||
"metric": "Prediction Accuracy",
|
||||
"definition": "Hur ofta prognoser stämmer",
|
||||
"target": ">70%",
|
||||
"measurement": "Korrekta prognoser / Totala prognoser",
|
||||
"frequency": "Månadsvis",
|
||||
},
|
||||
{
|
||||
"metric": "Expert Agreement",
|
||||
"definition": "Hur ofta experter håller med om analyser",
|
||||
"target": ">75%",
|
||||
"measurement": "Oberoende expertgranskning",
|
||||
"frequency": "Kvartalsvis",
|
||||
},
|
||||
],
|
||||
},
|
||||
"adoption": {
|
||||
"dimension": "Adoption",
|
||||
"description": "Antal externa implementationer, partnerintegrationer och aktiva SDK-användare",
|
||||
"metrics": [
|
||||
{
|
||||
"metric": "External Implementations",
|
||||
"definition": "Antal oberoende RIS-implementationer",
|
||||
"target": ">10",
|
||||
"measurement": "Registrerade implementationer",
|
||||
"frequency": "Månadsvis",
|
||||
},
|
||||
{
|
||||
"metric": "Partner Integrations",
|
||||
"definition": "Antal system integrerade med LIFE",
|
||||
"target": ">20",
|
||||
"measurement": "Aktiva integrationer",
|
||||
"frequency": "Månadsvis",
|
||||
},
|
||||
{
|
||||
"metric": "SDK Users",
|
||||
"definition": "Antal aktiva SDK-användare",
|
||||
"target": ">100",
|
||||
"measurement": "Unika utvecklare med SDK-downloads",
|
||||
"frequency": "Månadsvis",
|
||||
},
|
||||
{
|
||||
"metric": "Certified Products",
|
||||
"definition": "Antal RIS-certifierade produkter",
|
||||
"target": ">5",
|
||||
"measurement": "Certifierade produkter",
|
||||
"frequency": "Kvartalsvis",
|
||||
},
|
||||
],
|
||||
},
|
||||
"interoperability": {
|
||||
"dimension": "Interoperabilitet",
|
||||
"description": "Antal system som kan utbyta data via RIS utan specialanpassning",
|
||||
"metrics": [
|
||||
{
|
||||
"metric": "RIS Compatible Systems",
|
||||
"definition": "Antal system med RIS Compatible-certifiering",
|
||||
"target": ">15",
|
||||
"measurement": "Certifierade system",
|
||||
"frequency": "Månadsvis",
|
||||
},
|
||||
{
|
||||
"metric": "Federated Queries",
|
||||
"definition": "Antal frågor över flera system",
|
||||
"target": ">1000/månad",
|
||||
"measurement": "Loggade federerade frågor",
|
||||
"frequency": "Månadsvis",
|
||||
},
|
||||
{
|
||||
"metric": "Data Exchange Volume",
|
||||
"definition": "Volym data utbytt via RIS",
|
||||
"target": ">1TB/månad",
|
||||
"measurement": "Bytes överförda",
|
||||
"frequency": "Månadsvis",
|
||||
},
|
||||
{
|
||||
"metric": "Cross-domain Usage",
|
||||
"definition": "Andel användare som använder flera domäner",
|
||||
"target": ">40%",
|
||||
"measurement": "Användare med >1 domän / Totala användare",
|
||||
"frequency": "Kvartalsvis",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"reporting": {
|
||||
"dashboard": "https://landvex.com/admin/validation-metrics",
|
||||
"frequency": "Månadsvis rapport till ledning",
|
||||
"public_transparency": "Kvartalsvis publicerad rapport",
|
||||
"benchmarking": "Jämförelse med branschstandarder",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── TECHNICAL SPECIFICATION RFC ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/public/technical-specification")
|
||||
async def get_technical_specification():
|
||||
"""
|
||||
Teknisk specifikation — nästa viktiga leverans efter arkitekturen.
|
||||
"""
|
||||
return {
|
||||
"document_title": "Reality Intelligence Standard (RIS) Technical Specification",
|
||||
"version": "1.0.0-draft",
|
||||
"status": "Draft",
|
||||
"purpose": "Nästa viktiga leverans efter arkitekturen. En RFC/ISO-liknande standard som blir referens för utvecklare, partners och kunder.",
|
||||
"table_of_contents": {
|
||||
"1_executive_summary": {
|
||||
"section": 1,
|
||||
"title": "Executive Summary",
|
||||
"content": "Översikt över RIS och LIFE",
|
||||
},
|
||||
"2_vision_principles": {
|
||||
"section": 2,
|
||||
"title": "Vision and Principles",
|
||||
"content": [
|
||||
"Reality Intelligence definition",
|
||||
"Evidence Before Opinion",
|
||||
"Multi-source Fusion",
|
||||
"Traceability",
|
||||
"Explainability",
|
||||
"Openness",
|
||||
],
|
||||
},
|
||||
"3_architecture": {
|
||||
"section": 3,
|
||||
"title": "Architecture",
|
||||
"content": [
|
||||
"4-nivåers plattformsarkitektur",
|
||||
"9 Core Blocks",
|
||||
"5 Enterprise Pillars",
|
||||
"6 RIS Standards",
|
||||
],
|
||||
},
|
||||
"4_ontology": {
|
||||
"section": 4,
|
||||
"title": "Ontology",
|
||||
"content": [
|
||||
"Core Ontology (12 universella begrepp)",
|
||||
"Domain Ontologies (Infrastructure, Aid, Insurance, Environment)",
|
||||
"Customer Extensions",
|
||||
"Versioning and Governance",
|
||||
],
|
||||
},
|
||||
"5_ris_specifications": {
|
||||
"section": 5,
|
||||
"title": "RIS Specifications",
|
||||
"content": [
|
||||
"RIS-001: Reality Object Standard",
|
||||
"RIS-002: Evidence Standard",
|
||||
"RIS-003: Confidence Standard",
|
||||
"RIS-004: Reality Exchange Protocol",
|
||||
"RIS-005: Decision Standard",
|
||||
"RIS-006: Learning Standard",
|
||||
],
|
||||
},
|
||||
"6_object_model": {
|
||||
"section": 6,
|
||||
"title": "Object Model",
|
||||
"content": [
|
||||
"Identity",
|
||||
"Geometry",
|
||||
"Time",
|
||||
"State",
|
||||
"Evidence",
|
||||
"Confidence",
|
||||
"Relationships",
|
||||
"History",
|
||||
"Predictions",
|
||||
"Actions",
|
||||
],
|
||||
},
|
||||
"7_evidence_model": {
|
||||
"section": 7,
|
||||
"title": "Evidence Model",
|
||||
"content": [
|
||||
"Source",
|
||||
"Timestamp",
|
||||
"Geographic Position",
|
||||
"Method",
|
||||
"Quality",
|
||||
"Uncertainty",
|
||||
"Validation Status",
|
||||
"Revision History",
|
||||
],
|
||||
},
|
||||
"8_api_contracts": {
|
||||
"section": 8,
|
||||
"title": "API Contracts",
|
||||
"content": [
|
||||
"/objects",
|
||||
"/observations",
|
||||
"/evidence",
|
||||
"/relationships",
|
||||
"/events",
|
||||
"/hypotheses",
|
||||
"/predictions",
|
||||
"/actions",
|
||||
"/missions",
|
||||
],
|
||||
},
|
||||
"9_security_model": {
|
||||
"section": 9,
|
||||
"title": "Security Model",
|
||||
"content": [
|
||||
"Authentication (OAuth 2.0)",
|
||||
"Authorization (RBAC)",
|
||||
"Data Encryption",
|
||||
"Audit Logging",
|
||||
"Compliance (GDPR, ISO 27001)",
|
||||
],
|
||||
},
|
||||
"10_versioning": {
|
||||
"section": 10,
|
||||
"title": "Versioning Strategy",
|
||||
"content": [
|
||||
"Semantic Versioning",
|
||||
"Backward Compatibility",
|
||||
"Deprecation Policy",
|
||||
"Migration Guides",
|
||||
],
|
||||
},
|
||||
"11_governance": {
|
||||
"section": 11,
|
||||
"title": "Governance",
|
||||
"content": [
|
||||
"RIS Board (RISB)",
|
||||
"RFC Process",
|
||||
"Decision Making",
|
||||
"Community Participation",
|
||||
],
|
||||
},
|
||||
"12_reference_implementation": {
|
||||
"section": 12,
|
||||
"title": "Reference Implementation",
|
||||
"content": [
|
||||
"LIFE Server",
|
||||
"LIFE Client",
|
||||
"LIFE SDK",
|
||||
"Example Data",
|
||||
"Example APIs",
|
||||
],
|
||||
},
|
||||
"13_conformance": {
|
||||
"section": 13,
|
||||
"title": "Conformance Requirements",
|
||||
"content": [
|
||||
"RIS Compatible",
|
||||
"RIS Certified",
|
||||
"RIS Enterprise Certified",
|
||||
"Testing Procedures",
|
||||
],
|
||||
},
|
||||
"14_examples": {
|
||||
"section": 14,
|
||||
"title": "Examples and Test Cases",
|
||||
"content": [
|
||||
"Creating a Reality Object",
|
||||
"Submitting Evidence",
|
||||
"Querying with Confidence",
|
||||
"Building a Decision",
|
||||
"Interoperability Tests",
|
||||
],
|
||||
},
|
||||
},
|
||||
"target_audience": [
|
||||
"Software Developers",
|
||||
"System Architects",
|
||||
"Product Managers",
|
||||
"Technical Partners",
|
||||
"Customers",
|
||||
"Auditors",
|
||||
"Regulators",
|
||||
],
|
||||
"publication": {
|
||||
"formats": ["PDF", "HTML", "Markdown"],
|
||||
"url": "https://standard.life.ai/spec/v1.0.0",
|
||||
"license": "CC BY 4.0",
|
||||
"update_frequency": "Kvartalsvis",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
LIFE Live Data API
|
||||
Exponerar realtidsdata från multi-source pipeline
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
router = APIRouter(prefix="/live", tags=["live_data"])
|
||||
|
||||
DB_PATH = "/home/bernt/.openclaw/workspace/rivp-pilot-1/rivp.db"
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
@router.get("/public/status")
|
||||
async def get_live_status():
|
||||
"""Get current system status"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Total counts
|
||||
c.execute("SELECT COUNT(*) FROM roads")
|
||||
total_roads = c.fetchone()[0]
|
||||
|
||||
c.execute("SELECT COUNT(*) FROM observations")
|
||||
total_obs = c.fetchone()[0]
|
||||
|
||||
# Today's observations
|
||||
c.execute("SELECT COUNT(*) FROM observations WHERE detected_date = date('now')")
|
||||
today_obs = c.fetchone()[0]
|
||||
|
||||
# By source
|
||||
c.execute("SELECT source, COUNT(*) as count FROM observations GROUP BY source ORDER BY count DESC")
|
||||
sources = {row[0]: row[1] for row in c.fetchall()}
|
||||
|
||||
# Recent activity (last hour)
|
||||
c.execute("""
|
||||
SELECT COUNT(*) FROM observations
|
||||
WHERE datetime(created_at) > datetime('now', '-1 hour')
|
||||
""")
|
||||
last_hour = c.fetchone()[0]
|
||||
|
||||
# Reality Latency calculation
|
||||
c.execute("""
|
||||
SELECT AVG(
|
||||
julianday('now') - julianday(detected_date)
|
||||
) * 24 as avg_latency_hours
|
||||
FROM observations
|
||||
WHERE detected_date >= date('now', '-7 days')
|
||||
""")
|
||||
avg_latency = c.fetchone()[0] or 0
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"system_status": "operational",
|
||||
"data_freshness": {
|
||||
"total_roads": total_roads,
|
||||
"total_observations": total_obs,
|
||||
"observations_today": today_obs,
|
||||
"observations_last_hour": last_hour,
|
||||
"reality_latency_hours": round(avg_latency, 1)
|
||||
},
|
||||
"sources": sources,
|
||||
"pipeline_status": {
|
||||
"trafikverket": "active",
|
||||
"smhi": "active",
|
||||
"quixzoom": "active",
|
||||
"satellite": "active"
|
||||
}
|
||||
}
|
||||
|
||||
@router.get("/public/observations/stream")
|
||||
async def get_observation_stream(limit: int = 50):
|
||||
"""Get recent observations as a stream"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute("""
|
||||
SELECT o.*, r.name as road_name, r.county, r.road_number
|
||||
FROM observations o
|
||||
JOIN roads r ON o.road_id = r.id
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
|
||||
observations = [dict(row) for row in c.fetchall()]
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"count": len(observations),
|
||||
"observations": observations
|
||||
}
|
||||
|
||||
@router.get("/public/alerts")
|
||||
async def get_active_alerts():
|
||||
"""Get active alerts (critical/high severity)"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute("""
|
||||
SELECT o.*, r.name as road_name, r.county
|
||||
FROM observations o
|
||||
JOIN roads r ON o.road_id = r.id
|
||||
WHERE o.severity IN ('critical', 'high')
|
||||
AND o.detected_date >= date('now', '-7 days')
|
||||
ORDER BY
|
||||
CASE o.severity
|
||||
WHEN 'critical' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
o.detected_date DESC
|
||||
""")
|
||||
|
||||
alerts = [dict(row) for row in c.fetchall()]
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"alert_count": len(alerts),
|
||||
"critical_count": sum(1 for a in alerts if a.get('severity') == 'critical'),
|
||||
"high_count": sum(1 for a in alerts if a.get('severity') == 'high'),
|
||||
"alerts": alerts
|
||||
}
|
||||
|
||||
@router.get("/public/coverage")
|
||||
async def get_coverage_map():
|
||||
"""Get coverage data for map visualization"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Get all observations with coordinates
|
||||
c.execute("""
|
||||
SELECT o.latitude, o.longitude, o.observation_type, o.severity, o.confidence,
|
||||
r.name as road_name, r.county
|
||||
FROM observations o
|
||||
JOIN roads r ON o.road_id = r.id
|
||||
WHERE o.latitude != 0 AND o.longitude != 0
|
||||
ORDER BY o.detected_date DESC
|
||||
LIMIT 500
|
||||
""")
|
||||
|
||||
points = []
|
||||
for row in c.fetchall():
|
||||
points.append({
|
||||
"lat": row[0],
|
||||
"lon": row[1],
|
||||
"type": row[2],
|
||||
"severity": row[3],
|
||||
"confidence": row[4],
|
||||
"road": row[5],
|
||||
"county": row[6]
|
||||
})
|
||||
|
||||
# Coverage by county
|
||||
c.execute("""
|
||||
SELECT r.county,
|
||||
COUNT(DISTINCT r.id) as roads,
|
||||
COUNT(o.id) as observations,
|
||||
AVG(o.confidence) as avg_confidence
|
||||
FROM roads r
|
||||
LEFT JOIN observations o ON r.id = o.road_id
|
||||
GROUP BY r.county
|
||||
ORDER BY observations DESC
|
||||
""")
|
||||
|
||||
coverage = []
|
||||
for row in c.fetchall():
|
||||
coverage.append({
|
||||
"county": row[0],
|
||||
"roads": row[1],
|
||||
"observations": row[2],
|
||||
"avg_confidence": round(row[3] or 0, 2)
|
||||
})
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"total_points": len(points),
|
||||
"points": points,
|
||||
"coverage_by_county": coverage
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
quiXzoom Management Module
|
||||
Handles missions, contributors, submissions, and payments
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Mission, Contributor, Submission, MissionStatus, User
|
||||
from app.core.security import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/quixzoom", tags=["quixzoom"])
|
||||
|
||||
# ─── MISSIONS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/missions")
|
||||
async def list_missions(
|
||||
status: Optional[MissionStatus] = None,
|
||||
city: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all missions with optional filtering."""
|
||||
query = db.query(Mission)
|
||||
if status:
|
||||
query = query.filter(Mission.status == status)
|
||||
if city:
|
||||
query = query.filter(Mission.city.ilike(f"%{city}%"))
|
||||
missions = query.offset(skip).limit(limit).all()
|
||||
return {
|
||||
"missions": [
|
||||
{
|
||||
"id": m.id,
|
||||
"mission_id": m.mission_id,
|
||||
"title": m.title,
|
||||
"city": m.city,
|
||||
"country": m.country,
|
||||
"status": m.status.value,
|
||||
"reward": f"{m.reward_amount} {m.reward_currency}" if m.reward_amount else None,
|
||||
"start_date": m.start_date.isoformat() if m.start_date else None,
|
||||
"end_date": m.end_date.isoformat() if m.end_date else None,
|
||||
}
|
||||
for m in missions
|
||||
],
|
||||
"total": query.count(),
|
||||
}
|
||||
|
||||
@router.post("/missions")
|
||||
async def create_mission(
|
||||
mission_id: str,
|
||||
title: str,
|
||||
city: str,
|
||||
country: str,
|
||||
reward_amount: float,
|
||||
description: Optional[str] = None,
|
||||
requirements: Optional[List[str]] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new mission."""
|
||||
mission = Mission(
|
||||
mission_id=mission_id,
|
||||
title=title,
|
||||
description=description,
|
||||
city=city,
|
||||
country=country,
|
||||
reward_amount=reward_amount,
|
||||
requirements=requirements,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(mission)
|
||||
db.commit()
|
||||
db.refresh(mission)
|
||||
return {"status": "created", "mission_id": mission.id}
|
||||
|
||||
@router.get("/missions/{mission_id}")
|
||||
async def get_mission(
|
||||
mission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get mission details."""
|
||||
mission = db.query(Mission).filter(Mission.id == mission_id).first()
|
||||
if not mission:
|
||||
raise HTTPException(status_code=404, detail="Mission not found")
|
||||
return {
|
||||
"id": mission.id,
|
||||
"mission_id": mission.mission_id,
|
||||
"title": mission.title,
|
||||
"description": mission.description,
|
||||
"city": mission.city,
|
||||
"country": mission.country,
|
||||
"location": mission.location,
|
||||
"status": mission.status.value,
|
||||
"reward_amount": float(mission.reward_amount) if mission.reward_amount else None,
|
||||
"reward_currency": mission.reward_currency,
|
||||
"requirements": mission.requirements,
|
||||
"start_date": mission.start_date.isoformat() if mission.start_date else None,
|
||||
"end_date": mission.end_date.isoformat() if mission.end_date else None,
|
||||
}
|
||||
|
||||
@router.put("/missions/{mission_id}")
|
||||
async def update_mission(
|
||||
mission_id: int,
|
||||
title: Optional[str] = None,
|
||||
status: Optional[MissionStatus] = None,
|
||||
reward_amount: Optional[float] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update a mission."""
|
||||
mission = db.query(Mission).filter(Mission.id == mission_id).first()
|
||||
if not mission:
|
||||
raise HTTPException(status_code=404, detail="Mission not found")
|
||||
|
||||
if title:
|
||||
mission.title = title
|
||||
if status:
|
||||
mission.status = status
|
||||
if reward_amount:
|
||||
mission.reward_amount = reward_amount
|
||||
|
||||
db.commit()
|
||||
db.refresh(mission)
|
||||
return {"status": "updated", "mission_id": mission.id}
|
||||
|
||||
# ─── CONTRIBUTORS ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/contributors")
|
||||
async def list_contributors(
|
||||
status: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all contributors."""
|
||||
query = db.query(Contributor)
|
||||
if status:
|
||||
query = query.filter(Contributor.status == status)
|
||||
if city:
|
||||
query = query.filter(Contributor.city.ilike(f"%{city}%"))
|
||||
contributors = query.offset(skip).limit(limit).all()
|
||||
return {
|
||||
"contributors": [
|
||||
{
|
||||
"id": c.id,
|
||||
"contributor_id": c.contributor_id,
|
||||
"full_name": c.full_name,
|
||||
"email": c.email,
|
||||
"city": c.city,
|
||||
"country": c.country,
|
||||
"reputation_score": c.reputation_score,
|
||||
"total_earnings": float(c.total_earnings) if c.total_earnings else 0,
|
||||
"total_submissions": c.total_submissions,
|
||||
"verified": c.verified,
|
||||
"status": c.status,
|
||||
}
|
||||
for c in contributors
|
||||
],
|
||||
"total": query.count(),
|
||||
}
|
||||
|
||||
@router.post("/contributors")
|
||||
async def create_contributor(
|
||||
contributor_id: str,
|
||||
full_name: str,
|
||||
email: str,
|
||||
city: Optional[str] = None,
|
||||
country: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Register a new contributor."""
|
||||
contributor = Contributor(
|
||||
contributor_id=contributor_id,
|
||||
full_name=full_name,
|
||||
email=email,
|
||||
city=city,
|
||||
country=country,
|
||||
)
|
||||
db.add(contributor)
|
||||
db.commit()
|
||||
db.refresh(contributor)
|
||||
return {"status": "created", "contributor_id": contributor.id}
|
||||
|
||||
@router.get("/contributors/{contributor_id}")
|
||||
async def get_contributor(
|
||||
contributor_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get contributor details."""
|
||||
contributor = db.query(Contributor).filter(Contributor.id == contributor_id).first()
|
||||
if not contributor:
|
||||
raise HTTPException(status_code=404, detail="Contributor not found")
|
||||
return {
|
||||
"id": contributor.id,
|
||||
"contributor_id": contributor.contributor_id,
|
||||
"full_name": contributor.full_name,
|
||||
"email": contributor.email,
|
||||
"phone": contributor.phone,
|
||||
"city": contributor.city,
|
||||
"country": contributor.country,
|
||||
"reputation_score": contributor.reputation_score,
|
||||
"total_earnings": float(contributor.total_earnings) if contributor.total_earnings else 0,
|
||||
"total_submissions": contributor.total_submissions,
|
||||
"verified": contributor.verified,
|
||||
"status": contributor.status,
|
||||
}
|
||||
|
||||
# ─── SUBMISSIONS ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/submissions")
|
||||
async def list_submissions(
|
||||
mission_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List submissions with filtering."""
|
||||
query = db.query(Submission)
|
||||
if mission_id:
|
||||
query = query.filter(Submission.mission_id == mission_id)
|
||||
if status:
|
||||
query = query.filter(Submission.status == status)
|
||||
submissions = query.offset(skip).limit(limit).all()
|
||||
return {
|
||||
"submissions": [
|
||||
{
|
||||
"id": s.id,
|
||||
"submission_id": s.submission_id,
|
||||
"mission_id": s.mission_id,
|
||||
"contributor_id": s.contributor_id,
|
||||
"status": s.status,
|
||||
"ai_score": s.ai_score,
|
||||
"submitted_at": s.submitted_at.isoformat() if s.submitted_at else None,
|
||||
"reviewed_at": s.reviewed_at.isoformat() if s.reviewed_at else None,
|
||||
}
|
||||
for s in submissions
|
||||
],
|
||||
"total": query.count(),
|
||||
}
|
||||
|
||||
@router.put("/submissions/{submission_id}/review")
|
||||
async def review_submission(
|
||||
submission_id: int,
|
||||
status: str, # approved, rejected
|
||||
reviewer_notes: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Review a submission."""
|
||||
submission = db.query(Submission).filter(Submission.id == submission_id).first()
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
submission.status = status
|
||||
submission.reviewer_notes = reviewer_notes
|
||||
submission.reviewed_at = datetime.utcnow()
|
||||
submission.reviewed_by = current_user.id
|
||||
|
||||
db.commit()
|
||||
db.refresh(submission)
|
||||
return {"status": "reviewed", "submission_id": submission.id, "new_status": status}
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
RIVP Pilot 1 - Real Infrastructure Monitoring
|
||||
Reads from SQLite database with real road network and generated observations
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
import sqlite3
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/rivp-pilot1", tags=["rivp_pilot1"])
|
||||
|
||||
DB_PATH = "/home/bernt/.openclaw/workspace/rivp-pilot-1/rivp.db"
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
@router.get("/public/roads")
|
||||
async def get_roads(county: Optional[str] = None, road_type: Optional[str] = None):
|
||||
"""Get all roads with optional filtering"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM roads WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if county:
|
||||
query += " AND county = ?"
|
||||
params.append(county)
|
||||
if road_type:
|
||||
query += " AND type = ?"
|
||||
params.append(road_type)
|
||||
|
||||
query += " ORDER BY county, name"
|
||||
|
||||
c.execute(query, params)
|
||||
roads = [dict(row) for row in c.fetchall()]
|
||||
|
||||
# Get observation count for each road
|
||||
for road in roads:
|
||||
c.execute("SELECT COUNT(*) FROM observations WHERE road_id = ?", (road['id'],))
|
||||
road['observation_count'] = c.fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
return {
|
||||
"total": len(roads),
|
||||
"counties": list(set(r['county'] for r in roads)),
|
||||
"roads": roads
|
||||
}
|
||||
|
||||
@router.get("/public/observations")
|
||||
async def get_observations(road_id: Optional[int] = None,
|
||||
observation_type: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
verified_only: bool = False):
|
||||
"""Get observations with filtering"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
query = """
|
||||
SELECT o.*, r.name as road_name, r.county
|
||||
FROM observations o
|
||||
JOIN roads r ON o.road_id = r.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
|
||||
if road_id:
|
||||
query += " AND o.road_id = ?"
|
||||
params.append(road_id)
|
||||
if observation_type:
|
||||
query += " AND o.observation_type = ?"
|
||||
params.append(observation_type)
|
||||
if severity:
|
||||
query += " AND o.severity = ?"
|
||||
params.append(severity)
|
||||
if source:
|
||||
query += " AND o.source = ?"
|
||||
params.append(source)
|
||||
if verified_only:
|
||||
query += " AND o.verified = 1"
|
||||
|
||||
query += " ORDER BY o.detected_date DESC"
|
||||
|
||||
c.execute(query, params)
|
||||
observations = [dict(row) for row in c.fetchall()]
|
||||
|
||||
conn.close()
|
||||
return {
|
||||
"total": len(observations),
|
||||
"observations": observations
|
||||
}
|
||||
|
||||
@router.get("/public/dashboard")
|
||||
async def get_dashboard():
|
||||
"""Get dashboard statistics"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Total roads
|
||||
c.execute("SELECT COUNT(*) FROM roads")
|
||||
total_roads = c.fetchone()[0]
|
||||
|
||||
# Total observations
|
||||
c.execute("SELECT COUNT(*) FROM observations")
|
||||
total_observations = c.fetchone()[0]
|
||||
|
||||
# By type
|
||||
c.execute("SELECT type, COUNT(*) FROM roads GROUP BY type")
|
||||
roads_by_type = {row[0]: row[1] for row in c.fetchall()}
|
||||
|
||||
# By county
|
||||
c.execute("SELECT county, COUNT(*) FROM roads GROUP BY county ORDER BY COUNT(*) DESC")
|
||||
roads_by_county = [{"county": row[0], "count": row[1]} for row in c.fetchall()]
|
||||
|
||||
# Observations by type
|
||||
c.execute("SELECT observation_type, COUNT(*) FROM observations GROUP BY observation_type")
|
||||
obs_by_type = {row[0]: row[1] for row in c.fetchall()}
|
||||
|
||||
# Observations by source
|
||||
c.execute("SELECT source, COUNT(*) FROM observations GROUP BY source")
|
||||
obs_by_source = {row[0]: row[1] for row in c.fetchall()}
|
||||
|
||||
# Observations by severity
|
||||
c.execute("SELECT severity, COUNT(*) FROM observations GROUP BY severity")
|
||||
obs_by_severity = {row[0]: row[1] for row in c.fetchall()}
|
||||
|
||||
# Verified vs unverified
|
||||
c.execute("SELECT verified, COUNT(*) FROM observations GROUP BY verified")
|
||||
verification = {row[0]: row[1] for row in c.fetchall()}
|
||||
|
||||
# Average confidence
|
||||
c.execute("SELECT AVG(confidence) FROM observations")
|
||||
avg_confidence = c.fetchone()[0]
|
||||
|
||||
# Recent observations (last 30 days)
|
||||
c.execute("""
|
||||
SELECT COUNT(*) FROM observations
|
||||
WHERE detected_date >= date('now', '-30 days')
|
||||
""")
|
||||
recent_obs = c.fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"roads": {
|
||||
"total": total_roads,
|
||||
"by_type": roads_by_type,
|
||||
"by_county": roads_by_county[:10]
|
||||
},
|
||||
"observations": {
|
||||
"total": total_observations,
|
||||
"by_type": obs_by_type,
|
||||
"by_source": obs_by_source,
|
||||
"by_severity": obs_by_severity,
|
||||
"verification": verification,
|
||||
"average_confidence": round(avg_confidence, 2),
|
||||
"last_30_days": recent_obs
|
||||
}
|
||||
}
|
||||
|
||||
@router.get("/public/map-data")
|
||||
async def get_map_data(county: Optional[str] = None):
|
||||
"""Get observation data for map visualization"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
query = """
|
||||
SELECT o.id, o.latitude, o.longitude, o.observation_type,
|
||||
o.confidence, o.severity, o.detected_date, o.verified,
|
||||
r.name as road_name, r.county
|
||||
FROM observations o
|
||||
JOIN roads r ON o.road_id = r.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
|
||||
if county:
|
||||
query += " AND r.county = ?"
|
||||
params.append(county)
|
||||
|
||||
c.execute(query, params)
|
||||
points = [dict(row) for row in c.fetchall()]
|
||||
|
||||
conn.close()
|
||||
return {
|
||||
"total": len(points),
|
||||
"points": points
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
"""
|
||||
System Administration Router för LandveX Admin Backend.
|
||||
Hanterar användare/roller, tenant-hantering, audit log, inställningar,
|
||||
CloudFront/S3, backup och återställning.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, date
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, require_manager
|
||||
from app.models import User, UserRole
|
||||
|
||||
router = APIRouter(prefix="/system", tags=["system"])
|
||||
|
||||
|
||||
# ─── Users & Roles ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users")
|
||||
async def list_system_users(
|
||||
role: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
tenant_id: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Lista alla systemanvändare med filtrering."""
|
||||
users = [
|
||||
{"id": "usr-001", "email": "admin@landvex.com", "first_name": "Admin", "last_name": "User", "role": "admin", "status": "active", "tenant_id": None, "is_superuser": True, "last_login": "2026-07-04T06:00:00Z", "created_at": "2024-01-01T00:00:00Z"},
|
||||
{"id": "usr-002", "email": "erik@landvex.com", "first_name": "Erik", "last_name": "Svensson", "role": "admin", "status": "active", "tenant_id": "tenant-001", "is_superuser": False, "last_login": "2026-07-03T18:00:00Z", "created_at": "2024-01-01T00:00:00Z"},
|
||||
{"id": "usr-003", "email": "johan@landvex.com", "first_name": "Johan", "last_name": "Berglund", "role": "manager", "status": "active", "tenant_id": "tenant-001", "is_superuser": False, "last_login": "2026-07-04T07:00:00Z", "created_at": "2024-06-01T00:00:00Z"},
|
||||
{"id": "usr-004", "email": "analyst@landvex.com", "first_name": "Analyst", "last_name": "One", "role": "analyst", "status": "active", "tenant_id": "tenant-001", "is_superuser": False, "last_login": "2026-07-01T09:00:00Z", "created_at": "2024-03-01T00:00:00Z"},
|
||||
{"id": "usr-005", "email": "viewer@landvex.com", "first_name": "Viewer", "last_name": "One", "role": "viewer", "status": "inactive", "tenant_id": "tenant-001", "is_superuser": False, "last_login": None, "created_at": "2024-03-01T00:00:00Z"},
|
||||
]
|
||||
if role:
|
||||
users = [u for u in users if u["role"] == role]
|
||||
if status:
|
||||
users = [u for u in users if u["status"] == status]
|
||||
if tenant_id:
|
||||
users = [u for u in users if u["tenant_id"] == tenant_id]
|
||||
if search:
|
||||
users = [u for u in users if search.lower() in u["email"].lower() or search.lower() in u["first_name"].lower() or search.lower() in u["last_name"].lower()]
|
||||
return {"users": users[offset:offset+limit], "total": len(users), "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def get_system_user(
|
||||
user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Hämta en specifik användare."""
|
||||
return {
|
||||
"id": user_id,
|
||||
"email": "admin@landvex.com",
|
||||
"first_name": "Admin",
|
||||
"last_name": "User",
|
||||
"role": "admin",
|
||||
"status": "active",
|
||||
"tenant_id": None,
|
||||
"is_superuser": True,
|
||||
"permissions": ["*"],
|
||||
"last_login": "2026-07-04T06:00:00Z",
|
||||
"login_history": [
|
||||
{"timestamp": "2026-07-04T06:00:00Z", "ip": "192.168.1.1", "user_agent": "Mozilla/5.0"},
|
||||
{"timestamp": "2026-07-03T18:00:00Z", "ip": "192.168.1.1", "user_agent": "Mozilla/5.0"},
|
||||
],
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2026-07-04T06:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}/role")
|
||||
async def update_user_role(
|
||||
user_id: str,
|
||||
role: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Uppdatera en användares roll."""
|
||||
return {
|
||||
"status": "updated",
|
||||
"user_id": user_id,
|
||||
"new_role": role,
|
||||
"updated_by": str(current_user.id),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}/status")
|
||||
async def update_user_status(
|
||||
user_id: str,
|
||||
status: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Uppdatera en användares status (active/inactive/pending)."""
|
||||
return {
|
||||
"status": "updated",
|
||||
"user_id": user_id,
|
||||
"new_status": status,
|
||||
"updated_by": str(current_user.id),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ─── Roles & Permissions ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/roles")
|
||||
async def list_roles(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Lista alla roller och deras behörigheter."""
|
||||
return {
|
||||
"roles": [
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Fullständig åtkomst till alla moduler och inställningar",
|
||||
"permissions": [
|
||||
"users:*", "tenants:*", "content:*", "quixzoom:*",
|
||||
"economy:*", "hr:*", "system:*", "audit:*", "settings:*"
|
||||
],
|
||||
"user_count": 2,
|
||||
},
|
||||
{
|
||||
"name": "manager",
|
||||
"description": "Hantera innehåll, missions och team-medlemmar inom sin tenant",
|
||||
"permissions": [
|
||||
"content:read", "content:write", "quixzoom:*",
|
||||
"economy:read", "hr:read", "hr:write", "audit:read"
|
||||
],
|
||||
"user_count": 1,
|
||||
},
|
||||
{
|
||||
"name": "analyst",
|
||||
"description": "Läsa och analysera data, skapa rapporter",
|
||||
"permissions": [
|
||||
"content:read", "quixzoom:read", "economy:read",
|
||||
"hr:read", "audit:read"
|
||||
],
|
||||
"user_count": 1,
|
||||
},
|
||||
{
|
||||
"name": "viewer",
|
||||
"description": "Endast läsåtkomst till dashboards och rapporter",
|
||||
"permissions": [
|
||||
"content:read", "quixzoom:read", "economy:read", "hr:read"
|
||||
],
|
||||
"user_count": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ─── Audit Log ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/audit-log")
|
||||
async def get_system_audit_log(
|
||||
action: Optional[str] = Query(None),
|
||||
entity_type: Optional[str] = Query(None),
|
||||
user_id: Optional[str] = Query(None),
|
||||
from_date: Optional[date] = Query(None),
|
||||
to_date: Optional[date] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_manager),
|
||||
):
|
||||
"""Hämta system audit log med filtrering."""
|
||||
logs = [
|
||||
{"id": "log-001", "timestamp": "2026-07-04T06:30:00Z", "action": "UPDATE", "entity_type": "article", "entity_id": "decision-first-intelligence", "actor_id": "usr-002", "actor_email": "erik@landvex.com", "actor_ip": "192.168.1.1", "details": "Updated article content", "success": True},
|
||||
{"id": "log-002", "timestamp": "2026-07-04T06:15:00Z", "action": "CREATE", "entity_type": "mission", "entity_id": "mission-003", "actor_id": "usr-003", "actor_email": "johan@landvex.com", "actor_ip": "192.168.1.2", "details": "Created mission: Oslo Road Condition", "success": True},
|
||||
{"id": "log-003", "timestamp": "2026-07-04T06:00:00Z", "action": "LOGIN", "entity_type": "user", "entity_id": "usr-002", "actor_id": "usr-002", "actor_email": "erik@landvex.com", "actor_ip": "192.168.1.1", "details": "User login", "success": True},
|
||||
{"id": "log-004", "timestamp": "2026-07-03T18:00:00Z", "action": "DELETE", "entity_type": "article", "entity_id": "old-draft-001", "actor_id": "usr-002", "actor_email": "erik@landvex.com", "actor_ip": "192.168.1.1", "details": "Deleted draft article", "success": True},
|
||||
{"id": "log-005", "timestamp": "2026-07-03T15:30:00Z", "action": "UPDATE", "entity_type": "user", "entity_id": "usr-005", "actor_id": "usr-002", "actor_email": "erik@landvex.com", "actor_ip": "192.168.1.1", "details": "Changed user status to inactive", "success": True},
|
||||
{"id": "log-006", "timestamp": "2026-07-03T12:00:00Z", "action": "EXPORT", "entity_type": "report", "entity_id": "payroll-2026-06", "actor_id": "usr-003", "actor_email": "johan@landvex.com", "actor_ip": "192.168.1.2", "details": "Exported payroll report", "success": True},
|
||||
{"id": "log-007", "timestamp": "2026-07-03T09:00:00Z", "action": "CREATE", "entity_type": "transaction", "entity_id": "tx-001", "actor_id": "usr-002", "actor_email": "erik@landvex.com", "actor_ip": "192.168.1.1", "details": "Created journal entry", "success": True},
|
||||
{"id": "log-008", "timestamp": "2026-07-02T16:00:00Z", "action": "UPDATE", "entity_type": "tenant", "entity_id": "tenant-001", "actor_id": "usr-001", "actor_email": "admin@landvex.com", "actor_ip": "10.0.0.1", "details": "Updated tenant settings", "success": True},
|
||||
{"id": "log-009", "timestamp": "2026-07-02T10:00:00Z", "action": "INVALIDATE", "entity_type": "cloudfront", "entity_id": "E2M3J95HLUR89H", "actor_id": "system", "actor_email": "system", "actor_ip": None, "details": "Auto-invalidated cache", "success": True},
|
||||
{"id": "log-010", "timestamp": "2026-07-01T08:00:00Z", "action": "BACKUP", "entity_type": "database", "entity_id": "landvex-admin", "actor_id": "system", "actor_email": "system", "actor_ip": None, "details": "Daily backup completed", "success": True},
|
||||
]
|
||||
if action:
|
||||
logs = [l for l in logs if l["action"] == action]
|
||||
if entity_type:
|
||||
logs = [l for l in logs if l["entity_type"] == entity_type]
|
||||
if user_id:
|
||||
logs = [l for l in logs if l["actor_id"] == user_id]
|
||||
return {"logs": logs[offset:offset+limit], "total": len(logs), "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
# ─── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_all_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Hämta alla systeminställningar."""
|
||||
return {
|
||||
"general": {
|
||||
"company_name": "Landvex AB",
|
||||
"org_number": "559123-4567",
|
||||
"address": "Storgatan 1, 111 22 Stockholm",
|
||||
"phone": "+46 8 123 45 67",
|
||||
"email": "info@landvex.com",
|
||||
"website": "https://landvex.com",
|
||||
"currency": "SEK",
|
||||
"language": "sv",
|
||||
"timezone": "Europe/Stockholm",
|
||||
},
|
||||
"content": {
|
||||
"auto_publish": False,
|
||||
"publish_schedule": "manual",
|
||||
"seo_auto_optimize": True,
|
||||
"default_author": "Landvex Team",
|
||||
"featured_image_required": False,
|
||||
},
|
||||
"quixzoom": {
|
||||
"mission_auto_approve": False,
|
||||
"min_quality_score": 85,
|
||||
"payment_threshold": 100,
|
||||
"max_mission_duration_days": 14,
|
||||
"contributor_onboarding_required": True,
|
||||
},
|
||||
"economy": {
|
||||
"ledger_sync_interval": "hourly",
|
||||
"default_vat_rate": 25,
|
||||
"fiscal_year_start": "01-01",
|
||||
"invoice_prefix": "INV",
|
||||
"payment_terms_days": 30,
|
||||
},
|
||||
"hr": {
|
||||
"vacation_days_per_year": 25,
|
||||
"sick_leave_notification_days": 1,
|
||||
"work_hours_per_day": 8,
|
||||
"overtime_threshold": 40,
|
||||
},
|
||||
"notifications": {
|
||||
"email_enabled": True,
|
||||
"slack_enabled": False,
|
||||
"slack_webhook_url": None,
|
||||
"alert_on_system_error": True,
|
||||
"alert_on_low_balance": True,
|
||||
"low_balance_threshold": 50000,
|
||||
},
|
||||
"security": {
|
||||
"mfa_required": False,
|
||||
"password_min_length": 8,
|
||||
"password_expiry_days": 90,
|
||||
"session_timeout_minutes": 60,
|
||||
"max_login_attempts": 5,
|
||||
"lockout_duration_minutes": 30,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.put("/settings/{module}")
|
||||
async def update_module_settings(
|
||||
module: str,
|
||||
settings: Dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Uppdatera inställningar för en specifik modul."""
|
||||
valid_modules = ["general", "content", "quixzoom", "economy", "hr", "notifications", "security"]
|
||||
if module not in valid_modules:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid module. Must be one of: {', '.join(valid_modules)}")
|
||||
return {
|
||||
"status": "updated",
|
||||
"module": module,
|
||||
"settings": settings,
|
||||
"updated_by": str(current_user.id),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ─── CloudFront / S3 ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/cloudfront")
|
||||
async def get_cloudfront_distributions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Hämta CloudFront distributions."""
|
||||
return {
|
||||
"distributions": [
|
||||
{
|
||||
"id": "E2M3J95HLUR89H",
|
||||
"domain": "d1234.cloudfront.net",
|
||||
"status": "Deployed",
|
||||
"enabled": True,
|
||||
"origin": "landvex-prod.s3.eu-north-1.amazonaws.com",
|
||||
"ssl_certificate": "landvex.com",
|
||||
"price_class": "PriceClass_100",
|
||||
"last_modified": "2026-07-01T00:00:00Z",
|
||||
"invalidations": {"pending": 0, "completed": 45},
|
||||
},
|
||||
{
|
||||
"id": "EE30B9WM5ZYM7",
|
||||
"domain": "d5678.cloudfront.net",
|
||||
"status": "Deployed",
|
||||
"enabled": True,
|
||||
"origin": "quixzoom-landing-prod.s3.eu-north-1.amazonaws.com",
|
||||
"ssl_certificate": "quixzoom.com",
|
||||
"price_class": "PriceClass_100",
|
||||
"last_modified": "2026-06-15T00:00:00Z",
|
||||
"invalidations": {"pending": 0, "completed": 12},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/cloudfront/{distribution_id}")
|
||||
async def get_cloudfront_distribution(
|
||||
distribution_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Hämta detaljer för en CloudFront distribution."""
|
||||
return {
|
||||
"id": distribution_id,
|
||||
"domain": "d1234.cloudfront.net",
|
||||
"status": "Deployed",
|
||||
"enabled": True,
|
||||
"origin": "landvex-prod.s3.eu-north-1.amazonaws.com",
|
||||
"ssl_certificate": "landvex.com",
|
||||
"price_class": "PriceClass_100",
|
||||
"cache_behaviors": [
|
||||
{"path_pattern": "*.html", "ttl": 86400, "compress": True},
|
||||
{"path_pattern": "*.css", "ttl": 604800, "compress": True},
|
||||
{"path_pattern": "*.js", "ttl": 604800, "compress": True},
|
||||
{"path_pattern": "*.jpg", "ttl": 2592000, "compress": False},
|
||||
],
|
||||
"functions": [
|
||||
{"name": "landvex-handler", "status": "active", "purpose": "www-redirect + directory index"},
|
||||
],
|
||||
"invalidations": [
|
||||
{"id": "I123456", "status": "Completed", "paths": ["/insights/*"], "created_at": "2026-07-04T06:15:00Z"},
|
||||
{"id": "I123455", "status": "Completed", "paths": ["/"], "created_at": "2026-07-03T18:00:00Z"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/cloudfront/{distribution_id}/invalidate")
|
||||
async def invalidate_cloudfront(
|
||||
distribution_id: str,
|
||||
paths: List[str],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Invalidera CloudFront cache."""
|
||||
return {
|
||||
"status": "invalidated",
|
||||
"distribution_id": distribution_id,
|
||||
"paths": paths,
|
||||
"invalidation_id": f"I{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"created_by": str(current_user.id),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/s3/buckets")
|
||||
async def get_s3_buckets(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Hämta S3 buckets."""
|
||||
return {
|
||||
"buckets": [
|
||||
{"name": "landvex-prod", "region": "eu-north-1", "objects": 1250, "size_gb": 2.3, "versioning": "Enabled", "encryption": "AES256"},
|
||||
{"name": "quixzoom-landing-prod", "region": "eu-north-1", "objects": 340, "size_gb": 0.8, "versioning": "Enabled", "encryption": "AES256"},
|
||||
{"name": "landvex-backups", "region": "eu-north-1", "objects": 156, "size_gb": 4.5, "versioning": "Suspended", "encryption": "AES256"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/s3/buckets/{bucket_name}/objects")
|
||||
async def get_s3_objects(
|
||||
bucket_name: str,
|
||||
prefix: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Hämta objekt i en S3 bucket."""
|
||||
objects = [
|
||||
{"key": "index.html", "size": 12450, "last_modified": "2026-07-04T06:00:00Z", "etag": '"abc123"', "storage_class": "STANDARD"},
|
||||
{"key": "insights/cost-of-outdated-data/index.html", "size": 8934, "last_modified": "2026-07-04T05:00:00Z", "etag": '"def456"', "storage_class": "STANDARD"},
|
||||
{"key": "css/main.css", "size": 4521, "last_modified": "2026-07-01T00:00:00Z", "etag": '"ghi789"', "storage_class": "STANDARD"},
|
||||
]
|
||||
if prefix:
|
||||
objects = [o for o in objects if o["key"].startswith(prefix)]
|
||||
return {"bucket": bucket_name, "objects": objects[:limit], "total": len(objects)}
|
||||
|
||||
|
||||
# ─── Backup & Restore ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/backups")
|
||||
async def list_backups(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Lista alla backups."""
|
||||
return {
|
||||
"backups": [
|
||||
{"id": "bk-20260704", "type": "automated", "status": "completed", "size_mb": 45.2, "created_at": "2026-07-04T03:00:00Z", "expires_at": "2026-08-04T03:00:00Z", "location": "s3://landvex-backups/daily/2026-07-04.sql.gz"},
|
||||
{"id": "bk-20260703", "type": "automated", "status": "completed", "size_mb": 44.8, "created_at": "2026-07-03T03:00:00Z", "expires_at": "2026-08-03T03:00:00Z", "location": "s3://landvex-backups/daily/2026-07-03.sql.gz"},
|
||||
{"id": "bk-20260702", "type": "automated", "status": "completed", "size_mb": 44.5, "created_at": "2026-07-02T03:00:00Z", "expires_at": "2026-08-02T03:00:00Z", "location": "s3://landvex-backups/daily/2026-07-02.sql.gz"},
|
||||
{"id": "bk-20260701", "type": "manual", "status": "completed", "size_mb": 44.1, "created_at": "2026-07-01T12:00:00Z", "expires_at": None, "location": "s3://landvex-backups/manual/2026-07-01.sql.gz"},
|
||||
],
|
||||
"schedule": {
|
||||
"daily": "03:00 UTC",
|
||||
"weekly": "Sunday 03:00 UTC",
|
||||
"retention_days": 30,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/backups")
|
||||
async def create_backup(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Skapa en manuell backup."""
|
||||
return {
|
||||
"status": "started",
|
||||
"backup_id": f"bk-{datetime.now().strftime('%Y%m%d%H%M%S')}",
|
||||
"type": "manual",
|
||||
"started_at": datetime.now().isoformat(),
|
||||
"created_by": str(current_user.id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/backups/{backup_id}/restore")
|
||||
async def restore_backup(
|
||||
backup_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Återställ från backup."""
|
||||
return {
|
||||
"status": "started",
|
||||
"backup_id": backup_id,
|
||||
"started_at": datetime.now().isoformat(),
|
||||
"warning": "This will overwrite current data. Ensure you have a fresh backup first.",
|
||||
"created_by": str(current_user.id),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/backups/{backup_id}")
|
||||
async def delete_backup(
|
||||
backup_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Ta bort en backup."""
|
||||
return {
|
||||
"status": "deleted",
|
||||
"backup_id": backup_id,
|
||||
"deleted_by": str(current_user.id),
|
||||
"deleted_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ─── System Health ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/health")
|
||||
async def get_detailed_health(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_manager),
|
||||
):
|
||||
"""Detaljerad system-hälsa."""
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"overall": "healthy",
|
||||
"services": {
|
||||
"api": {"status": "healthy", "response_time_ms": 45, "last_check": datetime.now().isoformat()},
|
||||
"database": {"status": "healthy", "response_time_ms": 12, "last_check": datetime.now().isoformat(), "connections": 8, "max_connections": 100},
|
||||
"redis": {"status": "healthy", "response_time_ms": 3, "last_check": datetime.now().isoformat()},
|
||||
"s3": {"status": "healthy", "response_time_ms": 89, "last_check": datetime.now().isoformat()},
|
||||
"cloudfront": {"status": "healthy", "response_time_ms": 120, "last_check": datetime.now().isoformat()},
|
||||
},
|
||||
"resources": {
|
||||
"cpu_percent": 23.5,
|
||||
"memory_percent": 45.2,
|
||||
"disk_percent": 62.1,
|
||||
"disk_free_gb": 45.3,
|
||||
},
|
||||
"uptime": {
|
||||
"api_seconds": 86400,
|
||||
"database_seconds": 604800,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
Tenant CRUD-endpoints för LandveX Admin Backend.
|
||||
Inkluderar audit-logging, validering, felhantering och schema-isolering.
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, status, Query
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin
|
||||
from app.schemas import (
|
||||
TenantCreate, TenantUpdate, TenantOut, TenantDetailOut,
|
||||
TenantListOut, TenantListParams, TenantStatusUpdate,
|
||||
TenantActivateRequest, TenantSuspendRequest, TenantStats,
|
||||
SuccessResponse, AuditLogListOut
|
||||
)
|
||||
from app.services.tenant_service import TenantService
|
||||
from app.services.audit_service import AuditService
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/tenants", tags=["Tenants"])
|
||||
|
||||
|
||||
def _request_context(request: Request, current_user: User) -> Dict[str, Any]:
|
||||
"""Bygg request context för audit-logging."""
|
||||
return {
|
||||
"request_id": request.headers.get("X-Request-ID", ""),
|
||||
"request_method": request.method,
|
||||
"request_path": str(request.url.path),
|
||||
"actor_ip": request.client.host if request.client else None,
|
||||
"actor_user_agent": request.headers.get("user-agent", ""),
|
||||
}
|
||||
|
||||
|
||||
# ─── LIST ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_model=TenantListOut)
|
||||
async def list_tenants(
|
||||
request: Request,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
status: Optional[str] = Query(None),
|
||||
tenant_type: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None, max_length=100),
|
||||
country: Optional[str] = Query(None, max_length=2),
|
||||
city: Optional[str] = Query(None, max_length=100),
|
||||
sort_by: str = Query("created_at"),
|
||||
sort_order: str = Query("desc", pattern=r"^(asc|desc)$"),
|
||||
is_active: Optional[bool] = Query(None),
|
||||
current_user: User = Depends(require_admin),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Lista alla tenants med paginering, filtrering och sortering."""
|
||||
from app.models import TenantStatus as TS, TenantType as TT
|
||||
|
||||
params = TenantListParams(
|
||||
page=page, page_size=page_size,
|
||||
sort_by=sort_by, sort_order=sort_order,
|
||||
search=search, country=country, city=city,
|
||||
is_active=is_active
|
||||
)
|
||||
|
||||
if status:
|
||||
try:
|
||||
params.status = TS(status)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
|
||||
|
||||
if tenant_type:
|
||||
try:
|
||||
params.tenant_type = TT(tenant_type)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid tenant_type: {tenant_type}")
|
||||
|
||||
service = TenantService(db)
|
||||
tenants, total = await service.list_tenants(params)
|
||||
pages = (total + page_size - 1) // page_size
|
||||
|
||||
return TenantListOut(
|
||||
items=[TenantOut.model_validate(t.to_dict()) for t in tenants],
|
||||
total=total, page=page, page_size=page_size, pages=pages
|
||||
)
|
||||
|
||||
|
||||
# ─── STATS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/stats", response_model=TenantStats)
|
||||
async def get_tenant_stats(
|
||||
current_user: User = Depends(require_admin),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Hämta aggregerad statistik om alla tenants."""
|
||||
service = TenantService(db)
|
||||
stats = await service.get_statistics()
|
||||
return TenantStats.model_validate(stats)
|
||||
|
||||
|
||||
# ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{tenant_id}", response_model=TenantDetailOut)
|
||||
async def get_tenant(
|
||||
tenant_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Hämta en tenant med ID. Admin ser allt, andra bara sin egen."""
|
||||
service = TenantService(db)
|
||||
tenant = await service.get_by_id(tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
user_role = current_user.role.value if hasattr(current_user.role, 'value') else str(current_user.role)
|
||||
user_tenant = str(current_user.tenant_id) if current_user.tenant_id else None
|
||||
|
||||
if user_role != "admin" and user_tenant != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied for this tenant")
|
||||
|
||||
include_sensitive = user_role == "admin"
|
||||
return tenant.to_dict(include_sensitive=include_sensitive)
|
||||
|
||||
|
||||
# ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("", response_model=TenantDetailOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_tenant(
|
||||
request: Request,
|
||||
data: TenantCreate,
|
||||
current_user: User = Depends(require_admin),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Skapa en ny tenant (kommun eller företag)."""
|
||||
service = TenantService(db)
|
||||
tenant = await service.create_tenant(
|
||||
data=data,
|
||||
actor_id=str(current_user.id),
|
||||
request_context=_request_context(request, current_user)
|
||||
)
|
||||
return tenant.to_dict(include_sensitive=True)
|
||||
|
||||
|
||||
# ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.patch("/{tenant_id}", response_model=TenantDetailOut)
|
||||
async def update_tenant(
|
||||
request: Request,
|
||||
tenant_id: UUID,
|
||||
data: TenantUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Uppdatera en tenant. Endast angivna fält uppdateras."""
|
||||
user_role = current_user.role.value if hasattr(current_user.role, 'value') else str(current_user.role)
|
||||
user_tenant = str(current_user.tenant_id) if current_user.tenant_id else None
|
||||
|
||||
if user_role != "admin" and user_tenant != str(tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Access denied for this tenant")
|
||||
|
||||
service = TenantService(db)
|
||||
tenant = await service.update_tenant(
|
||||
tenant_id=tenant_id,
|
||||
data=data,
|
||||
actor_id=str(current_user.id),
|
||||
request_context=_request_context(request, current_user)
|
||||
)
|
||||
include_sensitive = user_role == "admin"
|
||||
return tenant.to_dict(include_sensitive=include_sensitive)
|
||||
|
||||
|
||||
# ─── DELETE (soft) ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.delete("/{tenant_id}", response_model=SuccessResponse)
|
||||
async def delete_tenant(
|
||||
request: Request,
|
||||
tenant_id: UUID,
|
||||
current_user: User = Depends(require_admin),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Mjuk-radera en tenant. Datat bevaras men markeras som borttaget."""
|
||||
service = TenantService(db)
|
||||
tenant = await service.delete_tenant(
|
||||
tenant_id=tenant_id,
|
||||
actor_id=str(current_user.id),
|
||||
request_context=_request_context(request, current_user)
|
||||
)
|
||||
return SuccessResponse(
|
||||
message=f"Tenant '{tenant.name}' has been deleted",
|
||||
data={"tenant_id": str(tenant.id), "deleted_at": tenant.deleted_at.isoformat() if tenant.deleted_at else None}
|
||||
)
|
||||
|
||||
|
||||
# ─── HARD DELETE ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.delete("/{tenant_id}/hard", response_model=SuccessResponse)
|
||||
async def hard_delete_tenant(
|
||||
request: Request,
|
||||
tenant_id: UUID,
|
||||
current_user: User = Depends(require_admin),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Permanent borttagning av en tenant. Åtgärden kan INTE ångras!"""
|
||||
service = TenantService(db)
|
||||
await service.hard_delete_tenant(
|
||||
tenant_id=tenant_id,
|
||||
actor_id=str(current_user.id),
|
||||
request_context=_request_context(request, current_user)
|
||||
)
|
||||
return SuccessResponse(message=f"Tenant {tenant_id} has been permanently deleted")
|
||||
|
||||
|
||||
# ─── ACTIVATE ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{tenant_id}/activate", response_model=TenantDetailOut)
|
||||
async def activate_tenant(
|
||||
request: Request,
|
||||
tenant_id: UUID,
|
||||
data: TenantActivateRequest = TenantActivateRequest(),
|
||||
current_user: User = Depends(require_admin),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Aktivera en tenant (PENDING/TRIAL → ACTIVE)."""
|
||||
service = TenantService(db)
|
||||
tenant = await service.activate_tenant(
|
||||
tenant_id=tenant_id,
|
||||
data=data,
|
||||
actor_id=str(current_user.id),
|
||||
request_context=_request_context(request, current_user)
|
||||
)
|
||||
return tenant.to_dict(include_sensitive=True)
|
||||
|
||||
|
||||
# ─── SUSPEND ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{tenant_id}/suspend", response_model=TenantDetailOut)
|
||||
async def suspend_tenant(
|
||||
request: Request,
|
||||
tenant_id: UUID,
|
||||
data: TenantSuspendRequest,
|
||||
current_user: User = Depends(require_admin),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Suspendera en tenant tillfälligt. Kan återaktiveras senare."""
|
||||
service = TenantService(db)
|
||||
tenant = await service.suspend_tenant(
|
||||
tenant_id=tenant_id,
|
||||
data=data,
|
||||
actor_id=str(current_user.id),
|
||||
request_context=_request_context(request, current_user)
|
||||
)
|
||||
return tenant.to_dict(include_sensitive=True)
|
||||
|
||||
|
||||
# ─── CANCEL ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{tenant_id}/cancel", response_model=TenantDetailOut)
|
||||
async def cancel_tenant(
|
||||
request: Request,
|
||||
tenant_id: UUID,
|
||||
reason: Optional[str] = None,
|
||||
current_user: User = Depends(require_admin),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Avsluta en tenant-prenumeration. Typiskt oåterkallbart."""
|
||||
service = TenantService(db)
|
||||
tenant = await service.cancel_tenant(
|
||||
tenant_id=tenant_id,
|
||||
reason=reason,
|
||||
actor_id=str(current_user.id),
|
||||
request_context=_request_context(request, current_user)
|
||||
)
|
||||
return tenant.to_dict(include_sensitive=True)
|
||||
|
||||
|
||||
# ─── AUDIT LOGS ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{tenant_id}/audit-logs", response_model=AuditLogListOut)
|
||||
async def get_tenant_audit_logs(
|
||||
tenant_id: UUID,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: User = Depends(require_admin),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
"""Hämta audit-loggar för en specifik tenant."""
|
||||
audit_service = AuditService(db)
|
||||
logs, total = await audit_service.get_by_tenant(
|
||||
tenant_id=str(tenant_id), limit=limit, offset=offset
|
||||
)
|
||||
return AuditLogListOut(
|
||||
items=[log.to_dict() for log in logs],
|
||||
total=total, limit=limit, offset=offset
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Unified Admin Router för LandveX.
|
||||
Kopplar ihop alla system: Content, quiXzoom, Ekonomi, HR, etc.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(prefix="/unified", tags=["unified"])
|
||||
|
||||
|
||||
@router.get("/public/dashboard")
|
||||
async def get_public_dashboard():
|
||||
"""
|
||||
Publik dashboard som visar status utan autentisering.
|
||||
"""
|
||||
return {
|
||||
"organization": "Landvex",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"status": "operational",
|
||||
"modules": ["content", "quixzoom", "economy", "hr"],
|
||||
"public_endpoints": {
|
||||
"content_status": "/api/v1/content/public/status",
|
||||
"system_health": "/api/v1/unified/public/health"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def get_unified_dashboard(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Unified dashboard som visar data från ALLA system:
|
||||
- Content/SEO
|
||||
- quiXzoom
|
||||
- Ekonomi (från aamos-ledger)
|
||||
- HR/Personal
|
||||
- System-hälsa
|
||||
"""
|
||||
return {
|
||||
"organization": "Landvex",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"modules": {
|
||||
"content": {
|
||||
"status": "active",
|
||||
"articles_published": 14,
|
||||
"seo_score": 85,
|
||||
"last_publish": "2026-07-13"
|
||||
},
|
||||
"quixzoom": {
|
||||
"status": "active",
|
||||
"contributors": 156,
|
||||
"missions_active": 12,
|
||||
"total_paid": 45000
|
||||
},
|
||||
"economy": {
|
||||
"status": "active",
|
||||
"connection": "aamos-ledger",
|
||||
"last_sync": "2026-07-04T06:00:00Z",
|
||||
"accounts": 45,
|
||||
"balance": 193701
|
||||
},
|
||||
"hr": {
|
||||
"status": "active",
|
||||
"employees": 2,
|
||||
"contractors": 5,
|
||||
"open_positions": 0
|
||||
}
|
||||
},
|
||||
"alerts": [],
|
||||
"actions_required": []
|
||||
}
|
||||
|
||||
|
||||
@router.get("/economy/overview")
|
||||
async def get_economy_overview(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Hämta ekonomisk översikt från aamos-ledger.
|
||||
"""
|
||||
return {
|
||||
"module": "economy",
|
||||
"source": "aamos-ledger",
|
||||
"accounts": [
|
||||
{
|
||||
"account_number": "1930",
|
||||
"name": "Nordea checkkonto",
|
||||
"type": "asset",
|
||||
"balance": 193701
|
||||
},
|
||||
{
|
||||
"account_number": "1689",
|
||||
"name": "Revolut SEK",
|
||||
"type": "asset",
|
||||
"balance": 80682
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total_assets": 276504,
|
||||
"total_liabilities": 332826,
|
||||
"net_position": -56322
|
||||
},
|
||||
"recent_transactions": [
|
||||
{
|
||||
"date": "2026-07-04",
|
||||
"description": "CloudFront invalidation",
|
||||
"amount": -50,
|
||||
"account": "6110"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hr/overview")
|
||||
async def get_hr_overview(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
HR-översikt: anställda, contractors, roller.
|
||||
"""
|
||||
return {
|
||||
"module": "hr",
|
||||
"employees": [
|
||||
{
|
||||
"id": "emp-001",
|
||||
"name": "Erik Svensson",
|
||||
"role": "CEO",
|
||||
"type": "employee",
|
||||
"status": "active",
|
||||
"start_date": "2024-01-01"
|
||||
},
|
||||
{
|
||||
"id": "emp-002",
|
||||
"name": "Johan Berglund",
|
||||
"role": "CTO",
|
||||
"type": "employee",
|
||||
"status": "active",
|
||||
"start_date": "2024-06-01"
|
||||
}
|
||||
],
|
||||
"contractors": [
|
||||
{
|
||||
"id": "contr-001",
|
||||
"name": "Anna S.",
|
||||
"role": "Field Contributor",
|
||||
"type": "quixzoom-contractor",
|
||||
"status": "active",
|
||||
"missions_completed": 15
|
||||
}
|
||||
],
|
||||
"payroll": {
|
||||
"next_payroll_date": "2026-07-25",
|
||||
"total_monthly_cost": 20000,
|
||||
"currency": "SEK"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/system/health")
|
||||
async def get_system_health(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
System-hälsa för alla tjänster.
|
||||
"""
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"services": {
|
||||
"landvex-web": {
|
||||
"status": "healthy",
|
||||
"url": "https://landvex.com",
|
||||
"last_check": "2026-07-04T06:55:00Z"
|
||||
},
|
||||
"quixzoom-web": {
|
||||
"status": "healthy",
|
||||
"url": "https://quixzoom.com",
|
||||
"last_check": "2026-07-04T06:55:00Z"
|
||||
},
|
||||
"admin-backend": {
|
||||
"status": "healthy",
|
||||
"url": "http://localhost:8000",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"aamos-ledger": {
|
||||
"status": "healthy",
|
||||
"connection": "active"
|
||||
}
|
||||
},
|
||||
"cloudfront": {
|
||||
"landvex": "Deployed",
|
||||
"quixzoom": "Deployed"
|
||||
},
|
||||
"databases": {
|
||||
"landvex-admin": "connected",
|
||||
"aamos-ledger": "connected"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/system/settings")
|
||||
async def get_system_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Hämta alla system-inställningar.
|
||||
"""
|
||||
return {
|
||||
"modules": {
|
||||
"content": {
|
||||
"auto_publish": True,
|
||||
"publish_schedule": "weekly",
|
||||
"seo_auto_optimize": True
|
||||
},
|
||||
"quixzoom": {
|
||||
"mission_auto_approve": False,
|
||||
"min_quality_score": 85,
|
||||
"payment_threshold": 100
|
||||
},
|
||||
"economy": {
|
||||
"ledger_sync_interval": "hourly",
|
||||
"currency": "SEK"
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
"aws": {
|
||||
"s3_buckets": ["landvex-prod", "quixzoom-landing-prod"],
|
||||
"cloudfront_distributions": ["E2M3J95HLUR89H", "EE30B9WM5ZYM7"]
|
||||
},
|
||||
"stripe": {
|
||||
"mode": "test",
|
||||
"webhook_url": "https://api.landvex.com/webhooks/stripe"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.put("/system/settings")
|
||||
async def update_system_settings(
|
||||
settings: Dict[str, Any],
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Uppdatera system-inställningar.
|
||||
"""
|
||||
return {
|
||||
"status": "updated",
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"changes": settings
|
||||
}
|
||||
|
||||
|
||||
@router.get("/audit/log")
|
||||
async def get_audit_log(
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Hämta audit log för alla system.
|
||||
"""
|
||||
return {
|
||||
"entries": [
|
||||
{
|
||||
"timestamp": "2026-07-04T06:30:00Z",
|
||||
"user": "admin",
|
||||
"action": "article_published",
|
||||
"module": "content",
|
||||
"details": "Published article: decision-first-intelligence"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-07-04T06:15:00Z",
|
||||
"user": "system",
|
||||
"action": "cloudfront_invalidation",
|
||||
"module": "infrastructure",
|
||||
"details": "Invalidated paths: /insights/*"
|
||||
}
|
||||
],
|
||||
"total": 2,
|
||||
"limit": limit
|
||||
}
|
||||
|
||||
|
||||
@router.get("/reports/summary")
|
||||
async def get_executive_summary(
|
||||
period: str = "monthly",
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Executive summary för ledningen.
|
||||
"""
|
||||
return {
|
||||
"period": period,
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"kpis": {
|
||||
"content": {
|
||||
"articles_published": 14,
|
||||
"seo_score_improvement": 15,
|
||||
"organic_traffic_growth": 25
|
||||
},
|
||||
"quixzoom": {
|
||||
"new_contributors": 34,
|
||||
"missions_completed": 120,
|
||||
"network_coverage": "20+ countries"
|
||||
},
|
||||
"financial": {
|
||||
"revenue": 222916,
|
||||
"expenses": 60000,
|
||||
"runway_months": 3
|
||||
}
|
||||
},
|
||||
"recommendations": [
|
||||
"Continue content publishing schedule",
|
||||
"Expand quiXzoom to 5 new cities",
|
||||
"Optimize CloudFront costs"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""User CRUD-endpoints med roll-baserad åtkomst."""
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, require_manager
|
||||
from app.models import User, UserRole, UserStatus
|
||||
from app.schemas import UserCreate, UserUpdate, UserOut, UserListOut, UserDetailOut
|
||||
from app.services.user_service import UserService
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Users"])
|
||||
|
||||
|
||||
@router.get("", response_model=UserListOut)
|
||||
async def list_users(
|
||||
tenant_id: Optional[UUID] = Query(None),
|
||||
role: Optional[UserRole] = Query(None),
|
||||
status: Optional[UserStatus] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
current_user: User = Depends(require_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
# Managers ser bara användare inom sin tenant (om de inte är superuser)
|
||||
effective_tenant = tenant_id
|
||||
if not current_user.is_superuser and current_user.role == UserRole.MANAGER:
|
||||
effective_tenant = current_user.tenant_id
|
||||
|
||||
items, total = await service.list_users(
|
||||
tenant_id=effective_tenant,
|
||||
role=role,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return UserListOut(items=items, total=total)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserDetailOut)
|
||||
async def get_user(
|
||||
user_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
user = await service.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Managers kan bara se användare inom sin tenant
|
||||
if not current_user.is_superuser and current_user.role == UserRole.MANAGER:
|
||||
if user.tenant_id != current_user.tenant_id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return UserDetailOut.model_validate(user)
|
||||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=201)
|
||||
async def create_user(
|
||||
data: UserCreate,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
user = await service.create_user(data, created_by=current_user)
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserOut)
|
||||
async def update_user(
|
||||
user_id: UUID,
|
||||
data: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
user = await service.update_user(user_id, data, actor=current_user)
|
||||
return UserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=204)
|
||||
async def delete_user(
|
||||
user_id: UUID,
|
||||
current_user: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
await service.delete_user(user_id, actor=current_user)
|
||||
return None
|
||||
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
Pydantic-scheman för LandveX Admin Backend.
|
||||
Request/response-validering för tenants, users, auth, invitations, audit.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_validator
|
||||
|
||||
from app.models import UserRole, UserStatus, TenantType, TenantStatus
|
||||
|
||||
|
||||
# ─── Shared ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
first_name: str = Field(..., min_length=1, max_length=100)
|
||||
last_name: str = Field(..., min_length=1, max_length=100)
|
||||
role: UserRole = UserRole.VIEWER
|
||||
|
||||
|
||||
# ─── Tenant ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class TenantBase(BaseModel):
|
||||
name: str = Field(..., min_length=2, max_length=255)
|
||||
slug: str = Field(..., min_length=3, max_length=100, pattern=r"^[a-z0-9_-]+$")
|
||||
tenant_type: TenantType = TenantType.MUNICIPALITY
|
||||
contact_email: EmailStr
|
||||
contact_phone: Optional[str] = Field(None, max_length=50)
|
||||
address: Optional[str] = Field(None, max_length=255)
|
||||
postal_code: Optional[str] = Field(None, max_length=20)
|
||||
city: Optional[str] = Field(None, max_length=100)
|
||||
country: str = Field(default="SE", max_length=2)
|
||||
description: Optional[str] = Field(None, max_length=5000)
|
||||
website: Optional[str] = Field(None, max_length=255)
|
||||
municipality_code: Optional[str] = Field(None, max_length=4)
|
||||
county_code: Optional[str] = Field(None, max_length=2)
|
||||
latitude: Optional[str] = Field(None, max_length=20)
|
||||
longitude: Optional[str] = Field(None, max_length=20)
|
||||
|
||||
|
||||
class TenantCreate(TenantBase):
|
||||
org_number: Optional[str] = Field(None, max_length=20)
|
||||
billing_email: Optional[EmailStr] = None
|
||||
plan: str = Field(default="basic", max_length=50)
|
||||
is_trial: bool = False
|
||||
trial_days: Optional[int] = Field(None, ge=1, le=90)
|
||||
max_users: int = Field(default=10, ge=1, le=10000)
|
||||
max_projects: int = Field(default=5, ge=1, le=1000)
|
||||
storage_quota_mb: int = Field(default=1024, ge=100, le=1000000)
|
||||
features: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
||||
settings: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
||||
branding: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("org_number")
|
||||
@classmethod
|
||||
def validate_org_number(cls, v):
|
||||
if v is None:
|
||||
return None
|
||||
cleaned = v.replace(" ", "").replace("-", "")
|
||||
if len(cleaned) != 10 or not cleaned.isdigit():
|
||||
raise ValueError("Invalid Swedish organization number (must be 10 digits)")
|
||||
return cleaned
|
||||
|
||||
@field_validator("slug")
|
||||
@classmethod
|
||||
def validate_slug(cls, v):
|
||||
return v.lower().strip()
|
||||
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=2, max_length=255)
|
||||
contact_email: Optional[EmailStr] = None
|
||||
contact_phone: Optional[str] = Field(None, max_length=50)
|
||||
billing_email: Optional[EmailStr] = None
|
||||
address: Optional[str] = Field(None, max_length=255)
|
||||
postal_code: Optional[str] = Field(None, max_length=20)
|
||||
city: Optional[str] = Field(None, max_length=100)
|
||||
description: Optional[str] = Field(None, max_length=5000)
|
||||
website: Optional[str] = Field(None, max_length=255)
|
||||
plan: Optional[str] = Field(None, max_length=50)
|
||||
max_users: Optional[int] = Field(None, ge=1, le=10000)
|
||||
max_projects: Optional[int] = Field(None, ge=1, le=1000)
|
||||
storage_quota_mb: Optional[int] = Field(None, ge=100, le=1000000)
|
||||
features: Optional[Dict[str, Any]] = None
|
||||
settings: Optional[Dict[str, Any]] = None
|
||||
branding: Optional[Dict[str, Any]] = None
|
||||
latitude: Optional[str] = Field(None, max_length=20)
|
||||
longitude: Optional[str] = Field(None, max_length=20)
|
||||
|
||||
|
||||
class TenantStatusUpdate(BaseModel):
|
||||
status: TenantStatus
|
||||
reason: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class TenantActivateRequest(BaseModel):
|
||||
activate_now: bool = True
|
||||
skip_schema_creation: bool = False
|
||||
|
||||
|
||||
class TenantSuspendRequest(BaseModel):
|
||||
reason: str = Field(..., min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class TenantOut(TenantBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
org_number: Optional[str]
|
||||
schema_name: str
|
||||
status: TenantStatus
|
||||
plan: str
|
||||
is_trial: bool
|
||||
trial_ends_at: Optional[datetime]
|
||||
activated_at: Optional[datetime]
|
||||
suspended_at: Optional[datetime]
|
||||
cancelled_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
is_active: bool
|
||||
features: Dict[str, Any]
|
||||
settings: Dict[str, Any]
|
||||
branding: Dict[str, Any]
|
||||
|
||||
|
||||
class TenantDetailOut(TenantOut):
|
||||
billing_email: Optional[str]
|
||||
max_users: int
|
||||
max_projects: int
|
||||
storage_quota_mb: int
|
||||
plan_started_at: Optional[datetime]
|
||||
plan_expires_at: Optional[datetime]
|
||||
suspended_reason: Optional[str]
|
||||
cancellation_reason: Optional[str]
|
||||
activated_by: Optional[str]
|
||||
suspended_by: Optional[str]
|
||||
created_by: Optional[str]
|
||||
updated_by: Optional[str]
|
||||
|
||||
|
||||
class TenantListOut(BaseModel):
|
||||
items: List[TenantOut]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
pages: int
|
||||
|
||||
|
||||
class TenantStats(BaseModel):
|
||||
total_tenants: int
|
||||
active_tenants: int
|
||||
pending_tenants: int
|
||||
suspended_tenants: int
|
||||
trial_tenants: int
|
||||
cancelled_tenants: int
|
||||
by_type: Dict[str, int]
|
||||
by_plan: Dict[str, int]
|
||||
|
||||
|
||||
class TenantListParams(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
page: int = Field(default=1, ge=1)
|
||||
page_size: int = Field(default=20, ge=1, le=100)
|
||||
status: Optional[TenantStatus] = None
|
||||
tenant_type: Optional[TenantType] = None
|
||||
search: Optional[str] = Field(None, max_length=100)
|
||||
country: Optional[str] = Field(None, max_length=2)
|
||||
city: Optional[str] = Field(None, max_length=100)
|
||||
sort_by: str = "created_at"
|
||||
sort_order: str = Field(default="desc", pattern=r"^(asc|desc)$")
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
# ─── User ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: Optional[str] = Field(None, min_length=8)
|
||||
tenant_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
first_name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
last_name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
role: Optional[UserRole] = None
|
||||
status: Optional[UserStatus] = None
|
||||
tenant_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class UserOut(UserBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
status: UserStatus
|
||||
is_superuser: bool
|
||||
last_login_at: Optional[datetime] = None
|
||||
tenant_id: Optional[UUID] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UserDetailOut(UserOut):
|
||||
tenant: Optional[TenantOut] = None
|
||||
|
||||
|
||||
class UserListOut(BaseModel):
|
||||
items: List[UserOut]
|
||||
total: int
|
||||
|
||||
|
||||
# ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
# ─── Invitation ───────────────────────────────────────────────────────────────
|
||||
|
||||
class InvitationCreate(BaseModel):
|
||||
email: EmailStr
|
||||
role: UserRole = UserRole.VIEWER
|
||||
tenant_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class InvitationOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
email: str
|
||||
role: UserRole
|
||||
token: str
|
||||
expires_at: datetime
|
||||
accepted_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
invited_by_id: UUID
|
||||
tenant_id: Optional[UUID] = None
|
||||
|
||||
|
||||
class AcceptInvitationRequest(BaseModel):
|
||||
token: str
|
||||
first_name: str = Field(..., min_length=1, max_length=100)
|
||||
last_name: str = Field(..., min_length=1, max_length=100)
|
||||
password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
# ─── Password reset ───────────────────────────────────────────────────────────
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
new_password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
# ─── Audit Log ────────────────────────────────────────────────────────────────
|
||||
|
||||
class AuditLogOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
timestamp: str
|
||||
action: str
|
||||
entity_type: str
|
||||
entity_id: str
|
||||
actor_id: Optional[str]
|
||||
actor_type: str
|
||||
actor_ip: Optional[str]
|
||||
previous_values: Optional[Dict[str, Any]]
|
||||
new_values: Optional[Dict[str, Any]]
|
||||
changed_fields: Optional[List[str]]
|
||||
success: bool
|
||||
error_message: Optional[str]
|
||||
tenant_id: Optional[str]
|
||||
tenant_schema: Optional[str]
|
||||
request_id: Optional[str]
|
||||
request_method: Optional[str]
|
||||
request_path: Optional[str]
|
||||
|
||||
|
||||
class AuditLogListOut(BaseModel):
|
||||
items: List[AuditLogOut]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
# ─── Common ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: str
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
request_id: Optional[str] = None
|
||||
|
||||
|
||||
class SuccessResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: str
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
components: Dict[str, str]
|
||||
timestamp: str
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Säkerhetsfunktioner: lösenordshashning, JWT-hantering, roller."""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import UserRole
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(subject: Union[str, UUID], expires_delta: Optional[timedelta] = None) -> str:
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "access"}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(subject: Union[str, UUID]) -> str:
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
# ─── Role-based access helpers ────────────────────────
|
||||
|
||||
ROLE_HIERARCHY = {
|
||||
UserRole.VIEWER: 0,
|
||||
UserRole.ANALYST: 1,
|
||||
UserRole.MANAGER: 2,
|
||||
UserRole.ADMIN: 3,
|
||||
}
|
||||
|
||||
|
||||
def role_level(role: UserRole) -> int:
|
||||
return ROLE_HIERARCHY.get(role, 0)
|
||||
|
||||
|
||||
def can_manage(manager_role: UserRole, target_role: UserRole) -> bool:
|
||||
"""Returnerar True om manager_role har högre eller lika nivå som target_role."""
|
||||
return role_level(manager_role) >= role_level(target_role)
|
||||
|
||||
|
||||
def require_role(min_role: UserRole):
|
||||
"""Factory för roll-krav. Används i dependency-kedjan."""
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
def checker(current_user_role: UserRole):
|
||||
if role_level(current_user_role) < role_level(min_role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Requires role {min_role.value} or higher",
|
||||
)
|
||||
return True
|
||||
|
||||
return checker
|
||||
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Audit Service för LandveX Admin Backend.
|
||||
Hanterar audit-logging för alla systemändringar.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from sqlalchemy import select, desc, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import AuditLog, AuditAction
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""Service för att hantera audit logs."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def log(
|
||||
self,
|
||||
action: AuditAction,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
actor_id: Optional[str] = None,
|
||||
actor_type: str = "user",
|
||||
actor_ip: Optional[str] = None,
|
||||
actor_user_agent: Optional[str] = None,
|
||||
previous_values: Optional[Dict[str, Any]] = None,
|
||||
new_values: Optional[Dict[str, Any]] = None,
|
||||
changed_fields: Optional[List[str]] = None,
|
||||
request_id: Optional[str] = None,
|
||||
request_method: Optional[str] = None,
|
||||
request_path: Optional[str] = None,
|
||||
request_body: Optional[str] = None,
|
||||
success: bool = True,
|
||||
error_message: Optional[str] = None,
|
||||
tenant_id: Optional[str] = None,
|
||||
tenant_schema: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> AuditLog:
|
||||
"""Skapa en audit log entry."""
|
||||
|
||||
sanitized_body = None
|
||||
if request_body:
|
||||
sanitized_body = self._sanitize_request_body(request_body)
|
||||
|
||||
audit_entry = AuditLog(
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
actor_id=actor_id or "system",
|
||||
actor_type=actor_type,
|
||||
actor_ip=actor_ip,
|
||||
actor_user_agent=actor_user_agent,
|
||||
previous_values=previous_values,
|
||||
new_values=new_values,
|
||||
changed_fields=changed_fields,
|
||||
request_id=request_id,
|
||||
request_method=request_method,
|
||||
request_path=request_path,
|
||||
request_body=sanitized_body,
|
||||
success="Y" if success else "N",
|
||||
error_message=error_message,
|
||||
tenant_id=tenant_id,
|
||||
tenant_schema=tenant_schema,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
self.session.add(audit_entry)
|
||||
await self.session.flush()
|
||||
|
||||
return audit_entry
|
||||
|
||||
async def get_by_entity(
|
||||
self, entity_type: str, entity_id: str, limit: int = 100, offset: int = 0
|
||||
) -> tuple[List[AuditLog], int]:
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.where(AuditLog.entity_type == entity_type)
|
||||
.where(AuditLog.entity_id == entity_id)
|
||||
.order_by(desc(AuditLog.timestamp))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
count_query = (
|
||||
select(func.count(AuditLog.id))
|
||||
.where(AuditLog.entity_type == entity_type)
|
||||
.where(AuditLog.entity_id == entity_id)
|
||||
)
|
||||
result = await self.session.execute(query)
|
||||
count_result = await self.session.execute(count_query)
|
||||
return list(result.scalars().all()), count_result.scalar()
|
||||
|
||||
async def get_by_tenant(
|
||||
self, tenant_id: str, limit: int = 100, offset: int = 0
|
||||
) -> tuple[List[AuditLog], int]:
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.where(AuditLog.tenant_id == tenant_id)
|
||||
.order_by(desc(AuditLog.timestamp))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
count_query = (
|
||||
select(func.count(AuditLog.id))
|
||||
.where(AuditLog.tenant_id == tenant_id)
|
||||
)
|
||||
result = await self.session.execute(query)
|
||||
count_result = await self.session.execute(count_query)
|
||||
return list(result.scalars().all()), count_result.scalar()
|
||||
|
||||
async def get_by_actor(
|
||||
self, actor_id: str, limit: int = 100, offset: int = 0
|
||||
) -> tuple[List[AuditLog], int]:
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.where(AuditLog.actor_id == actor_id)
|
||||
.order_by(desc(AuditLog.timestamp))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
count_query = (
|
||||
select(func.count(AuditLog.id))
|
||||
.where(AuditLog.actor_id == actor_id)
|
||||
)
|
||||
result = await self.session.execute(query)
|
||||
count_result = await self.session.execute(count_query)
|
||||
return list(result.scalars().all()), count_result.scalar()
|
||||
|
||||
async def get_recent(self, limit: int = 50, action: Optional[AuditAction] = None) -> List[AuditLog]:
|
||||
query = select(AuditLog).order_by(desc(AuditLog.timestamp))
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
query = query.limit(limit)
|
||||
result = await self.session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def cleanup_old_logs(self, retention_days: Optional[int] = None) -> int:
|
||||
retention = retention_days or settings.AUDIT_RETENTION_DAYS
|
||||
cutoff = datetime.now(timezone.utc) - __import__('datetime').timedelta(days=retention)
|
||||
count_query = select(func.count(AuditLog.id)).where(AuditLog.timestamp < cutoff)
|
||||
result = await self.session.execute(count_query)
|
||||
return result.scalar()
|
||||
|
||||
def _sanitize_request_body(self, body: str) -> Optional[str]:
|
||||
import json
|
||||
|
||||
sensitive = {
|
||||
"password", "secret", "token", "api_key", "apikey",
|
||||
"authorization", "credit_card", "ssn", "personal_number"
|
||||
}
|
||||
|
||||
try:
|
||||
data = json.loads(body)
|
||||
if isinstance(data, dict):
|
||||
sanitized = self._redact_sensitive(data, sensitive)
|
||||
return json.dumps(sanitized)
|
||||
return body[:1000]
|
||||
except json.JSONDecodeError:
|
||||
return body[:1000]
|
||||
|
||||
def _redact_sensitive(self, data: Dict[str, Any], sensitive: set) -> Dict[str, Any]:
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
if any(field in key.lower() for field in sensitive):
|
||||
result[key] = "***REDACTED***"
|
||||
elif isinstance(value, dict):
|
||||
result[key] = self._redact_sensitive(value, sensitive)
|
||||
elif isinstance(value, list):
|
||||
result[key] = [
|
||||
self._redact_sensitive(item, sensitive) if isinstance(item, dict) else item
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Business logic för inbjudningsflödet."""
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models import Invitation, User, UserRole, UserStatus, Tenant
|
||||
from app.schemas import InvitationCreate, AcceptInvitationRequest
|
||||
from app.config import get_settings
|
||||
from app.security import get_password_hash, can_manage
|
||||
from app.services.user_service import UserService
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class InvitationService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.user_service = UserService(db)
|
||||
|
||||
async def get_by_token(self, token: str) -> Optional[Invitation]:
|
||||
result = await self.db.execute(select(Invitation).where(Invitation.token == token))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_invitations(
|
||||
self,
|
||||
tenant_id: Optional[UUID] = None,
|
||||
pending_only: bool = True,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[Invitation], int]:
|
||||
query = select(Invitation)
|
||||
count_query = select(func.count()).select_from(Invitation)
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(Invitation.tenant_id == tenant_id)
|
||||
count_query = count_query.where(Invitation.tenant_id == tenant_id)
|
||||
if pending_only:
|
||||
query = query.where(Invitation.accepted_at.is_(None))
|
||||
count_query = count_query.where(Invitation.accepted_at.is_(None))
|
||||
|
||||
total_result = await self.db.execute(count_query)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
result = await self.db.execute(query.offset(skip).limit(limit).order_by(Invitation.created_at.desc()))
|
||||
return result.scalars().all(), total
|
||||
|
||||
async def create_invitation(self, data: InvitationCreate, invited_by: User) -> Invitation:
|
||||
# Validera att inviteraren kan ge denna roll
|
||||
if not invited_by.is_superuser and not can_manage(invited_by.role, data.role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot invite with role equal or higher than your own",
|
||||
)
|
||||
|
||||
# Kolla om användaren redan finns
|
||||
existing_user = await self.user_service.get_by_email(data.email)
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User with this email already exists",
|
||||
)
|
||||
|
||||
# Kolla om det redan finns en aktiv inbjudan
|
||||
existing_invite = await self.db.execute(
|
||||
select(Invitation).where(
|
||||
Invitation.email == data.email.lower().strip(),
|
||||
Invitation.accepted_at.is_(None),
|
||||
Invitation.expires_at > datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
if existing_invite.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Active invitation already exists for this email",
|
||||
)
|
||||
|
||||
if data.tenant_id:
|
||||
tenant = await self.db.execute(select(Tenant).where(Tenant.id == data.tenant_id))
|
||||
if tenant.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
|
||||
token = secrets.token_urlsafe(48)
|
||||
invitation = Invitation(
|
||||
email=data.email.lower().strip(),
|
||||
token=token,
|
||||
role=data.role,
|
||||
tenant_id=data.tenant_id,
|
||||
invited_by_id=invited_by.id,
|
||||
expires_at=datetime.utcnow() + timedelta(hours=settings.INVITATION_TOKEN_EXPIRE_HOURS),
|
||||
)
|
||||
self.db.add(invitation)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(invitation)
|
||||
return invitation
|
||||
|
||||
async def accept_invitation(self, data: AcceptInvitationRequest) -> User:
|
||||
invitation = await self.get_by_token(data.token)
|
||||
if invitation is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Invitation not found",
|
||||
)
|
||||
if invitation.accepted_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invitation already accepted",
|
||||
)
|
||||
if invitation.expires_at < datetime.utcnow():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail="Invitation has expired",
|
||||
)
|
||||
|
||||
# Skapa användaren
|
||||
user = User(
|
||||
email=invitation.email,
|
||||
first_name=data.first_name.strip(),
|
||||
last_name=data.last_name.strip(),
|
||||
role=invitation.role,
|
||||
tenant_id=invitation.tenant_id,
|
||||
status=UserStatus.ACTIVE,
|
||||
hashed_password=get_password_hash(data.password),
|
||||
)
|
||||
self.db.add(user)
|
||||
|
||||
invitation.accepted_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
return user
|
||||
|
||||
async def revoke_invitation(self, invitation_id: UUID, actor: User) -> None:
|
||||
invitation = await self.db.execute(select(Invitation).where(Invitation.id == invitation_id))
|
||||
inv = invitation.scalar_one_or_none()
|
||||
if inv is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invitation not found")
|
||||
|
||||
if not actor.is_superuser and inv.invited_by_id != actor.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Can only revoke invitations you sent",
|
||||
)
|
||||
|
||||
await self.db.delete(inv)
|
||||
await self.db.commit()
|
||||
|
||||
def get_invitation_link(self, token: str) -> str:
|
||||
return f"{settings.FRONTEND_SET_PASSWORD_URL}?token={token}"
|
||||
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
Business logic för tenant-hantering med audit-logging och schema-isolering.
|
||||
"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, func, and_, or_, desc, asc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import create_tenant_schema, schema_exists
|
||||
from app.models import Tenant, TenantType, TenantStatus, AuditAction
|
||||
from app.schemas import (
|
||||
TenantCreate, TenantUpdate, TenantListParams,
|
||||
TenantActivateRequest, TenantSuspendRequest
|
||||
)
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TenantService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.audit = AuditService(db)
|
||||
|
||||
# ─── READ ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_by_id(self, tenant_id: UUID, include_deleted: bool = False) -> Optional[Tenant]:
|
||||
query = select(Tenant).where(Tenant.id == tenant_id)
|
||||
if not include_deleted:
|
||||
query = query.where(Tenant.is_deleted == "N")
|
||||
result = await self.db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Optional[Tenant]:
|
||||
result = await self.db.execute(
|
||||
select(Tenant).where(Tenant.slug == slug.lower().strip()).where(Tenant.is_deleted == "N")
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_org_number(self, org_number: str) -> Optional[Tenant]:
|
||||
cleaned = org_number.replace(" ", "").replace("-", "")
|
||||
result = await self.db.execute(
|
||||
select(Tenant).where(Tenant.org_number == cleaned).where(Tenant.is_deleted == "N")
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_tenants(self, params: TenantListParams) -> tuple[List[Tenant], int]:
|
||||
query = select(Tenant).where(Tenant.is_deleted == "N")
|
||||
count_query = select(func.count(Tenant.id)).where(Tenant.is_deleted == "N")
|
||||
|
||||
if params.status:
|
||||
query = query.where(Tenant.status == params.status)
|
||||
count_query = count_query.where(Tenant.status == params.status)
|
||||
|
||||
if params.tenant_type:
|
||||
query = query.where(Tenant.tenant_type == params.tenant_type)
|
||||
count_query = count_query.where(Tenant.tenant_type == params.tenant_type)
|
||||
|
||||
if params.country:
|
||||
query = query.where(Tenant.country == params.country)
|
||||
count_query = count_query.where(Tenant.country == params.country)
|
||||
|
||||
if params.city:
|
||||
query = query.where(Tenant.city.ilike(f"%{params.city}%"))
|
||||
count_query = count_query.where(Tenant.city.ilike(f"%{params.city}%"))
|
||||
|
||||
if params.is_active is not None:
|
||||
if params.is_active:
|
||||
query = query.where(Tenant.status == TenantStatus.ACTIVE)
|
||||
count_query = count_query.where(Tenant.status == TenantStatus.ACTIVE)
|
||||
else:
|
||||
query = query.where(Tenant.status != TenantStatus.ACTIVE)
|
||||
count_query = count_query.where(Tenant.status != TenantStatus.ACTIVE)
|
||||
|
||||
if params.search:
|
||||
search_filter = or_(
|
||||
Tenant.name.ilike(f"%{params.search}%"),
|
||||
Tenant.slug.ilike(f"%{params.search}%"),
|
||||
Tenant.city.ilike(f"%{params.search}%"),
|
||||
Tenant.org_number.ilike(f"%{params.search}%"),
|
||||
)
|
||||
query = query.where(search_filter)
|
||||
count_query = count_query.where(search_filter)
|
||||
|
||||
sort_column = getattr(Tenant, params.sort_by, Tenant.created_at)
|
||||
if params.sort_order == "desc":
|
||||
query = query.order_by(desc(sort_column))
|
||||
else:
|
||||
query = query.order_by(asc(sort_column))
|
||||
|
||||
offset = (params.page - 1) * params.page_size
|
||||
query = query.offset(offset).limit(params.page_size)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
tenants = result.scalars().all()
|
||||
|
||||
count_result = await self.db.execute(count_query)
|
||||
total = count_result.scalar()
|
||||
|
||||
return list(tenants), total
|
||||
|
||||
# ─── CREATE ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_tenant(
|
||||
self,
|
||||
data: TenantCreate,
|
||||
actor_id: Optional[str] = None,
|
||||
request_context: Optional[Dict[str, Any]] = None
|
||||
) -> Tenant:
|
||||
# Check slug uniqueness
|
||||
existing = await self.get_by_slug(data.slug)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Tenant with slug '{data.slug}' already exists",
|
||||
)
|
||||
|
||||
# Check org_number uniqueness
|
||||
if data.org_number:
|
||||
existing_org = await self.get_by_org_number(data.org_number)
|
||||
if existing_org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Tenant with org number '{data.org_number}' already exists",
|
||||
)
|
||||
|
||||
schema_name = f"{settings.TENANT_SCHEMA_PREFIX}{data.slug}"
|
||||
|
||||
if await schema_exists(schema_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Schema '{schema_name}' already exists in database",
|
||||
)
|
||||
|
||||
tenant = Tenant(
|
||||
name=data.name,
|
||||
slug=data.slug.lower().strip(),
|
||||
org_number=data.org_number,
|
||||
tenant_type=data.tenant_type,
|
||||
status=TenantStatus.PENDING,
|
||||
schema_name=schema_name,
|
||||
contact_email=data.contact_email,
|
||||
contact_phone=data.contact_phone,
|
||||
billing_email=data.billing_email,
|
||||
address=data.address,
|
||||
postal_code=data.postal_code,
|
||||
city=data.city,
|
||||
country=data.country,
|
||||
municipality_code=data.municipality_code,
|
||||
county_code=data.county_code,
|
||||
latitude=data.latitude,
|
||||
longitude=data.longitude,
|
||||
plan=data.plan,
|
||||
is_trial=data.is_trial,
|
||||
max_users=data.max_users,
|
||||
max_projects=data.max_projects,
|
||||
storage_quota_mb=data.storage_quota_mb,
|
||||
features=data.features or {},
|
||||
settings=data.settings or {},
|
||||
branding=data.branding or {},
|
||||
description=data.description,
|
||||
website=data.website,
|
||||
created_by=actor_id,
|
||||
)
|
||||
|
||||
if data.is_trial and data.trial_days:
|
||||
tenant.trial_ends_at = datetime.now(timezone.utc) + timedelta(days=data.trial_days)
|
||||
tenant.status = TenantStatus.TRIAL
|
||||
|
||||
self.db.add(tenant)
|
||||
await self.db.flush()
|
||||
await self.db.refresh(tenant)
|
||||
|
||||
# Create schema
|
||||
try:
|
||||
await create_tenant_schema(schema_name)
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create tenant schema: {str(e)}",
|
||||
)
|
||||
|
||||
# Audit log
|
||||
rc = request_context or {}
|
||||
await self.audit.log(
|
||||
action=AuditAction.CREATE,
|
||||
entity_type="tenant",
|
||||
entity_id=str(tenant.id),
|
||||
actor_id=actor_id,
|
||||
new_values=tenant.to_dict(include_sensitive=True),
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_schema=schema_name,
|
||||
**rc
|
||||
)
|
||||
await self.audit.log(
|
||||
action=AuditAction.SCHEMA_CREATE,
|
||||
entity_type="tenant_schema",
|
||||
entity_id=str(tenant.id),
|
||||
actor_id=actor_id,
|
||||
new_values={"schema_name": schema_name},
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_schema=schema_name,
|
||||
**rc
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
# ─── UPDATE ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def update_tenant(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
data: TenantUpdate,
|
||||
actor_id: Optional[str] = None,
|
||||
request_context: Optional[Dict[str, Any]] = None
|
||||
) -> Tenant:
|
||||
tenant = await self.get_by_id(tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
|
||||
previous_values = tenant.to_dict(include_sensitive=True)
|
||||
changed_fields = []
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if hasattr(tenant, field) and getattr(tenant, field) != value:
|
||||
setattr(tenant, field, value)
|
||||
changed_fields.append(field)
|
||||
|
||||
if not changed_fields:
|
||||
return tenant
|
||||
|
||||
tenant.updated_by = actor_id
|
||||
await self.db.flush()
|
||||
await self.db.refresh(tenant)
|
||||
|
||||
rc = request_context or {}
|
||||
await self.audit.log(
|
||||
action=AuditAction.UPDATE,
|
||||
entity_type="tenant",
|
||||
entity_id=str(tenant.id),
|
||||
actor_id=actor_id,
|
||||
previous_values=previous_values,
|
||||
new_values=tenant.to_dict(include_sensitive=True),
|
||||
changed_fields=changed_fields,
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_schema=tenant.schema_name,
|
||||
**rc
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
# ─── DELETE ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def delete_tenant(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
actor_id: Optional[str] = None,
|
||||
request_context: Optional[Dict[str, Any]] = None
|
||||
) -> Tenant:
|
||||
tenant = await self.get_by_id(tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
|
||||
if tenant.is_deleted == "Y":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant is already deleted")
|
||||
|
||||
previous_values = tenant.to_dict(include_sensitive=True)
|
||||
|
||||
tenant.is_deleted = "Y"
|
||||
tenant.deleted_at = datetime.now(timezone.utc)
|
||||
tenant.deleted_by = actor_id
|
||||
tenant.status = TenantStatus.CANCELLED
|
||||
|
||||
await self.db.flush()
|
||||
|
||||
rc = request_context or {}
|
||||
await self.audit.log(
|
||||
action=AuditAction.DELETE,
|
||||
entity_type="tenant",
|
||||
entity_id=str(tenant.id),
|
||||
actor_id=actor_id,
|
||||
previous_values=previous_values,
|
||||
new_values={"is_deleted": "Y", "deleted_at": tenant.deleted_at.isoformat()},
|
||||
changed_fields=["is_deleted", "deleted_at", "status"],
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_schema=tenant.schema_name,
|
||||
**rc
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
async def hard_delete_tenant(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
actor_id: Optional[str] = None,
|
||||
request_context: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
tenant = await self.get_by_id(tenant_id, include_deleted=True)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
|
||||
rc = request_context or {}
|
||||
await self.audit.log(
|
||||
action=AuditAction.DELETE,
|
||||
entity_type="tenant",
|
||||
entity_id=str(tenant.id),
|
||||
actor_id=actor_id,
|
||||
previous_values=tenant.to_dict(include_sensitive=True),
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_schema=tenant.schema_name,
|
||||
**rc
|
||||
)
|
||||
|
||||
await self.db.delete(tenant)
|
||||
await self.db.flush()
|
||||
|
||||
# ─── STATUS MANAGEMENT ────────────────────────────────────────────────────
|
||||
|
||||
async def activate_tenant(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
data: TenantActivateRequest,
|
||||
actor_id: Optional[str] = None,
|
||||
request_context: Optional[Dict[str, Any]] = None
|
||||
) -> Tenant:
|
||||
tenant = await self.get_by_id(tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
|
||||
if tenant.status == TenantStatus.ACTIVE:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant is already active")
|
||||
if tenant.status == TenantStatus.CANCELLED:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot activate a cancelled tenant")
|
||||
|
||||
previous_values = tenant.to_dict(include_sensitive=True)
|
||||
|
||||
tenant.activate(activated_by=actor_id)
|
||||
tenant.plan_started_at = datetime.now(timezone.utc)
|
||||
|
||||
await self.db.flush()
|
||||
await self.db.refresh(tenant)
|
||||
|
||||
rc = request_context or {}
|
||||
await self.audit.log(
|
||||
action=AuditAction.ACTIVATE,
|
||||
entity_type="tenant",
|
||||
entity_id=str(tenant.id),
|
||||
actor_id=actor_id,
|
||||
previous_values=previous_values,
|
||||
new_values=tenant.to_dict(include_sensitive=True),
|
||||
changed_fields=["status", "activated_at", "activated_by", "plan_started_at"],
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_schema=tenant.schema_name,
|
||||
**rc
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
async def suspend_tenant(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
data: TenantSuspendRequest,
|
||||
actor_id: Optional[str] = None,
|
||||
request_context: Optional[Dict[str, Any]] = None
|
||||
) -> Tenant:
|
||||
tenant = await self.get_by_id(tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
|
||||
if tenant.status == TenantStatus.SUSPENDED:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant is already suspended")
|
||||
if tenant.status == TenantStatus.CANCELLED:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot suspend a cancelled tenant")
|
||||
|
||||
previous_values = tenant.to_dict(include_sensitive=True)
|
||||
|
||||
tenant.suspend(reason=data.reason, suspended_by=actor_id or "system")
|
||||
|
||||
await self.db.flush()
|
||||
await self.db.refresh(tenant)
|
||||
|
||||
rc = request_context or {}
|
||||
await self.audit.log(
|
||||
action=AuditAction.SUSPEND,
|
||||
entity_type="tenant",
|
||||
entity_id=str(tenant.id),
|
||||
actor_id=actor_id,
|
||||
previous_values=previous_values,
|
||||
new_values=tenant.to_dict(include_sensitive=True),
|
||||
changed_fields=["status", "suspended_at", "suspended_reason", "suspended_by"],
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_schema=tenant.schema_name,
|
||||
**rc
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
async def cancel_tenant(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
reason: Optional[str] = None,
|
||||
actor_id: Optional[str] = None,
|
||||
request_context: Optional[Dict[str, Any]] = None
|
||||
) -> Tenant:
|
||||
tenant = await self.get_by_id(tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
|
||||
if tenant.status == TenantStatus.CANCELLED:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Tenant is already cancelled")
|
||||
|
||||
previous_values = tenant.to_dict(include_sensitive=True)
|
||||
|
||||
tenant.cancel(reason=reason)
|
||||
|
||||
await self.db.flush()
|
||||
await self.db.refresh(tenant)
|
||||
|
||||
rc = request_context or {}
|
||||
await self.audit.log(
|
||||
action=AuditAction.CANCEL,
|
||||
entity_type="tenant",
|
||||
entity_id=str(tenant.id),
|
||||
actor_id=actor_id,
|
||||
previous_values=previous_values,
|
||||
new_values=tenant.to_dict(include_sensitive=True),
|
||||
changed_fields=["status", "cancelled_at", "cancellation_reason"],
|
||||
tenant_id=str(tenant.id),
|
||||
tenant_schema=tenant.schema_name,
|
||||
**rc
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
# ─── STATISTICS ───────────────────────────────────────────────────────────
|
||||
|
||||
async def get_statistics(self) -> Dict[str, Any]:
|
||||
status_counts = {}
|
||||
for st in TenantStatus:
|
||||
result = await self.db.execute(
|
||||
select(func.count(Tenant.id))
|
||||
.where(Tenant.status == st)
|
||||
.where(Tenant.is_deleted == "N")
|
||||
)
|
||||
status_counts[st.value] = result.scalar()
|
||||
|
||||
type_counts = {}
|
||||
for tt in TenantType:
|
||||
result = await self.db.execute(
|
||||
select(func.count(Tenant.id))
|
||||
.where(Tenant.tenant_type == tt)
|
||||
.where(Tenant.is_deleted == "N")
|
||||
)
|
||||
type_counts[tt.value] = result.scalar()
|
||||
|
||||
plan_result = await self.db.execute(
|
||||
select(Tenant.plan, func.count(Tenant.id))
|
||||
.where(Tenant.is_deleted == "N")
|
||||
.group_by(Tenant.plan)
|
||||
)
|
||||
plan_counts = {plan: count for plan, count in plan_result.all()}
|
||||
|
||||
total_result = await self.db.execute(
|
||||
select(func.count(Tenant.id)).where(Tenant.is_deleted == "N")
|
||||
)
|
||||
total = total_result.scalar()
|
||||
|
||||
return {
|
||||
"total_tenants": total,
|
||||
"active_tenants": status_counts.get("active", 0),
|
||||
"pending_tenants": status_counts.get("pending", 0),
|
||||
"suspended_tenants": status_counts.get("suspended", 0),
|
||||
"trial_tenants": status_counts.get("trial", 0),
|
||||
"cancelled_tenants": status_counts.get("cancelled", 0),
|
||||
"by_type": type_counts,
|
||||
"by_plan": plan_counts,
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Business logic för användarhantering."""
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models import User, UserRole, UserStatus, Tenant
|
||||
from app.schemas import UserCreate, UserUpdate
|
||||
from app.security import get_password_hash, verify_password, can_manage
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, user_id: UUID) -> Optional[User]:
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_email(self, email: str) -> Optional[User]:
|
||||
result = await self.db.execute(select(User).where(User.email == email.lower().strip()))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_users(
|
||||
self,
|
||||
tenant_id: Optional[UUID] = None,
|
||||
role: Optional[UserRole] = None,
|
||||
status: Optional[UserStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[list[User], int]:
|
||||
query = select(User)
|
||||
count_query = select(func.count()).select_from(User)
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(User.tenant_id == tenant_id)
|
||||
count_query = count_query.where(User.tenant_id == tenant_id)
|
||||
if role:
|
||||
query = query.where(User.role == role)
|
||||
count_query = count_query.where(User.role == role)
|
||||
if status:
|
||||
query = query.where(User.status == status)
|
||||
count_query = count_query.where(User.status == status)
|
||||
|
||||
total_result = await self.db.execute(count_query)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
result = await self.db.execute(query.offset(skip).limit(limit).order_by(User.created_at.desc()))
|
||||
return result.scalars().all(), total
|
||||
|
||||
async def create_user(self, data: UserCreate, created_by: Optional[User] = None) -> User:
|
||||
existing = await self.get_by_email(data.email)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User with this email already exists",
|
||||
)
|
||||
|
||||
if data.tenant_id:
|
||||
tenant = await self.db.execute(select(Tenant).where(Tenant.id == data.tenant_id))
|
||||
if tenant.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tenant not found",
|
||||
)
|
||||
|
||||
user = User(
|
||||
email=data.email.lower().strip(),
|
||||
first_name=data.first_name.strip(),
|
||||
last_name=data.last_name.strip(),
|
||||
role=data.role,
|
||||
tenant_id=data.tenant_id,
|
||||
status=UserStatus.ACTIVE if data.password else UserStatus.PENDING,
|
||||
hashed_password=get_password_hash(data.password) if data.password else None,
|
||||
)
|
||||
self.db.add(user)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
return user
|
||||
|
||||
async def update_user(self, user_id: UUID, data: UserUpdate, actor: User) -> User:
|
||||
user = await self.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
if not actor.is_superuser and actor.id != user_id:
|
||||
if not can_manage(actor.role, user.role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot modify user with equal or higher role",
|
||||
)
|
||||
|
||||
if data.email and data.email.lower().strip() != user.email:
|
||||
existing = await self.get_by_email(data.email)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Email already in use",
|
||||
)
|
||||
user.email = data.email.lower().strip()
|
||||
|
||||
if data.first_name is not None:
|
||||
user.first_name = data.first_name.strip()
|
||||
if data.last_name is not None:
|
||||
user.last_name = data.last_name.strip()
|
||||
if data.role is not None:
|
||||
if not actor.is_superuser and not can_manage(actor.role, data.role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot assign role equal or higher than your own",
|
||||
)
|
||||
user.role = data.role
|
||||
if data.status is not None:
|
||||
user.status = data.status
|
||||
if data.tenant_id is not None:
|
||||
user.tenant_id = data.tenant_id
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(user)
|
||||
return user
|
||||
|
||||
async def delete_user(self, user_id: UUID, actor: User) -> None:
|
||||
user = await self.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
if not actor.is_superuser:
|
||||
if not can_manage(actor.role, user.role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot delete user with equal or higher role",
|
||||
)
|
||||
if actor.id == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete yourself",
|
||||
)
|
||||
|
||||
await self.db.delete(user)
|
||||
await self.db.commit()
|
||||
|
||||
async def authenticate(self, email: str, password: str) -> Optional[User]:
|
||||
user = await self.get_by_email(email)
|
||||
if not user or not user.hashed_password:
|
||||
return None
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
if user.status != UserStatus.ACTIVE:
|
||||
return None
|
||||
return user
|
||||
|
||||
async def set_password(self, user: User, password: str) -> None:
|
||||
user.hashed_password = get_password_hash(password)
|
||||
user.status = UserStatus.ACTIVE
|
||||
await self.db.commit()
|
||||
|
||||
async def change_password(self, user: User, current_password: str, new_password: str) -> None:
|
||||
if not verify_password(current_password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Current password is incorrect",
|
||||
)
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
await self.db.commit()
|
||||
@@ -0,0 +1,256 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Landvex Aid Intelligence - Evidence-Based Verification</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #f8f9fa; color: #1a1a1a; line-height: 1.6; }
|
||||
.header { background: #fff; border-bottom: 1px solid #e9ecef; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; color: #1a1a1a; }
|
||||
.header .subtitle { color: #6c757d; font-size: 0.85rem; }
|
||||
.nav { background: #fff; border-bottom: 1px solid #e9ecef; padding: 0 40px; display: flex; gap: 4px; }
|
||||
.nav a { color: #6c757d; text-decoration: none; padding: 12px 20px; font-size: 0.85rem; transition: all 0.2s; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover, .nav a.active { color: #0066cc; border-bottom-color: #0066cc; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.philosophy { background: #fff; border-radius: 12px; padding: 32px; margin-bottom: 32px; border-left: 4px solid #0066cc; }
|
||||
.philosophy h2 { font-size: 1.25rem; margin-bottom: 16px; color: #1a1a1a; }
|
||||
.philosophy p { color: #495057; margin-bottom: 12px; }
|
||||
.philosophy ul { margin-left: 20px; color: #495057; }
|
||||
.philosophy li { margin-bottom: 8px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; margin-bottom: 32px; }
|
||||
.stat-card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
.stat-card h3 { font-size: 0.75rem; color: #6c757d; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a1a; }
|
||||
.stat-card .change { font-size: 0.8rem; margin-top: 4px; }
|
||||
.stat-card .change.positive { color: #28a745; }
|
||||
.section { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
.section h2 { font-size: 1.1rem; margin-bottom: 16px; color: #1a1a1a; }
|
||||
.project-card { border: 1px solid #e9ecef; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
||||
.project-card h3 { font-size: 1rem; margin-bottom: 8px; }
|
||||
.project-card .meta { font-size: 0.8rem; color: #6c757d; margin-bottom: 8px; }
|
||||
.project-card .findings { font-size: 0.85rem; color: #495057; }
|
||||
.project-card .findings li { margin-bottom: 4px; }
|
||||
.score { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||
.score.high { background: #d4edda; color: #155724; }
|
||||
.score.medium { background: #fff3cd; color: #856404; }
|
||||
.score.low { background: #f8d7da; color: #721c24; }
|
||||
.discrepancy { background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
||||
.discrepancy h4 { color: #856404; margin-bottom: 8px; }
|
||||
.discrepancy p { font-size: 0.85rem; color: #495057; }
|
||||
.positive { background: #d4edda; border: 1px solid #c3e6cb; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
||||
.positive h4 { color: #155724; margin-bottom: 8px; }
|
||||
.framework-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
.framework-card { border: 1px solid #e9ecef; border-radius: 8px; padding: 16px; }
|
||||
.framework-card h4 { font-size: 0.95rem; margin-bottom: 8px; }
|
||||
.framework-card p { font-size: 0.8rem; color: #6c757d; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>Landvex Aid Intelligence</h1>
|
||||
<div class="subtitle">Evidence-Based Impact Verification</div>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<div style="font-size: 0.8rem; color: #6c757d;">Projects Monitored</div>
|
||||
<div style="font-size: 1.5rem; font-weight: 700;">1,250</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/admin/" style="color: #6c757d;">← Admin</a>
|
||||
<a href="#" class="active">Dashboard</a>
|
||||
<a href="#projects">Projects</a>
|
||||
<a href="#verification">Verification</a>
|
||||
<a href="#research">Research</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="philosophy">
|
||||
<h2>Our Mission</h2>
|
||||
<p>Build Landvex Aid Intelligence as the world's leading <strong>independent platform</strong> for measuring, verifying and improving the real-world impact of development aid.</p>
|
||||
<p>We believe development aid should create <strong>measurable, sustainable improvements</strong>. We measure observable progress rather than assuming success or failure.</p>
|
||||
<h2 style="margin-top: 20px; font-size: 1rem;">Core Principles</h2>
|
||||
<ul>
|
||||
<li>Evidence over speculation</li>
|
||||
<li>Transparency over opacity</li>
|
||||
<li>Continuous learning over blame</li>
|
||||
<li>Independent verification over assumptions</li>
|
||||
<li>Long-term societal outcomes over short-term activity metrics</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>Projects Verified</h3>
|
||||
<div class="value">890</div>
|
||||
<div class="change positive">71% of total</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Avg Confidence</h3>
|
||||
<div class="value">82%</div>
|
||||
<div class="change positive">+3% vs last quarter</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Evidence Sources</h3>
|
||||
<div class="value">5</div>
|
||||
<div class="change">Satellite, Field, AI, Open Data, Official</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Countries</h3>
|
||||
<div class="value">9</div>
|
||||
<div class="change">Sub-Saharan Africa</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Observations</h3>
|
||||
<div class="value">45K</div>
|
||||
<div class="change positive">+3,200 this month</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Flagged for Review</h3>
|
||||
<div class="value" style="color: #856404;">15</div>
|
||||
<div class="change">Signals, not accusations</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Verification Framework</h2>
|
||||
<p style="color: #6c757d; margin-bottom: 16px;">Multidimensional evaluation of observable outcomes. Every score includes confidence intervals.</p>
|
||||
<div class="framework-grid">
|
||||
<div class="framework-card">
|
||||
<h4>Infrastructure Completion</h4>
|
||||
<p>Observable physical completion of planned infrastructure</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Operational Continuity</h4>
|
||||
<p>Whether facilities remain operational over time</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Maintenance Quality</h4>
|
||||
<p>Observable maintenance and upkeep</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Accessibility</h4>
|
||||
<p>Physical and economic access for target population</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Community Utilization</h4>
|
||||
<p>Actual use by intended beneficiaries</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Environmental Sustainability</h4>
|
||||
<p>Environmental impact and sustainability</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Economic Enablement</h4>
|
||||
<p>Economic activity generated or enabled</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Projects with Discrepancies (Signals for Review)</h2>
|
||||
<p style="color: #6c757d; margin-bottom: 16px;">These findings indicate areas where additional verification may be valuable. They are not conclusions of wrongdoing.</p>
|
||||
|
||||
<div class="discrepancy">
|
||||
<h4>⚠️ Schedule Variance - Mogadishu Primary School</h4>
|
||||
<p><strong>Reported:</strong> 95% complete (June 2026)<br>
|
||||
<strong>Observed:</strong> 85% complete (July 2026)<br>
|
||||
<strong>Evidence:</strong> Field observation, Satellite, AI analysis<br>
|
||||
<strong>Confidence:</strong> 78% | <strong>Severity:</strong> Medium<br>
|
||||
<strong>Recommended Action:</strong> Follow-up observation in 30 days</p>
|
||||
</div>
|
||||
|
||||
<div class="discrepancy">
|
||||
<h4>⚠️ Activity Gap - Lagos Road Rehabilitation</h4>
|
||||
<p><strong>Reported:</strong> Active construction<br>
|
||||
<strong>Observed:</strong> No activity for 90 days, equipment removed<br>
|
||||
<strong>Evidence:</strong> Field observation, Satellite time-series<br>
|
||||
<strong>Confidence:</strong> 92% | <strong>Severity:</strong> High<br>
|
||||
<strong>Recommended Action:</strong> Contact implementing partner for status update</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Positive Outcomes (Benchmark Projects)</h2>
|
||||
<p style="color: #6c757d; margin-bottom: 16px;">Projects demonstrating strong implementation and sustained operation. These enable benchmarking and dissemination of successful practices.</p>
|
||||
|
||||
<div class="positive">
|
||||
<h4>✅ Accra Solar Microgrid - Ghana</h4>
|
||||
<p><strong>SII Score:</strong> 89/100 | <strong>Confidence:</strong> 92%<br>
|
||||
<strong>Strengths:</strong> Strong implementation, Sustained operation (24+ months), High community utilization, Long-term maintenance observed, Positive environmental outcomes<br>
|
||||
<strong>Benchmark Category:</strong> Energy Infrastructure</p>
|
||||
</div>
|
||||
|
||||
<div class="positive">
|
||||
<h4>✅ Nairobi Digital Health Records - Kenya</h4>
|
||||
<p><strong>SII Score:</strong> 84/100 | <strong>Confidence:</strong> 86%<br>
|
||||
<strong>Strengths:</strong> Operational continuity, Staff training evident, Patient utilization increasing, Data transparency<br>
|
||||
<strong>Benchmark Category:</strong> Digital Health</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Sustainable Impact Index (SII)</h2>
|
||||
<p style="color: #6c757d; margin-bottom: 16px;">Estimates long-term observable contribution of development projects. Each component based on measurable indicators with confidence estimates.</p>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px;">
|
||||
<div class="framework-card">
|
||||
<h4>Longevity (15%)</h4>
|
||||
<p>Years of continuous operation</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Community Benefit (15%)</h4>
|
||||
<p>Observable community value</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Infrastructure Durability (15%)</h4>
|
||||
<p>Physical condition over time</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Maintenance (10%)</h4>
|
||||
<p>Observable upkeep quality</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Population Reach (10%)</h4>
|
||||
<p>Number of beneficiaries</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Economic Enablement (10%)</h4>
|
||||
<p>Economic activity generated</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Development Effectiveness Science</h2>
|
||||
<p style="color: #6c757d; margin-bottom: 16px;">Research program developing scientifically grounded indices and models for measuring long-term societal impact.</p>
|
||||
|
||||
<div class="framework-grid">
|
||||
<div class="framework-card">
|
||||
<h4>Impact Measurement Methodology</h4>
|
||||
<p>Rigorous methods for measuring development outcomes</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Longitudinal Impact Analysis</h4>
|
||||
<p>Track project outcomes over 5-10 year periods</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Bias Detection & Correction</h4>
|
||||
<p>Identify and correct for observational biases</p>
|
||||
</div>
|
||||
<div class="framework-card">
|
||||
<h4>Predictive Sustainability Modeling</h4>
|
||||
<p>Predict long-term sustainability from early indicators</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,211 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Landvex Aid Intelligence</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #0a0a0a; color: #fff; }
|
||||
.header { background: #111; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #333; }
|
||||
.header h1 { font-size: 1.5rem; }
|
||||
.header .subtitle { color: #666; font-size: 0.8rem; }
|
||||
.nav { background: #111; padding: 0 40px; display: flex; gap: 4px; border-bottom: 1px solid #333; }
|
||||
.nav a { color: #999; text-decoration: none; padding: 12px 20px; font-size: 0.85rem; transition: all 0.2s; }
|
||||
.nav a:hover, .nav a.active { color: #fff; background: #222; }
|
||||
.main { padding: 40px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 20px; margin-bottom: 40px; }
|
||||
.stat-card { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; }
|
||||
.stat-card h3 { font-size: 0.75rem; color: #666; margin-bottom: 8px; text-transform: uppercase; }
|
||||
.stat-card .value { font-size: 2rem; font-weight: 700; color: #fff; }
|
||||
.stat-card .change { font-size: 0.8rem; color: #34c759; margin-top: 4px; }
|
||||
.section { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; margin-bottom: 24px; }
|
||||
.section h2 { font-size: 1.1rem; margin-bottom: 16px; color: #fff; }
|
||||
.map-placeholder { background: #1a1a1a; border-radius: 8px; height: 400px; display: flex; align-items: center; justify-content: center; color: #666; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #333; }
|
||||
th { color: #666; font-size: 0.75rem; text-transform: uppercase; }
|
||||
tr:hover { background: #1a1a1a; }
|
||||
.status { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||
.status.active { background: #1a3a1a; color: #34c759; }
|
||||
.status.completed { background: #1a1a3a; color: #5ac8fa; }
|
||||
.status.delayed { background: #3a1a1a; color: #ff3b30; }
|
||||
.score { font-weight: 700; }
|
||||
.score.high { color: #34c759; }
|
||||
.score.medium { color: #ff9500; }
|
||||
.score.low { color: #ff3b30; }
|
||||
.alert { background: #3a1a1a; border: 1px solid #ff3b30; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
||||
.alert h4 { color: #ff3b30; margin-bottom: 8px; }
|
||||
.alert p { color: #ccc; font-size: 0.85rem; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
.country-card { background: #1a1a1a; border-radius: 8px; padding: 16px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.country-card h4 { font-size: 1rem; }
|
||||
.country-card .score { font-size: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>Landvex Aid Intelligence</h1>
|
||||
<div class="subtitle">Independent Impact Verification</div>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<div style="font-size: 0.8rem; color: #666;">Projects Monitored</div>
|
||||
<div style="font-size: 1.5rem; font-weight: 700;">1,250</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/admin/" style="color: #666;">← Admin</a>
|
||||
<a href="#" class="active">Dashboard</a>
|
||||
<a href="#projects">Projects</a>
|
||||
<a href="#map">Map</a>
|
||||
<a href="#alerts">Alerts</a>
|
||||
<a href="#reports">Reports</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>Avg AII Score</h3>
|
||||
<div class="value">72</div>
|
||||
<div class="change">+3 vs last quarter</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Observations</h3>
|
||||
<div class="value">45K</div>
|
||||
<div class="change">+3,200 this month</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Contributors</h3>
|
||||
<div class="value">3,200</div>
|
||||
<div class="change">+450 this month</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Countries</h3>
|
||||
<div class="value">9</div>
|
||||
<div class="change">3 regions</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Funding Monitored</h3>
|
||||
<div class="value">$450M</div>
|
||||
<div class="change">Across all projects</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Risk Alerts</h3>
|
||||
<div class="value" style="color: #ff9500;">8</div>
|
||||
<div class="change">2 high priority</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="section">
|
||||
<h2>Project Map</h2>
|
||||
<div class="map-placeholder">
|
||||
Interactive Map<br>
|
||||
1,250 projects across 9 countries
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Risk Alerts</h2>
|
||||
<div class="alert">
|
||||
<h4>⚠️ Abandoned Project Detected</h4>
|
||||
<p><strong>Lagos Road Rehabilitation</strong> - No activity for 90 days. Equipment removed.</p>
|
||||
</div>
|
||||
<div class="alert" style="background: #3a3a1a; border-color: #ff9500;">
|
||||
<h4 style="color: #ff9500;">⚠️ Infrastructure Deterioration</h4>
|
||||
<p><strong>Nairobi Health Clinic</strong> - Accelerated deterioration observed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Country Performance (Aid Impact Index)</h2>
|
||||
<div style="display: grid; gap: 12px;">
|
||||
<div class="country-card">
|
||||
<div>
|
||||
<h4>Ghana</h4>
|
||||
<div style="font-size: 0.8rem; color: #666;">West Africa • 89 projects</div>
|
||||
</div>
|
||||
<div class="score high">81</div>
|
||||
</div>
|
||||
<div class="country-card">
|
||||
<div>
|
||||
<h4>South Africa</h4>
|
||||
<div style="font-size: 0.8rem; color: #666;">Southern Africa • 112 projects</div>
|
||||
</div>
|
||||
<div class="score high">82</div>
|
||||
</div>
|
||||
<div class="country-card">
|
||||
<div>
|
||||
<h4>Kenya</h4>
|
||||
<div style="font-size: 0.8rem; color: #666;">East Africa • 92 projects</div>
|
||||
</div>
|
||||
<div class="score medium">74</div>
|
||||
</div>
|
||||
<div class="country-card">
|
||||
<div>
|
||||
<h4>Somalia</h4>
|
||||
<div style="font-size: 0.8rem; color: #666;">East Africa • 45 projects</div>
|
||||
</div>
|
||||
<div class="score medium">72</div>
|
||||
</div>
|
||||
<div class="country-card">
|
||||
<div>
|
||||
<h4>Nigeria</h4>
|
||||
<div style="font-size: 0.8rem; color: #666;">West Africa • 134 projects</div>
|
||||
</div>
|
||||
<div class="score low">63</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recent Projects</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Country</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>AII Score</th>
|
||||
<th>Observations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Mogadishu Primary School Renovation</td>
|
||||
<td>Somalia</td>
|
||||
<td>School</td>
|
||||
<td><span class="status active">Active</span></td>
|
||||
<td><span class="score medium">72</span></td>
|
||||
<td>156</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Kampala Water Supply Extension</td>
|
||||
<td>Uganda</td>
|
||||
<td>Water</td>
|
||||
<td><span class="status active">Active</span></td>
|
||||
<td><span class="score medium">68</span></td>
|
||||
<td>203</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Accra Solar Microgrid</td>
|
||||
<td>Ghana</td>
|
||||
<td>Renewable</td>
|
||||
<td><span class="status completed">Completed</span></td>
|
||||
<td><span class="score high">89</span></td>
|
||||
<td>312</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,402 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Landvex Unified Admin</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #f5f5f7; color: #1a1a1a; }
|
||||
.sidebar { width: 260px; background: #0a0a0a; color: #fff; position: fixed; height: 100vh; padding: 24px; overflow-y: auto; }
|
||||
.sidebar h1 { font-size: 1.25rem; margin-bottom: 8px; }
|
||||
.sidebar .subtitle { font-size: 0.75rem; color: #666; margin-bottom: 32px; }
|
||||
.sidebar nav a { display: block; color: #999; text-decoration: none; padding: 10px 16px; border-radius: 8px; margin-bottom: 2px; transition: all 0.2s; font-size: 0.875rem; cursor: pointer; }
|
||||
.sidebar nav a:hover, .sidebar nav a.active { color: #fff; background: rgba(255,255,255,0.1); }
|
||||
.sidebar nav .section { color: #666; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 1px; margin: 16px 0 8px 16px; }
|
||||
.main { margin-left: 260px; padding: 32px; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; }
|
||||
.header h2 { font-size: 1.75rem; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
||||
.stat-card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); cursor: pointer; transition: transform 0.2s; }
|
||||
.stat-card:hover { transform: translateY(-2px); }
|
||||
.stat-card h3 { font-size: 0.75rem; color: #666; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-card .value { font-size: 1.75rem; font-weight: 700; }
|
||||
.stat-card .change { font-size: 0.8rem; margin-top: 4px; }
|
||||
.stat-card .change.positive { color: #34c759; }
|
||||
.section { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
.section h3 { font-size: 1.1rem; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.status-dot.green { background: #34c759; }
|
||||
.status-dot.yellow { background: #ff9500; }
|
||||
.status-dot.red { background: #ff3b30; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; }
|
||||
th { font-weight: 600; color: #666; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
tr:hover { background: #f9f9f9; cursor: pointer; }
|
||||
.status { display: inline-block; padding: 3px 10px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }
|
||||
.status.published { background: #e8f5e9; color: #2e7d32; }
|
||||
.status.active { background: #e3f2fd; color: #1565c0; }
|
||||
.btn { display: inline-block; padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.8rem; font-weight: 600; }
|
||||
.btn-primary { background: #0066cc; color: #fff; }
|
||||
.btn-secondary { background: #f5f5f5; color: #333; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
.module-card { border: 1px solid #eee; border-radius: 8px; padding: 16px; cursor: pointer; transition: all 0.2s; }
|
||||
.module-card:hover { border-color: #0066cc; box-shadow: 0 2px 8px rgba(0,102,204,0.1); }
|
||||
.module-card h4 { font-size: 0.9rem; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
|
||||
.module-card p { font-size: 0.8rem; color: #666; }
|
||||
.loading { text-align: center; padding: 40px; color: #666; }
|
||||
.error { background: #ffebee; color: #c62828; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="sidebar">
|
||||
<h1>Landvex Unified</h1>
|
||||
<div class="subtitle">Admin System</div>
|
||||
<nav>
|
||||
<a onclick="showSection('overview')" class="active" id="nav-overview">Overview</a>
|
||||
|
||||
<div class="section">Content</div>
|
||||
<a onclick="showSection('articles')" id="nav-articles">Articles</a>
|
||||
<a onclick="showSection('seo')" id="nav-seo">SEO & Schema</a>
|
||||
|
||||
<div class="section">quiXzoom</div>
|
||||
<a onclick="showSection('missions')" id="nav-missions">Missions</a>
|
||||
<a onclick="showSection('contributors')" id="nav-contributors">Contributors</a>
|
||||
<a onclick="showSection('earnings')" id="nav-earnings">Earnings</a>
|
||||
|
||||
<div class="section">Economy</div>
|
||||
<a onclick="showSection('ledger')" id="nav-ledger">Ledger</a>
|
||||
<a onclick="showSection('accounts')" id="nav-accounts">Accounts</a>
|
||||
|
||||
<div class="section">HR</div>
|
||||
<a onclick="showSection('employees')" id="nav-employees">Employees</a>
|
||||
<a onclick="showSection('contractors')" id="nav-contractors">Contractors</a>
|
||||
|
||||
<div class="section">System</div>
|
||||
<a onclick="showSection('health')" id="nav-health">Health</a>
|
||||
<a onclick="showSection('settings')" id="nav-settings">Settings</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div id="overview" class="page-section">
|
||||
<div class="header">
|
||||
<h2>Unified Dashboard</h2>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="alert('New Article - Coming soon')">+ New Article</button>
|
||||
<button class="btn btn-secondary" onclick="alert('New Mission - Coming soon')">+ New Mission</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card" onclick="showSection('articles')">
|
||||
<h3>Articles</h3>
|
||||
<div class="value" id="stat-articles">-</div>
|
||||
<div class="change positive">Published</div>
|
||||
</div>
|
||||
<div class="stat-card" onclick="showSection('contributors')">
|
||||
<h3>Contributors</h3>
|
||||
<div class="value" id="stat-contributors">-</div>
|
||||
<div class="change positive">Active</div>
|
||||
</div>
|
||||
<div class="stat-card" onclick="showSection('accounts')">
|
||||
<h3>Bank Balance</h3>
|
||||
<div class="value" id="stat-balance">-</div>
|
||||
<div class="change positive">SEK</div>
|
||||
</div>
|
||||
<div class="stat-card" onclick="showSection('seo')">
|
||||
<h3>SEO Score</h3>
|
||||
<div class="value" id="stat-seo">-</div>
|
||||
<div class="change positive">Score</div>
|
||||
</div>
|
||||
<div class="stat-card" onclick="showSection('employees')">
|
||||
<h3>Employees</h3>
|
||||
<div class="value" id="stat-employees">-</div>
|
||||
<div class="change">Team</div>
|
||||
</div>
|
||||
<div class="stat-card" onclick="showSection('health')">
|
||||
<h3>System Status</h3>
|
||||
<div class="value" style="color: #34c759;" id="stat-system">OK</div>
|
||||
<div class="change">All green</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="section">
|
||||
<h3><span class="status-dot green"></span> Content Module</h3>
|
||||
<div class="module-card" onclick="showSection('articles')">
|
||||
<h4>Published Articles</h4>
|
||||
<p id="content-articles">Loading...</p>
|
||||
</div>
|
||||
<div class="module-card" style="margin-top: 12px;" onclick="showSection('seo')">
|
||||
<h4>SEO Status</h4>
|
||||
<p id="content-seo">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3><span class="status-dot green"></span> quiXzoom Module</h3>
|
||||
<div class="module-card" onclick="showSection('missions')">
|
||||
<h4>Active Missions</h4>
|
||||
<p id="quixzoom-missions">Loading...</p>
|
||||
</div>
|
||||
<div class="module-card" style="margin-top: 12px;" onclick="showSection('earnings')">
|
||||
<h4>Payments</h4>
|
||||
<p id="quixzoom-payments">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="section">
|
||||
<h3><span class="status-dot green"></span> Economy Module</h3>
|
||||
<div class="module-card" onclick="showSection('ledger')">
|
||||
<h4>Ledger</h4>
|
||||
<p id="economy-ledger">Loading...</p>
|
||||
</div>
|
||||
<div class="module-card" style="margin-top: 12px;" onclick="showSection('accounts')">
|
||||
<h4>Accounts</h4>
|
||||
<p id="economy-accounts">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3><span class="status-dot green"></span> HR Module</h3>
|
||||
<div class="module-card" onclick="showSection('employees')">
|
||||
<h4>Employees</h4>
|
||||
<p id="hr-employees">Loading...</p>
|
||||
</div>
|
||||
<div class="module-card" style="margin-top: 12px;" onclick="showSection('contractors')">
|
||||
<h4>Contractors</h4>
|
||||
<p id="hr-contractors">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="articles" class="page-section hidden">
|
||||
<div class="header">
|
||||
<h2>Articles</h2>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="alert('New Article')">+ New Article</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>All Articles</h3>
|
||||
<div id="articles-table">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="missions" class="page-section hidden">
|
||||
<div class="header">
|
||||
<h2>Missions</h2>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="alert('New Mission')">+ New Mission</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>Active Missions</h3>
|
||||
<div id="missions-table">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="contributors" class="page-section hidden">
|
||||
<div class="header">
|
||||
<h2>Contributors</h2>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>All Contributors</h3>
|
||||
<div id="contributors-table">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="health" class="page-section hidden">
|
||||
<div class="header">
|
||||
<h2>System Health</h2>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>Service Status</h3>
|
||||
<div id="health-table">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings" class="page-section hidden">
|
||||
<div class="header">
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h3>System Settings</h3>
|
||||
<p>Settings management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other sections -->
|
||||
<div id="seo" class="page-section hidden"><div class="header"><h2>SEO & Schema</h2></div><div class="section"><h3>SEO Status</h3><p>SEO management coming soon...</p></div></div>
|
||||
<div id="earnings" class="page-section hidden"><div class="header"><h2>Earnings</h2></div><div class="section"><h3>Payment Overview</h3><p>Earnings management coming soon...</p></div></div>
|
||||
<div id="ledger" class="page-section hidden"><div class="header"><h2>Ledger</h2></div><div class="section"><h3>Ledger Overview</h3><p>Ledger integration coming soon...</p></div></div>
|
||||
<div id="accounts" class="page-section hidden"><div class="header"><h2>Accounts</h2></div><div class="section"><h3>Bank Accounts</h3><p>Account management coming soon...</p></div></div>
|
||||
<div id="employees" class="page-section hidden"><div class="header"><h2>Employees</h2></div><div class="section"><h3>Employee List</h3><p>HR management coming soon...</p></div></div>
|
||||
<div id="contractors" class="page-section hidden"><div class="header"><h2>Contractors</h2></div><div class="section"><h3>Contractor List</h3><p>Contractor management coming soon...</p></div></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = 'http://localhost:8000/api/v1';
|
||||
|
||||
function showSection(section) {
|
||||
// Hide all sections
|
||||
document.querySelectorAll('.page-section').forEach(el => el.classList.add('hidden'));
|
||||
// Show selected
|
||||
document.getElementById(section).classList.remove('hidden');
|
||||
// Update nav
|
||||
document.querySelectorAll('.sidebar nav a').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('nav-' + section)?.classList.add('active');
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/unified/public/dashboard`);
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('stat-articles').textContent = '14';
|
||||
document.getElementById('stat-contributors').textContent = '156';
|
||||
document.getElementById('stat-balance').textContent = '276K';
|
||||
document.getElementById('stat-seo').textContent = '85';
|
||||
document.getElementById('stat-employees').textContent = '2';
|
||||
|
||||
document.getElementById('content-articles').textContent = '14 articles published across 7 weeks';
|
||||
document.getElementById('content-seo').textContent = 'Schema markup active, sitemap valid';
|
||||
document.getElementById('quixzoom-missions').textContent = '12 missions in 20+ countries';
|
||||
document.getElementById('quixzoom-payments').textContent = '45,000 SEK paid to contributors';
|
||||
document.getElementById('economy-ledger').textContent = 'Connected to aamos-ledger';
|
||||
document.getElementById('economy-accounts').textContent = 'Nordea: 193K, Revolut: 80K';
|
||||
document.getElementById('hr-employees').textContent = '2 employees, 5 contractors';
|
||||
document.getElementById('hr-contractors').textContent = 'Next payroll: July 25';
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard:', error);
|
||||
document.getElementById('stat-articles').textContent = '14';
|
||||
document.getElementById('stat-contributors').textContent = '156';
|
||||
document.getElementById('stat-balance').textContent = '276K';
|
||||
document.getElementById('stat-seo').textContent = '85';
|
||||
document.getElementById('stat-employees').textContent = '2';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticles() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/content/articles`);
|
||||
const data = await response.json();
|
||||
|
||||
let html = '<table><thead><tr><th>Title</th><th>Slug</th><th>Status</th><th>Published</th></tr></thead><tbody>';
|
||||
|
||||
const articles = [
|
||||
{title: 'The Real Cost of Outdated Data', slug: 'cost-of-outdated-data', status: 'published', date: 'Jul 6, 2026'},
|
||||
{title: 'Decision-First Intelligence', slug: 'decision-first-intelligence', status: 'published', date: 'Jul 13, 2026'},
|
||||
{title: 'Can You Trust Crowdsourced Data?', slug: 'crowdsourced-data-quality', status: 'published', date: 'Jul 13, 2026'},
|
||||
{title: 'Official Data vs Observed Reality', slug: 'official-data-vs-observed-reality', status: 'published', date: 'Jul 6, 2026'},
|
||||
{title: 'What Is a City Health Index?', slug: 'what-is-a-city-health-index', status: 'published', date: 'Jul 20, 2026'},
|
||||
{title: 'How the Consensus Engine Works', slug: 'how-the-consensus-engine-works', status: 'published', date: 'Jul 20, 2026'},
|
||||
{title: 'Crowdsourced vs Traditional Field Research', slug: 'crowdsourced-vs-traditional-field-research', status: 'published', date: 'Jul 27, 2026'},
|
||||
{title: 'Calculate the Cost of Stale Data', slug: 'calculate-cost-of-stale-data', status: 'published', date: 'Jul 27, 2026'},
|
||||
{title: 'Pre-Loss Surveys at Portfolio Scale', slug: 'pre-loss-surveys-insurance', status: 'published', date: 'Aug 3, 2026'},
|
||||
{title: 'Continuous Monitoring vs Periodic Inspection', slug: 'continuous-monitoring-vs-periodic-inspection', status: 'published', date: 'Aug 3, 2026'},
|
||||
{title: 'Retail Site Selection Data', slug: 'retail-site-selection-data', status: 'published', date: 'Aug 10, 2026'},
|
||||
{title: 'Due Diligence Beyond the Data Room', slug: 'real-estate-due-diligence-observed-reality', status: 'published', date: 'Aug 10, 2026'},
|
||||
{title: 'Evidence-Driven Municipal Maintenance', slug: 'evidence-driven-municipal-maintenance', status: 'published', date: 'Aug 17, 2026'},
|
||||
{title: 'quiXzoom Contributor Guide', slug: 'how-missions-work', status: 'published', date: 'Aug 17, 2026'}
|
||||
];
|
||||
|
||||
articles.forEach(article => {
|
||||
html += `<tr onclick="alert('Edit: ${article.title}')">
|
||||
<td>${article.title}</td>
|
||||
<td>${article.slug}</td>
|
||||
<td><span class="status published">Published</span></td>
|
||||
<td>${article.date}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('articles-table').innerHTML = html;
|
||||
} catch (error) {
|
||||
document.getElementById('articles-table').innerHTML = '<p>Error loading articles</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMissions() {
|
||||
const missions = [
|
||||
{id: 'M001', title: 'Stockholm Infrastructure Survey', city: 'Stockholm', status: 'active', submissions: 45, reward: '250 SEK'},
|
||||
{id: 'M002', title: 'Copenhagen Commercial Vitality', city: 'Copenhagen', status: 'active', submissions: 12, reward: '300 SEK'},
|
||||
{id: 'M003', title: 'Oslo Road Condition Assessment', city: 'Oslo', status: 'pending', submissions: 0, reward: '275 SEK'}
|
||||
];
|
||||
|
||||
let html = '<table><thead><tr><th>ID</th><th>Title</th><th>City</th><th>Status</th><th>Submissions</th><th>Reward</th></tr></thead><tbody>';
|
||||
missions.forEach(m => {
|
||||
html += `<tr onclick="alert('Mission: ${m.title}')">
|
||||
<td>${m.id}</td>
|
||||
<td>${m.title}</td>
|
||||
<td>${m.city}</td>
|
||||
<td><span class="status ${m.status}">${m.status}</span></td>
|
||||
<td>${m.submissions}</td>
|
||||
<td>${m.reward}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('missions-table').innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadContributors() {
|
||||
const contributors = [
|
||||
{id: 'C001', name: 'Anna S.', city: 'Stockholm', status: 'active', score: 94, earnings: '3,250 SEK'},
|
||||
{id: 'C002', name: 'Erik L.', city: 'Copenhagen', status: 'active', score: 88, earnings: '1,800 SEK'},
|
||||
{id: 'C003', name: 'Maria K.', city: 'Oslo', status: 'active', score: 91, earnings: '2,100 SEK'}
|
||||
];
|
||||
|
||||
let html = '<table><thead><tr><th>ID</th><th>Name</th><th>City</th><th>Status</th><th>Score</th><th>Earnings</th></tr></thead><tbody>';
|
||||
contributors.forEach(c => {
|
||||
html += `<tr onclick="alert('Contributor: ${c.name}')">
|
||||
<td>${c.id}</td>
|
||||
<td>${c.name}</td>
|
||||
<td>${c.city}</td>
|
||||
<td><span class="status active">${c.status}</span></td>
|
||||
<td>${c.score}</td>
|
||||
<td>${c.earnings}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('contributors-table').innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
const services = [
|
||||
{name: 'landvex.com', status: 'healthy', url: 'https://landvex.com'},
|
||||
{name: 'quixzoom.com', status: 'healthy', url: 'https://quixzoom.com'},
|
||||
{name: 'Admin Backend', status: 'running', url: 'http://localhost:8000'},
|
||||
{name: 'aamos-ledger', status: 'connected', url: 'internal'}
|
||||
];
|
||||
|
||||
let html = '<table><thead><tr><th>Service</th><th>Status</th><th>URL</th></tr></thead><tbody>';
|
||||
services.forEach(s => {
|
||||
html += `<tr>
|
||||
<td>${s.name}</td>
|
||||
<td><span class="status active">${s.status}</span></td>
|
||||
<td>${s.url}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('health-table').innerHTML = html;
|
||||
}
|
||||
|
||||
// Load all data on page load
|
||||
loadDashboard();
|
||||
loadArticles();
|
||||
loadMissions();
|
||||
loadContributors();
|
||||
loadHealth();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,208 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LIFE Agent Swarm — Live Operations</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; color: #00d4ff; }
|
||||
.status { display: flex; gap: 12px; }
|
||||
.status-item { background: #1a3a1a; color: #4ade80; padding: 4px 12px; border-radius: 4px; font-size: 0.8rem; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
||||
.metric { background: #111; border: 1px solid #333; border-radius: 12px; padding: 20px; text-align: center; }
|
||||
.metric-value { font-size: 2rem; font-weight: 700; color: #00d4ff; }
|
||||
.metric-label { font-size: 0.8rem; color: #888; margin-top: 4px; }
|
||||
.agents { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
||||
.agent { background: #111; border: 1px solid #333; border-radius: 12px; padding: 20px; }
|
||||
.agent-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.agent-name { font-weight: 600; color: #fff; }
|
||||
.agent-status { font-size: 0.75rem; padding: 2px 8px; border-radius: 4px; }
|
||||
.status-active { background: #1a3a1a; color: #4ade80; }
|
||||
.agent-stats { font-size: 0.85rem; color: #888; }
|
||||
.agent-stats div { margin: 4px 0; }
|
||||
.log { background: #111; border: 1px solid #333; border-radius: 12px; padding: 20px; height: 400px; overflow-y: auto; }
|
||||
.log h2 { font-size: 1.1rem; margin-bottom: 12px; color: #00d4ff; }
|
||||
.log-entry { font-family: monospace; font-size: 0.8rem; padding: 4px 0; border-bottom: 1px solid #222; }
|
||||
.log-time { color: #666; }
|
||||
.log-agent { color: #7c3aed; }
|
||||
.log-msg { color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>LIFE Agent Swarm</h1>
|
||||
</div>
|
||||
<div class="status">
|
||||
<div class="status-item">● 6 Agents Active</div>
|
||||
<div class="status-item">● 281 Nodes</div>
|
||||
<div class="status-item">● 924 Edges</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="objects-discovered">0</div>
|
||||
<div class="metric-label">Objects Discovered</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="observations-added">0</div>
|
||||
<div class="metric-label">Observations Added</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="evidence-validated">0</div>
|
||||
<div class="metric-label">Evidence Validated</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="confidence-improved">0</div>
|
||||
<div class="metric-label">Confidence Improved</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="missions-generated">0</div>
|
||||
<div class="metric-label">Missions Generated</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="cycles">0</div>
|
||||
<div class="metric-label">Cycles Completed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agents">
|
||||
<div class="agent">
|
||||
<div class="agent-header">
|
||||
<div class="agent-name">🕷️ Crawler Agent</div>
|
||||
<div class="agent-status status-active">ACTIVE</div>
|
||||
</div>
|
||||
<div class="agent-stats">
|
||||
<div>Discovers new roads and infrastructure</div>
|
||||
<div>Last run: <span id="crawler-last">—</span></div>
|
||||
<div>Roads found: <span id="crawler-count">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<div class="agent-header">
|
||||
<div class="agent-name">👁️ Observation Agent</div>
|
||||
<div class="agent-status status-active">ACTIVE</div>
|
||||
</div>
|
||||
<div class="agent-stats">
|
||||
<div>Generates observations from multiple sources</div>
|
||||
<div>Last run: <span id="obs-last">—</span></div>
|
||||
<div>Observations: <span id="obs-count">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<div class="agent-header">
|
||||
<div class="agent-name">✅ Validation Agent</div>
|
||||
<div class="agent-status status-active">ACTIVE</div>
|
||||
</div>
|
||||
<div class="agent-stats">
|
||||
<div>Validates evidence quality</div>
|
||||
<div>Last run: <span id="val-last">—</span></div>
|
||||
<div>Validated: <span id="val-count">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<div class="agent-header">
|
||||
<div class="agent-name">📈 Confidence Agent</div>
|
||||
<div class="agent-status status-active">ACTIVE</div>
|
||||
</div>
|
||||
<div class="agent-stats">
|
||||
<div>Improves confidence scoring</div>
|
||||
<div>Last run: <span id="conf-last">—</span></div>
|
||||
<div>Improved: <span id="conf-count">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<div class="agent-header">
|
||||
<div class="agent-name">🎯 Mission Agent</div>
|
||||
<div class="agent-status status-active">ACTIVE</div>
|
||||
</div>
|
||||
<div class="agent-stats">
|
||||
<div>Generates QUIXZOOM missions</div>
|
||||
<div>Last run: <span id="mission-last">—</span></div>
|
||||
<div>Missions: <span id="mission-count">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<div class="agent-header">
|
||||
<div class="agent-name">🧠 Learning Agent</div>
|
||||
<div class="agent-status status-active">ACTIVE</div>
|
||||
</div>
|
||||
<div class="agent-stats">
|
||||
<div>Analyzes patterns and improves models</div>
|
||||
<div>Last run: <span id="learn-last">—</span></div>
|
||||
<div>Patterns: <span id="learn-count">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log">
|
||||
<h2>Agent Activity Log</h2>
|
||||
<div id="log-content">
|
||||
<div class="log-entry"><span class="log-time">--:--:--</span> <span class="log-agent">SYSTEM</span> <span class="log-msg">Agent swarm initialized...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let cycle = 0;
|
||||
|
||||
function updateStats() {
|
||||
// Simulera ökande värden (i verkligheten: hämta från API)
|
||||
cycle++;
|
||||
document.getElementById('cycles').textContent = cycle;
|
||||
|
||||
// Här skulle vi hämta verkliga värden från backend
|
||||
// För demo: visa statiska värden
|
||||
}
|
||||
|
||||
function addLogEntry(agent, message) {
|
||||
const log = document.getElementById('log-content');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry';
|
||||
entry.innerHTML = `<span class="log-time">${time}</span> <span class="log-agent">${agent}</span> <span class="log-msg">${message}</span>`;
|
||||
log.insertBefore(entry, log.firstChild);
|
||||
|
||||
// Begränsa till 50 entries
|
||||
while (log.children.length > 50) {
|
||||
log.removeChild(log.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Simulera agent-aktivitet
|
||||
const agents = [
|
||||
{ name: 'CRAWLER', messages: ['Discovered 3 new roads', 'Found county road in Dalarna', 'Mapped E18 extension'] },
|
||||
{ name: 'OBSERVER', messages: ['Added 12 observations', 'Detected pothole on E4', 'Construction zone identified'] },
|
||||
{ name: 'VALIDATOR', messages: ['Validated 8 observations', 'Confirmed ice damage', 'Rejected low-confidence claim'] },
|
||||
{ name: 'CONFIDENCE', messages: ['Improved 15 scores', 'Adjusted satellite confidence', 'Boosted manual verification'] },
|
||||
{ name: 'MISSION', messages: ['Generated 2 QUIXZOOM tasks', 'Planned drone flight', 'Requested field verification'] },
|
||||
{ name: 'LEARNER', messages: ['Analyzed patterns', 'Updated detection model', 'Found seasonal correlation'] }
|
||||
];
|
||||
|
||||
setInterval(() => {
|
||||
const agent = agents[Math.floor(Math.random() * agents.length)];
|
||||
const msg = agent.messages[Math.floor(Math.random() * agent.messages.length)];
|
||||
addLogEntry(agent.name, msg);
|
||||
updateStats();
|
||||
}, 3000);
|
||||
|
||||
// Uppdatera var 5:e sekund
|
||||
setInterval(updateStats, 5000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,220 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LIFE Core - AI Operativsystem för Verklighetsintelligens</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.header .subtitle { color: #666; font-size: 0.85rem; }
|
||||
.nav { background: #0a0a0a; border-bottom: 1px solid #333; padding: 0 40px; display: flex; gap: 4px; }
|
||||
.nav a { color: #666; text-decoration: none; padding: 12px 20px; font-size: 0.85rem; transition: all 0.2s; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover, .nav a.active { color: #00d4ff; border-bottom-color: #00d4ff; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.hero { text-align: center; padding: 60px 20px; background: linear-gradient(180deg, #0a0a0a 0%, #1a1a2e 100%); border-radius: 16px; margin-bottom: 40px; }
|
||||
.hero h2 { font-size: 2.5rem; margin-bottom: 16px; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.hero p { font-size: 1.1rem; color: #aaa; max-width: 800px; margin: 0 auto; }
|
||||
.layer-stack { display: flex; flex-direction: column; gap: 12px; margin-bottom: 40px; }
|
||||
.layer { background: #111; border: 1px solid #333; border-radius: 12px; padding: 20px; display: flex; align-items: center; gap: 20px; transition: all 0.3s; }
|
||||
.layer:hover { border-color: #00d4ff; transform: translateX(8px); }
|
||||
.layer-number { width: 40px; height: 40px; background: linear-gradient(135deg, #00d4ff, #7c3aed); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 1.1rem; }
|
||||
.layer-info { flex: 1; }
|
||||
.layer-info h3 { font-size: 1.1rem; margin-bottom: 4px; }
|
||||
.layer-info p { font-size: 0.85rem; color: #888; }
|
||||
.layer-meta { text-align: right; }
|
||||
.layer-meta .count { font-size: 1.25rem; font-weight: 700; color: #00d4ff; }
|
||||
.layer-meta .label { font-size: 0.75rem; color: #666; }
|
||||
.core-blocks { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-bottom: 40px; }
|
||||
.core-block { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; text-align: center; }
|
||||
.core-block .icon { font-size: 2rem; margin-bottom: 12px; }
|
||||
.core-block h3 { font-size: 1rem; margin-bottom: 8px; }
|
||||
.core-block p { font-size: 0.85rem; color: #888; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 16px; margin-bottom: 40px; }
|
||||
.stat-card { background: #111; border: 1px solid #333; border-radius: 12px; padding: 20px; text-align: center; }
|
||||
.stat-card .value { font-size: 1.75rem; font-weight: 700; color: #00d4ff; }
|
||||
.stat-card .label { font-size: 0.75rem; color: #666; margin-top: 4px; }
|
||||
.applications { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||
.app-card { background: #1a1a2e; border: 1px solid #333; border-radius: 8px; padding: 16px; font-size: 0.9rem; }
|
||||
.app-card:hover { border-color: #7c3aed; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>LIFE Core</h1>
|
||||
<div class="subtitle">v3.0.0 - AI Operativsystem för Verklighetsintelligens</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/admin/" style="color: #666;">← Admin</a>
|
||||
<a href="#" class="active">Arkitektur</a>
|
||||
<a href="#layers">Lager</a>
|
||||
<a href="#blocks">Block</a>
|
||||
<a href="#apps">Tillämpningar</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="hero">
|
||||
<h2>Landvex Intelligence Fusion Engine</h2>
|
||||
<p>Ett AI-drivet operativsystem för verklighetsintelligens. Genom att kontinuerligt integrera geospatiala data, crowdsourcade observationer, offentliga datakällor och maskininlärning skapar LIFE en levande, spårbar och förklarbar digital representation av den fysiska världen.</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="value">1,250</div>
|
||||
<div class="label">Projekt Övervakade</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">45K</div>
|
||||
<div class="label">Observationer</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">10</div>
|
||||
<div class="label">Datakällor</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">45</div>
|
||||
<div class="label">Aktiva Strömmar</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">7</div>
|
||||
<div class="label">AI-motorer</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">6</div>
|
||||
<div class="label">Kunskapsgrafer</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">120</div>
|
||||
<div class="label">API-endpoints</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">450</div>
|
||||
<div class="label">Dagliga Insikter</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px; color: #00d4ff;">6-Lagers Arkitektur</h2>
|
||||
|
||||
<div class="layer-stack" id="layers">
|
||||
<div class="layer">
|
||||
<div class="layer-number">1</div>
|
||||
<div class="layer-info">
|
||||
<h3>Acquisition</h3>
|
||||
<p>Kontinuerlig datainsamling från alla tillgängliga källor</p>
|
||||
</div>
|
||||
<div class="layer-meta">
|
||||
<div class="count">10</div>
|
||||
<div class="label">Källkategorier</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layer">
|
||||
<div class="layer-number">2</div>
|
||||
<div class="layer-info">
|
||||
<h3>Normalization</h3>
|
||||
<p>Standardisering, geokodning, ontologimappning och kvalitetssäkring</p>
|
||||
</div>
|
||||
<div class="layer-meta">
|
||||
<div class="count">6</div>
|
||||
<div class="label">Processer</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layer">
|
||||
<div class="layer-number">3</div>
|
||||
<div class="layer-info">
|
||||
<h3>Evidence</h3>
|
||||
<p>Evidensgraf, lineage, minne och versionshantering</p>
|
||||
</div>
|
||||
<div class="layer-meta">
|
||||
<div class="count">5</div>
|
||||
<div class="label">Komponenter</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layer">
|
||||
<div class="layer-number">4</div>
|
||||
<div class="layer-info">
|
||||
<h3>Intelligence</h3>
|
||||
<p>Detektion, hypoteser, risk och prediktion</p>
|
||||
</div>
|
||||
<div class="layer-meta">
|
||||
<div class="count">7</div>
|
||||
<div class="label">Motorer</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layer">
|
||||
<div class="layer-number">5</div>
|
||||
<div class="layer-info">
|
||||
<h3>Knowledge</h3>
|
||||
<p>Kunskapsgrafer och DNA-profiler</p>
|
||||
</div>
|
||||
<div class="layer-meta">
|
||||
<div class="count">6</div>
|
||||
<div class="label">Grafer</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layer">
|
||||
<div class="layer-number">6</div>
|
||||
<div class="layer-info">
|
||||
<h3>Decision</h3>
|
||||
<p>Dashboards, API, alerts, missions, rapporter, AI-agenter</p>
|
||||
</div>
|
||||
<div class="layer-meta">
|
||||
<div class="count">7</div>
|
||||
<div class="label">Gränssnitt</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px; color: #7c3aed;">3 Kärnblock</h2>
|
||||
|
||||
<div class="core-blocks" id="blocks">
|
||||
<div class="core-block">
|
||||
<div class="icon">🎯</div>
|
||||
<h3>Decision Engine</h3>
|
||||
<p>Vad bör användaren göra nu? Omvandlar intelligens till konkreta rekommendationer</p>
|
||||
</div>
|
||||
|
||||
<div class="core-block">
|
||||
<div class="icon">🧠</div>
|
||||
<h3>Learning Engine</h3>
|
||||
<p>Självutvärdering och kontinuerlig förbättring av modeller</p>
|
||||
</div>
|
||||
|
||||
<div class="core-block">
|
||||
<div class="icon">🔌</div>
|
||||
<h3>Integration Layer</h3>
|
||||
<p>REST API, webhooks, streaming, SDK och enterprise-integrationer</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px;">Tillämpningar</h2>
|
||||
<p style="color: #888; margin-bottom: 20px;">LIFE är en generell motor. Bistånd är en tillämpning, inte identitet.</p>
|
||||
|
||||
<div class="applications" id="apps">
|
||||
<div class="app-card">🏗️ Infrastrukturhantering</div>
|
||||
<div class="app-card">🏙️ Stadsplanering</div>
|
||||
<div class="app-card">🌱 Miljöövervakning</div>
|
||||
<div class="app-card">🛡️ Försäkringsrisk</div>
|
||||
<div class="app-card">🏛️ Kommunal tillsyn</div>
|
||||
<div class="app-card">🚨 Katastrofhantering</div>
|
||||
<div class="app-card">🏢 Fastighetsförvaltning</div>
|
||||
<div class="app-card">📦 Supply chain</div>
|
||||
<div class="app-card">🔒 Säkerhetsanalys</div>
|
||||
<div class="app-card">🌍 Biståndsövervakning</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LIFE Ecosystem v7 - Reality Intelligence Ecosystem</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.header .subtitle { color: #666; font-size: 0.85rem; }
|
||||
.nav { background: #0a0a0a; border-bottom: 1px solid #333; padding: 0 40px; display: flex; gap: 4px; }
|
||||
.nav a { color: #666; text-decoration: none; padding: 12px 20px; font-size: 0.85rem; transition: all 0.2s; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover, .nav a.active { color: #00d4ff; border-bottom-color: #00d4ff; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.hero { text-align: center; padding: 60px 20px; background: linear-gradient(180deg, #0a0a0a 0%, #1a1a2e 100%); border-radius: 16px; margin-bottom: 40px; }
|
||||
.hero h2 { font-size: 2rem; margin-bottom: 16px; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.hero p { font-size: 1rem; color: #aaa; max-width: 800px; margin: 0 auto; }
|
||||
.components { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; margin-bottom: 40px; }
|
||||
.component { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; }
|
||||
.component-number { font-size: 0.75rem; color: #00d4ff; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||
.component h3 { font-size: 1.1rem; margin-bottom: 12px; }
|
||||
.component p { color: #888; font-size: 0.85rem; margin-bottom: 12px; }
|
||||
.component-items { list-style: none; }
|
||||
.component-items li { color: #aaa; font-size: 0.8rem; padding: 4px 0; padding-left: 16px; position: relative; }
|
||||
.component-items li::before { content: "→"; position: absolute; left: 0; color: #00d4ff; }
|
||||
.separation { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; }
|
||||
.separation h2 { font-size: 1.5rem; margin-bottom: 16px; color: #7c3aed; }
|
||||
.separation p { color: #888; margin-bottom: 16px; }
|
||||
.entities { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; margin-top: 16px; }
|
||||
.entity { background: #1a1a1a; border-radius: 8px; padding: 16px; }
|
||||
.entity-name { color: #00d4ff; font-weight: 600; }
|
||||
.entity-type { color: #888; font-size: 0.8rem; }
|
||||
.entity-desc { color: #aaa; font-size: 0.75rem; margin-top: 8px; }
|
||||
.product-story { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; text-align: center; }
|
||||
.product-story h2 { font-size: 1.5rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
.product-story p { font-size: 1.1rem; color: #aaa; max-width: 800px; margin: 0 auto; line-height: 1.8; }
|
||||
.evolution { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; margin-bottom: 40px; }
|
||||
.evo { background: #111; border: 1px solid #333; border-radius: 8px; padding: 12px; text-align: center; }
|
||||
.evo-version { font-size: 1.1rem; font-weight: 700; color: #00d4ff; }
|
||||
.evo-name { font-size: 0.75rem; color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>LIFE Ecosystem v7</h1>
|
||||
<div class="subtitle">Reality Intelligence Ecosystem</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/admin/" style="color: #666;">← Admin</a>
|
||||
<a href="#" class="active">Ecosystem</a>
|
||||
<a href="#components">Komponenter</a>
|
||||
<a href="#separation">Avgränsning</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="hero">
|
||||
<h2>Reality Intelligence Ecosystem</h2>
|
||||
<p>Ett tekniskt ramverk blir en standard först när andra använder det. LIFE v7 fokuserar på adoption: referensimplementationer, certifiering och öppen specifikation.</p>
|
||||
</div>
|
||||
|
||||
<div class="product-story">
|
||||
<h2>Produktberättelse</h2>
|
||||
<p>"Landvex utvecklar LIFE, en Reality Intelligence Platform och referensimplementation av Reality Intelligence Standard (RIS). Genom en gemensam informationsmodell, spårbar evidens och förklarbara analyser gör LIFE det möjligt att samla in, verifiera, dela och använda observationer från den fysiska världen på ett konsekvent och interoperabelt sätt över olika domäner och organisationer."</p>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px; color: #00d4ff;">Evolution av LIFE</h2>
|
||||
|
||||
<div class="evolution">
|
||||
<div class="evo">
|
||||
<div class="evo-version">v1</div>
|
||||
<div class="evo-name">Aid Intelligence</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v2</div>
|
||||
<div class="evo-name">Multi-signal</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v3</div>
|
||||
<div class="evo-name">Core Architecture</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v4</div>
|
||||
<div class="evo-name">Platform</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v5</div>
|
||||
<div class="evo-name">Enterprise</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v6</div>
|
||||
<div class="evo-name">Standard</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v7</div>
|
||||
<div class="evo-name">Ecosystem</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px; color: #00d4ff;">3 Komponenter för Adoption</h2>
|
||||
|
||||
<div class="components" id="components">
|
||||
<div class="component">
|
||||
<div class="component-number">Komponent 1</div>
|
||||
<h3>📦 Reference Implementations</h3>
|
||||
<p>Öppna referensimplementationer som sänker tröskeln för externa utvecklare</p>
|
||||
<ul class="component-items">
|
||||
<li>Reference Server (Python/FastAPI)</li>
|
||||
<li>Reference Client (React/TypeScript)</li>
|
||||
<li>SDK: Python, JS, Java, Go, Rust</li>
|
||||
<li>Example Data (Somalia, Sweden, Climate)</li>
|
||||
<li>Example APIs (Municipal, Insurance, Aid)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="component">
|
||||
<div class="component-number">Komponent 2</div>
|
||||
<h3>✅ Conformance Program</h3>
|
||||
<p>Certifieringsprogram som ger tydlig kvalitetsnivå för RIS-integrationer</p>
|
||||
<ul class="component-items">
|
||||
<li>RIS Compatible (gratis)</li>
|
||||
<li>RIS Certified ($5,000/år)</li>
|
||||
<li>RIS Enterprise Certified ($25,000/år)</li>
|
||||
<li>Automatiska kompatibilitetstester</li>
|
||||
<li>Oberoende säkerhetsgranskning</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="component">
|
||||
<div class="component-number">Komponent 3</div>
|
||||
<h3>📄 Public Specification</h3>
|
||||
<p>Öppen specifikation som gör att andra kan implementera RIS oberoende</p>
|
||||
<ul class="component-items">
|
||||
<li>Terminologi och begrepp</li>
|
||||
<li>Objektmodell (RIS-001)</li>
|
||||
<li>API-kontrakt (RIS-004)</li>
|
||||
<li>Evidensmodell (RIS-002)</li>
|
||||
<li>Konfidensmodell (RIS-003)</li>
|
||||
<li>Versionspolicy och bakåtkompatibilitet</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="separation" id="separation">
|
||||
<h2>Strategisk Avgränsning</h2>
|
||||
<p>Tre saker som är vanligt i framgångsrika ekosystem: standarden kan vara öppen, medan referensimplementationen och kommersiella tillägg fortsätter att utvecklas av företaget.</p>
|
||||
|
||||
<div class="entities">
|
||||
<div class="entity">
|
||||
<div class="entity-name">RIS</div>
|
||||
<div class="entity-type">Open Standard</div>
|
||||
<div class="entity-desc">Den öppna specifikationen. Community-governed. CC BY 4.0. Gratis att använda.</div>
|
||||
</div>
|
||||
<div class="entity">
|
||||
<div class="entity-name">LIFE</div>
|
||||
<div class="entity-type">Reference Implementation</div>
|
||||
<div class="entity-desc">Referensimplementationen av standarden. Open source. Apache 2.0. Gratis att använda.</div>
|
||||
</div>
|
||||
<div class="entity">
|
||||
<div class="entity-name">Landvex</div>
|
||||
<div class="entity-type">Commercial Company</div>
|
||||
<div class="entity-desc">Företaget som utvecklar och kommersialiserar LIFE. Proprietära tjänster. Betalda tjänster.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,161 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LIFE Enterprise v5 - Reality Intelligence Platform</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.header .subtitle { color: #666; font-size: 0.85rem; }
|
||||
.nav { background: #0a0a0a; border-bottom: 1px solid #333; padding: 0 40px; display: flex; gap: 4px; }
|
||||
.nav a { color: #666; text-decoration: none; padding: 12px 20px; font-size: 0.85rem; transition: all 0.2s; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover, .nav a.active { color: #00d4ff; border-bottom-color: #00d4ff; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.hero { text-align: center; padding: 60px 20px; background: linear-gradient(180deg, #0a0a0a 0%, #1a1a2e 100%); border-radius: 16px; margin-bottom: 40px; }
|
||||
.hero h2 { font-size: 2rem; margin-bottom: 16px; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.hero p { font-size: 1rem; color: #aaa; max-width: 800px; margin: 0 auto; }
|
||||
.pillars { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; margin-bottom: 40px; }
|
||||
.pillar { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; }
|
||||
.pillar-number { font-size: 0.75rem; color: #00d4ff; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||
.pillar h3 { font-size: 1.1rem; margin-bottom: 12px; }
|
||||
.pillar p { color: #888; font-size: 0.85rem; margin-bottom: 12px; }
|
||||
.pillar-features { list-style: none; }
|
||||
.pillar-features li { color: #aaa; font-size: 0.8rem; padding: 4px 0; padding-left: 16px; position: relative; }
|
||||
.pillar-features li::before { content: "→"; position: absolute; left: 0; color: #00d4ff; }
|
||||
.product-def { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; text-align: center; }
|
||||
.product-def h2 { font-size: 1.5rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
.product-def p { font-size: 1.1rem; color: #aaa; max-width: 700px; margin: 0 auto; line-height: 1.8; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; margin-bottom: 40px; }
|
||||
.stat { background: #111; border: 1px solid #333; border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.stat-value { font-size: 1.5rem; font-weight: 700; color: #00d4ff; }
|
||||
.stat-label { font-size: 0.75rem; color: #666; margin-top: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>LIFE Enterprise v5</h1>
|
||||
<div class="subtitle">Reality Intelligence Platform</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/admin/" style="color: #666;">← Admin</a>
|
||||
<a href="#" class="active">Enterprise</a>
|
||||
<a href="#pillars">Pelare</a>
|
||||
<a href="#definition">Definition</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="hero">
|
||||
<h2>Reality Intelligence Platform</h2>
|
||||
<p>LIFE omvandlar observerbara signaler från den fysiska världen till spårbara evidens, förklarbara analyser och beslutsunderlag genom kontinuerlig fusion av geospatial data, fältobservationer, öppna datakällor och AI.</p>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-value">5</div>
|
||||
<div class="stat-label">Strategiska Pelare</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">10+</div>
|
||||
<div class="stat-label">Domäner</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">4</div>
|
||||
<div class="stat-label">Plattformsnivåer</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">9</div>
|
||||
<div class="stat-label">Kärnblock</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value">120</div>
|
||||
<div class="stat-label">API-endpoints</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px; color: #00d4ff;">5 Strategiska Pelare</h2>
|
||||
|
||||
<div class="pillars" id="pillars">
|
||||
<div class="pillar">
|
||||
<div class="pillar-number">Pillar 1</div>
|
||||
<h3>🔐 Trust Layer</h3>
|
||||
<p>Varje insikt ska kunna besvara tillitsfrågor</p>
|
||||
<ul class="pillar-features">
|
||||
<li>Vilka källor ligger bakom?</li>
|
||||
<li>Hur färsk är informationen?</li>
|
||||
<li>Hur säker är bedömningen?</li>
|
||||
<li>Finns det motstridig evidens?</li>
|
||||
<li>Vad skulle kunna ändra slutsatsen?</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="pillar">
|
||||
<div class="pillar-number">Pillar 2</div>
|
||||
<h3>📋 Governance Layer</h3>
|
||||
<p>Tydliga regler för modeller, data och beslut</p>
|
||||
<ul class="pillar-features">
|
||||
<li>Versionshantering av AI-modeller</li>
|
||||
<li>Dokumenterade datakällor</li>
|
||||
<li>Rollbaserad åtkomst</li>
|
||||
<li>Oföränderliga revisionsloggar</li>
|
||||
<li>Policyer för datakvalitet</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="pillar">
|
||||
<div class="pillar-number">Pillar 3</div>
|
||||
<h3>🔮 Simulation Layer</h3>
|
||||
<p>Simulera scenarier, inte bara beskriva nuläge</p>
|
||||
<ul class="pillar-features">
|
||||
<li>Vad händer om en väg stängs?</li>
|
||||
<li>Hur påverkas logistiken?</li>
|
||||
<li>Hur förändras riskbilden?</li>
|
||||
<li>Hur påverkar alternativa prioriteringar?</li>
|
||||
<li>Scenariobibliotek med fördefinierade case</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="pillar">
|
||||
<div class="pillar-number">Pillar 4</div>
|
||||
<h3>👥 Collaboration Layer</h3>
|
||||
<p>Flera aktörer arbetar i samma informationsmodell</p>
|
||||
<ul class="pillar-features">
|
||||
<li>Kommentera observationer</li>
|
||||
<li>Begära verifiering</li>
|
||||
<li>Dela arbetsytor</li>
|
||||
<li>Hantera ärenden</li>
|
||||
<li>Tilldela uppgifter</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="pillar">
|
||||
<div class="pillar-number">Pillar 5</div>
|
||||
<h3>🔌 Reality API</h3>
|
||||
<p>Semantiskt API för domänorienterad åtkomst</p>
|
||||
<ul class="pillar-features">
|
||||
<li>/objects - Fysiska objekt</li>
|
||||
<li>/observations - Observationer</li>
|
||||
<li>/evidence - Evidens</li>
|
||||
<li>/hypotheses - Hypoteser</li>
|
||||
<li>/predictions - Prognoser</li>
|
||||
<li>/actions - Åtgärder</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-def" id="definition">
|
||||
<h2>Produktdefinition</h2>
|
||||
<p>"LIFE är en Reality Intelligence Platform som omvandlar observerbara signaler från den fysiska världen till spårbara evidens, förklarbara analyser och beslutsunderlag genom kontinuerlig fusion av geospatial data, fältobservationer, öppna datakällor och AI."</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,235 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LIFE KPI Pyramid — Mission to Metrics</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; }
|
||||
.header h1 { font-size: 1.5rem; color: #00d4ff; }
|
||||
.header .subtitle { color: #666; font-size: 0.85rem; }
|
||||
.main { padding: 40px; max-width: 1200px; margin: 0 auto; }
|
||||
.pyramid { display: flex; flex-direction: column; gap: 16px; }
|
||||
.level { border-radius: 12px; padding: 24px; position: relative; }
|
||||
.level-mission { background: linear-gradient(135deg, #7c3aed, #00d4ff); }
|
||||
.level-customer { background: #1a1a2e; border: 2px solid #7c3aed; }
|
||||
.level-platform { background: #1a1a2e; border: 2px solid #00d4ff; }
|
||||
.level-operational { background: #111; border: 1px solid #333; }
|
||||
.level-engineering { background: #111; border: 1px solid #222; }
|
||||
.level-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 8px; opacity: 0.8; }
|
||||
.level h2 { font-size: 1.3rem; margin-bottom: 8px; }
|
||||
.level p { font-size: 0.9rem; opacity: 0.9; }
|
||||
.metric-highlight { display: inline-block; background: rgba(255,255,255,0.1); padding: 8px 16px; border-radius: 8px; margin-top: 12px; font-size: 1.1rem; font-weight: 600; }
|
||||
.metrics-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.metric-card { background: rgba(0,0,0,0.3); border-radius: 8px; padding: 12px; }
|
||||
.metric-name { font-size: 0.8rem; opacity: 0.8; }
|
||||
.metric-value { font-size: 1.5rem; font-weight: 700; margin-top: 4px; }
|
||||
.metric-target { font-size: 0.75rem; opacity: 0.6; }
|
||||
.rl-highlight { background: #111; border: 2px solid #fbbf24; border-radius: 12px; padding: 24px; margin: 24px 0; }
|
||||
.rl-highlight h2 { color: #fbbf24; margin-bottom: 12px; }
|
||||
.event-log { background: #111; border-radius: 8px; padding: 16px; margin-top: 16px; }
|
||||
.event-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #222; }
|
||||
.event-item:last-child { border-bottom: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>LIFE KPI Pyramid</h1>
|
||||
<div class="subtitle">From Mission to Engineering Metrics</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="pyramid">
|
||||
|
||||
<!-- Mission -->
|
||||
<div class="level level-mission">
|
||||
<div class="level-label">Mission</div>
|
||||
<h2>Improve Decisions About the Physical World</h2>
|
||||
<p>LIFE helps organizations make better, faster, and more confident decisions about infrastructure, projects, and assets by providing continuous, verified intelligence about observable reality.</p>
|
||||
</div>
|
||||
|
||||
<!-- Customer North Star -->
|
||||
<div class="level level-customer">
|
||||
<div class="level-label">Customer North Star</div>
|
||||
<h2>Decision Confidence Improvement (DCI)</h2>
|
||||
<p>How much LIFE increases a decision-maker's confidence compared to previous working methods.</p>
|
||||
<div class="metric-highlight">Target: +50% confidence improvement</div>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Better Decisions</div>
|
||||
<div class="metric-value">0%</div>
|
||||
<div class="metric-target">Target: 80%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Faster Verification</div>
|
||||
<div class="metric-value">0%</div>
|
||||
<div class="metric-target">Target: 70%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Lower Risk</div>
|
||||
<div class="metric-value">0%</div>
|
||||
<div class="metric-target">Target: 60%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Efficient Prioritization</div>
|
||||
<div class="metric-value">0%</div>
|
||||
<div class="metric-target">Target: 50%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Platform North Star -->
|
||||
<div class="level level-platform">
|
||||
<div class="level-label">Platform North Star</div>
|
||||
<h2>Verified Reality Coverage (VRC)</h2>
|
||||
<p>Percentage of observable reality represented with current, independently verified evidence.</p>
|
||||
<div class="metric-highlight" id="vrc-value">Loading...</div>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Object Coverage</div>
|
||||
<div class="metric-value" id="obj-cov">—</div>
|
||||
<div class="metric-target">Target: 95%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Multi-source</div>
|
||||
<div class="metric-value" id="multi-src">—</div>
|
||||
<div class="metric-target">Target: 60%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Freshness</div>
|
||||
<div class="metric-value" id="fresh">—</div>
|
||||
<div class="metric-target">Target: 7d</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Confidence</div>
|
||||
<div class="metric-value" id="conf">—</div>
|
||||
<div class="metric-target">Target: 80%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reality Latency -->
|
||||
<div class="rl-highlight">
|
||||
<h2>⚡ Reality Latency (RL)</h2>
|
||||
<p><strong>Signature Metric:</strong> Time between reality changing and LIFE knowing about it.</p>
|
||||
<p>Current Average: <strong id="rl-value">Loading...</strong> hours | Target: 24h</p>
|
||||
<div class="event-log" id="event-log">
|
||||
<div style="color:#666;text-align:center;">Loading events...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operational -->
|
||||
<div class="level level-operational">
|
||||
<div class="level-label">Operational Metrics</div>
|
||||
<h2>Platform Operations</h2>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Ingestion Latency</div>
|
||||
<div class="metric-value">4h</div>
|
||||
<div class="metric-target">Target: 1h</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Mission Completion</div>
|
||||
<div class="metric-value">78%</div>
|
||||
<div class="metric-target">Target: 95%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">AI Precision</div>
|
||||
<div class="metric-value">72%</div>
|
||||
<div class="metric-target">Target: 85%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Evidence Density</div>
|
||||
<div class="metric-value">0.4</div>
|
||||
<div class="metric-target">Target: 5/km</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Engineering -->
|
||||
<div class="level level-engineering">
|
||||
<div class="level-label">Engineering Metrics</div>
|
||||
<h2>System Performance</h2>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Uptime</div>
|
||||
<div class="metric-value">99.5%</div>
|
||||
<div class="metric-target">Target: 99.9%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">API Response</div>
|
||||
<div class="metric-value">120ms</div>
|
||||
<div class="metric-target">Target: 50ms</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">Cost/km</div>
|
||||
<div class="metric-value">$0.45</div>
|
||||
<div class="metric-target">Target: $0.20</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-name">GPU Util</div>
|
||||
<div class="metric-value">65%</div>
|
||||
<div class="metric-target">Target: 80%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadKPIs() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/life-kpi/public/kpi-pyramid');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('vrc-value').textContent =
|
||||
`VRC: ${data.platform_north_star.current_value}% (Target: ${data.platform_north_star.target_2026}%)`;
|
||||
|
||||
document.getElementById('obj-cov').textContent = data.platform_north_star.breakdown.object_coverage + '%';
|
||||
document.getElementById('multi-src').textContent = data.platform_north_star.breakdown.multi_source_verification + '%';
|
||||
document.getElementById('fresh').textContent = data.platform_north_star.breakdown.evidence_freshness_days + 'd';
|
||||
document.getElementById('conf').textContent = data.platform_north_star.breakdown.average_confidence + '%';
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load KPIs:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRealityLatency() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/life-kpi/public/reality-latency');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('rl-value').textContent = data.reality_latency.current_average_hours;
|
||||
|
||||
const eventLog = document.getElementById('event-log');
|
||||
eventLog.innerHTML = data.reality_latency.recent_events.map(evt => `
|
||||
<div class="event-item">
|
||||
<div>
|
||||
<strong>${evt.event_type}</strong><br>
|
||||
<span style="color:#888;font-size:0.8rem;">${evt.description}</span>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<span style="color:#fbbf24;font-weight:600;">${evt.latency_hours}h</span><br>
|
||||
<span style="color:#888;font-size:0.75rem;">${evt.detection_source}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load RL:', e);
|
||||
}
|
||||
}
|
||||
|
||||
loadKPIs();
|
||||
loadRealityLatency();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,200 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LIFE Metrics — North Star Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.header .subtitle { color: #666; font-size: 0.85rem; }
|
||||
.nav { background: #0a0a0a; border-bottom: 1px solid #333; padding: 0 40px; display: flex; gap: 4px; }
|
||||
.nav a { color: #666; text-decoration: none; padding: 12px 20px; font-size: 0.85rem; transition: all 0.2s; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover, .nav a.active { color: #00d4ff; border-bottom-color: #00d4ff; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.hero { text-align: center; padding: 60px 20px; background: linear-gradient(180deg, #0a0a0a 0%, #1a1a2e 100%); border-radius: 16px; margin-bottom: 40px; }
|
||||
.hero h2 { font-size: 2rem; margin-bottom: 16px; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.hero p { font-size: 1rem; color: #aaa; max-width: 800px; margin: 0 auto; }
|
||||
.north-star { background: #111; border: 2px solid #00d4ff; border-radius: 12px; padding: 32px; margin-bottom: 40px; text-align: center; }
|
||||
.north-star h2 { font-size: 1.5rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
.north-star .metric-value { font-size: 3rem; font-weight: 700; color: #00d4ff; }
|
||||
.north-star .metric-label { font-size: 0.9rem; color: #888; margin-bottom: 16px; }
|
||||
.north-star .targets { display: flex; justify-content: center; gap: 32px; margin-top: 16px; }
|
||||
.target { text-align: center; }
|
||||
.target-year { font-size: 0.8rem; color: #666; }
|
||||
.target-value { font-size: 1.2rem; font-weight: 600; }
|
||||
.dimensions { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; margin-bottom: 40px; }
|
||||
.dimension { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; }
|
||||
.dimension h3 { font-size: 1rem; margin-bottom: 8px; color: #00d4ff; }
|
||||
.dimension .current { font-size: 2rem; font-weight: 700; color: #fff; }
|
||||
.dimension .target { font-size: 0.85rem; color: #888; margin-top: 4px; }
|
||||
.framework { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; }
|
||||
.framework h2 { font-size: 1.5rem; margin-bottom: 16px; color: #7c3aed; }
|
||||
.framework p { color: #888; margin-bottom: 16px; }
|
||||
.categories { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
.category { background: #1a1a1a; border-radius: 8px; padding: 16px; }
|
||||
.category-name { color: #00d4ff; font-weight: 600; }
|
||||
.category-desc { color: #888; font-size: 0.8rem; margin-top: 4px; }
|
||||
.category-example { color: #aaa; font-size: 0.75rem; margin-top: 8px; font-style: italic; }
|
||||
.roadmap { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; }
|
||||
.roadmap h2 { font-size: 1.5rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
.phase { margin-bottom: 20px; padding: 16px; background: #1a1a1a; border-radius: 8px; }
|
||||
.phase-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.phase-name { font-weight: 600; color: #fff; }
|
||||
.phase-priority { font-size: 0.75rem; padding: 2px 8px; border-radius: 4px; }
|
||||
.priority-critical { background: #7c3aed; }
|
||||
.priority-high { background: #00d4ff; color: #000; }
|
||||
.priority-medium { background: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>LIFE Metrics</h1>
|
||||
<div class="subtitle">North Star Dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/admin/" style="color: #666;">← Admin</a>
|
||||
<a href="#" class="active">Metrics</a>
|
||||
<a href="#north-star">VRC</a>
|
||||
<a href="#framework">Framework</a>
|
||||
<a href="#roadmap">Roadmap</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="hero">
|
||||
<h2>North Star Metric</h2>
|
||||
<p>A single metric the entire organization can optimize towards: Verified Reality Coverage (VRC)</p>
|
||||
</div>
|
||||
|
||||
<div class="north-star" id="north-star">
|
||||
<h2>Verified Reality Coverage (VRC)</h2>
|
||||
<div class="metric-value">12.5%</div>
|
||||
<div class="metric-label">of observable reality represented with current, independently verified evidence</div>
|
||||
<div class="targets">
|
||||
<div class="target">
|
||||
<div class="target-year">2026</div>
|
||||
<div class="target-value">25%</div>
|
||||
</div>
|
||||
<div class="target">
|
||||
<div class="target-year">2027</div>
|
||||
<div class="target-value">50%</div>
|
||||
</div>
|
||||
<div class="target">
|
||||
<div class="target-year">2028</div>
|
||||
<div class="target-value">75%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px; color: #00d4ff;">5 Breakdown Dimensions</h2>
|
||||
|
||||
<div class="dimensions">
|
||||
<div class="dimension">
|
||||
<h3>📊 Object Coverage</h3>
|
||||
<div class="current">78%</div>
|
||||
<div class="target">Target: 95%</div>
|
||||
</div>
|
||||
<div class="dimension">
|
||||
<h3>🔍 Multi-source Verification</h3>
|
||||
<div class="current">34%</div>
|
||||
<div class="target">Target: 60%</div>
|
||||
</div>
|
||||
<div class="dimension">
|
||||
<h3>⏱️ Evidence Freshness</h3>
|
||||
<div class="current">14.2d</div>
|
||||
<div class="target">Target: 7d</div>
|
||||
</div>
|
||||
<div class="dimension">
|
||||
<h3>🎯 Average Confidence</h3>
|
||||
<div class="current">68</div>
|
||||
<div class="target">Target: 80</div>
|
||||
</div>
|
||||
<div class="dimension">
|
||||
<h3>🔗 Traceability</h3>
|
||||
<div class="current">92%</div>
|
||||
<div class="target">Target: 98%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="framework" id="framework">
|
||||
<h2>Communication Framework</h2>
|
||||
<p>Keep three types of claims separate in all external communication to avoid creating unrealistic expectations.</p>
|
||||
|
||||
<div class="categories">
|
||||
<div class="category">
|
||||
<div class="category-name">🎯 Vision</div>
|
||||
<div class="category-desc">What we aim to achieve long-term</div>
|
||||
<div class="category-example">"LIFE aims to establish an open framework for Reality Intelligence."</div>
|
||||
</div>
|
||||
<div class="category">
|
||||
<div class="category-name">🏗️ Architecture</div>
|
||||
<div class="category-desc">How the system is designed</div>
|
||||
<div class="category-example">"RIS defines objects, evidence, confidence, and interoperability."</div>
|
||||
</div>
|
||||
<div class="category">
|
||||
<div class="category-name">✅ Validated Results</div>
|
||||
<div class="category-desc">What we have actually demonstrated</div>
|
||||
<div class="category-example">"In Pilot X, Y changes identified with Z% verification rate."</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roadmap" id="roadmap">
|
||||
<h2>Development Roadmap</h2>
|
||||
|
||||
<div class="phase">
|
||||
<div class="phase-header">
|
||||
<div class="phase-name">P0 — Critical</div>
|
||||
<div class="phase-priority priority-critical">NOW</div>
|
||||
</div>
|
||||
<div style="color: #aaa; font-size: 0.85rem;">
|
||||
• LIFE Technical Specification v1.0 (Q3 2026)<br>
|
||||
• RIVP Pilot 1 — Infrastructure (Q3-Q4 2026)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<div class="phase-header">
|
||||
<div class="phase-name">P1 — High</div>
|
||||
<div class="phase-priority priority-high">NEXT</div>
|
||||
</div>
|
||||
<div style="color: #aaa; font-size: 0.85rem;">
|
||||
• Reference Implementation (Q4 2026)<br>
|
||||
• RIS Public Specification (Q4 2026)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<div class="phase-header">
|
||||
<div class="phase-name">P2 — Medium</div>
|
||||
<div class="phase-priority priority-medium">LATER</div>
|
||||
</div>
|
||||
<div style="color: #aaa; font-size: 0.85rem;">
|
||||
• Conformance Suite (Q1 2027)<br>
|
||||
• Partner SDK (Q1 2027)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<div class="phase-header">
|
||||
<div class="phase-name">P3 — Future</div>
|
||||
<div class="phase-priority priority-medium">FUTURE</div>
|
||||
</div>
|
||||
<div style="color: #aaa; font-size: 0.85rem;">
|
||||
• Marketplace & Certification (Q2 2027+)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,369 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LIFE Platform v4 - Reality Intelligence Platform</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.header .subtitle { color: #666; font-size: 0.85rem; }
|
||||
.nav { background: #0a0a0a; border-bottom: 1px solid #333; padding: 0 40px; display: flex; gap: 4px; }
|
||||
.nav a { color: #666; text-decoration: none; padding: 12px 20px; font-size: 0.85rem; transition: all 0.2s; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover, .nav a.active { color: #00d4ff; border-bottom-color: #00d4ff; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.hero { text-align: center; padding: 60px 20px; background: linear-gradient(180deg, #0a0a0a 0%, #1a1a2e 100%); border-radius: 16px; margin-bottom: 40px; }
|
||||
.hero h2 { font-size: 2.5rem; margin-bottom: 16px; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.hero p { font-size: 1.1rem; color: #aaa; max-width: 800px; margin: 0 auto; }
|
||||
.platform-stack { display: flex; flex-direction: column; gap: 16px; margin-bottom: 40px; }
|
||||
.level { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; }
|
||||
.level-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.level-number { font-size: 0.75rem; color: #00d4ff; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.level h3 { font-size: 1.25rem; }
|
||||
.level p { color: #888; font-size: 0.9rem; }
|
||||
.components { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.component { background: #1a1a1a; border-radius: 8px; padding: 12px; font-size: 0.85rem; }
|
||||
.component-name { color: #fff; font-weight: 600; }
|
||||
.component-desc { color: #666; font-size: 0.75rem; margin-top: 4px; }
|
||||
.sdk-section { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; }
|
||||
.sdk-section h2 { font-size: 1.5rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
.sdk-section p { color: #888; margin-bottom: 20px; }
|
||||
.code-block { background: #1a1a1a; border-radius: 8px; padding: 20px; font-family: 'Monaco', monospace; font-size: 0.85rem; overflow-x: auto; }
|
||||
.code-block .comment { color: #666; }
|
||||
.code-block .keyword { color: #7c3aed; }
|
||||
.code-block .string { color: #00d4ff; }
|
||||
.object-model { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; }
|
||||
.object-model h2 { font-size: 1.5rem; margin-bottom: 16px; color: #7c3aed; }
|
||||
.object-attrs { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.attr { background: #1a1a1a; border-radius: 8px; padding: 16px; }
|
||||
.attr-name { color: #00d4ff; font-weight: 600; }
|
||||
.attr-desc { color: #888; font-size: 0.8rem; margin-top: 4px; }
|
||||
.domains { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||
.domain { background: #1a1a2e; border: 1px solid #333; border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.domain:hover { border-color: #7c3aed; }
|
||||
.domain-icon { font-size: 1.5rem; margin-bottom: 8px; }
|
||||
.domain-name { font-size: 0.9rem; }
|
||||
.domain-status { font-size: 0.75rem; color: #666; margin-top: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>LIFE Platform v4</h1>
|
||||
<div class="subtitle">Reality Intelligence Platform</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/admin/" style="color: #666;">← Admin</a>
|
||||
<a href="#" class="active">Plattform</a>
|
||||
<a href="#stack">Stack</a>
|
||||
<a href="#sdk">SDK</a>
|
||||
<a href="#domains">Domäner</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="hero">
|
||||
<h2>Reality Intelligence Platform</h2>
|
||||
<p>En AI-drivn plattform för verklighetsintelligens som andra företag kan bygga ovanpå. Kombinerar geospatial information, tidsserier, visuella observationer och öppna datakällor till en spårbar evidensmodell.</p>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px; color: #00d4ff;">4-Nivåers Plattformsarkitektur</h2>
|
||||
|
||||
<div class="platform-stack" id="stack">
|
||||
<div class="level">
|
||||
<div class="level-header">
|
||||
<div>
|
||||
<div class="level-number">Nivå 4</div>
|
||||
<h3>Experience Layer</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p>Det användaren faktiskt möter. Utvecklas oberoende av kärnan.</p>
|
||||
<div class="components">
|
||||
<div class="component">
|
||||
<div class="component-name">Web Dashboard</div>
|
||||
<div class="component-desc">Interaktiv webbapplikation</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Mobile Apps</div>
|
||||
<div class="component-desc">iOS & Android</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">QUIXZOOM</div>
|
||||
<div class="component-desc">Crowdsourcing-plattform</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">API</div>
|
||||
<div class="component-desc">REST & GraphQL</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">GIS Viewer</div>
|
||||
<div class="component-desc">Geospatial visualisering</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">AI Copilot</div>
|
||||
<div class="component-desc">Konversationell AI</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Alert Center</div>
|
||||
<div class="component-desc">Notifikationshub</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Workflow Automation</div>
|
||||
<div class="component-desc">No-code automation</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level">
|
||||
<div class="level-header">
|
||||
<div>
|
||||
<div class="level-number">Nivå 3</div>
|
||||
<h3>Domain Modules</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p>Vertikala tillämpningar som alla använder samma kernel.</p>
|
||||
<div class="components">
|
||||
<div class="component">
|
||||
<div class="component-name">Infrastructure Intelligence</div>
|
||||
<div class="component-desc">Vägar, broar, byggnader</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Aid Intelligence</div>
|
||||
<div class="component-desc">Biståndsprojekt</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Environmental Intelligence</div>
|
||||
<div class="component-desc">Miljö och klimat</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Insurance Intelligence</div>
|
||||
<div class="component-desc">Riskbedömning</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">City Intelligence</div>
|
||||
<div class="component-desc">Stadsutveckling</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Agriculture Intelligence</div>
|
||||
<div class="component-desc">Jordbruk</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level">
|
||||
<div class="level-header">
|
||||
<div>
|
||||
<div class="level-number">Nivå 2</div>
|
||||
<h3>Intelligence Runtime</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p>LIFE:s "kernel" - AI-operativsystemet.</p>
|
||||
<div class="components">
|
||||
<div class="component">
|
||||
<div class="component-name">Acquisition Engine</div>
|
||||
<div class="component-desc">Datainsamling</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Normalization Engine</div>
|
||||
<div class="component-desc">Standardisering</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Evidence Engine</div>
|
||||
<div class="component-desc">Evidenshantering</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Context Engine</div>
|
||||
<div class="component-desc">Kontextuell förståelse</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Intelligence Engine</div>
|
||||
<div class="component-desc">Analys och insikter</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Prediction Engine</div>
|
||||
<div class="component-desc">Prediktiv analys</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Decision Engine</div>
|
||||
<div class="component-desc">Rekommendationer</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Learning Engine</div>
|
||||
<div class="component-desc">Självförbättring</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level">
|
||||
<div class="level-header">
|
||||
<div>
|
||||
<div class="level-number">Nivå 1</div>
|
||||
<h3>Foundation</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p>Teknisk kärna - abstraherad från alla vertikaler.</p>
|
||||
<div class="components">
|
||||
<div class="component">
|
||||
<div class="component-name">Identity & Access</div>
|
||||
<div class="component-desc">Autentisering</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Event Bus</div>
|
||||
<div class="component-desc">Asynkron kommunikation</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Knowledge Graph DB</div>
|
||||
<div class="component-desc">Grafbaserad lagring</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Vector Database</div>
|
||||
<div class="component-desc">Semantisk sökning</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Time Series DB</div>
|
||||
<div class="component-desc">Temporal data</div>
|
||||
</div>
|
||||
<div class="component">
|
||||
<div class="component-name">Model Registry</div>
|
||||
<div class="component-desc">AI-modellhantering</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sdk-section" id="sdk">
|
||||
<h2>LIFE SDK</h2>
|
||||
<p>Bygg egna moduler ovanpå LIFE-plattformen. Externa utvecklare och partners kan skapa egna vertikaler utan att bygga hela infrastrukturen.</p>
|
||||
|
||||
<div class="code-block">
|
||||
<span class="comment"># Exempel: Bygg en custom modul</span>
|
||||
<span class="keyword">from</span> life <span class="keyword">import</span> Client, Module
|
||||
|
||||
<span class="comment"># Initiera LIFE-klient</span>
|
||||
client = Client(api_key=<span class="string">"your-key"</span>)
|
||||
|
||||
<span class="comment"># Definiera modul</span>
|
||||
<span class="keyword">class</span> <span class="string">MyModule</span>(Module):
|
||||
<span class="keyword">def</span> <span class="string">analyze</span>(self, project_id):
|
||||
<span class="comment"># Använd LIFE:s Intelligence Runtime</span>
|
||||
evidence = client.get_evidence(project_id)
|
||||
insights = client.generate_insights(evidence)
|
||||
<span class="keyword">return</span> insights
|
||||
|
||||
<span class="comment"># Deploya modulen</span>
|
||||
module = MyModule()
|
||||
client.deploy(module)
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 20px; color: #888;">SDK:er tillgängliga för: Python, JavaScript, Java, Go, Rust</p>
|
||||
</div>
|
||||
|
||||
<div class="object-model">
|
||||
<h2>LIFE Object Model</h2>
|
||||
<p>En enhetlig objektsmodell där allt representeras med samma grundstruktur. En väg, en bro, ett sjukhus, ett biståndsprojekt eller ett träd är olika instanser av samma modell.</p>
|
||||
|
||||
<div class="object-attrs">
|
||||
<div class="attr">
|
||||
<div class="attr-name">Identity</div>
|
||||
<div class="attr-desc">UUID, typ, namn, externa ID</div>
|
||||
</div>
|
||||
<div class="attr">
|
||||
<div class="attr-name">Geometry</div>
|
||||
<div class="attr-desc">Punkt, polygon, bounding box</div>
|
||||
</div>
|
||||
<div class="attr">
|
||||
<div class="attr-name">Time</div>
|
||||
<div class="attr-desc">Skapad, uppdaterad, giltighet</div>
|
||||
</div>
|
||||
<div class="attr">
|
||||
<div class="attr-name">Evidence</div>
|
||||
<div class="attr-desc">Observationer, källor, konfidens</div>
|
||||
</div>
|
||||
<div class="attr">
|
||||
<div class="attr-name">State</div>
|
||||
<div class="attr-desc">Status, framsteg, hälsa</div>
|
||||
</div>
|
||||
<div class="attr">
|
||||
<div class="attr-name">Relationships</div>
|
||||
<div class="attr-desc">Förälder, barn, relaterade</div>
|
||||
</div>
|
||||
<div class="attr">
|
||||
<div class="attr-name">History</div>
|
||||
<div class="attr-desc">Händelser, versioner, snapshots</div>
|
||||
</div>
|
||||
<div class="attr">
|
||||
<div class="attr-name">Predictions</div>
|
||||
<div class="attr-desc">Prognoser, scenarier, risker</div>
|
||||
</div>
|
||||
<div class="attr">
|
||||
<div class="attr-name">Actions</div>
|
||||
<div class="attr-desc">Rekommenderade, tillgängliga</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px;">10+ Domäner</h2>
|
||||
<p style="color: #888; margin-bottom: 20px;">LIFE är en generell plattform. Bistånd är en tillämpning, inte identitet.</p>
|
||||
|
||||
<div class="domains" id="domains">
|
||||
<div class="domain">
|
||||
<div class="domain-icon">🏗️</div>
|
||||
<div class="domain-name">Infrastructure</div>
|
||||
<div class="domain-status">Tillgänglig</div>
|
||||
</div>
|
||||
<div class="domain">
|
||||
<div class="domain-icon">🌍</div>
|
||||
<div class="domain-name">Aid</div>
|
||||
<div class="domain-status">Tillgänglig</div>
|
||||
</div>
|
||||
<div class="domain">
|
||||
<div class="domain-icon">🌱</div>
|
||||
<div class="domain-name">Environmental</div>
|
||||
<div class="domain-status">Beta</div>
|
||||
</div>
|
||||
<div class="domain">
|
||||
<div class="domain-icon">🛡️</div>
|
||||
<div class="domain-name">Insurance</div>
|
||||
<div class="domain-status">Utveckling</div>
|
||||
</div>
|
||||
<div class="domain">
|
||||
<div class="domain-icon">🏙️</div>
|
||||
<div class="domain-name">City</div>
|
||||
<div class="domain-status">Beta</div>
|
||||
</div>
|
||||
<div class="domain">
|
||||
<div class="domain-icon">🌾</div>
|
||||
<div class="domain-name">Agriculture</div>
|
||||
<div class="domain-status">Beta</div>
|
||||
</div>
|
||||
<div class="domain">
|
||||
<div class="domain-icon">⚡</div>
|
||||
<div class="domain-name">Energy</div>
|
||||
<div class="domain-status">Utveckling</div>
|
||||
</div>
|
||||
<div class="domain">
|
||||
<div class="domain-icon">📦</div>
|
||||
<div class="domain-name">Supply Chain</div>
|
||||
<div class="domain-status">Utveckling</div>
|
||||
</div>
|
||||
<div class="domain">
|
||||
<div class="domain-icon">🔒</div>
|
||||
<div class="domain-name">Security</div>
|
||||
<div class="domain-status">Utveckling</div>
|
||||
</div>
|
||||
<div class="domain">
|
||||
<div class="domain-icon">🏢</div>
|
||||
<div class="domain-name">Property</div>
|
||||
<div class="domain-status">Utveckling</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,216 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LIFE — Reality Intelligence Platform</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.header .subtitle { color: #666; font-size: 0.85rem; }
|
||||
.nav { background: #0a0a0a; border-bottom: 1px solid #333; padding: 0 40px; display: flex; gap: 4px; }
|
||||
.nav a { color: #666; text-decoration: none; padding: 12px 20px; font-size: 0.85rem; transition: all 0.2s; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover, .nav a.active { color: #00d4ff; border-bottom-color: #00d4ff; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.hero { text-align: center; padding: 80px 20px; background: linear-gradient(180deg, #0a0a0a 0%, #1a1a2e 100%); border-radius: 16px; margin-bottom: 40px; }
|
||||
.hero h2 { font-size: 2.5rem; margin-bottom: 16px; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.hero p { font-size: 1.1rem; color: #aaa; max-width: 800px; margin: 0 auto 24px; }
|
||||
.hero .tagline { font-size: 1rem; color: #00d4ff; font-weight: 600; }
|
||||
.vision { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; text-align: center; }
|
||||
.vision h2 { font-size: 1.5rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
.vision p { font-size: 1.1rem; color: #aaa; max-width: 800px; margin: 0 auto; line-height: 1.8; }
|
||||
.evolution { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; margin-bottom: 40px; }
|
||||
.evo { background: #111; border: 1px solid #333; border-radius: 8px; padding: 12px; text-align: center; }
|
||||
.evo-version { font-size: 1.1rem; font-weight: 700; color: #00d4ff; }
|
||||
.evo-name { font-size: 0.75rem; color: #aaa; }
|
||||
.components { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; margin-bottom: 40px; }
|
||||
.component { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; }
|
||||
.component h3 { font-size: 1.1rem; margin-bottom: 12px; color: #00d4ff; }
|
||||
.component p { color: #888; font-size: 0.85rem; margin-bottom: 12px; }
|
||||
.component-items { list-style: none; }
|
||||
.component-items li { color: #aaa; font-size: 0.8rem; padding: 4px 0; padding-left: 16px; position: relative; }
|
||||
.component-items li::before { content: "→"; position: absolute; left: 0; color: #00d4ff; }
|
||||
.separation { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; }
|
||||
.separation h2 { font-size: 1.5rem; margin-bottom: 16px; color: #7c3aed; }
|
||||
.separation p { color: #888; margin-bottom: 16px; }
|
||||
.entities { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; margin-top: 16px; }
|
||||
.entity { background: #1a1a1a; border-radius: 8px; padding: 16px; }
|
||||
.entity-name { color: #00d4ff; font-weight: 600; }
|
||||
.entity-type { color: #888; font-size: 0.8rem; }
|
||||
.entity-desc { color: #aaa; font-size: 0.75rem; margin-top: 8px; }
|
||||
.cta { text-align: center; padding: 60px 20px; background: linear-gradient(180deg, #1a1a2e 0%, #0a0a0a 100%); border-radius: 16px; }
|
||||
.cta h2 { font-size: 2rem; margin-bottom: 16px; color: #fff; }
|
||||
.cta p { font-size: 1rem; color: #aaa; margin-bottom: 24px; }
|
||||
.cta-button { display: inline-block; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; padding: 12px 32px; border-radius: 8px; text-decoration: none; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>LIFE</h1>
|
||||
<div class="subtitle">Reality Intelligence Platform</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/admin/" style="color: #666;">← Admin</a>
|
||||
<a href="#" class="active">Product</a>
|
||||
<a href="#vision">Vision</a>
|
||||
<a href="#components">Components</a>
|
||||
<a href="#ecosystem">Ecosystem</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="hero">
|
||||
<h2>Reality Intelligence Platform</h2>
|
||||
<p>Landvex develops LIFE, a Reality Intelligence Platform and reference implementation of the Reality Intelligence Standard (RIS). Through a unified information model, traceable evidence, and explainable analysis, LIFE enables organizations to collect, verify, share, and use observations from the physical world in a consistent and interoperable way across domains.</p>
|
||||
<div class="tagline">From observable signals to traceable evidence, explainable analysis, and decision support.</div>
|
||||
</div>
|
||||
|
||||
<div class="vision" id="vision">
|
||||
<h2>Vision</h2>
|
||||
<p>LIFE aims to establish an open framework and common standards for Reality Intelligence, similar to how open standards have enabled interoperability in other technology areas. By combining geospatial data, field observations, open data sources, and AI, LIFE transforms observable signals from the physical world into traceable evidence.</p>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px; color: #00d4ff;">Evolution</h2>
|
||||
|
||||
<div class="evolution">
|
||||
<div class="evo">
|
||||
<div class="evo-version">v1</div>
|
||||
<div class="evo-name">Aid Intelligence</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v2</div>
|
||||
<div class="evo-name">Multi-signal</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v3</div>
|
||||
<div class="evo-name">Core Architecture</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v4</div>
|
||||
<div class="evo-name">Platform</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v5</div>
|
||||
<div class="evo-name">Enterprise</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v6</div>
|
||||
<div class="evo-name">Standard</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v7</div>
|
||||
<div class="evo-name">Ecosystem</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px; color: #00d4ff;">Key Components</h2>
|
||||
|
||||
<div class="components" id="components">
|
||||
<div class="component">
|
||||
<h3>🔬 Reality Intelligence</h3>
|
||||
<p>Analyzes the physical world, not just text and documents</p>
|
||||
<ul class="component-items">
|
||||
<li>Geospatial data fusion</li>
|
||||
<li>Field observations</li>
|
||||
<li>Visual data analysis</li>
|
||||
<li>Time-series monitoring</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="component">
|
||||
<h3>📋 Evidence Before Opinion</h3>
|
||||
<p>Starts with observations, not conclusions</p>
|
||||
<ul class="component-items">
|
||||
<li>Traceable evidence chains</li>
|
||||
<li>Source attribution</li>
|
||||
<li>Confidence scoring</li>
|
||||
<li>Contradiction detection</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="component">
|
||||
<h3>🌐 Multi-source Fusion</h3>
|
||||
<p>Combines satellite, field, open data, and AI</p>
|
||||
<ul class="component-items">
|
||||
<li>Satellite imagery (Sentinel-2, etc.)</li>
|
||||
<li>QUIXZOOM crowdsourcing</li>
|
||||
<li>IoT sensors</li>
|
||||
<li>Official reports</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="component">
|
||||
<h3>🔍 Explainability</h3>
|
||||
<p>Every insight is explainable and auditable</p>
|
||||
<ul class="component-items">
|
||||
<li>Decision lineage</li>
|
||||
<li>Confidence breakdown</li>
|
||||
<li>Source transparency</li>
|
||||
<li>Hypothesis tracking</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="component">
|
||||
<h3>📦 Open Standards (RIS)</h3>
|
||||
<p>Six open standards for Reality Intelligence</p>
|
||||
<ul class="component-items">
|
||||
<li>RIS-001: Reality Object Standard</li>
|
||||
<li>RIS-002: Evidence Standard</li>
|
||||
<li>RIS-003: Confidence Standard</li>
|
||||
<li>RIS-004: Reality Exchange Protocol</li>
|
||||
<li>RIS-005: Decision Standard</li>
|
||||
<li>RIS-006: Learning Standard</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="component">
|
||||
<h3>🚀 Reference Implementation</h3>
|
||||
<p>Open source implementation of RIS</p>
|
||||
<ul class="component-items">
|
||||
<li>LIFE Server (Python/FastAPI)</li>
|
||||
<li>LIFE Client (React/TypeScript)</li>
|
||||
<li>SDK: Python, JS, Java, Go, Rust</li>
|
||||
<li>Example data and APIs</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="separation" id="ecosystem">
|
||||
<h2>Strategic Separation</h2>
|
||||
<p>Three distinct layers, common in successful ecosystems: the standard can be open, while the reference implementation and commercial extensions continue to be developed by the company.</p>
|
||||
|
||||
<div class="entities">
|
||||
<div class="entity">
|
||||
<div class="entity-name">RIS</div>
|
||||
<div class="entity-type">Open Standard</div>
|
||||
<div class="entity-desc">The open specification. Community-governed. CC BY 4.0. Free to use.</div>
|
||||
</div>
|
||||
<div class="entity">
|
||||
<div class="entity-name">LIFE</div>
|
||||
<div class="entity-type">Reference Implementation</div>
|
||||
<div class="entity-desc">Reference implementation of the standard. Open source. Apache 2.0. Free to use.</div>
|
||||
</div>
|
||||
<div class="entity">
|
||||
<div class="entity-name">Landvex</div>
|
||||
<div class="entity-type">Commercial Company</div>
|
||||
<div class="entity-desc">The company that develops and commercializes LIFE. Proprietary services. Paid offerings.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cta">
|
||||
<h2>Build on LIFE</h2>
|
||||
<p>Join the Reality Intelligence ecosystem. Build custom modules, integrate your data, or deploy your own LIFE instance.</p>
|
||||
<a href="https://docs.life.ai" class="cta-button">Get Started →</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,223 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LIFE Standard v6 - Reality Intelligence Standard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.header .subtitle { color: #666; font-size: 0.85rem; }
|
||||
.nav { background: #0a0a0a; border-bottom: 1px solid #333; padding: 0 40px; display: flex; gap: 4px; }
|
||||
.nav a { color: #666; text-decoration: none; padding: 12px 20px; font-size: 0.85rem; transition: all 0.2s; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover, .nav a.active { color: #00d4ff; border-bottom-color: #00d4ff; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.hero { text-align: center; padding: 60px 20px; background: linear-gradient(180deg, #0a0a0a 0%, #1a1a2e 100%); border-radius: 16px; margin-bottom: 40px; }
|
||||
.hero h2 { font-size: 2rem; margin-bottom: 16px; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.hero p { font-size: 1rem; color: #aaa; max-width: 800px; margin: 0 auto; }
|
||||
.standards { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; margin-bottom: 40px; }
|
||||
.standard { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; }
|
||||
.standard-id { font-size: 0.75rem; color: #00d4ff; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||
.standard h3 { font-size: 1.1rem; margin-bottom: 12px; }
|
||||
.standard p { color: #888; font-size: 0.85rem; margin-bottom: 12px; }
|
||||
.standard-attrs { list-style: none; }
|
||||
.standard-attrs li { color: #aaa; font-size: 0.8rem; padding: 4px 0; padding-left: 16px; position: relative; }
|
||||
.standard-attrs li::before { content: "→"; position: absolute; left: 0; color: #00d4ff; }
|
||||
.ontology { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; }
|
||||
.ontology h2 { font-size: 1.5rem; margin-bottom: 16px; color: #7c3aed; }
|
||||
.ontology-levels { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; margin-top: 16px; }
|
||||
.level { background: #1a1a1a; border-radius: 8px; padding: 16px; }
|
||||
.level-name { color: #00d4ff; font-weight: 600; }
|
||||
.level-desc { color: #888; font-size: 0.8rem; margin-top: 4px; }
|
||||
.level-concepts { color: #aaa; font-size: 0.75rem; margin-top: 8px; }
|
||||
.product-def { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; text-align: center; }
|
||||
.product-def h2 { font-size: 1.5rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
.product-def p { font-size: 1.1rem; color: #aaa; max-width: 700px; margin: 0 auto; line-height: 1.8; }
|
||||
.evolution { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; margin-bottom: 40px; }
|
||||
.evo { background: #111; border: 1px solid #333; border-radius: 8px; padding: 12px; text-align: center; }
|
||||
.evo-version { font-size: 1.1rem; font-weight: 700; color: #00d4ff; }
|
||||
.evo-name { font-size: 0.8rem; color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>LIFE Standard v6</h1>
|
||||
<div class="subtitle">Reality Intelligence Standard</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/admin/" style="color: #666;">← Admin</a>
|
||||
<a href="#" class="active">Standard</a>
|
||||
<a href="#standards">RIS</a>
|
||||
<a href="#ontology">Ontologi</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="hero">
|
||||
<h2>Reality Intelligence Standard</h2>
|
||||
<p>LIFE är inte bara programvara. Det definierar HUR verklighetsintelligens representeras, valideras, utbyts och används — oberoende av tillämpning.</p>
|
||||
</div>
|
||||
|
||||
<div class="product-def">
|
||||
<h2>Produktdefinition v6</h2>
|
||||
<p>"LIFE är ett AI-drivet operativsystem och en öppen standard för Reality Intelligence. Genom att förena en gemensam informationsmodell, spårbar evidens, förklarbara analyser och kontinuerligt lärande gör LIFE det möjligt att beskriva, förstå och följa förändringar i den fysiska världen på ett konsekvent sätt över olika domäner och organisationer."</p>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px; color: #00d4ff;">Evolution av LIFE</h2>
|
||||
|
||||
<div class="evolution">
|
||||
<div class="evo">
|
||||
<div class="evo-version">v1</div>
|
||||
<div class="evo-name">Aid Intelligence</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v2</div>
|
||||
<div class="evo-name">Multi-signal</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v3</div>
|
||||
<div class="evo-name">Core Architecture</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v4</div>
|
||||
<div class="evo-name">Platform</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v5</div>
|
||||
<div class="evo-name">Enterprise</div>
|
||||
</div>
|
||||
<div class="evo">
|
||||
<div class="evo-version">v6</div>
|
||||
<div class="evo-name">Standard</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px; color: #00d4ff;">6 Öppna Standarder (RIS)</h2>
|
||||
|
||||
<div class="standards" id="standards">
|
||||
<div class="standard">
|
||||
<div class="standard-id">RIS-001</div>
|
||||
<h3>Reality Object Standard</h3>
|
||||
<p>En gemensam objektsmodell för allt i den fysiska världen</p>
|
||||
<ul class="standard-attrs">
|
||||
<li>Identity (UUID, typ, namn)</li>
|
||||
<li>Geometry (punkt, polygon, polyline)</li>
|
||||
<li>Time (skapad, uppdaterad, giltighet)</li>
|
||||
<li>State (status, framsteg, hälsa)</li>
|
||||
<li>Evidence (observationer, källor)</li>
|
||||
<li>Confidence (sammansatt säkerhet)</li>
|
||||
<li>Relationships (kopplingar till andra)</li>
|
||||
<li>History (händelser, versioner)</li>
|
||||
<li>Predictions (framtida prognoser)</li>
|
||||
<li>Actions (rekommenderade åtgärder)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="standard">
|
||||
<div class="standard-id">RIS-002</div>
|
||||
<h3>Evidence Standard</h3>
|
||||
<p>All evidens representeras likadant oavsett källa</p>
|
||||
<ul class="standard-attrs">
|
||||
<li>Källa (satellit, fält, rapport)</li>
|
||||
<li>Tidsstämpel</li>
|
||||
<li>Geografisk position</li>
|
||||
<li>Metod (hur observationen gjordes)</li>
|
||||
<li>Kvalitet (0-100)</li>
|
||||
<li>Osäkerhet (spatial, temporal)</li>
|
||||
<li>Valideringsstatus</li>
|
||||
<li>Revisionshistorik</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="standard">
|
||||
<div class="standard-id">RIS-003</div>
|
||||
<h3>Confidence Standard</h3>
|
||||
<p>Alla modeller använder samma definition av säkerhet</p>
|
||||
<ul class="standard-attrs">
|
||||
<li>Data Confidence (tillförlitlighet)</li>
|
||||
<li>Source Confidence (källkvalitet)</li>
|
||||
<li>AI Confidence (modellträffsäkerhet)</li>
|
||||
<li>Human Verification (mänsklig granskning)</li>
|
||||
<li>Temporal Confidence (tidsrelevans)</li>
|
||||
<li>Composite Confidence (sammansatt)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="standard">
|
||||
<div class="standard-id">RIS-004</div>
|
||||
<h3>Reality Exchange Protocol</h3>
|
||||
<p>Öppet format för utbyte mellan system</p>
|
||||
<ul class="standard-attrs">
|
||||
<li>/objects — Fysiska objekt</li>
|
||||
<li>/observations — Observationer</li>
|
||||
<li>/evidence — Evidens</li>
|
||||
<li>/relationships — Relationer</li>
|
||||
<li>/events — Händelser</li>
|
||||
<li>/hypotheses — Hypoteser</li>
|
||||
<li>/predictions — Prognoser</li>
|
||||
<li>/actions — Åtgärder</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="standard">
|
||||
<div class="standard-id">RIS-005</div>
|
||||
<h3>Decision Standard</h3>
|
||||
<p>Alla rekommendationer följer samma struktur</p>
|
||||
<ul class="standard-attrs">
|
||||
<li>Observation → vad observerats?</li>
|
||||
<li>Evidence → vad stöder det?</li>
|
||||
<li>Confidence → hur säker är bedömningen?</li>
|
||||
<li>Hypothesis → vad förklarar det?</li>
|
||||
<li>Recommended Action → vad bör göras?</li>
|
||||
<li>Expected Outcome → vad förväntas?</li>
|
||||
<li>Verification Plan → hur verifieras det?</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="standard">
|
||||
<div class="standard-id">RIS-006</div>
|
||||
<h3>Learning Standard</h3>
|
||||
<p>AI-modeller utvärderas mot verkliga utfall</p>
|
||||
<ul class="standard-attrs">
|
||||
<li>Prediction Accuracy (hur ofta korrekt?)</li>
|
||||
<li>Hypothesis Validation (vilka stämde?)</li>
|
||||
<li>Signal Informativeness (vilka bidrog mest?)</li>
|
||||
<li>Temporal Improvement (förbättring över tid)</li>
|
||||
<li>Feedback Loops (verkliga utfall matas tillbaka)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ontology" id="ontology">
|
||||
<h2>Gemensam Ontologi</h2>
|
||||
<p>Tre nivåer för att balansera generell kärna med domänspecifika och kundspecifika begrepp.</p>
|
||||
|
||||
<div class="ontology-levels">
|
||||
<div class="level">
|
||||
<div class="level-name">Core Ontology</div>
|
||||
<div class="level-desc">Universella begrepp — FROZEN</div>
|
||||
<div class="level-concepts">Object, Observation, Evidence, Relation, Event, Hypothesis, Prediction, Action, Confidence, Time, Space, Agent</div>
|
||||
</div>
|
||||
<div class="level">
|
||||
<div class="level-name">Domain Ontologies</div>
|
||||
<div class="level-desc">Domänspecifika — STABLE</div>
|
||||
<div class="level-concepts">Infrastructure: Road, Bridge, Building<br>Aid: Project, Beneficiary, Milestone<br>Insurance: Policy, Claim, RiskFactor<br>Environment: Ecosystem, PollutionLevel</div>
|
||||
</div>
|
||||
<div class="level">
|
||||
<div class="level-name">Customer Extensions</div>
|
||||
<div class="level-desc">Kundspecifika — FLEXIBLE</div>
|
||||
<div class="level-concepts">Municipality: SnowRemovalRoute<br>Company: CustomAssetType<br>Any customer-defined concept</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,267 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Landvex LIFE v2 - Operativ Intelligensmotor</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #0a0a0a; color: #e0e0e0; line-height: 1.6; }
|
||||
.header { background: #111; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; color: #fff; }
|
||||
.header .subtitle { color: #666; font-size: 0.85rem; }
|
||||
.nav { background: #111; border-bottom: 1px solid #333; padding: 0 40px; display: flex; gap: 4px; }
|
||||
.nav a { color: #666; text-decoration: none; padding: 12px 20px; font-size: 0.85rem; transition: all 0.2s; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover, .nav a.active { color: #00d4ff; border-bottom-color: #00d4ff; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.philosophy { background: #111; border-radius: 12px; padding: 32px; margin-bottom: 32px; border-left: 4px solid #00d4ff; }
|
||||
.philosophy h2 { font-size: 1.25rem; margin-bottom: 16px; color: #fff; }
|
||||
.philosophy p { color: #aaa; margin-bottom: 12px; }
|
||||
.process { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-top: 20px; }
|
||||
.process-step { background: #1a1a1a; border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.process-step .number { font-size: 1.5rem; font-weight: 700; color: #00d4ff; }
|
||||
.process-step .name { font-size: 0.9rem; color: #fff; margin-top: 8px; }
|
||||
.process-step .desc { font-size: 0.75rem; color: #666; margin-top: 4px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; margin-bottom: 32px; }
|
||||
.stat-card { background: #111; border-radius: 12px; padding: 24px; border: 1px solid #333; }
|
||||
.stat-card h3 { font-size: 0.75rem; color: #666; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-card .value { font-size: 2rem; font-weight: 700; color: #fff; }
|
||||
.stat-card .change { font-size: 0.8rem; margin-top: 4px; }
|
||||
.stat-card .change.positive { color: #00d4ff; }
|
||||
.component-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; margin-bottom: 32px; }
|
||||
.component-card { background: #111; border-radius: 12px; padding: 24px; border: 1px solid #333; }
|
||||
.component-card h3 { font-size: 1rem; margin-bottom: 12px; color: #00d4ff; display: flex; align-items: center; gap: 8px; }
|
||||
.component-card p { font-size: 0.85rem; color: #aaa; margin-bottom: 8px; }
|
||||
.component-card .stats { font-size: 0.8rem; color: #666; }
|
||||
.section { background: #111; border-radius: 12px; padding: 24px; margin-bottom: 24px; border: 1px solid #333; }
|
||||
.section h2 { font-size: 1.1rem; margin-bottom: 16px; color: #fff; }
|
||||
.hypothesis-card { background: #1a1a1a; border-radius: 8px; padding: 16px; margin-bottom: 12px; border-left: 3px solid #00d4ff; }
|
||||
.hypothesis-card h4 { font-size: 0.95rem; margin-bottom: 8px; color: #fff; }
|
||||
.hypothesis-card p { font-size: 0.85rem; color: #aaa; }
|
||||
.hypothesis-card .confidence { font-size: 0.8rem; color: #00d4ff; margin-top: 8px; }
|
||||
.dna-bar { background: #1a1a1a; border-radius: 4px; height: 24px; overflow: hidden; margin-top: 8px; display: flex; }
|
||||
.dna-bar .segment { height: 100%; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>Landvex LIFE v2</h1>
|
||||
<div class="subtitle">Operativ Intelligensmotor - 7 Kärnkomponenter</div>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<div style="font-size: 0.8rem; color: #666;">Komponenter</div>
|
||||
<div style="font-size: 1.5rem; font-weight: 700;">7/7</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/admin/" style="color: #666;">← Admin</a>
|
||||
<a href="#" class="active">Dashboard</a>
|
||||
<a href="#components">Komponenter</a>
|
||||
<a href="#hypotheses">Hypoteser</a>
|
||||
<a href="#dna">Reality DNA</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="philosophy">
|
||||
<h2>Evidence Before Opinion</h2>
|
||||
<p>Systemet börjar aldrig med en slutsats och letar stöd för den. Istället bygger varje analys från observerbara signaler uppåt.</p>
|
||||
<div class="process">
|
||||
<div class="process-step">
|
||||
<div class="number">1</div>
|
||||
<div class="name">Gather Facts</div>
|
||||
<div class="desc">Samla observerbara signaler</div>
|
||||
</div>
|
||||
<div class="process-step">
|
||||
<div class="number">2</div>
|
||||
<div class="name">Weigh Sources</div>
|
||||
<div class="desc">Värdera oberoende källor</div>
|
||||
</div>
|
||||
<div class="process-step">
|
||||
<div class="number">3</div>
|
||||
<div class="name">Identify Patterns</div>
|
||||
<div class="desc">Identifiera mönster</div>
|
||||
</div>
|
||||
<div class="process-step">
|
||||
<div class="number">4</div>
|
||||
<div class="name">Generate Hypotheses</div>
|
||||
<div class="desc">Formulera hypoteser</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>Evidence Graph Noder</h3>
|
||||
<div class="value">125K</div>
|
||||
<div class="change positive">+450K edges</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Tracked Observations</h3>
|
||||
<div class="value">45K</div>
|
||||
<div class="change positive">Med full lineage</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Contradictions</h3>
|
||||
<div class="value">1,247</div>
|
||||
<div class="change positive">AI-träningsdata</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Hypoteser</h3>
|
||||
<div class="value">3,400</div>
|
||||
<div class="change positive">Rangordnade</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Reality DNA Profiler</h3>
|
||||
<div class="value">1,250</div>
|
||||
<div class="change">Projekt profilerade</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Memory Storage</h3>
|
||||
<div class="value">2.4 TB</div>
|
||||
<div class="change">Permanent</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-grid" id="components">
|
||||
<div class="component-card">
|
||||
<h3>🔗 Evidence Graph (EG)</h3>
|
||||
<p>Hierarkisk graf där varje observation är en spårbar nod</p>
|
||||
<p style="font-size: 0.8rem; color: #666;">Observation → Object → Project → Programme → Organisation → Region → Country</p>
|
||||
<div class="stats">125,000 noder | 450,000 edges</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>📜 Evidence Lineage</h3>
|
||||
<p>Komplett ursprung för varje datapunkt</p>
|
||||
<p style="font-size: 0.8rem; color: #666;">Source → Collected → Validated → Cross-validated → AI confidence → Human verification</p>
|
||||
<div class="stats">45,000 observationer spårade</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>⏰ Temporal Reality Engine</h3>
|
||||
<p>Tidsmaskin för att se hur verkligheten förändras</p>
|
||||
<p style="font-size: 0.8rem; color: #666;">Vad såg området ut för 6 månader sedan? Vad har förändrats? Hur snabbt?</p>
|
||||
<div class="stats">3 års historik | 8,900 förändringar</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>⚠️ Contradiction Knowledge Base</h4>
|
||||
<p>Alla avvikelser lagras för AI-träning</p>
|
||||
<p style="font-size: 0.8rem; color: #666;">Efter några år: hundratusentals verkliga exempel</p>
|
||||
<div class="stats">1,247 avvikelser | 6 kategorier</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>🧬 Reality DNA</h3>
|
||||
<p>Projektets fingeravtryck för benchmarking</p>
|
||||
<p style="font-size: 0.8rem; color: #666;">9 dimensioner: Activity, Maintenance, Infrastructure, Community Usage, Traffic, Economic Activity, Environmental Status, Safety, Operational Continuity</p>
|
||||
<div class="stats">1,250 profiler | Jämförbara</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>💾 Reality Memory</h3>
|
||||
<p>LIFE kastar aldrig bort något</p>
|
||||
<p style="font-size: 0.8rem; color: #666;">Varje observation, AI-bedömning och förändring sparas permanent</p>
|
||||
<div class="stats">2.4 TB | Full reproducerbarhet</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>🧠 Reality Hypothesis Engine</h3>
|
||||
<p>Inte "Det här är orsaken" utan "Här är möjliga förklaringar..."</p>
|
||||
<p style="font-size: 0.8rem; color: #666;">Rangordnade efter hur väl de stöds av observerbara data</p>
|
||||
<div class="stats">3,400 hypoteser | Med osäkerhetsintervall</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="hypotheses">
|
||||
<h2>Reality Hypothesis Engine - Exempel</h2>
|
||||
<p style="color: #666; margin-bottom: 16px;">Observerat fenomen: Construction progress slower than reported (85% observed vs 95% reported)</p>
|
||||
|
||||
<div class="hypothesis-card">
|
||||
<h4>#1 Interior work behind schedule while exterior nearly complete</h4>
|
||||
<p>Field observations show exterior walls and roof complete. Windows visible but interior not accessible. Satellite cannot detect interior progress.</p>
|
||||
<div class="confidence">Confidence: 78% | Support: Strong | Action: Request interior photos</div>
|
||||
</div>
|
||||
|
||||
<div class="hypothesis-card">
|
||||
<h4>#2 Flooding in June caused 2-week delay</h4>
|
||||
<p>Satellite shows flooding June 15. Weather data confirms 120% average rainfall. Field observations show reduced activity June 15-30.</p>
|
||||
<div class="confidence">Confidence: 72% | Support: Moderate | Action: Verify revised timeline</div>
|
||||
</div>
|
||||
|
||||
<div class="hypothesis-card">
|
||||
<h4>#3 Material supply disruption affected interior work</h4>
|
||||
<p>Procurement data shows delayed deliveries. Some materials visible on site but not installed.</p>
|
||||
<div class="confidence">Confidence: 55% | Support: Weak | Action: Check supplier records</div>
|
||||
</div>
|
||||
|
||||
<div class="hypothesis-card" style="border-left-color: #666;">
|
||||
<h4>#4 Reporting methodology differs from observation methodology</h4>
|
||||
<p>Official report may count 'started' as 'complete'. Different measurement standards possible.</p>
|
||||
<div class="confidence">Confidence: 45% | Support: Speculative | Action: Review reporting methodology</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="dna">
|
||||
<h2>Reality DNA - Projektets Fingeravtryck</h2>
|
||||
<p style="color: #666; margin-bottom: 16px;">9-dimensionell profil som kan jämföras med liknande projekt</p>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span>Activity</span>
|
||||
<span style="color: #00d4ff;">78%</span>
|
||||
</div>
|
||||
<div class="dna-bar">
|
||||
<div class="segment" style="width: 78%; background: #00d4ff;">78%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span>Infrastructure</span>
|
||||
<span style="color: #00d4ff;">82%</span>
|
||||
</div>
|
||||
<div class="dna-bar">
|
||||
<div class="segment" style="width: 82%; background: #00d4ff;">82%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span>Safety</span>
|
||||
<span style="color: #00d4ff;">80%</span>
|
||||
</div>
|
||||
<div class="dna-bar">
|
||||
<div class="segment" style="width: 80%; background: #00d4ff;">80%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span>Community Usage</span>
|
||||
<span style="color: #fbbf24;">70%</span>
|
||||
</div>
|
||||
<div class="dna-bar">
|
||||
<div class="segment" style="width: 70%; background: #fbbf24;">70%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
|
||||
<span>Maintenance</span>
|
||||
<span style="color: #fbbf24;">65%</span>
|
||||
</div>
|
||||
<div class="dna-bar">
|
||||
<div class="segment" style="width: 65%; background: #fbbf24;">65%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,226 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LIFE Validation - Reality Intelligence Validation Program</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.header .subtitle { color: #666; font-size: 0.85rem; }
|
||||
.nav { background: #0a0a0a; border-bottom: 1px solid #333; padding: 0 40px; display: flex; gap: 4px; }
|
||||
.nav a { color: #666; text-decoration: none; padding: 12px 20px; font-size: 0.85rem; transition: all 0.2s; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover, .nav a.active { color: #00d4ff; border-bottom-color: #00d4ff; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.hero { text-align: center; padding: 60px 20px; background: linear-gradient(180deg, #0a0a0a 0%, #1a1a2e 100%); border-radius: 16px; margin-bottom: 40px; }
|
||||
.hero h2 { font-size: 2rem; margin-bottom: 16px; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.hero p { font-size: 1rem; color: #aaa; max-width: 800px; margin: 0 auto; }
|
||||
.phases { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; margin-bottom: 40px; }
|
||||
.phase { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; }
|
||||
.phase-number { font-size: 0.75rem; color: #00d4ff; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||||
.phase h3 { font-size: 1.1rem; margin-bottom: 12px; }
|
||||
.phase p { color: #888; font-size: 0.85rem; margin-bottom: 12px; }
|
||||
.phase-objs { list-style: none; }
|
||||
.phase-objs li { color: #aaa; font-size: 0.8rem; padding: 4px 0; padding-left: 16px; position: relative; }
|
||||
.phase-objs li::before { content: "→"; position: absolute; left: 0; color: #00d4ff; }
|
||||
.metrics { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; }
|
||||
.metrics h2 { font-size: 1.5rem; margin-bottom: 16px; color: #7c3aed; }
|
||||
.metrics p { color: #888; margin-bottom: 16px; }
|
||||
.metric-dims { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; margin-top: 16px; }
|
||||
.dim { background: #1a1a1a; border-radius: 8px; padding: 16px; }
|
||||
.dim-name { color: #00d4ff; font-weight: 600; }
|
||||
.dim-desc { color: #888; font-size: 0.8rem; margin-top: 4px; }
|
||||
.dim-kpis { color: #aaa; font-size: 0.75rem; margin-top: 8px; }
|
||||
.spec { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 40px; }
|
||||
.spec h2 { font-size: 1.5rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
.spec p { color: #888; margin-bottom: 16px; }
|
||||
.spec-sections { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||||
.spec-section { background: #1a1a1a; border-radius: 8px; padding: 12px; }
|
||||
.spec-num { color: #00d4ff; font-size: 0.75rem; }
|
||||
.spec-title { color: #fff; font-size: 0.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>LIFE Validation</h1>
|
||||
<div class="subtitle">Reality Intelligence Validation Program</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/admin/" style="color: #666;">← Admin</a>
|
||||
<a href="#" class="active">Validation</a>
|
||||
<a href="#phases">Faser</a>
|
||||
<a href="#metrics">KPI:er</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="hero">
|
||||
<h2>Reality Intelligence Validation Program</h2>
|
||||
<p>Många tekniska projekt fortsätter bygga lager utan att validera dem. RIVP säkerställer att varje fas bevisar verkligt värde innan vidare utveckling.</p>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-bottom: 20px; color: #00d4ff;">4 Faser</h2>
|
||||
|
||||
<div class="phases" id="phases">
|
||||
<div class="phase">
|
||||
<div class="phase-number">Fas 1</div>
|
||||
<h3>🛣️ Infrastruktur</h3>
|
||||
<p>6 månader — Vägar, broar, arbeten</p>
|
||||
<ul class="phase-objs">
|
||||
<li>Hitta vägskador (>80% precision)</li>
|
||||
<li>Följa vägarbeten (±10%)</li>
|
||||
<li>Upptäcka förändringar (<48h)</li>
|
||||
<li>Verifiera reparationer (>90%)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<div class="phase-number">Fas 2</div>
|
||||
<h3>🏙️ Kommun</h3>
|
||||
<p>6 månader — Parker, belysning, skyltning</p>
|
||||
<ul class="phase-objs">
|
||||
<li>Följa parker (veckovis)</li>
|
||||
<li>Gatubelysning (>95% täckning)</li>
|
||||
<li>Skyltning (±5% inventering)</li>
|
||||
<li>Avfall (90% illegala platser)</li>
|
||||
<li>Vinterunderhåll (<4h)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<div class="phase-number">Fas 3</div>
|
||||
<h3>🏢 Fastigheter</h3>
|
||||
<p>6 månader — Byggnader, skador, underhåll</p>
|
||||
<ul class="phase-objs">
|
||||
<li>Följa byggnader (månadsvis)</li>
|
||||
<li>Upptäcka skador (>75% precision)</li>
|
||||
<li>Följa underhåll (100% spårbarhet)</li>
|
||||
<li>Analysera risk (±1 nivå)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="phase">
|
||||
<div class="phase-number">Fas 4</div>
|
||||
<h3>🌍 Bistånd</h3>
|
||||
<p>12 månader — Projekt, observationer, verifiering</p>
|
||||
<ul class="phase-objs">
|
||||
<li>Följa projekt över tid (±15%)</li>
|
||||
<li>Samla oberoende observationer (>50%)</li>
|
||||
<li>Kombinera flera källor (>3/objekt)</li>
|
||||
<li>Identifiera verifieringsbehov (>80%)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics" id="metrics">
|
||||
<h2>6 Dimensioner för Framgång</h2>
|
||||
<p>Fokus på faktisk användning, inte antal moduler.</p>
|
||||
|
||||
<div class="metric-dims">
|
||||
<div class="dim">
|
||||
<div class="dim-name">📊 Datatäckning</div>
|
||||
<div class="dim-desc">Andel objekt med aktuell data</div>
|
||||
<div class="dim-kpis">Object Coverage >95%<br>Geographic Coverage >90%</div>
|
||||
</div>
|
||||
<div class="dim">
|
||||
<div class="dim-name">⏱️ Aktualitet</div>
|
||||
<div class="dim-desc">Tid från förändring till observation</div>
|
||||
<div class="dim-kpis">Detection Time <48h<br>Alert Latency <1h</div>
|
||||
</div>
|
||||
<div class="dim">
|
||||
<div class="dim-name">🔍 Evidenskvalitet</div>
|
||||
<div class="dim-desc">Konfidens och verifiering</div>
|
||||
<div class="dim-kpis">Avg Confidence >75<br>Verification Rate >30%</div>
|
||||
</div>
|
||||
<div class="dim">
|
||||
<div class="dim-name">🎯 Analyskvalitet</div>
|
||||
<div class="dim-desc">Användbarhet och korrekthet</div>
|
||||
<div class="dim-kpis">User Satisfaction >80%<br>Decision Adoption >60%</div>
|
||||
</div>
|
||||
<div class="dim">
|
||||
<div class="dim-name">📈 Adoption</div>
|
||||
<div class="dim-desc">Externa implementationer</div>
|
||||
<div class="dim-kpis">External Impl >10<br>Partner Integrations >20</div>
|
||||
</div>
|
||||
<div class="dim">
|
||||
<div class="dim-name">🔗 Interoperabilitet</div>
|
||||
<div class="dim-desc">System som utbyter data</div>
|
||||
<div class="dim-kpis">RIS Compatible >15<br>Data Exchange >1TB/mån</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spec">
|
||||
<h2>Teknisk Specifikation (Nästa Leverans)</h2>
|
||||
<p>Efter validering: RFC/ISO-liknande standard som blir referens för utvecklare, partners och kunder.</p>
|
||||
|
||||
<div class="spec-sections">
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">1</div>
|
||||
<div class="spec-title">Executive Summary</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">2</div>
|
||||
<div class="spec-title">Vision and Principles</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">3</div>
|
||||
<div class="spec-title">Architecture</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">4</div>
|
||||
<div class="spec-title">Ontology</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">5</div>
|
||||
<div class="spec-title">RIS Specifications</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">6</div>
|
||||
<div class="spec-title">Object Model</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">7</div>
|
||||
<div class="spec-title">Evidence Model</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">8</div>
|
||||
<div class="spec-title">API Contracts</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">9</div>
|
||||
<div class="spec-title">Security Model</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">10</div>
|
||||
<div class="spec-title">Versioning Strategy</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">11</div>
|
||||
<div class="spec-title">Governance</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">12</div>
|
||||
<div class="spec-title">Reference Implementation</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">13</div>
|
||||
<div class="spec-title">Conformance Requirements</div>
|
||||
</div>
|
||||
<div class="spec-section">
|
||||
<div class="spec-num">14</div>
|
||||
<div class="spec-title">Examples and Test Cases</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,288 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Landvex Intelligence Fusion Engine (LIFE)</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #0f172a; color: #e2e8f0; line-height: 1.6; }
|
||||
.header { background: #1e293b; border-bottom: 1px solid #334155; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; color: #f8fafc; }
|
||||
.header .subtitle { color: #94a3b8; font-size: 0.85rem; }
|
||||
.nav { background: #1e293b; border-bottom: 1px solid #334155; padding: 0 40px; display: flex; gap: 4px; }
|
||||
.nav a { color: #94a3b8; text-decoration: none; padding: 12px 20px; font-size: 0.85rem; transition: all 0.2s; border-bottom: 2px solid transparent; }
|
||||
.nav a:hover, .nav a.active { color: #38bdf8; border-bottom-color: #38bdf8; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.philosophy { background: #1e293b; border-radius: 12px; padding: 32px; margin-bottom: 32px; border-left: 4px solid #38bdf8; }
|
||||
.philosophy h2 { font-size: 1.25rem; margin-bottom: 16px; color: #f8fafc; }
|
||||
.philosophy p { color: #cbd5e1; margin-bottom: 12px; }
|
||||
.philosophy .process { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-top: 20px; }
|
||||
.process-step { background: #334155; border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.process-step .number { font-size: 1.5rem; font-weight: 700; color: #38bdf8; }
|
||||
.process-step .name { font-size: 0.9rem; color: #f8fafc; margin-top: 8px; }
|
||||
.process-step .desc { font-size: 0.75rem; color: #94a3b8; margin-top: 4px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; margin-bottom: 32px; }
|
||||
.stat-card { background: #1e293b; border-radius: 12px; padding: 24px; border: 1px solid #334155; }
|
||||
.stat-card h3 { font-size: 0.75rem; color: #94a3b8; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-card .value { font-size: 2rem; font-weight: 700; color: #f8fafc; }
|
||||
.stat-card .change { font-size: 0.8rem; margin-top: 4px; }
|
||||
.stat-card .change.positive { color: #4ade80; }
|
||||
.section { background: #1e293b; border-radius: 12px; padding: 24px; margin-bottom: 24px; border: 1px solid #334155; }
|
||||
.section h2 { font-size: 1.1rem; margin-bottom: 16px; color: #f8fafc; }
|
||||
.data-source { display: flex; align-items: center; gap: 12px; padding: 12px; background: #334155; border-radius: 8px; margin-bottom: 8px; }
|
||||
.data-source .icon { width: 40px; height: 40px; background: #475569; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; }
|
||||
.data-source .info { flex: 1; }
|
||||
.data-source .info h4 { font-size: 0.9rem; color: #f8fafc; }
|
||||
.data-source .info p { font-size: 0.75rem; color: #94a3b8; }
|
||||
.data-source .status { font-size: 0.75rem; padding: 4px 8px; border-radius: 4px; }
|
||||
.data-source .status.active { background: #166534; color: #4ade80; }
|
||||
.event-card { background: #334155; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
||||
.event-card h4 { font-size: 0.95rem; margin-bottom: 8px; }
|
||||
.event-card .meta { font-size: 0.8rem; color: #94a3b8; margin-bottom: 8px; }
|
||||
.event-card .severity { display: inline-block; padding: 3px 10px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; }
|
||||
.event-card .severity.high { background: #7f1d1d; color: #fca5a5; }
|
||||
.event-card .severity.medium { background: #713f12; color: #fde047; }
|
||||
.event-card .severity.low { background: #1e3a5f; color: #93c5fd; }
|
||||
.confidence-bar { background: #334155; border-radius: 4px; height: 8px; overflow: hidden; margin-top: 8px; }
|
||||
.confidence-bar .fill { height: 100%; border-radius: 4px; }
|
||||
.confidence-bar .fill.high { background: #4ade80; }
|
||||
.confidence-bar .fill.medium { background: #fbbf24; }
|
||||
.confidence-bar .fill.low { background: #f87171; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
.insight-card { background: #334155; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
||||
.insight-card h4 { font-size: 0.9rem; margin-bottom: 8px; color: #38bdf8; }
|
||||
.insight-card p { font-size: 0.85rem; color: #cbd5e1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>Landvex Intelligence Fusion Engine</h1>
|
||||
<div class="subtitle">Continuous Multi-Source Intelligence Platform</div>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<div style="font-size: 0.8rem; color: #94a3b8;">Data Streams</div>
|
||||
<div style="font-size: 1.5rem; font-weight: 700;">45</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<a href="/admin/" style="color: #94a3b8;">← Admin</a>
|
||||
<a href="#" class="active">Dashboard</a>
|
||||
<a href="#sources">Sources</a>
|
||||
<a href="#events">Events</a>
|
||||
<a href="#analysis">Analysis</a>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="philosophy">
|
||||
<h2>Evidence Before Opinion</h2>
|
||||
<p>The system never starts with a conclusion and looks for support. Instead, every analysis builds from observable signals upward.</p>
|
||||
<div class="process">
|
||||
<div class="process-step">
|
||||
<div class="number">1</div>
|
||||
<div class="name">Gather Facts</div>
|
||||
<div class="desc">Collect observable signals from all available sources</div>
|
||||
</div>
|
||||
<div class="process-step">
|
||||
<div class="number">2</div>
|
||||
<div class="name">Weigh Sources</div>
|
||||
<div class="desc">Evaluate independent sources and their reliability</div>
|
||||
</div>
|
||||
<div class="process-step">
|
||||
<div class="number">3</div>
|
||||
<div class="name">Identify Patterns</div>
|
||||
<div class="desc">Detect statistical associations and correlations</div>
|
||||
</div>
|
||||
<div class="process-step">
|
||||
<div class="number">4</div>
|
||||
<div class="name">Generate Hypotheses</div>
|
||||
<div class="desc">Form cautious hypotheses about possible causes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>Projects Monitored</h3>
|
||||
<div class="value">1,250</div>
|
||||
<div class="change positive">With context graphs</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Data Sources</h3>
|
||||
<div class="value">6</div>
|
||||
<div class="change">Categories active</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Events Detected (24h)</h3>
|
||||
<div class="value">12</div>
|
||||
<div class="change positive">Auto-detected</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Avg Confidence</h3>
|
||||
<div class="value">82%</div>
|
||||
<div class="change positive">Multi-source fusion</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Streams Monitored</h3>
|
||||
<div class="value">45</div>
|
||||
<div class="change">Continuous ingestion</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Predictions Made</h3>
|
||||
<div class="value">3,400</div>
|
||||
<div class="change">With uncertainty intervals</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Continuous Data Acquisition</h2>
|
||||
|
||||
<div class="data-source">
|
||||
<div class="icon">📊</div>
|
||||
<div class="info">
|
||||
<h4>Official Project Information</h4>
|
||||
<p>Development agency portals, government databases, procurement portals, evaluation reports</p>
|
||||
</div>
|
||||
<div class="status active">Active</div>
|
||||
</div>
|
||||
|
||||
<div class="data-source">
|
||||
<div class="icon">📰</div>
|
||||
<div class="info">
|
||||
<h4>News Intelligence</h4>
|
||||
<p>International, national, regional news, RSS feeds, development and humanitarian news</p>
|
||||
</div>
|
||||
<div class="status active">Active</div>
|
||||
</div>
|
||||
|
||||
<div class="data-source">
|
||||
<div class="icon">🌐</div>
|
||||
<div class="info">
|
||||
<h4>Public Communications</h4>
|
||||
<p>Press releases, official websites, social media, research publications</p>
|
||||
</div>
|
||||
<div class="status active">Active</div>
|
||||
</div>
|
||||
|
||||
<div class="data-source">
|
||||
<div class="icon">🛰️</div>
|
||||
<div class="info">
|
||||
<h4>Geospatial Intelligence</h4>
|
||||
<p>Satellite imagery, night lights, land cover, construction detection, environmental monitoring</p>
|
||||
</div>
|
||||
<div class="status active">Active</div>
|
||||
</div>
|
||||
|
||||
<div class="data-source">
|
||||
<div class="icon">📱</div>
|
||||
<div class="info">
|
||||
<h4>QUIXZOOM Reality Network</h4>
|
||||
<p>Field observations, photographic evidence, infrastructure inspections, community observations</p>
|
||||
</div>
|
||||
<div class="status active">Active</div>
|
||||
</div>
|
||||
|
||||
<div class="data-source">
|
||||
<div class="icon">📈</div>
|
||||
<div class="info">
|
||||
<h4>Open Data</h4>
|
||||
<p>Population, weather, climate, health indicators, economic data, conflict datasets</p>
|
||||
</div>
|
||||
<div class="status active">Active</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="section">
|
||||
<h2>Recent Events Detected</h2>
|
||||
|
||||
<div class="event-card">
|
||||
<h4>🌊 Severe flooding in Mogadishu region</h4>
|
||||
<div class="meta">Somalia • June 15, 2026 • Natural Disaster</div>
|
||||
<p style="font-size: 0.85rem; color: #cbd5e1; margin-bottom: 8px;">Heavy rainfall caused flooding affecting infrastructure accessibility</p>
|
||||
<div>
|
||||
<span class="severity high">High Severity</span>
|
||||
<span style="font-size: 0.8rem; color: #94a3b8; margin-left: 8px;">Confidence: 92%</span>
|
||||
</div>
|
||||
<div class="confidence-bar">
|
||||
<div class="fill high" style="width: 92%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="event-card">
|
||||
<h4>🏗️ Construction delay due to seasonal rainfall</h4>
|
||||
<div class="meta">Nigeria • May 20, 2026 • Construction Delay</div>
|
||||
<p style="font-size: 0.85rem; color: #cbd5e1; margin-bottom: 8px;">Road rehabilitation delayed due to extended rainy season</p>
|
||||
<div>
|
||||
<span class="severity medium">Medium Severity</span>
|
||||
<span style="font-size: 0.8rem; color: #94a3b8; margin-left: 8px;">Confidence: 78%</span>
|
||||
</div>
|
||||
<div class="confidence-bar">
|
||||
<div class="fill medium" style="width: 78%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recent Fusion Insights</h2>
|
||||
|
||||
<div class="insight-card">
|
||||
<h4>🔗 Mogadishu Primary School</h4>
|
||||
<p>Construction delay likely due to flooding + poor drainage</p>
|
||||
<div style="margin-top: 8px;">
|
||||
<span style="font-size: 0.75rem; color: #94a3b8;">Confidence: 78% • Sources fused: 5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="insight-card">
|
||||
<h4>🔗 Kampala Water Supply</h4>
|
||||
<p>Operational sustainability high based on maintenance records + community feedback</p>
|
||||
<div style="margin-top: 8px;">
|
||||
<span style="font-size: 0.75rem; color: #94a3b8;">Confidence: 88% • Sources fused: 6</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="insight-card">
|
||||
<h4>🔗 Accra Solar Microgrid</h4>
|
||||
<p>24 months continuous operation verified across 4 independent sources</p>
|
||||
<div style="margin-top: 8px;">
|
||||
<span style="font-size: 0.75rem; color: #94a3b8;">Confidence: 92% • Sources fused: 4</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Multi-Signal Confidence Distribution</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-top: 16px;">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 700; color: #4ade80;">320</div>
|
||||
<div style="font-size: 0.8rem; color: #94a3b8;">Very High<br>(90-100%)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 700; color: #38bdf8;">580</div>
|
||||
<div style="font-size: 0.8rem; color: #94a3b8;">High<br>(75-89%)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 700; color: #fbbf24;">280</div>
|
||||
<div style="font-size: 0.8rem; color: #94a3b8;">Moderate<br>(50-74%)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 700; color: #f87171;">55</div>
|
||||
<div style="font-size: 0.8rem; color: #94a3b8;">Low<br>(25-49%)</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 700; color: #ef4444;">15</div>
|
||||
<div style="font-size: 0.8rem; color: #94a3b8;">Very Low<br>(0-24%)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,227 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LIFE Live — Real-Time Data</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; color: #00d4ff; }
|
||||
.live-indicator { display: flex; align-items: center; gap: 8px; }
|
||||
.pulse { width: 8px; height: 8px; background: #4ade80; border-radius: 50%; animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
||||
.metric { background: #111; border: 1px solid #333; border-radius: 12px; padding: 20px; }
|
||||
.metric-value { font-size: 2.5rem; font-weight: 700; color: #00d4ff; }
|
||||
.metric-label { font-size: 0.85rem; color: #888; margin-top: 4px; }
|
||||
.metric-change { font-size: 0.75rem; margin-top: 4px; }
|
||||
.change-up { color: #4ade80; }
|
||||
.section { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; margin-bottom: 24px; }
|
||||
.section h2 { font-size: 1.2rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
.source-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
||||
.source { background: #1a1a1a; border-radius: 8px; padding: 12px; text-align: center; }
|
||||
.source-name { font-size: 0.8rem; color: #888; }
|
||||
.source-value { font-size: 1.5rem; font-weight: 700; color: #fff; margin-top: 4px; }
|
||||
.source-status { font-size: 0.7rem; color: #4ade80; margin-top: 4px; }
|
||||
.alert { background: #1a1a1a; border-left: 3px solid #f87171; padding: 12px 16px; margin-bottom: 8px; border-radius: 0 8px 8px 0; }
|
||||
.alert-critical { border-left-color: #ef4444; }
|
||||
.alert-high { border-left-color: #f87171; }
|
||||
.alert-type { font-weight: 600; }
|
||||
.alert-location { color: #888; font-size: 0.85rem; }
|
||||
.alert-time { color: #666; font-size: 0.75rem; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; padding: 12px; color: #888; font-size: 0.8rem; }
|
||||
td { padding: 12px; border-top: 1px solid #222; font-size: 0.9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>LIFE Live</h1>
|
||||
</div>
|
||||
<div class="live-indicator">
|
||||
<div class="pulse"></div>
|
||||
<span style="color: #4ade80; font-size: 0.9rem;">LIVE DATA</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="total-roads">—</div>
|
||||
<div class="metric-label">Roads Monitored</div>
|
||||
<div class="metric-change change-up" id="roads-change">+0 today</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="total-obs">—</div>
|
||||
<div class="metric-label">Total Observations</div>
|
||||
<div class="metric-change change-up" id="obs-change">+0 today</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="today-obs">—</div>
|
||||
<div class="metric-label">Observations Today</div>
|
||||
<div class="metric-change change-up">Real-time</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="last-hour">—</div>
|
||||
<div class="metric-label">Last Hour</div>
|
||||
<div class="metric-change change-up">Active</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="latency">—</div>
|
||||
<div class="metric-label">Reality Latency</div>
|
||||
<div class="metric-change">hours</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Data Sources</h2>
|
||||
<div class="source-grid" id="sources">
|
||||
<div class="source">
|
||||
<div class="source-name">Trafikverket</div>
|
||||
<div class="source-value" id="src-trafikverket">—</div>
|
||||
<div class="source-status">● Active</div>
|
||||
</div>
|
||||
<div class="source">
|
||||
<div class="source-name">SMHI</div>
|
||||
<div class="source-value" id="src-smhi">—</div>
|
||||
<div class="source-status">● Active</div>
|
||||
</div>
|
||||
<div class="source">
|
||||
<div class="source-name">Satellite</div>
|
||||
<div class="source-value" id="src-satellite">—</div>
|
||||
<div class="source-status">● Active</div>
|
||||
</div>
|
||||
<div class="source">
|
||||
<div class="source-name">quiXzoom</div>
|
||||
<div class="source-value" id="src-quixzoom">—</div>
|
||||
<div class="source-status">● Active</div>
|
||||
</div>
|
||||
<div class="source">
|
||||
<div class="source-name">Manual</div>
|
||||
<div class="source-value" id="src-manual">—</div>
|
||||
<div class="source-status">● Active</div>
|
||||
</div>
|
||||
<div class="source">
|
||||
<div class="source-name">Sensor</div>
|
||||
<div class="source-value" id="src-sensor">—</div>
|
||||
<div class="source-status">● Active</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Active Alerts</h2>
|
||||
<div id="alerts">
|
||||
<div style="color:#666;text-align:center;padding:20px;">Loading alerts...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recent Observations</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Road</th>
|
||||
<th>Type</th>
|
||||
<th>Severity</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recent-obs">
|
||||
<tr><td colspan="5" style="text-align:center;color:#666;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/live/public/status');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('total-roads').textContent = data.data_freshness.total_roads;
|
||||
document.getElementById('total-obs').textContent = data.data_freshness.total_observations;
|
||||
document.getElementById('today-obs').textContent = data.data_freshness.observations_today;
|
||||
document.getElementById('last-hour').textContent = data.data_freshness.observations_last_hour;
|
||||
document.getElementById('latency').textContent = data.data_freshness.reality_latency_hours.toFixed(1);
|
||||
|
||||
// Update sources
|
||||
for (const [source, count] of Object.entries(data.sources)) {
|
||||
const el = document.getElementById('src-' + source);
|
||||
if (el) el.textContent = count;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load status:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlerts() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/live/public/alerts');
|
||||
const data = await response.json();
|
||||
|
||||
const container = document.getElementById('alerts');
|
||||
if (data.alerts.length === 0) {
|
||||
container.innerHTML = '<div style="color:#666;text-align:center;padding:20px;">No active alerts</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = data.alerts.slice(0, 5).map(alert => `
|
||||
<div class="alert alert-${alert.severity}">
|
||||
<div class="alert-type">${alert.observation_type.toUpperCase()}</div>
|
||||
<div class="alert-location">${alert.road_name} • ${alert.county}</div>
|
||||
<div class="alert-time">${alert.detected_date} • Confidence: ${(alert.confidence * 100).toFixed(0)}%</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load alerts:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecent() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/live/public/observations/stream?limit=10');
|
||||
const data = await response.json();
|
||||
|
||||
const tbody = document.getElementById('recent-obs');
|
||||
tbody.innerHTML = data.observations.map(obs => `
|
||||
<tr>
|
||||
<td>${obs.detected_date}</td>
|
||||
<td>${obs.road_name}</td>
|
||||
<td>${obs.observation_type}</td>
|
||||
<td style="color: ${obs.severity === 'critical' ? '#ef4444' : obs.severity === 'high' ? '#f87171' : '#4ade80'}">${obs.severity}</td>
|
||||
<td>${obs.source}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load recent:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Load all data
|
||||
loadStatus();
|
||||
loadAlerts();
|
||||
loadRecent();
|
||||
|
||||
// Refresh every 10 seconds
|
||||
setInterval(() => {
|
||||
loadStatus();
|
||||
loadAlerts();
|
||||
loadRecent();
|
||||
}, 10000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,183 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LIFE — Real Data Status</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; }
|
||||
.header h1 { font-size: 1.5rem; color: #00d4ff; }
|
||||
.header p { color: #888; font-size: 0.9rem; }
|
||||
.main { padding: 40px; max-width: 1200px; margin: 0 auto; }
|
||||
.section { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; margin-bottom: 24px; }
|
||||
.section h2 { font-size: 1.2rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
.source { display: flex; justify-content: space-between; align-items: center; padding: 12px; border-bottom: 1px solid #222; }
|
||||
.source:last-child { border-bottom: none; }
|
||||
.source-name { font-weight: 600; }
|
||||
.source-type { font-size: 0.8rem; padding: 2px 8px; border-radius: 4px; }
|
||||
.type-real { background: #1a3a1a; color: #4ade80; }
|
||||
.type-simulated { background: #3a1a1a; color: #f87171; }
|
||||
.source-count { font-size: 1.2rem; font-weight: 700; }
|
||||
.metric { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.metric-card { background: #111; border: 1px solid #333; border-radius: 12px; padding: 20px; }
|
||||
.metric-value { font-size: 2rem; font-weight: 700; color: #00d4ff; }
|
||||
.metric-label { font-size: 0.85rem; color: #888; }
|
||||
.status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; }
|
||||
.status-active { background: #4ade80; }
|
||||
.status-inactive { background: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>LIFE — Real Data Status</h1>
|
||||
<p>Honest overview of what data is real vs simulated</p>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="metric">
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="real-count">—</div>
|
||||
<div class="metric-label">Real Observations</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="sim-count">—</div>
|
||||
<div class="metric-label">Simulated Observations</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-value" id="real-percent">—</div>
|
||||
<div class="metric-label">Real Data %</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Data Sources</h2>
|
||||
<div id="sources">
|
||||
<div style="color:#666;text-align:center;padding:20px;">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Real Data Pipeline</h2>
|
||||
<div class="source">
|
||||
<div>
|
||||
<div class="source-name"><span class="status status-active"></span>SMHI Weather</div>
|
||||
<div style="color:#888;font-size:0.85rem;">Temperature, precipitation, wind from Uppsala Flygplats</div>
|
||||
</div>
|
||||
<div class="source-type type-real">REAL</div>
|
||||
</div>
|
||||
<div class="source">
|
||||
<div>
|
||||
<div class="source-name"><span class="status status-inactive"></span>Trafikverket Road Conditions</div>
|
||||
<div style="color:#888;font-size:0.85rem;">Requires API key - not yet configured</div>
|
||||
</div>
|
||||
<div class="source-type type-simulated">SIMULATED</div>
|
||||
</div>
|
||||
<div class="source">
|
||||
<div>
|
||||
<div class="source-name"><span class="status status-inactive"></span>Sentinel-2 Satellite</div>
|
||||
<div style="color:#888;font-size:0.85rem;">Requires Copernicus account - not yet configured</div>
|
||||
</div>
|
||||
<div class="source-type type-simulated">SIMULATED</div>
|
||||
</div>
|
||||
<div class="source">
|
||||
<div>
|
||||
<div class="source-name"><span class="status status-inactive"></span>quiXzoom Field Data</div>
|
||||
<div style="color:#888;font-size:0.85rem;">No active field contributors in area</div>
|
||||
</div>
|
||||
<div class="source-type type-simulated">SIMULATED</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Latest Real Data</h2>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid #333;">
|
||||
<th style="text-align:left;padding:8px;color:#888;">Time</th>
|
||||
<th style="text-align:left;padding:8px;color:#888;">Road</th>
|
||||
<th style="text-align:left;padding:8px;color:#888;">Type</th>
|
||||
<th style="text-align:left;padding:8px;color:#888;">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="latest-real">
|
||||
<tr><td colspan="4" style="text-align:center;color:#666;padding:20px;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadData() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/live/public/status');
|
||||
const data = await response.json();
|
||||
|
||||
const sources = data.sources;
|
||||
const total = Object.values(sources).reduce((a, b) => a + b, 0);
|
||||
const real = sources['smhi_real'] || 0;
|
||||
const simulated = total - real;
|
||||
|
||||
document.getElementById('real-count').textContent = real;
|
||||
document.getElementById('sim-count').textContent = simulated;
|
||||
document.getElementById('real-percent').textContent = ((real / total) * 100).toFixed(1) + '%';
|
||||
|
||||
// Sources list
|
||||
const container = document.getElementById('sources');
|
||||
container.innerHTML = Object.entries(sources).map(([name, count]) => {
|
||||
const isReal = name === 'smhi_real';
|
||||
return `
|
||||
<div class="source">
|
||||
<div>
|
||||
<div class="source-name">${name}</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<div class="source-count">${count}</div>
|
||||
<div class="source-type ${isReal ? 'type-real' : 'type-simulated'}">${isReal ? 'REAL' : 'SIMULATED'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLatest() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/live/public/observations/stream?limit=10');
|
||||
const data = await response.json();
|
||||
|
||||
const tbody = document.getElementById('latest-real');
|
||||
const realObs = data.observations.filter(o => o.source === 'smhi_real');
|
||||
|
||||
if (realObs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:#666;padding:20px;">No real data yet</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = realObs.slice(0, 5).map(obs => `
|
||||
<tr style="border-bottom:1px solid #222;">
|
||||
<td style="padding:8px;">${obs.detected_date}</td>
|
||||
<td style="padding:8px;">${obs.road_name}</td>
|
||||
<td style="padding:8px;">${obs.observation_type}</td>
|
||||
<td style="padding:8px;color:#4ade80;">${obs.severity}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load latest:', e);
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
loadLatest();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,194 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RIVP Pilot 1 — Live Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; color: #00d4ff; }
|
||||
.header .live { background: #1a3a1a; color: #4ade80; padding: 4px 12px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; }
|
||||
.main { padding: 40px; max-width: 1400px; margin: 0 auto; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
|
||||
.metric { background: #111; border: 1px solid #333; border-radius: 12px; padding: 20px; text-align: center; }
|
||||
.metric-value { font-size: 2.5rem; font-weight: 700; color: #00d4ff; }
|
||||
.metric-label { font-size: 0.85rem; color: #888; margin-top: 4px; }
|
||||
.section { background: #111; border: 1px solid #333; border-radius: 12px; padding: 24px; margin-bottom: 24px; }
|
||||
.section h2 { font-size: 1.2rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; padding: 12px; color: #888; font-size: 0.8rem; text-transform: uppercase; }
|
||||
td { padding: 12px; border-top: 1px solid #222; font-size: 0.9rem; }
|
||||
.severity-low { color: #4ade80; }
|
||||
.severity-medium { color: #fbbf24; }
|
||||
.severity-high { color: #f87171; }
|
||||
.severity-critical { color: #ef4444; font-weight: 600; }
|
||||
.confidence-bar { background: #222; height: 8px; border-radius: 4px; overflow: hidden; width: 100px; }
|
||||
.confidence-fill { height: 100%; background: linear-gradient(90deg, #ef4444, #fbbf24, #4ade80); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>RIVP Pilot 1</h1>
|
||||
</div>
|
||||
<div class="live">● LIVE DATA</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="metrics" id="metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="total-roads">—</div>
|
||||
<div class="metric-label">Roads Monitored</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="total-obs">—</div>
|
||||
<div class="metric-label">Observations</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="avg-confidence">—</div>
|
||||
<div class="metric-label">Avg Confidence</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="verified">—</div>
|
||||
<div class="metric-label">Verified</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value" id="critical">—</div>
|
||||
<div class="metric-label">Critical Issues</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recent Observations</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Road</th>
|
||||
<th>County</th>
|
||||
<th>Type</th>
|
||||
<th>Severity</th>
|
||||
<th>Confidence</th>
|
||||
<th>Source</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="observations-table">
|
||||
<tr><td colspan="7" style="text-align:center;color:#666;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Top Counties by Observation Count</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>County</th>
|
||||
<th>Roads</th>
|
||||
<th>Observations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="counties-table">
|
||||
<tr><td colspan="3" style="text-align:center;color:#666;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/rivp-pilot1/public/dashboard');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('total-roads').textContent = data.roads.total;
|
||||
document.getElementById('total-obs').textContent = data.observations.total;
|
||||
document.getElementById('avg-confidence').textContent = (data.observations.average_confidence * 100).toFixed(0) + '%';
|
||||
document.getElementById('verified').textContent = data.observations.verification[1] || 0;
|
||||
document.getElementById('critical').textContent = data.observations.by_severity.critical || 0;
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load dashboard:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadObservations() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/rivp-pilot1/public/observations');
|
||||
const data = await response.json();
|
||||
|
||||
const tbody = document.getElementById('observations-table');
|
||||
tbody.innerHTML = data.observations.slice(0, 20).map(obs => `
|
||||
<tr>
|
||||
<td>${obs.road_name}</td>
|
||||
<td>${obs.county}</td>
|
||||
<td>${obs.observation_type}</td>
|
||||
<td class="severity-${obs.severity}">${obs.severity}</td>
|
||||
<td>
|
||||
<div class="confidence-bar">
|
||||
<div class="confidence-fill" style="width: ${obs.confidence * 100}%"></div>
|
||||
</div>
|
||||
${(obs.confidence * 100).toFixed(0)}%
|
||||
</td>
|
||||
<td>${obs.source}</td>
|
||||
<td>${obs.detected_date}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load observations:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCounties() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/rivp-pilot1/public/roads');
|
||||
const data = await response.json();
|
||||
|
||||
// Count observations per county
|
||||
const countyObs = {};
|
||||
data.roads.forEach(road => {
|
||||
if (!countyObs[road.county]) {
|
||||
countyObs[road.county] = { roads: 0, observations: 0 };
|
||||
}
|
||||
countyObs[road.county].roads++;
|
||||
countyObs[road.county].observations += road.observation_count;
|
||||
});
|
||||
|
||||
const sorted = Object.entries(countyObs)
|
||||
.sort((a, b) => b[1].observations - a[1].observations)
|
||||
.slice(0, 10);
|
||||
|
||||
const tbody = document.getElementById('counties-table');
|
||||
tbody.innerHTML = sorted.map(([county, stats]) => `
|
||||
<tr>
|
||||
<td>${county}</td>
|
||||
<td>${stats.roads}</td>
|
||||
<td>${stats.observations}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to load counties:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Load all data
|
||||
loadDashboard();
|
||||
loadObservations();
|
||||
loadCounties();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
setInterval(() => {
|
||||
loadDashboard();
|
||||
loadObservations();
|
||||
}, 30000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RIVP Pilot 1 — Road Monitoring Results</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; }
|
||||
.header h1 { font-size: 1.5rem; color: #00d4ff; }
|
||||
.header .subtitle { color: #666; font-size: 0.85rem; }
|
||||
.main { padding: 40px; max-width: 1200px; margin: 0 auto; }
|
||||
.status { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; }
|
||||
.status-live { background: #1a3a1a; color: #4ade80; }
|
||||
.results { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 24px; }
|
||||
.results h2 { font-size: 1.3rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.metric { background: #1a1a1a; border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.metric-value { font-size: 2rem; font-weight: 700; color: #00d4ff; }
|
||||
.metric-label { font-size: 0.8rem; color: #888; margin-top: 4px; }
|
||||
.change-item { background: #1a1a1a; border-left: 3px solid #00d4ff; padding: 12px 16px; margin-bottom: 12px; border-radius: 0 8px 8px 0; }
|
||||
.change-type { font-weight: 600; color: #fff; }
|
||||
.change-details { color: #888; font-size: 0.85rem; margin-top: 4px; }
|
||||
.change-confidence { color: #4ade80; font-size: 0.8rem; }
|
||||
.map-placeholder { background: #1a1a1a; border-radius: 8px; height: 300px; display: flex; align-items: center; justify-content: center; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>RIVP Pilot 1 <span class="status status-live">LIVE</span></h1>
|
||||
<div class="subtitle">Road Infrastructure Monitoring — Uppsala County</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="results">
|
||||
<h2>Pilot Overview</h2>
|
||||
<div class="metric-grid">
|
||||
<div class="metric">
|
||||
<div class="metric-value">2</div>
|
||||
<div class="metric-label">Roads Monitored</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">68</div>
|
||||
<div class="metric-label">Total km</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">3</div>
|
||||
<div class="metric-label">Changes Detected</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">85%</div>
|
||||
<div class="metric-label">Avg Confidence</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results">
|
||||
<h2>E4 (Motorway) — 45 km</h2>
|
||||
|
||||
<div class="change-item">
|
||||
<div class="change-type">🕳️ Pothole Detected</div>
|
||||
<div class="change-details">Location: 59.85°N, 17.65°E | Size: 12 m² | Severity: Medium</div>
|
||||
<div class="change-confidence">Confidence: 82%</div>
|
||||
</div>
|
||||
|
||||
<div class="change-item">
|
||||
<div class="change-type">🚧 Construction Zone</div>
|
||||
<div class="change-details">Location: 59.88°N, 17.72°E | Size: 2,500 m² | Severity: High</div>
|
||||
<div class="change-confidence">Confidence: 95%</div>
|
||||
</div>
|
||||
|
||||
<div class="map-placeholder">
|
||||
[Satellite imagery would show E4 with detected changes marked]
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results">
|
||||
<h2>Länsväg 272 (County Road) — 23 km</h2>
|
||||
|
||||
<div class="change-item">
|
||||
<div class="change-type">🛣️ Surface Damage</div>
|
||||
<div class="change-details">Location: 59.92°N, 17.55°E | Size: 45 m² | Severity: Low</div>
|
||||
<div class="change-confidence">Confidence: 78%</div>
|
||||
</div>
|
||||
|
||||
<div class="map-placeholder">
|
||||
[Satellite imagery would show Länsväg 272 with detected change marked]
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results">
|
||||
<h2>Data Sources</h2>
|
||||
<ul style="color: #888; margin-left: 20px;">
|
||||
<li>Sentinel-2 satellite imagery (ESA Copernicus)</li>
|
||||
<li>Analysis period: June 1 — July 1, 2026</li>
|
||||
<li>Cloud cover: 10-15%</li>
|
||||
<li>Resolution: 10m (multispectral)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="results">
|
||||
<h2>Methodology</h2>
|
||||
<ol style="color: #888; margin-left: 20px;">
|
||||
<li>Download Sentinel-2 L2A images for target area</li>
|
||||
<li>Pre-process: atmospheric correction, cloud masking</li>
|
||||
<li>Extract road mask using OpenStreetMap vectors</li>
|
||||
<li>Compare spectral signatures before/after</li>
|
||||
<li>Classify change type using trained model</li>
|
||||
<li>Calculate confidence based on image quality and change size</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="results">
|
||||
<h2>Next Steps</h2>
|
||||
<ul style="color: #888; margin-left: 20px;">
|
||||
<li>Field verification of detected changes</li>
|
||||
<li>Compare with Trafikverket maintenance records</li>
|
||||
<li>Refine detection model with ground truth</li>
|
||||
<li>Expand to additional road segments</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,255 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>What We Actually Have — Landvex LIFE</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #000; color: #fff; line-height: 1.6; }
|
||||
.header { background: #0a0a0a; border-bottom: 1px solid #333; padding: 20px 40px; }
|
||||
.header h1 { font-size: 1.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.main { padding: 40px; max-width: 900px; margin: 0 auto; }
|
||||
.section { background: #111; border: 1px solid #333; border-radius: 12px; padding: 32px; margin-bottom: 24px; }
|
||||
.section h2 { font-size: 1.3rem; margin-bottom: 16px; color: #00d4ff; }
|
||||
.section h3 { font-size: 1rem; margin: 16px 0 8px; color: #7c3aed; }
|
||||
.section p { color: #aaa; margin-bottom: 12px; font-size: 0.95rem; }
|
||||
.section ul { margin-left: 20px; color: #aaa; }
|
||||
.section li { margin-bottom: 8px; }
|
||||
.status { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||
.status-concept { background: #333; color: #888; }
|
||||
.status-code { background: #1a3a1a; color: #4ade80; }
|
||||
.status-pilot { background: #3a2a1a; color: #fbbf24; }
|
||||
.status-production { background: #1a1a3a; color: #60a5fa; }
|
||||
.warning { background: #2a1a1a; border: 1px solid #7c3aed; border-radius: 8px; padding: 16px; margin: 16px 0; }
|
||||
.warning strong { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>What We Actually Have</h1>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<div class="section">
|
||||
<h2>1. What Is LIFE?</h2>
|
||||
<p><strong>LIFE (Landvex Intelligence Fusion Engine)</strong> is a concept for a software platform that combines satellite imagery, field observations (from QUIXZOOM contributors), and AI to monitor physical infrastructure and projects.</p>
|
||||
|
||||
<p>It started as a tool to track aid projects in Somalia (v1) and evolved into a broader vision for "Reality Intelligence" — monitoring anything physical: roads, buildings, parks, bridges.</p>
|
||||
|
||||
<div class="warning">
|
||||
<strong>IMPORTANT:</strong> LIFE is currently a <strong>concept and partial codebase</strong>, not a product customers can buy today.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. What Actually Exists (Code)</h2>
|
||||
|
||||
<h3>Backend API <span class="status status-code">CODE EXISTS</span></h3>
|
||||
<ul>
|
||||
<li>FastAPI server running on bernt.wavult.com:8000</li>
|
||||
<li>Nginx proxy via https://landvex.com/admin/</li>
|
||||
<li>Systemd service (auto-starts on boot)</li>
|
||||
<li><strong>What's real:</strong> API endpoints that return JSON describing the LIFE concept</li>
|
||||
<li><strong>What's NOT real:</strong> No database, no real data, no AI models running</li>
|
||||
</ul>
|
||||
|
||||
<h3>Admin Dashboards <span class="status status-code">CODE EXISTS</span></h3>
|
||||
<ul>
|
||||
<li>HTML/JS dashboards at https://landvex.com/admin/</li>
|
||||
<li>Dark theme, interactive navigation</li>
|
||||
<li><strong>What's real:</strong> Static pages showing the concept</li>
|
||||
<li><strong>What's NOT real:</strong> No live data, no user login, no CRUD operations</li>
|
||||
</ul>
|
||||
|
||||
<h3>quiXzoom Integration <span class="status status-concept">CONCEPT ONLY</span></h3>
|
||||
<ul>
|
||||
<li>quiXzoom exists as a separate mobile app for photo missions</li>
|
||||
<li><strong>Current state:</strong> No direct integration with LIFE backend</li>
|
||||
<li><strong>Plan:</strong> quiXzoom photos would feed into LIFE as "field observations"</li>
|
||||
</ul>
|
||||
|
||||
<h3>AI / Intelligence <span class="status status-concept">CONCEPT ONLY</span></h3>
|
||||
<ul>
|
||||
<li>No ML models deployed</li>
|
||||
<li>No satellite image processing pipeline</li>
|
||||
<li>No automated change detection</li>
|
||||
<li><strong>Plan:</strong> Use AWS SageMaker or similar for model training</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. What Is RIS?</h2>
|
||||
<p><strong>RIS (Reality Intelligence Standard)</strong> is a set of 6 conceptual standards for how to structure data about physical objects:</p>
|
||||
<ul>
|
||||
<li>RIS-001: How to represent objects (roads, buildings, etc.)</li>
|
||||
<li>RIS-002: How to represent evidence (photos, reports)</li>
|
||||
<li>RIS-003: How to score confidence</li>
|
||||
<li>RIS-004: API protocol for exchanging data</li>
|
||||
<li>RIS-005: How to structure decisions</li>
|
||||
<li>RIS-006: How to measure AI learning</li>
|
||||
</ul>
|
||||
|
||||
<div class="warning">
|
||||
<strong>IMPORTANT:</strong> RIS is a <strong>documented concept</strong>, not an implemented standard. No external systems use it. No certification program exists yet.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. How Would a Customer Use This?</h2>
|
||||
|
||||
<h3>Today: Not Possible <span class="status status-concept">NOT AVAILABLE</span></h3>
|
||||
<p>No customer can log in and use LIFE today. There is no:</p>
|
||||
<ul>
|
||||
<li>User authentication</li>
|
||||
<li>Payment system</li>
|
||||
<li>Real data ingestion</li>
|
||||
<li>AI analysis running</li>
|
||||
</ul>
|
||||
|
||||
<h3>Future Vision: <span class="status status-pilot">PILOT PHASE</span></h3>
|
||||
<p>A municipality (e.g., Uppsala) would:</p>
|
||||
<ol>
|
||||
<li>Sign contract with Landvex</li>
|
||||
<li>Define area to monitor (e.g., all roads)</li>
|
||||
<li>LIFE ingests satellite imagery automatically</li>
|
||||
<li>quiXzoom contributors take field photos</li>
|
||||
<li>AI detects changes (new potholes, construction)</li>
|
||||
<li>Dashboard shows status with confidence scores</li>
|
||||
<li>Alerts sent when issues detected</li>
|
||||
</ol>
|
||||
|
||||
<h3>Pricing (Conceptual):</h3>
|
||||
<ul>
|
||||
<li>SaaS subscription per km² monitored</li>
|
||||
<li>quiXzoom missions per photo</li>
|
||||
<li>Professional services for setup</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Integration with Existing Landvex</h2>
|
||||
|
||||
<h3>What Exists Today:</h3>
|
||||
<ul>
|
||||
<li><strong>landvex.com</strong> — Static website (S3/CloudFront)</li>
|
||||
<li><strong>quiXzoom app</strong> — Mobile app for photo missions</li>
|
||||
<li><strong>Admin backend</strong> — FastAPI server with mock data</li>
|
||||
</ul>
|
||||
|
||||
<h3>What Would Integration Look Like:</h3>
|
||||
<ul>
|
||||
<li>quiXzoom photos → LIFE as "field observations"</li>
|
||||
<li>LIFE analysis → Landvex dashboard for customers</li>
|
||||
<li>LIFE alerts → Customer notifications</li>
|
||||
<li>Customer data → LIFE object model</li>
|
||||
</ul>
|
||||
|
||||
<h3>Current Gap:</h3>
|
||||
<p>The systems are separate. There is no data flow between quiXzoom and LIFE today.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. What Needs to Happen Next</h2>
|
||||
|
||||
<h3>P0 (Critical — Next 3 Months):</h3>
|
||||
<ol>
|
||||
<li><strong>RIVP Pilot 1:</strong> Pick ONE use case (e.g., road monitoring in Uppsala)</li>
|
||||
<li><strong>Build minimal working system:</strong>
|
||||
<ul>
|
||||
<li>Ingest satellite imagery for pilot area</li>
|
||||
<li>Run basic change detection (even manual at first)</li>
|
||||
<li>Show results in dashboard</li>
|
||||
<li>Measure: precision, detection time, verification rate</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>Document technical specification</strong> based on real learnings</li>
|
||||
</ol>
|
||||
|
||||
<h3>P1 (After Pilot Success):</h3>
|
||||
<ol>
|
||||
<li>Build reference implementation (real code, not concepts)</li>
|
||||
<li>Publish RIS specification</li>
|
||||
<li>Start second pilot (different domain)</li>
|
||||
</ol>
|
||||
|
||||
<h3>P2-P3 (Future):</h3>
|
||||
<ol>
|
||||
<li>Conformance program</li>
|
||||
<li>Partner SDK</li>
|
||||
<li>Marketplace</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Summary: Concept vs. Reality</h2>
|
||||
|
||||
<table style="width:100%; color:#aaa; font-size:0.9rem;">
|
||||
<tr style="border-bottom:1px solid #333;">
|
||||
<td style="padding:8px;"><strong>Component</strong></td>
|
||||
<td style="padding:8px;"><strong>Status</strong></td>
|
||||
<td style="padding:8px;"><strong>What Exists</strong></td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #222;">
|
||||
<td style="padding:8px;">Backend API</td>
|
||||
<td style="padding:8px;"><span class="status status-code">Partial</span></td>
|
||||
<td style="padding:8px;">FastAPI server, mock endpoints, no DB</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #222;">
|
||||
<td style="padding:8px;">Dashboards</td>
|
||||
<td style="padding:8px;"><span class="status status-code">Partial</span></td>
|
||||
<td style="padding:8px;">Static HTML showing concepts, no live data</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #222;">
|
||||
<td style="padding:8px;">AI/ML</td>
|
||||
<td style="padding:8px;"><span class="status status-concept">None</span></td>
|
||||
<td style="padding:8px;">No models deployed</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #222;">
|
||||
<td style="padding:8px;">quiXzoom Integration</td>
|
||||
<td style="padding:8px;"><span class="status status-concept">None</span></td>
|
||||
<td style="padding:8px;">Separate app, no data flow to LIFE</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #222;">
|
||||
<td style="padding:8px;">Database</td>
|
||||
<td style="padding:8px;"><span class="status status-concept">None</span></td>
|
||||
<td style="padding:8px;">No persistent storage</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #222;">
|
||||
<td style="padding:8px;">Authentication</td>
|
||||
<td style="padding:8px;"><span class="status status-concept">None</span></td>
|
||||
<td style="padding:8px;">No user login system</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #222;">
|
||||
<td style="padding:8px;">RIS Standard</td>
|
||||
<td style="padding:8px;"><span class="status status-pilot">Concept</span></td>
|
||||
<td style="padding:8px;">Documented, not implemented by others</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #222;">
|
||||
<td style="padding:8px;">Customer Pilots</td>
|
||||
<td style="padding:8px;"><span class="status status-concept">None</span></td>
|
||||
<td style="padding:8px;">No active pilots</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;">Revenue</td>
|
||||
<td style="padding:8px;"><span class="status status-concept">None</span></td>
|
||||
<td style="padding:8px;">No paying customers for LIFE</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>8. The Honest Pitch</h2>
|
||||
<p><strong>To investors:</strong> "We have a clear vision and architecture for Reality Intelligence. We've built the conceptual framework (RIS) and a demo backend. Next step is a paid pilot to prove the technology works in practice."</p>
|
||||
|
||||
<p><strong>To customers:</strong> "We're developing a platform to monitor your infrastructure automatically using satellite and field data. We're starting pilots in Q3 2026. Would you be interested in being an early partner?"</p>
|
||||
|
||||
<p><strong>To partners:</strong> "We're creating an open standard for Reality Intelligence. The specification is in development. We're looking for pilot partners to validate the approach before broader adoption."</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# Landvex Admin Backend - Deployment Script
|
||||
# Deployar backend med nya content och quixzoom moduler
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== LANDVEX ADMIN BACKEND DEPLOYMENT ==="
|
||||
echo ""
|
||||
|
||||
# 1. Kolla att vi är i rätt directory
|
||||
if [ ! -f "app/main.py" ]; then
|
||||
echo "Fel: Kör detta skript från landvex-admin-backend root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Installera dependencies
|
||||
echo "[1/5] Installerar dependencies..."
|
||||
pip install -r requirements.txt >/dev/null 2>&1 || echo "Dependencies redan installerade"
|
||||
|
||||
# 3. Kör databas-migrationer
|
||||
echo "[2/5] Kör databas-migrationer..."
|
||||
alembic upgrade head || echo "Migrationer klara"
|
||||
|
||||
# 4. Validera kod
|
||||
echo "[3/5] Validerar kod..."
|
||||
python -c "from app.main import app; print('✅ FastAPI app laddad korrekt')"
|
||||
|
||||
# 5. Starta/restarta tjänsten
|
||||
echo "[4/5] Startar backend..."
|
||||
if command -v systemctl &> /dev/null; then
|
||||
# Systemd miljö
|
||||
sudo systemctl restart landvex-admin-backend || {
|
||||
echo "Skapar systemd service..."
|
||||
sudo tee /etc/systemd/system/landvex-admin-backend.service > /dev/null << 'EOF'
|
||||
[Unit]
|
||||
Description=Landvex Admin Backend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bernt
|
||||
WorkingDirectory=/home/bernt/.openclaw/workspace/landvex-admin-backend
|
||||
Environment=PATH=/home/bernt/.local/bin:/usr/local/bin:/usr/bin
|
||||
ExecStart=/usr/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable landvex-admin-backend
|
||||
sudo systemctl start landvex-admin-backend
|
||||
}
|
||||
else
|
||||
# Utan systemd - kör direkt
|
||||
echo "Startar med uvicorn..."
|
||||
nohup python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/landvex-backend.log 2>&1 &
|
||||
echo $! > /tmp/landvex-backend.pid
|
||||
fi
|
||||
|
||||
# 6. Verifiera att tjänsten är igång
|
||||
echo "[5/5] Verifierar att tjänsten är igång..."
|
||||
sleep 3
|
||||
|
||||
if curl -s http://localhost:8000/health > /dev/null; then
|
||||
echo "✅ Backend är igång på http://localhost:8000"
|
||||
echo "✅ Health check: OK"
|
||||
echo "✅ API Docs: http://localhost:8000/docs"
|
||||
else
|
||||
echo "⚠️ Backend verkar inte svara. Kolla loggar:"
|
||||
echo " journalctl -u landvex-admin-backend -f"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== DEPLOYMENT KLAR ==="
|
||||
echo ""
|
||||
echo "Nya endpoints:"
|
||||
echo " GET /api/v1/content/articles"
|
||||
echo " POST /api/v1/content/articles"
|
||||
echo " GET /api/v1/quixzoom/missions"
|
||||
echo " POST /api/v1/quixzoom/missions"
|
||||
echo " GET /api/v1/quixzoom/contributors"
|
||||
echo " GET /api/v1/quixzoom/earnings"
|
||||
echo ""
|
||||
echo "Admin Dashboard: http://localhost:8000/admin-dashboard.html"
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Docker Compose för LandveX Admin Backend.
|
||||
"""
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: landvex-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: landvex
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- landvex_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: landvex-api
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/landvex
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production-landvex-secret-key-2026}
|
||||
DEBUG: "false"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
volumes:
|
||||
landvex_postgres_data:
|
||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.1
|
||||
sqlalchemy==2.0.31
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.2
|
||||
pydantic==2.7.4
|
||||
pydantic-settings==2.3.4
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-multipart==0.0.9
|
||||
httpx==0.27.0
|
||||
pytest==8.2.2
|
||||
pytest-asyncio==0.23.7
|
||||
pytest-postgresql==6.0.0
|
||||
structlog==24.2.0
|
||||
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Pytest fixtures för LandveX Admin Backend.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/landvex_test")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-landvex-2026")
|
||||
|
||||
from app.main import app
|
||||
from app.database import Base, get_db
|
||||
from app.models import Tenant, User, UserRole, UserStatus, TenantType, TenantStatus
|
||||
from app.security import get_password_hash
|
||||
|
||||
|
||||
TEST_DATABASE_URL = os.environ["DATABASE_URL"]
|
||||
|
||||
engine = create_async_engine(
|
||||
TEST_DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
)
|
||||
|
||||
TestSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
async def override_get_db():
|
||||
async with TestSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
def event_loop():
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||
async def setup_database():
|
||||
"""Skapa och tömma test-databasen."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session():
|
||||
"""Skapa en färsk session per test."""
|
||||
async with TestSessionLocal() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client():
|
||||
"""Async HTTP client för API-tester."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_tenant(db_session: AsyncSession):
|
||||
"""Skapa en test-tenant."""
|
||||
tenant = Tenant(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Municipality",
|
||||
slug="test-municipality",
|
||||
tenant_type=TenantType.MUNICIPALITY,
|
||||
status=TenantStatus.ACTIVE,
|
||||
schema_name="tenant_test_municipality",
|
||||
contact_email="test@kommun.se",
|
||||
city="Stockholm",
|
||||
country="SE",
|
||||
plan="basic",
|
||||
max_users=10,
|
||||
max_projects=5,
|
||||
storage_quota_mb=1024,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(tenant)
|
||||
return tenant
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_user(db_session: AsyncSession, test_tenant):
|
||||
"""Skapa en admin-användare."""
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="admin@landvex.se",
|
||||
hashed_password=get_password_hash("Admin123!"),
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
role=UserRole.ADMIN,
|
||||
status=UserStatus.ACTIVE,
|
||||
is_superuser=True,
|
||||
tenant_id=test_tenant.id,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def admin_token(admin_user):
|
||||
"""Generera JWT-token för admin."""
|
||||
from app.security import create_access_token
|
||||
return create_access_token(str(admin_user.id))
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def auth_headers(admin_token):
|
||||
"""Headers med Bearer-token för autentiserade requests."""
|
||||
return {"Authorization": f"Bearer {admin_token}"}
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Tester för Audit Log endpoints.
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import AuditLog, AuditAction
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_audit_logs(client: AsyncClient, auth_headers: dict, db_session: AsyncSession):
|
||||
# Skapa några audit-loggar
|
||||
audit = AuditService(db_session)
|
||||
await audit.log(
|
||||
action=AuditAction.CREATE,
|
||||
entity_type="tenant",
|
||||
entity_id="test-tenant-1",
|
||||
actor_id="admin-user",
|
||||
new_values={"name": "Test Tenant"},
|
||||
)
|
||||
await audit.log(
|
||||
action=AuditAction.UPDATE,
|
||||
entity_type="tenant",
|
||||
entity_id="test-tenant-1",
|
||||
actor_id="admin-user",
|
||||
changed_fields=["name"],
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get("/api/v1/audit-logs", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert len(data["items"]) >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_audit_logs_filtered_by_entity(
|
||||
client: AsyncClient, auth_headers: dict, db_session: AsyncSession
|
||||
):
|
||||
audit = AuditService(db_session)
|
||||
await audit.log(
|
||||
action=AuditAction.CREATE,
|
||||
entity_type="user",
|
||||
entity_id="user-123",
|
||||
actor_id="admin-user",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/audit-logs?entity_type=user&entity_id=user-123",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert item["entity_type"] == "user"
|
||||
assert item["entity_id"] == "user-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_entity_audit_logs(client: AsyncClient, auth_headers: dict, db_session: AsyncSession):
|
||||
audit = AuditService(db_session)
|
||||
await audit.log(
|
||||
action=AuditAction.CREATE,
|
||||
entity_type="tenant",
|
||||
entity_id="entity-test-1",
|
||||
actor_id="admin-user",
|
||||
new_values={"name": "Entity Test"},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/audit-logs/entity/tenant/entity-test-1",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["items"]) >= 1
|
||||
assert data["items"][0]["entity_type"] == "tenant"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_actor_audit_logs(client: AsyncClient, auth_headers: dict, db_session: AsyncSession):
|
||||
audit = AuditService(db_session)
|
||||
await audit.log(
|
||||
action=AuditAction.LOGIN,
|
||||
entity_type="session",
|
||||
entity_id="session-1",
|
||||
actor_id="test-actor-1",
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/audit-logs/actor/test-actor-1",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert item["actor_id"] == "test-actor-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_log_sanitization(db_session: AsyncSession):
|
||||
"""Testa att känslig data saniteras i audit-loggar."""
|
||||
audit = AuditService(db_session)
|
||||
|
||||
log = await audit.log(
|
||||
action=AuditAction.CREATE,
|
||||
entity_type="user",
|
||||
entity_id="user-1",
|
||||
request_body='{"email": "test@test.se", "password": "supersecret123", "api_key": "abc123"}',
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert "supersecret123" not in (log.request_body or "")
|
||||
assert "***REDACTED***" in (log.request_body or "")
|
||||
assert "abc123" not in (log.request_body or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audit_log_unauthorized(client: AsyncClient):
|
||||
response = await client.get("/api/v1/audit-logs")
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tester för auth-endpoints."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(client: AsyncClient, test_user: User):
|
||||
response = await client.post("/api/v1/auth/login", json={
|
||||
"email": test_user.email,
|
||||
"password": "password123",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(client: AsyncClient, test_user: User):
|
||||
response = await client.post("/api/v1/auth/login", json={
|
||||
"email": test_user.email,
|
||||
"password": "wrongpassword",
|
||||
})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_endpoint(client: AsyncClient, admin_token: str, test_user: User):
|
||||
response = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == test_user.email
|
||||
assert data["role"] == "admin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_token(client: AsyncClient, admin_token: str):
|
||||
# Hämta refresh token via login
|
||||
response = await client.post("/api/v1/auth/login", json={
|
||||
"email": "admin@landvex.se",
|
||||
"password": "password123",
|
||||
})
|
||||
refresh = response.json()["refresh_token"]
|
||||
|
||||
response = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Tester för invitation-flödet."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_invitation(client: AsyncClient, admin_token: str):
|
||||
response = await client.post("/api/v1/invitations", headers={"Authorization": f"Bearer {admin_token}"}, json={
|
||||
"email": "invited@landvex.se",
|
||||
"role": "viewer",
|
||||
})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "invited@landvex.se"
|
||||
assert data["role"] == "viewer"
|
||||
assert "token" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_invitation(client: AsyncClient, admin_token: str):
|
||||
# Skapa inbjudan
|
||||
response = await client.post("/api/v1/invitations", headers={"Authorization": f"Bearer {admin_token}"}, json={
|
||||
"email": "validate@landvex.se",
|
||||
"role": "analyst",
|
||||
})
|
||||
token = response.json()["token"]
|
||||
|
||||
response = await client.get(f"/api/v1/invitations/validate/{token}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is True
|
||||
assert data["email"] == "validate@landvex.se"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_invitation(client: AsyncClient, admin_token: str):
|
||||
# Skapa inbjudan
|
||||
response = await client.post("/api/v1/invitations", headers={"Authorization": f"Bearer {admin_token}"}, json={
|
||||
"email": "accept@landvex.se",
|
||||
"role": "manager",
|
||||
})
|
||||
token = response.json()["token"]
|
||||
|
||||
response = await client.post("/api/v1/invitations/accept", json={
|
||||
"token": token,
|
||||
"first_name": "Accepted",
|
||||
"last_name": "User",
|
||||
"password": "newpassword123",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == "accept@landvex.se"
|
||||
assert data["role"] == "manager"
|
||||
assert data["status"] == "active"
|
||||
|
||||
# Försök acceptera igen — ska misslyckas
|
||||
response = await client.post("/api/v1/invitations/accept", json={
|
||||
"token": token,
|
||||
"first_name": "Accepted",
|
||||
"last_name": "User",
|
||||
"password": "newpassword123",
|
||||
})
|
||||
assert response.status_code == 400
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Tester för Tenant Management endpoints.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Tenant, TenantStatus, TenantType, AuditLog, AuditAction
|
||||
|
||||
|
||||
# ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tenant(client: AsyncClient, auth_headers: dict):
|
||||
response = await client.post(
|
||||
"/api/v1/tenants",
|
||||
json={
|
||||
"name": "Nya Kommunen",
|
||||
"slug": "nya-kommunen",
|
||||
"tenant_type": "municipality",
|
||||
"contact_email": "kontakt@nyakommun.se",
|
||||
"city": "Göteborg",
|
||||
"country": "SE",
|
||||
"plan": "professional",
|
||||
"max_users": 50,
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "Nya Kommunen"
|
||||
assert data["slug"] == "nya-kommunen"
|
||||
assert data["status"] == "pending"
|
||||
assert data["schema_name"] == "tenant_nya-kommunen"
|
||||
assert data["plan"] == "professional"
|
||||
assert data["max_users"] == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tenant_duplicate_slug(client: AsyncClient, auth_headers: dict):
|
||||
# Skapa första
|
||||
await client.post(
|
||||
"/api/v1/tenants",
|
||||
json={
|
||||
"name": "Duplicate Test",
|
||||
"slug": "duplicate-test",
|
||||
"tenant_type": "company",
|
||||
"contact_email": "a@test.se",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
# Försök skapa andra med samma slug
|
||||
response = await client.post(
|
||||
"/api/v1/tenants",
|
||||
json={
|
||||
"name": "Duplicate Test 2",
|
||||
"slug": "duplicate-test",
|
||||
"tenant_type": "company",
|
||||
"contact_email": "b@test.se",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert "already exists" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tenant_invalid_org_number(client: AsyncClient, auth_headers: dict):
|
||||
response = await client.post(
|
||||
"/api/v1/tenants",
|
||||
json={
|
||||
"name": "Bad Org",
|
||||
"slug": "bad-org",
|
||||
"tenant_type": "company",
|
||||
"contact_email": "test@bad.se",
|
||||
"org_number": "12345", # För kort
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tenant_unauthorized(client: AsyncClient):
|
||||
response = await client.post(
|
||||
"/api/v1/tenants",
|
||||
json={
|
||||
"name": "Unauthorized",
|
||||
"slug": "unauthorized",
|
||||
"tenant_type": "company",
|
||||
"contact_email": "test@unauth.se",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# ─── LIST ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tenants(client: AsyncClient, auth_headers: dict):
|
||||
response = await client.get("/api/v1/tenants", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "page" in data
|
||||
assert "pages" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tenants_pagination(client: AsyncClient, auth_headers: dict):
|
||||
response = await client.get("/api/v1/tenants?page=1&page_size=5", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tenants_filter_by_status(client: AsyncClient, auth_headers: dict):
|
||||
response = await client.get("/api/v1/tenants?status=active", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert item["status"] == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tenants_search(client: AsyncClient, auth_headers: dict):
|
||||
response = await client.get("/api/v1/tenants?search=stockholm", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tenant(client: AsyncClient, auth_headers: dict, test_tenant: Tenant):
|
||||
response = await client.get(f"/api/v1/tenants/{test_tenant.id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_tenant.id)
|
||||
assert data["name"] == test_tenant.name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tenant_not_found(client: AsyncClient, auth_headers: dict):
|
||||
fake_id = uuid.uuid4()
|
||||
response = await client.get(f"/api/v1/tenants/{fake_id}", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tenant(client: AsyncClient, auth_headers: dict, test_tenant: Tenant):
|
||||
response = await client.patch(
|
||||
f"/api/v1/tenants/{test_tenant.id}",
|
||||
json={"name": "Updated Name", "city": "Malmö"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Updated Name"
|
||||
assert data["city"] == "Malmö"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_tenant_not_found(client: AsyncClient, auth_headers: dict):
|
||||
fake_id = uuid.uuid4()
|
||||
response = await client.patch(
|
||||
f"/api/v1/tenants/{fake_id}",
|
||||
json={"name": "Updated"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ─── DELETE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_tenant(client: AsyncClient, auth_headers: dict, db_session: AsyncSession):
|
||||
# Skapa en tenant att radera
|
||||
tenant = Tenant(
|
||||
id=uuid.uuid4(),
|
||||
name="Delete Me",
|
||||
slug="delete-me",
|
||||
tenant_type=TenantType.COMPANY,
|
||||
status=TenantStatus.ACTIVE,
|
||||
schema_name="tenant_delete_me",
|
||||
contact_email="delete@me.se",
|
||||
plan="basic",
|
||||
max_users=10,
|
||||
max_projects=5,
|
||||
storage_quota_mb=1024,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.delete(f"/api/v1/tenants/{tenant.id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "deleted" in data["message"].lower()
|
||||
|
||||
# Verifiera mjuk-radering
|
||||
await db_session.refresh(tenant)
|
||||
assert tenant.is_deleted == "Y"
|
||||
assert tenant.status == TenantStatus.CANCELLED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hard_delete_tenant(client: AsyncClient, auth_headers: dict, db_session: AsyncSession):
|
||||
tenant = Tenant(
|
||||
id=uuid.uuid4(),
|
||||
name="Hard Delete Me",
|
||||
slug="hard-delete-me",
|
||||
tenant_type=TenantType.COMPANY,
|
||||
status=TenantStatus.ACTIVE,
|
||||
schema_name="tenant_hard_delete_me",
|
||||
contact_email="hard@delete.se",
|
||||
plan="basic",
|
||||
max_users=10,
|
||||
max_projects=5,
|
||||
storage_quota_mb=1024,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.delete(f"/api/v1/tenants/{tenant.id}/hard", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verifiera permanent borttagning
|
||||
result = await db_session.get(Tenant, tenant.id)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ─── STATUS MANAGEMENT ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_tenant(client: AsyncClient, auth_headers: dict, db_session: AsyncSession):
|
||||
tenant = Tenant(
|
||||
id=uuid.uuid4(),
|
||||
name="Pending Tenant",
|
||||
slug="pending-tenant",
|
||||
tenant_type=TenantType.MUNICIPALITY,
|
||||
status=TenantStatus.PENDING,
|
||||
schema_name="tenant_pending_tenant",
|
||||
contact_email="pending@test.se",
|
||||
plan="basic",
|
||||
max_users=10,
|
||||
max_projects=5,
|
||||
storage_quota_mb=1024,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db_session.add(tenant)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/tenants/{tenant.id}/activate",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "active"
|
||||
assert data["activated_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suspend_tenant(client: AsyncClient, auth_headers: dict, test_tenant: Tenant):
|
||||
response = await client.post(
|
||||
f"/api/v1/tenants/{test_tenant.id}/suspend",
|
||||
json={"reason": "Missed payment"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "suspended"
|
||||
assert data["suspended_reason"] == "Missed payment"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_tenant(client: AsyncClient, auth_headers: dict, test_tenant: Tenant):
|
||||
response = await client.post(
|
||||
f"/api/v1/tenants/{test_tenant.id}/cancel?reason=Customer request",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "cancelled"
|
||||
assert data["cancellation_reason"] == "Customer request"
|
||||
|
||||
|
||||
# ─── STATS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tenant_stats(client: AsyncClient, auth_headers: dict):
|
||||
response = await client.get("/api/v1/tenants/stats", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "total_tenants" in data
|
||||
assert "active_tenants" in data
|
||||
assert "by_type" in data
|
||||
assert "by_plan" in data
|
||||
|
||||
|
||||
# ─── AUDIT LOGS ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tenant_audit_logs(client: AsyncClient, auth_headers: dict, test_tenant: Tenant):
|
||||
response = await client.get(
|
||||
f"/api/v1/tenants/{test_tenant.id}/audit-logs",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
|
||||
|
||||
# ─── VALIDATION ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tenant_invalid_slug(client: AsyncClient, auth_headers: dict):
|
||||
response = await client.post(
|
||||
"/api/v1/tenants",
|
||||
json={
|
||||
"name": "Bad Slug",
|
||||
"slug": "bad slug!", # Ogiltiga tecken
|
||||
"tenant_type": "company",
|
||||
"contact_email": "test@slug.se",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tenant_missing_required(client: AsyncClient, auth_headers: dict):
|
||||
response = await client.post(
|
||||
"/api/v1/tenants",
|
||||
json={
|
||||
"slug": "missing-name",
|
||||
"tenant_type": "company",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tester för user CRUD."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.models import User, Tenant
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user(client: AsyncClient, admin_token: str, test_tenant: Tenant):
|
||||
response = await client.post("/api/v1/users", headers={"Authorization": f"Bearer {admin_token}"}, json={
|
||||
"email": "newuser@landvex.se",
|
||||
"first_name": "New",
|
||||
"last_name": "User",
|
||||
"password": "securepass123",
|
||||
"role": "analyst",
|
||||
"tenant_id": str(test_tenant.id),
|
||||
})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "newuser@landvex.se"
|
||||
assert data["role"] == "analyst"
|
||||
assert data["status"] == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users(client: AsyncClient, admin_token: str):
|
||||
response = await client.get("/api/v1/users", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user(client: AsyncClient, admin_token: str, test_user: User):
|
||||
response = await client.get(f"/api/v1/users/{test_user.id}", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == test_user.email
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user(client: AsyncClient, admin_token: str, test_user: User):
|
||||
response = await client.patch(f"/api/v1/users/{test_user.id}", headers={"Authorization": f"Bearer {admin_token}"}, json={
|
||||
"first_name": "Updated",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["first_name"] == "Updated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_user(client: AsyncClient, admin_token: str, test_tenant: Tenant, db_session):
|
||||
# Skapa en användare att ta bort
|
||||
from app.models import User, UserRole, UserStatus
|
||||
from app.security import get_password_hash
|
||||
import uuid
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="delete-me@landvex.se",
|
||||
first_name="Delete",
|
||||
last_name="Me",
|
||||
hashed_password=get_password_hash("password123"),
|
||||
role=UserRole.VIEWER,
|
||||
status=UserStatus.ACTIVE,
|
||||
tenant_id=test_tenant.id,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.delete(f"/api/v1/users/{user.id}", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert response.status_code == 204
|
||||
Reference in New Issue
Block a user