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.
Files changed (172) hide show
  1. app/__init__.py +0 -0
  2. app/__main__.py +56 -0
  3. app/cli.py +236 -0
  4. app/cli_static.py +92 -0
  5. app/config.py +94 -0
  6. app/core/__init__.py +0 -0
  7. app/core/cache.py +180 -0
  8. app/core/demo_projects.py +2551 -0
  9. app/core/events.py +174 -0
  10. app/core/hooks.py +167 -0
  11. app/core/i18n.py +3221 -0
  12. app/core/i18n_router.py +24 -0
  13. app/core/marketplace.py +1015 -0
  14. app/core/module_loader.py +234 -0
  15. app/core/permissions.py +159 -0
  16. app/core/plugin_manager.py +229 -0
  17. app/core/rate_limiter.py +40 -0
  18. app/core/sqlite_migrator.py +86 -0
  19. app/core/validation/__init__.py +0 -0
  20. app/core/validation/engine.py +391 -0
  21. app/core/validation/rules/__init__.py +1853 -0
  22. app/core/vector.py +345 -0
  23. app/database.py +126 -0
  24. app/dependencies.py +193 -0
  25. app/main.py +713 -0
  26. app/middleware/__init__.py +0 -0
  27. app/middleware/fingerprint.py +34 -0
  28. app/modules/__init__.py +0 -0
  29. app/modules/ai/__init__.py +13 -0
  30. app/modules/ai/ai_client.py +506 -0
  31. app/modules/ai/manifest.py +15 -0
  32. app/modules/ai/models.py +95 -0
  33. app/modules/ai/permissions.py +16 -0
  34. app/modules/ai/prompts.py +198 -0
  35. app/modules/ai/repository.py +78 -0
  36. app/modules/ai/router.py +858 -0
  37. app/modules/ai/schemas.py +155 -0
  38. app/modules/ai/service.py +1033 -0
  39. app/modules/assemblies/__init__.py +13 -0
  40. app/modules/assemblies/manifest.py +18 -0
  41. app/modules/assemblies/models.py +114 -0
  42. app/modules/assemblies/permissions.py +16 -0
  43. app/modules/assemblies/repository.py +196 -0
  44. app/modules/assemblies/router.py +473 -0
  45. app/modules/assemblies/schemas.py +173 -0
  46. app/modules/assemblies/service.py +677 -0
  47. app/modules/backup/__init__.py +0 -0
  48. app/modules/backup/manifest.py +12 -0
  49. app/modules/backup/router.py +470 -0
  50. app/modules/boq/__init__.py +12 -0
  51. app/modules/boq/ai_prompts.py +178 -0
  52. app/modules/boq/cad_import.py +745 -0
  53. app/modules/boq/epd_materials.py +266 -0
  54. app/modules/boq/events.py +156 -0
  55. app/modules/boq/manifest.py +15 -0
  56. app/modules/boq/models.py +272 -0
  57. app/modules/boq/pdf_export.py +1035 -0
  58. app/modules/boq/permissions.py +18 -0
  59. app/modules/boq/repository.py +342 -0
  60. app/modules/boq/router.py +3788 -0
  61. app/modules/boq/schemas.py +897 -0
  62. app/modules/boq/service.py +3368 -0
  63. app/modules/boq/templates.py +1274 -0
  64. app/modules/cad/__init__.py +0 -0
  65. app/modules/cad/manifest.py +15 -0
  66. app/modules/catalog/__init__.py +13 -0
  67. app/modules/catalog/manifest.py +15 -0
  68. app/modules/catalog/models.py +60 -0
  69. app/modules/catalog/permissions.py +17 -0
  70. app/modules/catalog/repository.py +199 -0
  71. app/modules/catalog/router.py +392 -0
  72. app/modules/catalog/schemas.py +111 -0
  73. app/modules/catalog/service.py +462 -0
  74. app/modules/changeorders/__init__.py +12 -0
  75. app/modules/changeorders/manifest.py +15 -0
  76. app/modules/changeorders/models.py +93 -0
  77. app/modules/changeorders/permissions.py +17 -0
  78. app/modules/changeorders/repository.py +145 -0
  79. app/modules/changeorders/router.py +320 -0
  80. app/modules/changeorders/schemas.py +157 -0
  81. app/modules/changeorders/service.py +317 -0
  82. app/modules/costmodel/__init__.py +13 -0
  83. app/modules/costmodel/manifest.py +18 -0
  84. app/modules/costmodel/models.py +156 -0
  85. app/modules/costmodel/permissions.py +15 -0
  86. app/modules/costmodel/repository.py +322 -0
  87. app/modules/costmodel/router.py +449 -0
  88. app/modules/costmodel/schemas.py +333 -0
  89. app/modules/costmodel/service.py +1061 -0
  90. app/modules/costs/__init__.py +12 -0
  91. app/modules/costs/manifest.py +15 -0
  92. app/modules/costs/models.py +56 -0
  93. app/modules/costs/permissions.py +18 -0
  94. app/modules/costs/repository.py +169 -0
  95. app/modules/costs/router.py +1948 -0
  96. app/modules/costs/schemas.py +110 -0
  97. app/modules/costs/service.py +235 -0
  98. app/modules/documents/__init__.py +12 -0
  99. app/modules/documents/manifest.py +15 -0
  100. app/modules/documents/models.py +51 -0
  101. app/modules/documents/permissions.py +16 -0
  102. app/modules/documents/repository.py +103 -0
  103. app/modules/documents/router.py +201 -0
  104. app/modules/documents/schemas.py +59 -0
  105. app/modules/documents/service.py +213 -0
  106. app/modules/projects/__init__.py +12 -0
  107. app/modules/projects/manifest.py +15 -0
  108. app/modules/projects/models.py +52 -0
  109. app/modules/projects/permissions.py +16 -0
  110. app/modules/projects/repository.py +81 -0
  111. app/modules/projects/router.py +235 -0
  112. app/modules/projects/schemas.py +79 -0
  113. app/modules/projects/service.py +160 -0
  114. app/modules/reporting/__init__.py +0 -0
  115. app/modules/reporting/manifest.py +15 -0
  116. app/modules/risk/__init__.py +12 -0
  117. app/modules/risk/manifest.py +15 -0
  118. app/modules/risk/models.py +54 -0
  119. app/modules/risk/permissions.py +16 -0
  120. app/modules/risk/repository.py +84 -0
  121. app/modules/risk/router.py +183 -0
  122. app/modules/risk/schemas.py +132 -0
  123. app/modules/risk/service.py +234 -0
  124. app/modules/schedule/__init__.py +12 -0
  125. app/modules/schedule/manifest.py +15 -0
  126. app/modules/schedule/models.py +170 -0
  127. app/modules/schedule/permissions.py +17 -0
  128. app/modules/schedule/repository.py +206 -0
  129. app/modules/schedule/router.py +487 -0
  130. app/modules/schedule/schemas.py +354 -0
  131. app/modules/schedule/service.py +1677 -0
  132. app/modules/takeoff/__init__.py +8 -0
  133. app/modules/takeoff/manifest.py +15 -0
  134. app/modules/takeoff/models.py +56 -0
  135. app/modules/takeoff/permissions.py +16 -0
  136. app/modules/takeoff/repository.py +51 -0
  137. app/modules/takeoff/router.py +990 -0
  138. app/modules/takeoff/schemas.py +86 -0
  139. app/modules/takeoff/service.py +211 -0
  140. app/modules/tendering/__init__.py +1 -0
  141. app/modules/tendering/manifest.py +15 -0
  142. app/modules/tendering/models.py +92 -0
  143. app/modules/tendering/permissions.py +19 -0
  144. app/modules/tendering/repository.py +120 -0
  145. app/modules/tendering/router.py +430 -0
  146. app/modules/tendering/schemas.py +160 -0
  147. app/modules/tendering/service.py +317 -0
  148. app/modules/users/__init__.py +11 -0
  149. app/modules/users/manifest.py +15 -0
  150. app/modules/users/models.py +82 -0
  151. app/modules/users/permissions.py +18 -0
  152. app/modules/users/repository.py +123 -0
  153. app/modules/users/router.py +266 -0
  154. app/modules/users/schemas.py +158 -0
  155. app/modules/users/service.py +476 -0
  156. app/modules/validation/__init__.py +0 -0
  157. app/schemas.py +268 -0
  158. app/scripts/__init__.py +0 -0
  159. app/scripts/create_tables.py +26 -0
  160. app/scripts/export_catalog_csv.py +245 -0
  161. app/scripts/export_full_catalog.py +288 -0
  162. app/scripts/seed_catalog.py +227 -0
  163. app/scripts/seed_demo.py +252 -0
  164. app/scripts/seed_demo_4d5d.py +273 -0
  165. app/scripts/seed_demo_estimates.py +859 -0
  166. app/scripts/seed_international.py +231 -0
  167. app/scripts/seed_schedule_demo.py +674 -0
  168. app/scripts/seed_sections.py +77 -0
  169. openconstructionerp-0.2.1.dist-info/METADATA +138 -0
  170. openconstructionerp-0.2.1.dist-info/RECORD +172 -0
  171. openconstructionerp-0.2.1.dist-info/WHEEL +4 -0
  172. 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