Skip to content

Commit a2d3b11

Browse files
jochem25claude
andcommitted
feat: add project management system with PostgreSQL backend
Full-stack project management for BIM validation projects: - Backend: async SQLAlchemy 2.0 + PostgreSQL (SQLite fallback for dev) - REST API: /api/v2/projects CRUD + file upload/download/delete - Frontend: ProjectStorage abstraction (server + local via File System Access API) - UI: ProjectList component in Backstage with create/open/delete - Docker: PostgreSQL service, persistent project file storage volume - i18n: NL + EN translations for project panel - .bvp format: local project file format for offline use Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c3e70c4 commit a2d3b11

19 files changed

Lines changed: 1781 additions & 16 deletions

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ COPY ids-bestanden/ ./ids-bestanden/
4646
# Copy built frontend from stage 1
4747
COPY --from=frontend-build /app/viewer/dist ./viewer/dist
4848

49-
# Create temp directories
50-
RUN mkdir -p /tmp/ifc_uploads /tmp/ifc_processed /tmp/ids_validation_jobs
49+
# Create temp directories and persistent project storage
50+
RUN mkdir -p /tmp/ifc_uploads /tmp/ifc_processed /tmp/ids_validation_jobs /data/projects
5151

5252
EXPOSE 8000
5353

docker-compose.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
11
services:
2+
db:
3+
image: postgres:16-alpine
4+
container_name: bim-validator-db
5+
restart: unless-stopped
6+
environment:
7+
POSTGRES_DB: bimvalidator
8+
POSTGRES_USER: bimvalidator
9+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
10+
volumes:
11+
- pgdata:/var/lib/postgresql/data
12+
networks:
13+
- default
14+
215
app:
316
build: .
417
container_name: bim-validator
518
restart: unless-stopped
19+
depends_on:
20+
- db
621
ports:
722
- "127.0.0.1:8000:8000"
823
environment:
924
- CORS_ORIGINS=*
25+
- DATABASE_URL=postgresql+asyncpg://bimvalidator:${POSTGRES_PASSWORD:-changeme}@db:5432/bimvalidator
26+
- PROJECT_FILES_DIR=/data/projects
1027
# Nextcloud cloud storage (opt-in — leave empty to disable)
1128
- NEXTCLOUD_URL=
1229
- NEXTCLOUD_SERVICE_USER=
@@ -16,6 +33,8 @@ services:
1633
- bim-uploads:/tmp/ifc_uploads
1734
- bim-processed:/tmp/ifc_processed
1835
- bim-jobs:/tmp/ids_validation_jobs
36+
# Persistent project file storage
37+
- bim-projects:/data/projects
1938
networks:
2039
- default
2140
- openaec_platform
@@ -25,9 +44,11 @@ services:
2544
memory: 4G
2645

2746
volumes:
47+
pgdata:
2848
bim-uploads:
2949
bim-processed:
3050
bim-jobs:
51+
bim-projects:
3152

3253
networks:
3354
openaec_platform:

server/database.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""
2+
Database setup — async SQLAlchemy 2.0 with PostgreSQL.
3+
4+
Provides the engine, session factory, and Base for ORM models.
5+
Falls back to SQLite for local development when DATABASE_URL is not set.
6+
"""
7+
8+
import os
9+
from contextlib import asynccontextmanager
10+
from typing import AsyncGenerator
11+
12+
from sqlalchemy.ext.asyncio import (
13+
AsyncSession,
14+
async_sessionmaker,
15+
create_async_engine,
16+
)
17+
from sqlalchemy.orm import DeclarativeBase
18+
19+
# Default to SQLite for local dev, PostgreSQL in production
20+
DATABASE_URL = os.environ.get(
21+
"DATABASE_URL",
22+
"sqlite+aiosqlite:///./bimvalidator.db",
23+
)
24+
25+
# SQLAlchemy async engine
26+
engine = create_async_engine(
27+
DATABASE_URL,
28+
echo=False,
29+
pool_pre_ping=True,
30+
)
31+
32+
# Session factory
33+
async_session = async_sessionmaker(
34+
engine,
35+
class_=AsyncSession,
36+
expire_on_commit=False,
37+
)
38+
39+
40+
class Base(DeclarativeBase):
41+
"""Base class for all ORM models."""
42+
pass
43+
44+
45+
async def init_db() -> None:
46+
"""Create all tables. Called on app startup."""
47+
async with engine.begin() as conn:
48+
await conn.run_sync(Base.metadata.create_all)
49+
50+
51+
async def close_db() -> None:
52+
"""Dispose engine. Called on app shutdown."""
53+
await engine.dispose()
54+
55+
56+
@asynccontextmanager
57+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
58+
"""Yield an async database session."""
59+
async with async_session() as session:
60+
try:
61+
yield session
62+
await session.commit()
63+
except Exception:
64+
await session.rollback()
65+
raise

server/main.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from fastapi.staticfiles import StaticFiles
2828

2929
from server.bcf_export import generate_bcf_zip
30+
from server.database import close_db, init_db
3031
from server.ids_validator import IDSValidator, ValidationReport, report_to_dict
3132
from server.ifc_processor import GLTF_AVAILABLE, IFCProcessor
3233
from server.job_manager import JobManager, JobStatusResponse
@@ -39,6 +40,7 @@
3940
ValidationStatus,
4041
)
4142
from server.project_manager import ProjectManager
43+
from server.routers.projects import router as projects_router
4244
from ifc_validator.standards.resolver import get_bundled_ids
4345

4446

@@ -75,6 +77,22 @@ def format(self, record: logging.LogRecord) -> str:
7577
redoc_url="/redoc",
7678
)
7779

80+
# Include persistent project router (PostgreSQL-backed)
81+
app.include_router(projects_router)
82+
83+
84+
@app.on_event("startup")
85+
async def startup():
86+
"""Initialize database tables on startup."""
87+
await init_db()
88+
89+
90+
@app.on_event("shutdown")
91+
async def shutdown():
92+
"""Clean up database connections on shutdown."""
93+
await close_db()
94+
95+
7896
# Configure CORS for browser access
7997
# In production, set CORS_ORIGINS env var (comma-separated)
8098
_default_origins = [

server/models/db_models.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
SQLAlchemy ORM models for projects and project files.
3+
"""
4+
5+
import uuid
6+
from datetime import datetime, timezone
7+
8+
from sqlalchemy import JSON, BigInteger, DateTime, ForeignKey, String, Text
9+
from sqlalchemy.orm import Mapped, mapped_column, relationship
10+
11+
from server.database import Base
12+
13+
14+
def _utcnow() -> datetime:
15+
return datetime.now(timezone.utc)
16+
17+
18+
def _new_uuid() -> str:
19+
return str(uuid.uuid4())
20+
21+
22+
class Project(Base):
23+
"""A BIM validation project containing IFC/BCF/IDS files."""
24+
25+
__tablename__ = "projects"
26+
27+
id: Mapped[str] = mapped_column(
28+
String(36), primary_key=True, default=_new_uuid
29+
)
30+
name: Mapped[str] = mapped_column(String(255), nullable=False)
31+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
32+
created_at: Mapped[datetime] = mapped_column(
33+
DateTime(timezone=True), default=_utcnow
34+
)
35+
updated_at: Mapped[datetime] = mapped_column(
36+
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
37+
)
38+
39+
files: Mapped[list["ProjectFile"]] = relationship(
40+
back_populates="project",
41+
cascade="all, delete-orphan",
42+
lazy="selectin",
43+
)
44+
45+
def to_dict(self) -> dict:
46+
return {
47+
"id": self.id,
48+
"name": self.name,
49+
"description": self.description,
50+
"createdAt": self.created_at.isoformat(),
51+
"updatedAt": self.updated_at.isoformat(),
52+
"files": [f.to_dict() for f in self.files],
53+
}
54+
55+
def to_summary(self) -> dict:
56+
return {
57+
"id": self.id,
58+
"name": self.name,
59+
"description": self.description,
60+
"createdAt": self.created_at.isoformat(),
61+
"updatedAt": self.updated_at.isoformat(),
62+
"fileCount": len(self.files),
63+
}
64+
65+
66+
class ProjectFile(Base):
67+
"""A file (IFC, BCF, or IDS) belonging to a project."""
68+
69+
__tablename__ = "project_files"
70+
71+
id: Mapped[str] = mapped_column(
72+
String(36), primary_key=True, default=_new_uuid
73+
)
74+
project_id: Mapped[str] = mapped_column(
75+
String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
76+
)
77+
file_type: Mapped[str] = mapped_column(
78+
String(10), nullable=False
79+
) # 'ifc', 'bcf', 'ids'
80+
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
81+
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
82+
disk_path: Mapped[str] = mapped_column(String(512), nullable=False)
83+
uploaded_at: Mapped[datetime] = mapped_column(
84+
DateTime(timezone=True), default=_utcnow
85+
)
86+
metadata_json: Mapped[dict | None] = mapped_column(
87+
"metadata", JSON, nullable=True
88+
)
89+
90+
project: Mapped["Project"] = relationship(back_populates="files")
91+
92+
def to_dict(self) -> dict:
93+
return {
94+
"id": self.id,
95+
"projectId": self.project_id,
96+
"fileType": self.file_type,
97+
"fileName": self.file_name,
98+
"fileSize": self.file_size,
99+
"uploadedAt": self.uploaded_at.isoformat(),
100+
"metadata": self.metadata_json,
101+
}

server/requirements.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,8 @@ aiofiles>=24.0.0
2121

2222
# Async HTTP client (Nextcloud WebDAV integration)
2323
httpx>=0.27.0
24+
25+
# Database (PostgreSQL + async SQLAlchemy)
26+
sqlalchemy[asyncio]>=2.0.0
27+
asyncpg>=0.30.0
28+
aiosqlite>=0.20.0 # SQLite fallback for local dev

server/routers/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)