openconstructionerp 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +0 -0
- app/__main__.py +56 -0
- app/cli.py +236 -0
- app/cli_static.py +92 -0
- app/config.py +94 -0
- app/core/__init__.py +0 -0
- app/core/cache.py +180 -0
- app/core/demo_projects.py +2551 -0
- app/core/events.py +174 -0
- app/core/hooks.py +167 -0
- app/core/i18n.py +3221 -0
- app/core/i18n_router.py +24 -0
- app/core/marketplace.py +1015 -0
- app/core/module_loader.py +234 -0
- app/core/permissions.py +159 -0
- app/core/plugin_manager.py +229 -0
- app/core/rate_limiter.py +40 -0
- app/core/sqlite_migrator.py +86 -0
- app/core/validation/__init__.py +0 -0
- app/core/validation/engine.py +391 -0
- app/core/validation/rules/__init__.py +1853 -0
- app/core/vector.py +345 -0
- app/database.py +126 -0
- app/dependencies.py +193 -0
- app/main.py +713 -0
- app/middleware/__init__.py +0 -0
- app/middleware/fingerprint.py +34 -0
- app/modules/__init__.py +0 -0
- app/modules/ai/__init__.py +13 -0
- app/modules/ai/ai_client.py +506 -0
- app/modules/ai/manifest.py +15 -0
- app/modules/ai/models.py +95 -0
- app/modules/ai/permissions.py +16 -0
- app/modules/ai/prompts.py +198 -0
- app/modules/ai/repository.py +78 -0
- app/modules/ai/router.py +858 -0
- app/modules/ai/schemas.py +155 -0
- app/modules/ai/service.py +1033 -0
- app/modules/assemblies/__init__.py +13 -0
- app/modules/assemblies/manifest.py +18 -0
- app/modules/assemblies/models.py +114 -0
- app/modules/assemblies/permissions.py +16 -0
- app/modules/assemblies/repository.py +196 -0
- app/modules/assemblies/router.py +473 -0
- app/modules/assemblies/schemas.py +173 -0
- app/modules/assemblies/service.py +677 -0
- app/modules/backup/__init__.py +0 -0
- app/modules/backup/manifest.py +12 -0
- app/modules/backup/router.py +470 -0
- app/modules/boq/__init__.py +12 -0
- app/modules/boq/ai_prompts.py +178 -0
- app/modules/boq/cad_import.py +745 -0
- app/modules/boq/epd_materials.py +266 -0
- app/modules/boq/events.py +156 -0
- app/modules/boq/manifest.py +15 -0
- app/modules/boq/models.py +272 -0
- app/modules/boq/pdf_export.py +1035 -0
- app/modules/boq/permissions.py +18 -0
- app/modules/boq/repository.py +342 -0
- app/modules/boq/router.py +3788 -0
- app/modules/boq/schemas.py +897 -0
- app/modules/boq/service.py +3368 -0
- app/modules/boq/templates.py +1274 -0
- app/modules/cad/__init__.py +0 -0
- app/modules/cad/manifest.py +15 -0
- app/modules/catalog/__init__.py +13 -0
- app/modules/catalog/manifest.py +15 -0
- app/modules/catalog/models.py +60 -0
- app/modules/catalog/permissions.py +17 -0
- app/modules/catalog/repository.py +199 -0
- app/modules/catalog/router.py +392 -0
- app/modules/catalog/schemas.py +111 -0
- app/modules/catalog/service.py +462 -0
- app/modules/changeorders/__init__.py +12 -0
- app/modules/changeorders/manifest.py +15 -0
- app/modules/changeorders/models.py +93 -0
- app/modules/changeorders/permissions.py +17 -0
- app/modules/changeorders/repository.py +145 -0
- app/modules/changeorders/router.py +320 -0
- app/modules/changeorders/schemas.py +157 -0
- app/modules/changeorders/service.py +317 -0
- app/modules/costmodel/__init__.py +13 -0
- app/modules/costmodel/manifest.py +18 -0
- app/modules/costmodel/models.py +156 -0
- app/modules/costmodel/permissions.py +15 -0
- app/modules/costmodel/repository.py +322 -0
- app/modules/costmodel/router.py +449 -0
- app/modules/costmodel/schemas.py +333 -0
- app/modules/costmodel/service.py +1061 -0
- app/modules/costs/__init__.py +12 -0
- app/modules/costs/manifest.py +15 -0
- app/modules/costs/models.py +56 -0
- app/modules/costs/permissions.py +18 -0
- app/modules/costs/repository.py +169 -0
- app/modules/costs/router.py +1948 -0
- app/modules/costs/schemas.py +110 -0
- app/modules/costs/service.py +235 -0
- app/modules/documents/__init__.py +12 -0
- app/modules/documents/manifest.py +15 -0
- app/modules/documents/models.py +51 -0
- app/modules/documents/permissions.py +16 -0
- app/modules/documents/repository.py +103 -0
- app/modules/documents/router.py +201 -0
- app/modules/documents/schemas.py +59 -0
- app/modules/documents/service.py +213 -0
- app/modules/projects/__init__.py +12 -0
- app/modules/projects/manifest.py +15 -0
- app/modules/projects/models.py +52 -0
- app/modules/projects/permissions.py +16 -0
- app/modules/projects/repository.py +81 -0
- app/modules/projects/router.py +235 -0
- app/modules/projects/schemas.py +79 -0
- app/modules/projects/service.py +160 -0
- app/modules/reporting/__init__.py +0 -0
- app/modules/reporting/manifest.py +15 -0
- app/modules/risk/__init__.py +12 -0
- app/modules/risk/manifest.py +15 -0
- app/modules/risk/models.py +54 -0
- app/modules/risk/permissions.py +16 -0
- app/modules/risk/repository.py +84 -0
- app/modules/risk/router.py +183 -0
- app/modules/risk/schemas.py +132 -0
- app/modules/risk/service.py +234 -0
- app/modules/schedule/__init__.py +12 -0
- app/modules/schedule/manifest.py +15 -0
- app/modules/schedule/models.py +170 -0
- app/modules/schedule/permissions.py +17 -0
- app/modules/schedule/repository.py +206 -0
- app/modules/schedule/router.py +487 -0
- app/modules/schedule/schemas.py +354 -0
- app/modules/schedule/service.py +1677 -0
- app/modules/takeoff/__init__.py +8 -0
- app/modules/takeoff/manifest.py +15 -0
- app/modules/takeoff/models.py +56 -0
- app/modules/takeoff/permissions.py +16 -0
- app/modules/takeoff/repository.py +51 -0
- app/modules/takeoff/router.py +990 -0
- app/modules/takeoff/schemas.py +86 -0
- app/modules/takeoff/service.py +211 -0
- app/modules/tendering/__init__.py +1 -0
- app/modules/tendering/manifest.py +15 -0
- app/modules/tendering/models.py +92 -0
- app/modules/tendering/permissions.py +19 -0
- app/modules/tendering/repository.py +120 -0
- app/modules/tendering/router.py +430 -0
- app/modules/tendering/schemas.py +160 -0
- app/modules/tendering/service.py +317 -0
- app/modules/users/__init__.py +11 -0
- app/modules/users/manifest.py +15 -0
- app/modules/users/models.py +82 -0
- app/modules/users/permissions.py +18 -0
- app/modules/users/repository.py +123 -0
- app/modules/users/router.py +266 -0
- app/modules/users/schemas.py +158 -0
- app/modules/users/service.py +476 -0
- app/modules/validation/__init__.py +0 -0
- app/schemas.py +268 -0
- app/scripts/__init__.py +0 -0
- app/scripts/create_tables.py +26 -0
- app/scripts/export_catalog_csv.py +245 -0
- app/scripts/export_full_catalog.py +288 -0
- app/scripts/seed_catalog.py +227 -0
- app/scripts/seed_demo.py +252 -0
- app/scripts/seed_demo_4d5d.py +273 -0
- app/scripts/seed_demo_estimates.py +859 -0
- app/scripts/seed_international.py +231 -0
- app/scripts/seed_schedule_demo.py +674 -0
- app/scripts/seed_sections.py +77 -0
- openconstructionerp-0.2.1.dist-info/METADATA +138 -0
- openconstructionerp-0.2.1.dist-info/RECORD +172 -0
- openconstructionerp-0.2.1.dist-info/WHEEL +4 -0
- openconstructionerp-0.2.1.dist-info/entry_points.txt +3 -0
app/__init__.py
ADDED
|
File without changes
|
app/__main__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Entry point for PyInstaller / standalone execution.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m app # Dev mode
|
|
5
|
+
openestimate-server.exe # Production (PyInstaller bundle)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import multiprocessing
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
"""Start the OpenConstructionERP backend server."""
|
|
15
|
+
import uvicorn
|
|
16
|
+
|
|
17
|
+
# Parse CLI args: --host X --port Y
|
|
18
|
+
host = "127.0.0.1"
|
|
19
|
+
port = 8741
|
|
20
|
+
args = sys.argv[1:]
|
|
21
|
+
for i, arg in enumerate(args):
|
|
22
|
+
if arg == "--host" and i + 1 < len(args):
|
|
23
|
+
host = args[i + 1]
|
|
24
|
+
elif arg == "--port" and i + 1 < len(args):
|
|
25
|
+
try:
|
|
26
|
+
port = int(args[i + 1])
|
|
27
|
+
except ValueError:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
# Also check env vars as fallback
|
|
31
|
+
host = os.environ.get("HOST", host)
|
|
32
|
+
port = int(os.environ.get("PORT", str(port)))
|
|
33
|
+
|
|
34
|
+
# Desktop app mode: serve frontend, production settings
|
|
35
|
+
if getattr(sys, "frozen", False):
|
|
36
|
+
os.environ.setdefault("SERVE_FRONTEND", "1")
|
|
37
|
+
os.environ.setdefault("APP_ENV", "production")
|
|
38
|
+
os.environ.setdefault("APP_DEBUG", "false")
|
|
39
|
+
|
|
40
|
+
print(f"Starting OpenConstructionERP on http://{host}:{port}")
|
|
41
|
+
|
|
42
|
+
# Use direct app import for PyInstaller compatibility
|
|
43
|
+
from app.main import create_app
|
|
44
|
+
app = create_app()
|
|
45
|
+
|
|
46
|
+
uvicorn.run(
|
|
47
|
+
app,
|
|
48
|
+
host=host,
|
|
49
|
+
port=port,
|
|
50
|
+
log_level="info",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
multiprocessing.freeze_support()
|
|
56
|
+
main()
|
app/cli.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""OpenEstimate CLI — run the platform from the command line.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
openestimate serve [--host HOST] [--port PORT] [--data-dir DIR]
|
|
5
|
+
openestimate init [--data-dir DIR]
|
|
6
|
+
openestimate version
|
|
7
|
+
openestimate seed [--demo]
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import webbrowser
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
DEFAULT_DATA_DIR = Path.home() / ".openestimate"
|
|
18
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
19
|
+
DEFAULT_PORT = 8080
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("openestimate.cli")
|
|
22
|
+
|
|
23
|
+
BANNER = r"""
|
|
24
|
+
____ ______ __ _ __
|
|
25
|
+
/ __ \____ ___ ____ / ____/____/ /_(_)___ ___ ____ _/ /_____ _____
|
|
26
|
+
/ / / / __ \/ _ \/ __ \/ __/ / ___/ __/ / __ `__ \/ __ `/ __/ __ \/ ___/
|
|
27
|
+
/ /_/ / /_/ / __/ / / / /___(__ ) /_/ / / / / / / /_/ / /_/ /_/ / /
|
|
28
|
+
\____/ .___/\___/_/ /_/_____/____/\__/_/_/ /_/ /_/\__,_/\__/\____/_/
|
|
29
|
+
/_/ v{version}
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _setup_env(data_dir: Path, host: str, port: int) -> None:
|
|
34
|
+
"""Configure environment variables for local-first operation."""
|
|
35
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
db_path = data_dir / "openestimate.db"
|
|
38
|
+
|
|
39
|
+
os.environ.setdefault("DATABASE_URL", f"sqlite+aiosqlite:///{db_path}")
|
|
40
|
+
os.environ.setdefault("DATABASE_SYNC_URL", f"sqlite:///{db_path}")
|
|
41
|
+
os.environ.setdefault("VECTOR_BACKEND", "lancedb")
|
|
42
|
+
os.environ.setdefault("VECTOR_DATA_DIR", str(data_dir / "vectors"))
|
|
43
|
+
os.environ.setdefault("APP_ENV", "development")
|
|
44
|
+
os.environ.setdefault("APP_DEBUG", "false")
|
|
45
|
+
os.environ.setdefault("ALLOWED_ORIGINS", f"http://{host}:{port}")
|
|
46
|
+
os.environ.setdefault("JWT_SECRET", "openestimate-local-dev-key")
|
|
47
|
+
|
|
48
|
+
# Enable frontend serving
|
|
49
|
+
os.environ["SERVE_FRONTEND"] = "true"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_serve(args: argparse.Namespace) -> None:
|
|
53
|
+
"""Start the OpenEstimate server."""
|
|
54
|
+
data_dir = Path(args.data_dir)
|
|
55
|
+
_setup_env(data_dir, args.host, args.port)
|
|
56
|
+
|
|
57
|
+
from app.config import get_settings
|
|
58
|
+
|
|
59
|
+
settings = get_settings()
|
|
60
|
+
print(BANNER.format(version=settings.app_version))
|
|
61
|
+
print(f" Data directory: {data_dir}")
|
|
62
|
+
print(f" Database: {os.environ.get('DATABASE_URL', 'sqlite')}")
|
|
63
|
+
print(f" Server: http://{args.host}:{args.port}")
|
|
64
|
+
print(f" API docs: http://{args.host}:{args.port}/api/docs")
|
|
65
|
+
print()
|
|
66
|
+
|
|
67
|
+
if args.open:
|
|
68
|
+
import threading
|
|
69
|
+
|
|
70
|
+
def _open_browser() -> None:
|
|
71
|
+
import time
|
|
72
|
+
|
|
73
|
+
time.sleep(2)
|
|
74
|
+
webbrowser.open(f"http://{args.host}:{args.port}")
|
|
75
|
+
|
|
76
|
+
threading.Thread(target=_open_browser, daemon=True).start()
|
|
77
|
+
|
|
78
|
+
import uvicorn
|
|
79
|
+
|
|
80
|
+
uvicorn.run(
|
|
81
|
+
"app.main:create_app",
|
|
82
|
+
factory=True,
|
|
83
|
+
host=args.host,
|
|
84
|
+
port=args.port,
|
|
85
|
+
log_level="info",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_init(args: argparse.Namespace) -> None:
|
|
90
|
+
"""Initialize data directory and database."""
|
|
91
|
+
data_dir = Path(args.data_dir)
|
|
92
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
(data_dir / "vectors").mkdir(exist_ok=True)
|
|
94
|
+
(data_dir / "uploads").mkdir(exist_ok=True)
|
|
95
|
+
|
|
96
|
+
print(f"Initialized data directory: {data_dir}")
|
|
97
|
+
print(f" Database will be at: {data_dir / 'openestimate.db'}")
|
|
98
|
+
print(f" Vector storage: {data_dir / 'vectors'}")
|
|
99
|
+
print(f" Uploads: {data_dir / 'uploads'}")
|
|
100
|
+
print()
|
|
101
|
+
print("Run 'openestimate serve' to start the server.")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_version(args: argparse.Namespace) -> None:
|
|
105
|
+
"""Print version information."""
|
|
106
|
+
try:
|
|
107
|
+
from app.config import Settings
|
|
108
|
+
|
|
109
|
+
version = Settings.model_fields["app_version"].default
|
|
110
|
+
except Exception:
|
|
111
|
+
version = "unknown"
|
|
112
|
+
|
|
113
|
+
print(f"OpenEstimate v{version}")
|
|
114
|
+
print(f"Python {sys.version}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_seed(args: argparse.Namespace) -> None:
|
|
118
|
+
"""Load demo data into the database."""
|
|
119
|
+
data_dir = Path(args.data_dir)
|
|
120
|
+
_setup_env(data_dir, DEFAULT_HOST, DEFAULT_PORT)
|
|
121
|
+
|
|
122
|
+
import asyncio
|
|
123
|
+
|
|
124
|
+
async def _run_seed() -> None:
|
|
125
|
+
# Initialize database tables
|
|
126
|
+
from app.config import get_settings
|
|
127
|
+
|
|
128
|
+
settings = get_settings()
|
|
129
|
+
if "sqlite" in settings.database_url:
|
|
130
|
+
from app.database import Base, engine
|
|
131
|
+
|
|
132
|
+
# Import all models
|
|
133
|
+
from app.modules.users import models as _ # noqa: F401
|
|
134
|
+
from app.modules.projects import models as _ # noqa: F401
|
|
135
|
+
from app.modules.boq import models as _ # noqa: F401
|
|
136
|
+
from app.modules.costs import models as _ # noqa: F401
|
|
137
|
+
|
|
138
|
+
async with engine.begin() as conn:
|
|
139
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
140
|
+
|
|
141
|
+
print("Database tables created.")
|
|
142
|
+
|
|
143
|
+
if args.demo:
|
|
144
|
+
print("Loading demo project data...")
|
|
145
|
+
from app.core.demo_projects import install_demo_project
|
|
146
|
+
from app.database import async_session_factory
|
|
147
|
+
|
|
148
|
+
async with async_session_factory() as session:
|
|
149
|
+
result = await install_demo_project(session, "office_tower_berlin")
|
|
150
|
+
await session.commit()
|
|
151
|
+
print(f"Demo project installed: {result.get('project_name', 'OK')}")
|
|
152
|
+
|
|
153
|
+
print("Seed complete.")
|
|
154
|
+
|
|
155
|
+
asyncio.run(_run_seed())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main() -> None:
|
|
159
|
+
"""CLI entry point."""
|
|
160
|
+
parser = argparse.ArgumentParser(
|
|
161
|
+
prog="openestimate",
|
|
162
|
+
description="OpenEstimate — open-source construction cost estimation platform",
|
|
163
|
+
)
|
|
164
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
165
|
+
|
|
166
|
+
# serve
|
|
167
|
+
serve_p = subparsers.add_parser("serve", help="Start the OpenEstimate server")
|
|
168
|
+
serve_p.add_argument(
|
|
169
|
+
"--host",
|
|
170
|
+
default=DEFAULT_HOST,
|
|
171
|
+
help=f"Bind host (default: {DEFAULT_HOST})",
|
|
172
|
+
)
|
|
173
|
+
serve_p.add_argument(
|
|
174
|
+
"--port",
|
|
175
|
+
type=int,
|
|
176
|
+
default=DEFAULT_PORT,
|
|
177
|
+
help=f"Bind port (default: {DEFAULT_PORT})",
|
|
178
|
+
)
|
|
179
|
+
serve_p.add_argument(
|
|
180
|
+
"--data-dir",
|
|
181
|
+
default=str(DEFAULT_DATA_DIR),
|
|
182
|
+
help=f"Data directory (default: {DEFAULT_DATA_DIR})",
|
|
183
|
+
)
|
|
184
|
+
serve_p.add_argument(
|
|
185
|
+
"--open",
|
|
186
|
+
action="store_true",
|
|
187
|
+
help="Open browser after startup",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# init
|
|
191
|
+
init_p = subparsers.add_parser("init", help="Initialize data directory")
|
|
192
|
+
init_p.add_argument(
|
|
193
|
+
"--data-dir",
|
|
194
|
+
default=str(DEFAULT_DATA_DIR),
|
|
195
|
+
help=f"Data directory (default: {DEFAULT_DATA_DIR})",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# version
|
|
199
|
+
subparsers.add_parser("version", help="Show version information")
|
|
200
|
+
|
|
201
|
+
# seed
|
|
202
|
+
seed_p = subparsers.add_parser("seed", help="Load seed/demo data")
|
|
203
|
+
seed_p.add_argument(
|
|
204
|
+
"--demo",
|
|
205
|
+
action="store_true",
|
|
206
|
+
help="Install demo project with sample data",
|
|
207
|
+
)
|
|
208
|
+
seed_p.add_argument(
|
|
209
|
+
"--data-dir",
|
|
210
|
+
default=str(DEFAULT_DATA_DIR),
|
|
211
|
+
help=f"Data directory (default: {DEFAULT_DATA_DIR})",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
args = parser.parse_args()
|
|
215
|
+
|
|
216
|
+
if args.command == "serve":
|
|
217
|
+
cmd_serve(args)
|
|
218
|
+
elif args.command == "init":
|
|
219
|
+
cmd_init(args)
|
|
220
|
+
elif args.command == "version":
|
|
221
|
+
cmd_version(args)
|
|
222
|
+
elif args.command == "seed":
|
|
223
|
+
cmd_seed(args)
|
|
224
|
+
elif args.command is None:
|
|
225
|
+
# Default: serve with defaults
|
|
226
|
+
args.host = DEFAULT_HOST
|
|
227
|
+
args.port = DEFAULT_PORT
|
|
228
|
+
args.data_dir = str(DEFAULT_DATA_DIR)
|
|
229
|
+
args.open = True
|
|
230
|
+
cmd_serve(args)
|
|
231
|
+
else:
|
|
232
|
+
parser.print_help()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
main()
|
app/cli_static.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Serve frontend static files from the installed package or dev build.
|
|
2
|
+
|
|
3
|
+
When running via `openestimate serve` or with SERVE_FRONTEND=true,
|
|
4
|
+
the FastAPI app serves the pre-built React frontend directly — no Nginx needed.
|
|
5
|
+
|
|
6
|
+
Frontend is found in two locations (checked in order):
|
|
7
|
+
1. app/_frontend_dist/ — bundled inside the Python wheel (pip install)
|
|
8
|
+
2. ../frontend/dist/ — development mode (repo checkout)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
from fastapi.staticfiles import StaticFiles
|
|
16
|
+
from starlette.responses import FileResponse
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_frontend_dir() -> Path:
|
|
22
|
+
"""Find the bundled frontend dist directory.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Path to the directory containing index.html and assets/.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
FileNotFoundError: If no frontend build is found.
|
|
29
|
+
"""
|
|
30
|
+
# Option 1: installed as package (pip install openestimate)
|
|
31
|
+
pkg_dir = Path(__file__).parent / "_frontend_dist"
|
|
32
|
+
if pkg_dir.is_dir() and (pkg_dir / "index.html").exists():
|
|
33
|
+
return pkg_dir
|
|
34
|
+
|
|
35
|
+
# Option 2: development — frontend/dist relative to repo root
|
|
36
|
+
repo_root = Path(__file__).resolve().parent.parent.parent # backend/app/../../
|
|
37
|
+
dev_dist = repo_root / "frontend" / "dist"
|
|
38
|
+
if dev_dist.is_dir() and (dev_dist / "index.html").exists():
|
|
39
|
+
return dev_dist
|
|
40
|
+
|
|
41
|
+
raise FileNotFoundError(
|
|
42
|
+
"Frontend dist not found. "
|
|
43
|
+
"Run 'npm run build' in frontend/ or install the openestimate wheel."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def mount_frontend(app: FastAPI) -> None:
|
|
48
|
+
"""Mount frontend static files on the FastAPI app.
|
|
49
|
+
|
|
50
|
+
Serves:
|
|
51
|
+
- /assets/* — hashed JS/CSS bundles (long cache)
|
|
52
|
+
- /favicon.svg, /logo.svg — static resources
|
|
53
|
+
- /* (catch-all) — index.html for SPA routing
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
frontend_dir = get_frontend_dir()
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
logger.warning("Frontend dist not found — serving API only")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
logger.info("Serving frontend from %s", frontend_dir)
|
|
62
|
+
|
|
63
|
+
# Serve hashed assets (JS, CSS)
|
|
64
|
+
assets_dir = frontend_dir / "assets"
|
|
65
|
+
if assets_dir.is_dir():
|
|
66
|
+
app.mount(
|
|
67
|
+
"/assets",
|
|
68
|
+
StaticFiles(directory=str(assets_dir)),
|
|
69
|
+
name="frontend-assets",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Serve individual static files
|
|
73
|
+
index_path = frontend_dir / "index.html"
|
|
74
|
+
|
|
75
|
+
for static_name in ("favicon.svg", "logo.svg"):
|
|
76
|
+
static_path = frontend_dir / static_name
|
|
77
|
+
if static_path.exists():
|
|
78
|
+
# Use a factory to capture the correct path in the closure
|
|
79
|
+
def _make_static_handler(fpath: Path): # noqa: ANN202
|
|
80
|
+
async def _handler(): # noqa: ANN202
|
|
81
|
+
return FileResponse(str(fpath))
|
|
82
|
+
return _handler
|
|
83
|
+
|
|
84
|
+
app.get(f"/{static_name}", include_in_schema=False)(
|
|
85
|
+
_make_static_handler(static_path)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# SPA catch-all: any route not matched by /api/* or /assets/* → index.html
|
|
89
|
+
@app.get("/{path:path}", include_in_schema=False)
|
|
90
|
+
async def spa_fallback(path: str) -> FileResponse:
|
|
91
|
+
"""Serve index.html for all frontend routes (SPA routing)."""
|
|
92
|
+
return FileResponse(str(index_path))
|
app/config.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Application configuration.
|
|
2
|
+
|
|
3
|
+
Loads from environment variables with .env file fallback.
|
|
4
|
+
All settings are typed and validated via Pydantic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import Field, computed_field
|
|
11
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Settings(BaseSettings):
|
|
15
|
+
"""OpenConstructionERP application settings."""
|
|
16
|
+
|
|
17
|
+
model_config = SettingsConfigDict(
|
|
18
|
+
env_file=".env",
|
|
19
|
+
env_file_encoding="utf-8",
|
|
20
|
+
case_sensitive=False,
|
|
21
|
+
extra="ignore",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# ── App ──────────────────────────────────────────────────────────────
|
|
25
|
+
app_name: str = "OpenConstructionERP"
|
|
26
|
+
app_version: str = "0.2.1"
|
|
27
|
+
app_env: Literal["development", "staging", "production"] = "development"
|
|
28
|
+
app_debug: bool = True
|
|
29
|
+
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
|
30
|
+
allowed_origins: str = "http://localhost:5173"
|
|
31
|
+
|
|
32
|
+
# ── Database ─────────────────────────────────────────────────────────
|
|
33
|
+
# Default: SQLite (zero config, works out of the box)
|
|
34
|
+
# For production: set DATABASE_URL=postgresql+asyncpg://user:pass@host/db
|
|
35
|
+
database_url: str = "sqlite+aiosqlite:///./openestimate.db"
|
|
36
|
+
database_sync_url: str = "sqlite:///./openestimate.db"
|
|
37
|
+
database_pool_size: int = 20
|
|
38
|
+
database_max_overflow: int = 10
|
|
39
|
+
database_echo: bool = False
|
|
40
|
+
|
|
41
|
+
# ── Redis ────────────────────────────────────────────────────────────
|
|
42
|
+
redis_url: str | None = "redis://localhost:6379/0"
|
|
43
|
+
|
|
44
|
+
# ── Storage (S3/MinIO) ───────────────────────────────────────────────
|
|
45
|
+
s3_endpoint: str = "http://localhost:9000"
|
|
46
|
+
s3_access_key: str = ""
|
|
47
|
+
s3_secret_key: str = ""
|
|
48
|
+
s3_bucket: str = "openestimate"
|
|
49
|
+
s3_region: str = "us-east-1"
|
|
50
|
+
|
|
51
|
+
# ── Auth ─────────────────────────────────────────────────────────────
|
|
52
|
+
jwt_secret: str = "openestimate-local-dev-key"
|
|
53
|
+
jwt_algorithm: str = "HS256"
|
|
54
|
+
jwt_expire_minutes: int = 60
|
|
55
|
+
jwt_refresh_expire_days: int = 30
|
|
56
|
+
|
|
57
|
+
# ── AI / Vector ──────────────────────────────────────────────────────
|
|
58
|
+
vector_backend: str = "lancedb" # "lancedb" (embedded, default) or "qdrant" (server)
|
|
59
|
+
qdrant_url: str | None = "http://localhost:6333"
|
|
60
|
+
vector_data_dir: str = "" # LanceDB storage path, default: ~/.openestimator/data/vectors
|
|
61
|
+
openai_api_key: str | None = None
|
|
62
|
+
anthropic_api_key: str | None = None
|
|
63
|
+
gemini_api_key: str | None = None
|
|
64
|
+
openrouter_api_key: str | None = None
|
|
65
|
+
mistral_api_key: str | None = None
|
|
66
|
+
groq_api_key: str | None = None
|
|
67
|
+
deepseek_api_key: str | None = None
|
|
68
|
+
|
|
69
|
+
# ── External Services ────────────────────────────────────────────────
|
|
70
|
+
cad_converter_url: str | None = "http://localhost:8001"
|
|
71
|
+
cv_pipeline_url: str | None = "http://localhost:8002"
|
|
72
|
+
|
|
73
|
+
# ── Validation ───────────────────────────────────────────────────────
|
|
74
|
+
default_validation_rule_sets: list[str] = Field(
|
|
75
|
+
default=["boq_quality"],
|
|
76
|
+
description="Default validation rule sets applied to all projects",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# ── Computed ─────────────────────────────────────────────────────────
|
|
80
|
+
@computed_field # type: ignore[prop-decorator]
|
|
81
|
+
@property
|
|
82
|
+
def is_production(self) -> bool:
|
|
83
|
+
return self.app_env == "production"
|
|
84
|
+
|
|
85
|
+
@computed_field # type: ignore[prop-decorator]
|
|
86
|
+
@property
|
|
87
|
+
def cors_origins(self) -> list[str]:
|
|
88
|
+
return [o.strip() for o in self.allowed_origins.split(",")]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@lru_cache
|
|
92
|
+
def get_settings() -> Settings:
|
|
93
|
+
"""Cached settings singleton."""
|
|
94
|
+
return Settings()
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/cache.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Simple caching layer with in-memory fallback.
|
|
2
|
+
|
|
3
|
+
Uses Redis if available, falls back to in-memory LRU cache.
|
|
4
|
+
Thread-safe. TTL-based expiration.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from app.core.cache import cache
|
|
8
|
+
|
|
9
|
+
# Set/get
|
|
10
|
+
await cache.set("key", {"data": 123}, ttl=300)
|
|
11
|
+
value = await cache.get("key")
|
|
12
|
+
|
|
13
|
+
# Decorator
|
|
14
|
+
@cached(ttl=60, prefix="costs")
|
|
15
|
+
async def search_costs(query: str) -> list:
|
|
16
|
+
...
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
from collections import OrderedDict
|
|
24
|
+
from functools import wraps
|
|
25
|
+
from threading import Lock
|
|
26
|
+
from typing import Any, Callable
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InMemoryCache:
|
|
32
|
+
"""LRU cache with TTL expiration. No external dependencies."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, max_size: int = 1000) -> None:
|
|
35
|
+
self.max_size = max_size
|
|
36
|
+
self._store: OrderedDict[str, tuple[Any, float]] = OrderedDict()
|
|
37
|
+
self._lock = Lock()
|
|
38
|
+
|
|
39
|
+
async def get(self, key: str) -> Any | None:
|
|
40
|
+
with self._lock:
|
|
41
|
+
if key not in self._store:
|
|
42
|
+
return None
|
|
43
|
+
value, expires_at = self._store[key]
|
|
44
|
+
if expires_at and time.time() > expires_at:
|
|
45
|
+
del self._store[key]
|
|
46
|
+
return None
|
|
47
|
+
self._store.move_to_end(key)
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
async def set(self, key: str, value: Any, ttl: int = 300) -> None:
|
|
51
|
+
expires_at = time.time() + ttl if ttl > 0 else 0
|
|
52
|
+
with self._lock:
|
|
53
|
+
self._store[key] = (value, expires_at)
|
|
54
|
+
self._store.move_to_end(key)
|
|
55
|
+
# Evict oldest if over max size
|
|
56
|
+
while len(self._store) > self.max_size:
|
|
57
|
+
self._store.popitem(last=False)
|
|
58
|
+
|
|
59
|
+
async def delete(self, key: str) -> None:
|
|
60
|
+
with self._lock:
|
|
61
|
+
self._store.pop(key, None)
|
|
62
|
+
|
|
63
|
+
async def clear(self) -> None:
|
|
64
|
+
with self._lock:
|
|
65
|
+
self._store.clear()
|
|
66
|
+
|
|
67
|
+
def stats(self) -> dict[str, Any]:
|
|
68
|
+
with self._lock:
|
|
69
|
+
now = time.time()
|
|
70
|
+
active = sum(1 for _, (_, exp) in self._store.items() if not exp or exp > now)
|
|
71
|
+
return {
|
|
72
|
+
"engine": "in-memory",
|
|
73
|
+
"total_keys": len(self._store),
|
|
74
|
+
"active_keys": active,
|
|
75
|
+
"max_size": self.max_size,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RedisCache:
|
|
80
|
+
"""Redis-backed cache. Falls back to in-memory if Redis unavailable."""
|
|
81
|
+
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
self._redis: Any | None = None
|
|
84
|
+
self._fallback = InMemoryCache()
|
|
85
|
+
|
|
86
|
+
async def _get_redis(self) -> Any | None:
|
|
87
|
+
if self._redis is not None:
|
|
88
|
+
return self._redis
|
|
89
|
+
try:
|
|
90
|
+
from app.config import get_settings
|
|
91
|
+
|
|
92
|
+
settings = get_settings()
|
|
93
|
+
if not settings.redis_url:
|
|
94
|
+
return None
|
|
95
|
+
import redis.asyncio as aioredis
|
|
96
|
+
|
|
97
|
+
self._redis = aioredis.from_url(
|
|
98
|
+
settings.redis_url, decode_responses=True
|
|
99
|
+
)
|
|
100
|
+
await self._redis.ping()
|
|
101
|
+
logger.info("Redis cache connected: %s", settings.redis_url)
|
|
102
|
+
return self._redis
|
|
103
|
+
except Exception:
|
|
104
|
+
logger.debug("Redis not available, using in-memory cache")
|
|
105
|
+
self._redis = False # Mark as unavailable
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
async def get(self, key: str) -> Any | None:
|
|
109
|
+
r = await self._get_redis()
|
|
110
|
+
if r:
|
|
111
|
+
try:
|
|
112
|
+
val = await r.get(f"oe:{key}")
|
|
113
|
+
return json.loads(val) if val else None
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
return await self._fallback.get(key)
|
|
117
|
+
|
|
118
|
+
async def set(self, key: str, value: Any, ttl: int = 300) -> None:
|
|
119
|
+
r = await self._get_redis()
|
|
120
|
+
if r:
|
|
121
|
+
try:
|
|
122
|
+
await r.setex(f"oe:{key}", ttl, json.dumps(value, default=str))
|
|
123
|
+
return
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
await self._fallback.set(key, value, ttl)
|
|
127
|
+
|
|
128
|
+
async def delete(self, key: str) -> None:
|
|
129
|
+
r = await self._get_redis()
|
|
130
|
+
if r:
|
|
131
|
+
try:
|
|
132
|
+
await r.delete(f"oe:{key}")
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
await self._fallback.delete(key)
|
|
136
|
+
|
|
137
|
+
async def clear(self) -> None:
|
|
138
|
+
await self._fallback.clear()
|
|
139
|
+
|
|
140
|
+
def stats(self) -> dict[str, Any]:
|
|
141
|
+
if self._redis and self._redis is not False:
|
|
142
|
+
return {"engine": "redis", "status": "connected"}
|
|
143
|
+
return self._fallback.stats()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Global cache instance
|
|
147
|
+
cache = RedisCache()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cached(ttl: int = 300, prefix: str = "") -> Callable:
|
|
151
|
+
"""Decorator for caching async function results.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
ttl: Time-to-live in seconds (default 5 minutes).
|
|
155
|
+
prefix: Key prefix for namespacing.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def decorator(func: Callable) -> Callable:
|
|
159
|
+
@wraps(func)
|
|
160
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
161
|
+
# Build cache key from function name + args
|
|
162
|
+
key_parts = [prefix or func.__name__]
|
|
163
|
+
key_parts.extend(str(a) for a in args)
|
|
164
|
+
key_parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
|
|
165
|
+
key = ":".join(key_parts)
|
|
166
|
+
|
|
167
|
+
# Check cache
|
|
168
|
+
result = await cache.get(key)
|
|
169
|
+
if result is not None:
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
# Call function and cache result
|
|
173
|
+
result = await func(*args, **kwargs)
|
|
174
|
+
if result is not None:
|
|
175
|
+
await cache.set(key, result, ttl)
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
return wrapper
|
|
179
|
+
|
|
180
|
+
return decorator
|