sirius-cli 0.1.0__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 (32) hide show
  1. sirius_cli/__init__.py +1 -0
  2. sirius_cli/cli.py +335 -0
  3. sirius_cli/generator.py +125 -0
  4. sirius_cli/parser.py +322 -0
  5. sirius_cli/templates/backend/Dockerfile.jinja2 +17 -0
  6. sirius_cli/templates/backend/alembic/env.py.jinja2 +70 -0
  7. sirius_cli/templates/backend/alembic/script.py.mako.jinja2 +26 -0
  8. sirius_cli/templates/backend/database.py.jinja2 +39 -0
  9. sirius_cli/templates/backend/main.py.jinja2 +178 -0
  10. sirius_cli/templates/backend/models.py.jinja2 +31 -0
  11. sirius_cli/templates/backend/requirements.txt.jinja2 +13 -0
  12. sirius_cli/templates/backend/schemas.py.jinja2 +80 -0
  13. sirius_cli/templates/docker-compose.yml.jinja2 +27 -0
  14. sirius_cli/templates/frontend/.env.jinja2 +3 -0
  15. sirius_cli/templates/frontend/Dockerfile.jinja2 +12 -0
  16. sirius_cli/templates/frontend/index.html.jinja2 +16 -0
  17. sirius_cli/templates/frontend/package.json.jinja2 +28 -0
  18. sirius_cli/templates/frontend/postcss.config.js.jinja2 +6 -0
  19. sirius_cli/templates/frontend/src/App.tsx.jinja2 +89 -0
  20. sirius_cli/templates/frontend/src/Dashboard.tsx.jinja2 +210 -0
  21. sirius_cli/templates/frontend/src/TableCrud.tsx.jinja2 +567 -0
  22. sirius_cli/templates/frontend/src/index.css.jinja2 +25 -0
  23. sirius_cli/templates/frontend/src/main.tsx.jinja2 +10 -0
  24. sirius_cli/templates/frontend/tailwind.config.js.jinja2 +21 -0
  25. sirius_cli/templates/frontend/tsconfig.json.jinja2 +25 -0
  26. sirius_cli/templates/frontend/vite.config.ts.jinja2 +10 -0
  27. sirius_cli-0.1.0.dist-info/METADATA +165 -0
  28. sirius_cli-0.1.0.dist-info/RECORD +32 -0
  29. sirius_cli-0.1.0.dist-info/WHEEL +5 -0
  30. sirius_cli-0.1.0.dist-info/entry_points.txt +2 -0
  31. sirius_cli-0.1.0.dist-info/licenses/LICENSE +619 -0
  32. sirius_cli-0.1.0.dist-info/top_level.txt +1 -0
sirius_cli/parser.py ADDED
@@ -0,0 +1,322 @@
1
+ import os
2
+ import re
3
+ import sqlite3
4
+ import json
5
+ import pandas as pd
6
+
7
+ # Irregular plural/singular mappings for FK heuristic resolution
8
+ _IRREGULAR_PLURALS = {
9
+ "person": "people",
10
+ "man": "men",
11
+ "woman": "women",
12
+ "child": "children",
13
+ "tooth": "teeth",
14
+ "foot": "feet",
15
+ "mouse": "mice",
16
+ "goose": "geese",
17
+ "ox": "oxen",
18
+ "leaf": "leaves",
19
+ "life": "lives",
20
+ "knife": "knives",
21
+ "wife": "wives",
22
+ "half": "halves",
23
+ "loaf": "loaves",
24
+ "potato": "potatoes",
25
+ "tomato": "tomatoes",
26
+ "cactus": "cacti",
27
+ "focus": "foci",
28
+ "fungus": "fungi",
29
+ "nucleus": "nuclei",
30
+ "syllabus": "syllabi",
31
+ "analysis": "analyses",
32
+ "diagnosis": "diagnoses",
33
+ "oasis": "oases",
34
+ "thesis": "theses",
35
+ "crisis": "crises",
36
+ "phenomenon": "phenomena",
37
+ "criterion": "criteria",
38
+ "datum": "data",
39
+ }
40
+ _IRREGULAR_SINGULARS = {v: k for k, v in _IRREGULAR_PLURALS.items()}
41
+
42
+ def _pluralize(word: str) -> str:
43
+ """Returns a best-guess plural of a singular English word."""
44
+ if word in _IRREGULAR_PLURALS:
45
+ return _IRREGULAR_PLURALS[word]
46
+ if word.endswith(("s", "sh", "ch", "x", "z")):
47
+ return word + "es"
48
+ if word.endswith("y") and len(word) > 1 and word[-2] not in "aeiou":
49
+ return word[:-1] + "ies"
50
+ if word.endswith(("f", "fe")):
51
+ return word.rstrip("e").rstrip("f") + "ves"
52
+ return word + "s"
53
+
54
+ def _singularize(word: str) -> str:
55
+ """Returns a best-guess singular of a plural English word."""
56
+ if word in _IRREGULAR_SINGULARS:
57
+ return _IRREGULAR_SINGULARS[word]
58
+ if word.endswith("ies") and len(word) > 3:
59
+ return word[:-3] + "y"
60
+ if word.endswith("ves"):
61
+ # could be -f or -fe ending
62
+ return word[:-3] + "f"
63
+ if word.endswith("es") and word[:-2] in ("",) or word.endswith(("ses", "shes", "ches", "xes", "zes")):
64
+ return word[:-2]
65
+ if word.endswith("s") and not word.endswith("ss"):
66
+ return word[:-1]
67
+ return word
68
+
69
+ def sanitize_table_name(name: str) -> str:
70
+ """Sanitizes file path/table name to a valid SQL identifier."""
71
+ base = os.path.splitext(os.path.basename(name))[0]
72
+ sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', base).lower()
73
+ if sanitized and sanitized[0].isdigit():
74
+ sanitized = "_" + sanitized
75
+ return sanitized or "table"
76
+
77
+ def sanitize_column_name(name: str) -> str:
78
+ """Sanitizes column name to a valid SQL/python attribute name."""
79
+ sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', str(name)).lower()
80
+ if sanitized and sanitized[0].isdigit():
81
+ sanitized = "_" + sanitized
82
+ return sanitized or "column"
83
+
84
+ def map_pandas_type(dtype, sample_values) -> str:
85
+ """Maps Pandas dtype to standard string types: Integer, Float, Boolean, DateTime, String."""
86
+ if pd.api.types.is_integer_dtype(dtype):
87
+ return "Integer"
88
+ elif pd.api.types.is_float_dtype(dtype):
89
+ return "Float"
90
+ elif pd.api.types.is_bool_dtype(dtype):
91
+ return "Boolean"
92
+ elif pd.api.types.is_datetime64_any_dtype(dtype):
93
+ return "DateTime"
94
+ else:
95
+ # Check string samples to see if they look like ISO dates or timestamps
96
+ for val in sample_values:
97
+ if isinstance(val, str):
98
+ try:
99
+ pd.to_datetime(val)
100
+ if len(val) >= 8 and any(char in val for char in ['-', '/', ':']):
101
+ return "DateTime"
102
+ except (ValueError, TypeError):
103
+ pass
104
+ return "String"
105
+
106
+ def parse_config_file(config_path: str):
107
+ """Loads entities and schemas directly from a JSON configuration file."""
108
+ with open(config_path, "r", encoding="utf-8") as f:
109
+ config = json.load(f)
110
+
111
+ project_name = config.get("project_name", "generated_project")
112
+ theme = config.get("theme", "blue")
113
+ entities = config.get("entities", {})
114
+
115
+ schemas = {}
116
+ for table_name, table_info in entities.items():
117
+ san_table = sanitize_table_name(table_name)
118
+ columns = []
119
+ has_id = False
120
+
121
+ for col in table_info.get("columns", []):
122
+ name = sanitize_column_name(col.get("name"))
123
+ c_type = col.get("type", "String")
124
+ is_pk = col.get("is_pk", False)
125
+ fk = col.get("foreign_key")
126
+
127
+ col_dict = {"name": name, "type": c_type, "is_pk": is_pk}
128
+ if fk:
129
+ col_dict["foreign_key"] = fk
130
+
131
+ if name == "id":
132
+ has_id = True
133
+ col_dict["is_pk"] = True
134
+
135
+ columns.append(col_dict)
136
+
137
+ if not has_id:
138
+ # Enforce an autoincrementing primary key
139
+ for c in columns:
140
+ if c["is_pk"]:
141
+ c["is_pk"] = False
142
+ columns.insert(0, {"name": "id", "type": "Integer", "is_pk": True})
143
+
144
+ schemas[san_table] = columns
145
+
146
+ return schemas, project_name, theme
147
+
148
+ def parse_csv_files(csv_paths: list) -> dict:
149
+ """Parses list of CSV paths, extracts schema types and infers relationships."""
150
+ schemas = {}
151
+ for path in csv_paths:
152
+ if not os.path.exists(path):
153
+ raise FileNotFoundError(f"CSV file not found: {path}")
154
+
155
+ table_name = sanitize_table_name(path)
156
+ df = pd.read_csv(path, nrows=100)
157
+
158
+ columns = []
159
+ has_id = False
160
+
161
+ for col in df.columns:
162
+ san_col = sanitize_column_name(col)
163
+ col_type = map_pandas_type(df[col].dtype, df[col].dropna().head(5).tolist())
164
+
165
+ if san_col == "id":
166
+ has_id = True
167
+ columns.append({"name": "id", "type": "Integer", "is_pk": True})
168
+ else:
169
+ columns.append({"name": san_col, "type": col_type, "is_pk": False})
170
+
171
+ if not has_id:
172
+ columns.insert(0, {"name": "id", "type": "Integer", "is_pk": True})
173
+
174
+ schemas[table_name] = columns
175
+
176
+ # Heuristic-based Relationship inference (shared by CSV and Excel parsers)
177
+ table_names = list(schemas.keys())
178
+ for table_name, columns in schemas.items():
179
+ for col in columns:
180
+ if col["name"].endswith("_id") and col["name"] != "id":
181
+ prefix = col["name"][:-3] # strip '_id'
182
+ matched_table = None
183
+ for t in table_names:
184
+ # Try exact match, then common plural/singular variants
185
+ candidates = {
186
+ prefix,
187
+ _pluralize(prefix),
188
+ _singularize(prefix),
189
+ prefix + "s",
190
+ prefix[:-1] if prefix.endswith("s") else prefix,
191
+ }
192
+ if t in candidates:
193
+ matched_table = t
194
+ break
195
+ if matched_table:
196
+ col["foreign_key"] = f"{matched_table}.id"
197
+
198
+ return schemas
199
+
200
+ def parse_excel_files(excel_paths: list) -> dict:
201
+ """Parses list of Excel (.xlsx/.xls) paths, extracts schema types and infers relationships."""
202
+ schemas = {}
203
+ for path in excel_paths:
204
+ if not os.path.exists(path):
205
+ raise FileNotFoundError(f"Excel file not found: {path}")
206
+
207
+ table_name = sanitize_table_name(path)
208
+ df = pd.read_excel(path, nrows=100)
209
+
210
+ columns = []
211
+ has_id = False
212
+
213
+ for col in df.columns:
214
+ san_col = sanitize_column_name(str(col))
215
+ col_type = map_pandas_type(df[col].dtype, df[col].dropna().head(5).tolist())
216
+
217
+ if san_col == "id":
218
+ has_id = True
219
+ columns.append({"name": "id", "type": "Integer", "is_pk": True})
220
+ else:
221
+ columns.append({"name": san_col, "type": col_type, "is_pk": False})
222
+
223
+ if not has_id:
224
+ columns.insert(0, {"name": "id", "type": "Integer", "is_pk": True})
225
+
226
+ schemas[table_name] = columns
227
+
228
+ # Run same FK heuristic as CSV parser
229
+ table_names = list(schemas.keys())
230
+ for table_name, columns in schemas.items():
231
+ for col in columns:
232
+ if col["name"].endswith("_id") and col["name"] != "id":
233
+ prefix = col["name"][:-3]
234
+ matched_table = None
235
+ for t in table_names:
236
+ candidates = {
237
+ prefix,
238
+ _pluralize(prefix),
239
+ _singularize(prefix),
240
+ prefix + "s",
241
+ prefix[:-1] if prefix.endswith("s") else prefix,
242
+ }
243
+ if t in candidates:
244
+ matched_table = t
245
+ break
246
+ if matched_table:
247
+ col["foreign_key"] = f"{matched_table}.id"
248
+
249
+ return schemas
250
+
251
+ def map_sqlite_type(sqlite_type: str) -> str:
252
+ """Maps SQLite column type to standard schema types."""
253
+ t = sqlite_type.upper()
254
+ if any(x in t for x in ["INT", "INTEGER", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"]):
255
+ return "Integer"
256
+ elif any(x in t for x in ["REAL", "DOUBLE", "FLOAT"]):
257
+ return "Float"
258
+ elif any(x in t for x in ["BOOL", "BOOLEAN"]):
259
+ return "Boolean"
260
+ elif any(x in t for x in ["DATE", "DATETIME", "TIMESTAMP"]):
261
+ return "DateTime"
262
+ else:
263
+ return "String"
264
+
265
+ def parse_sqlite_db(db_path: str) -> dict:
266
+ """Parses SQLite database extracting user tables and foreign key constraints."""
267
+ if not os.path.exists(db_path):
268
+ raise FileNotFoundError(f"SQLite database file not found: {db_path}")
269
+
270
+ schemas = {}
271
+ conn = sqlite3.connect(db_path)
272
+ cursor = conn.cursor()
273
+
274
+ # Query user tables
275
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';")
276
+ tables = [row[0] for row in cursor.fetchall()]
277
+
278
+ for table in tables:
279
+ san_table = sanitize_table_name(table)
280
+
281
+ # Query SQLite foreign key relationships for the table
282
+ # row structure: (id, seq, table, from, to, on_update, on_delete, match)
283
+ cursor.execute(f"PRAGMA foreign_key_list({table});")
284
+ fks = {}
285
+ for row in cursor.fetchall():
286
+ local_col = sanitize_column_name(row[3])
287
+ ref_table = sanitize_table_name(row[2])
288
+ ref_col = sanitize_column_name(row[4])
289
+ fks[local_col] = f"{ref_table}.{ref_col}"
290
+
291
+ cursor.execute(f"PRAGMA table_info({table});")
292
+ columns_info = cursor.fetchall()
293
+
294
+ columns = []
295
+ has_id = False
296
+
297
+ for info in columns_info:
298
+ col_name = sanitize_column_name(info[1])
299
+ col_type = map_sqlite_type(info[2])
300
+ is_pk = bool(info[5])
301
+
302
+ col_dict = {"name": col_name, "type": col_type, "is_pk": is_pk}
303
+ if col_name in fks:
304
+ col_dict["foreign_key"] = fks[col_name]
305
+
306
+ if col_name == "id":
307
+ has_id = True
308
+ col_dict["is_pk"] = True
309
+
310
+ columns.append(col_dict)
311
+
312
+ if not has_id:
313
+ # Enforce PK id
314
+ for c in columns:
315
+ if c["is_pk"]:
316
+ c["is_pk"] = False
317
+ columns.insert(0, {"name": "id", "type": "Integer", "is_pk": True})
318
+
319
+ schemas[san_table] = columns
320
+
321
+ conn.close()
322
+ return schemas
@@ -0,0 +1,17 @@
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ build-essential \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY . .
13
+
14
+ EXPOSE {{ port }}
15
+
16
+ # We assume uvicorn is installed and backend is a python package
17
+ CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "{{ port }}"]
@@ -0,0 +1,70 @@
1
+ import sys
2
+ import os
3
+ # Add parent of the backend directory to sys.path so backend is importable
4
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
5
+
6
+ from logging.config import fileConfig
7
+
8
+ from sqlalchemy import engine_from_config
9
+ from sqlalchemy import pool
10
+
11
+ from alembic import context
12
+
13
+ # this is the Alembic Config object, which provides
14
+ # access to the values within the .ini file in use.
15
+ config = context.config
16
+
17
+ # Interpret the config file for Python logging.
18
+ # This line sets up loggers basically.
19
+ if config.config_file_name is not None:
20
+ fileConfig(config.config_file_name)
21
+
22
+ # Import engine and Base from backend package
23
+ from backend.database import Base, engine
24
+ import backend.models # ensures models are loaded for metadata extraction
25
+ target_metadata = Base.metadata
26
+
27
+
28
+ def run_migrations_offline() -> None:
29
+ """Run migrations in 'offline' mode.
30
+
31
+ This configures the context with just a URL
32
+ and not an Engine, though an Engine is acceptable
33
+ here as well. By skipping the Engine creation
34
+ we don't even need a DBAPI to be available.
35
+
36
+ """
37
+ url = str(engine.url)
38
+ context.configure(
39
+ url=url,
40
+ target_metadata=target_metadata,
41
+ literal_binds=True,
42
+ dialect_opts={"paramstyle": "named"},
43
+ )
44
+
45
+ with context.begin_transaction():
46
+ context.run_migrations()
47
+
48
+
49
+ def run_migrations_online() -> None:
50
+ """Run migrations in 'online' mode.
51
+
52
+ In this scenario we need to create an Engine
53
+ and associate a connection with the context.
54
+
55
+ """
56
+ connectable = engine
57
+
58
+ with connectable.connect() as connection:
59
+ context.configure(
60
+ connection=connection, target_metadata=target_metadata
61
+ )
62
+
63
+ with context.begin_transaction():
64
+ context.run_migrations()
65
+
66
+
67
+ if context.is_offline_mode():
68
+ run_migrations_offline()
69
+ else:
70
+ run_migrations_online()
@@ -0,0 +1,26 @@
1
+ {% raw %}
2
+ """${message}
3
+
4
+ Revision ID: ${up_revision}
5
+ Revises: ${down_revision}
6
+ Create Date: ${create_date}
7
+
8
+ """
9
+ from alembic import op
10
+ import sqlalchemy as sa
11
+ ${imports if imports else ""}
12
+
13
+ # revision identifiers, used by Alembic.
14
+ revision = ${repr(up_revision)}
15
+ down_revision = ${repr(down_revision)}
16
+ branch_labels = ${repr(branch_labels)}
17
+ depends_on = ${repr(depends_on)}
18
+
19
+
20
+ def upgrade() -> None:
21
+ ${upgrades if upgrades else "pass"}
22
+
23
+
24
+ def downgrade() -> None:
25
+ ${downgrades if downgrades else "pass"}
26
+ {% endraw %}
@@ -0,0 +1,39 @@
1
+ import os
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.ext.declarative import declarative_base
4
+ from sqlalchemy.orm import sessionmaker
5
+
6
+ {% if db_type == 'pg' %}
7
+ # PostgreSQL connection — set DATABASE_URL environment variable for custom credentials
8
+ DATABASE_URL = os.environ.get(
9
+ "DATABASE_URL",
10
+ "postgresql://postgres:postgres@localhost:5432/{{ project_name | default('app', true) }}"
11
+ )
12
+ engine = create_engine(DATABASE_URL)
13
+ {% elif db_type == 'mysql' %}
14
+ # MySQL connection — set DATABASE_URL environment variable for custom credentials
15
+ DATABASE_URL = os.environ.get(
16
+ "DATABASE_URL",
17
+ "mysql+pymysql://root:root@localhost:3306/{{ project_name | default('app', true) }}"
18
+ )
19
+ engine = create_engine(DATABASE_URL)
20
+ {% else %}
21
+ # SQLite (default) — file lives alongside the backend package
22
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
23
+ db_path = os.path.join(BASE_DIR, "app.db")
24
+ SQLALCHEMY_DATABASE_URL = f"sqlite:///{db_path}"
25
+ engine = create_engine(
26
+ SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
27
+ )
28
+ {% endif %}
29
+
30
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
31
+
32
+ Base = declarative_base()
33
+
34
+ def get_db():
35
+ db = SessionLocal()
36
+ try:
37
+ yield db
38
+ finally:
39
+ db.close()
@@ -0,0 +1,178 @@
1
+ import io
2
+ import json
3
+ from fastapi import FastAPI, Depends, HTTPException, Query, status
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.responses import StreamingResponse
6
+ from sqlalchemy import or_, cast, String as SAString, desc as sa_desc, asc as sa_asc
7
+ from sqlalchemy.orm import Session
8
+ from typing import List, Optional
9
+
10
+ from .database import engine, get_db
11
+ from . import models, schemas
12
+
13
+ # Automatically create tables if they do not exist
14
+ models.Base.metadata.create_all(bind=engine)
15
+
16
+ app = FastAPI(title="Proto-CLI Generated Backend API", version="0.1.0")
17
+
18
+ app.add_middleware(
19
+ CORSMiddleware,
20
+ allow_origins=["*"],
21
+ allow_credentials=True,
22
+ allow_methods=["*"],
23
+ allow_headers=["*"],
24
+ )
25
+
26
+ @app.get("/")
27
+ def read_root():
28
+ return {"message": "Welcome to the generated Proto-CLI API!"}
29
+
30
+ @app.get("/api/metadata")
31
+ def get_metadata(db: Session = Depends(get_db)):
32
+ meta = []
33
+ {% for table_name, columns in schemas.items() %}
34
+ {% set model_name = table_name.replace('_', ' ').title().replace(' ', '') + "Model" %}
35
+ meta.append({
36
+ "name": "{{ table_name }}",
37
+ "count": db.query(models.{{ model_name }}).count(),
38
+ "columns": json.loads("""{{ columns | tojson }}""")
39
+ })
40
+ {% endfor %}
41
+ return meta
42
+
43
+ {% for table_name, columns in schemas.items() %}
44
+ {% set model_name = table_name.replace('_', ' ').title().replace(' ', '') + "Model" %}
45
+ {% set schema_response = table_name.replace('_', ' ').title().replace(' ', '') + "Response" %}
46
+ {% set schema_create = table_name.replace('_', ' ').title().replace(' ', '') + "Create" %}
47
+ {% set schema_update = table_name.replace('_', ' ').title().replace(' ', '') + "Update" %}
48
+
49
+ # --- Endpoints for {{ table_name }} ---
50
+
51
+ @app.get("/api/{{ table_name }}", response_model=List[schemas.{{ schema_response }}])
52
+ def list_{{ table_name }}(
53
+ limit: int = 100,
54
+ offset: int = 0,
55
+ search: Optional[str] = Query(None, description="Search across all text columns"),
56
+ order_by: Optional[str] = Query(None, description="Column name to sort by"),
57
+ dir: Optional[str] = Query("asc", description="Sort direction: asc or desc"),
58
+ db: Session = Depends(get_db)
59
+ ):
60
+ query = db.query(models.{{ model_name }})
61
+
62
+ # Server-side search: filter across all string-castable columns
63
+ if search:
64
+ search_term = f"%{search}%"
65
+ search_filters = []
66
+ for col in models.{{ model_name }}.__table__.columns:
67
+ search_filters.append(cast(col, SAString).ilike(search_term))
68
+ if search_filters:
69
+ query = query.filter(or_(*search_filters))
70
+
71
+ # Column sorting
72
+ if order_by and hasattr(models.{{ model_name }}, order_by):
73
+ col_attr = getattr(models.{{ model_name }}, order_by)
74
+ query = query.order_by(sa_desc(col_attr) if dir == "desc" else sa_asc(col_attr))
75
+
76
+ records = query.offset(offset).limit(limit).all()
77
+ return records
78
+
79
+ @app.get("/api/{{ table_name }}/export")
80
+ def export_{{ table_name }}(
81
+ fmt: Optional[str] = Query("csv", description="Export format: csv or xlsx"),
82
+ db: Session = Depends(get_db)
83
+ ):
84
+ records = db.query(models.{{ model_name }}).all()
85
+ col_names = [c.name for c in models.{{ model_name }}.__table__.columns]
86
+
87
+ if fmt == "xlsx":
88
+ try:
89
+ from openpyxl import Workbook
90
+ except ImportError:
91
+ raise HTTPException(status_code=500, detail="openpyxl is not installed on the server")
92
+
93
+ wb = Workbook()
94
+ ws = wb.active
95
+ ws.title = "{{ table_name }}"
96
+ ws.append(col_names)
97
+ for record in records:
98
+ row = []
99
+ for col in col_names:
100
+ val = getattr(record, col)
101
+ row.append(val if val is not None else "")
102
+ ws.append(row)
103
+
104
+ buf = io.BytesIO()
105
+ wb.save(buf)
106
+ buf.seek(0)
107
+
108
+ return StreamingResponse(
109
+ buf,
110
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
111
+ headers={"Content-Disposition": "attachment; filename={{ table_name }}.xlsx"}
112
+ )
113
+
114
+ # Default: CSV export
115
+ def generate():
116
+ yield ",".join(col_names) + "\n"
117
+ for record in records:
118
+ row_values = []
119
+ for col in col_names:
120
+ val = getattr(record, col)
121
+ if val is None:
122
+ row_values.append("")
123
+ elif isinstance(val, bool):
124
+ row_values.append(str(val))
125
+ else:
126
+ val_str = str(val).replace('"', '""')
127
+ if ',' in val_str or '\n' in val_str or '"' in val_str:
128
+ row_values.append(f'"{val_str}"')
129
+ else:
130
+ row_values.append(val_str)
131
+ yield ",".join(row_values) + "\n"
132
+
133
+ return StreamingResponse(
134
+ generate(),
135
+ media_type="text/csv",
136
+ headers={"Content-Disposition": "attachment; filename={{ table_name }}.csv"}
137
+ )
138
+
139
+ @app.get("/api/{{ table_name }}/{id}", response_model=schemas.{{ schema_response }})
140
+ def get_{{ table_name }}(id: int, db: Session = Depends(get_db)):
141
+ record = db.query(models.{{ model_name }}).filter(models.{{ model_name }}.id == id).first()
142
+ if not record:
143
+ raise HTTPException(status_code=404, detail="Record not found in {{ table_name }}")
144
+ return record
145
+
146
+ @app.post("/api/{{ table_name }}", response_model=schemas.{{ schema_response }}, status_code=status.HTTP_201_CREATED)
147
+ def create_{{ table_name }}(payload: schemas.{{ schema_create }}, db: Session = Depends(get_db)):
148
+ db_record = models.{{ model_name }}(**payload.model_dump())
149
+ db.add(db_record)
150
+ db.commit()
151
+ db.refresh(db_record)
152
+ return db_record
153
+
154
+ @app.put("/api/{{ table_name }}/{id}", response_model=schemas.{{ schema_response }})
155
+ def update_{{ table_name }}(id: int, payload: schemas.{{ schema_update }}, db: Session = Depends(get_db)):
156
+ db_record = db.query(models.{{ model_name }}).filter(models.{{ model_name }}.id == id).first()
157
+ if not db_record:
158
+ raise HTTPException(status_code=404, detail="Record not found in {{ table_name }}")
159
+
160
+ update_data = payload.model_dump(exclude_unset=True)
161
+ for key, value in update_data.items():
162
+ setattr(db_record, key, value)
163
+
164
+ db.commit()
165
+ db.refresh(db_record)
166
+ return db_record
167
+
168
+ @app.delete("/api/{{ table_name }}/{id}", status_code=status.HTTP_204_NO_CONTENT)
169
+ def delete_{{ table_name }}(id: int, db: Session = Depends(get_db)):
170
+ db_record = db.query(models.{{ model_name }}).filter(models.{{ model_name }}.id == id).first()
171
+ if not db_record:
172
+ raise HTTPException(status_code=404, detail="Record not found in {{ table_name }}")
173
+
174
+ db.delete(db_record)
175
+ db.commit()
176
+ return None
177
+
178
+ {% endfor %}
@@ -0,0 +1,31 @@
1
+ from sqlalchemy import Column, Integer, Float, String, Boolean, DateTime, ForeignKey
2
+ from sqlalchemy.orm import relationship
3
+ from .database import Base
4
+
5
+
6
+ {% for table_name, columns in schemas.items() %}
7
+ {% set class_name = table_name.replace('_', ' ').title().replace(' ', '') + "Model" %}
8
+ class {{ class_name }}(Base):
9
+ __tablename__ = "{{ table_name }}"
10
+
11
+ {% for col in columns %}
12
+ {% if col.is_pk %}
13
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
14
+ {% elif col.foreign_key %}
15
+ {{ col.name }} = Column({{ col.type }}, ForeignKey('{{ col.foreign_key }}'), nullable=True)
16
+ {% else %}
17
+ {{ col.name }} = Column({{ col.type }}, nullable=True)
18
+ {% endif %}
19
+ {% endfor %}
20
+
21
+ # Relationships
22
+ {% for col in columns %}
23
+ {% if col.foreign_key %}
24
+ {% set ref_table = col.foreign_key.split('.')[0] %}
25
+ {% set ref_class = ref_table.replace('_', ' ').title().replace(' ', '') + "Model" %}
26
+ {% set prop_name = col.name[:-3] if col.name.endswith('_id') else ref_table %}
27
+ {{ prop_name }} = relationship("{{ ref_class }}")
28
+ {% endif %}
29
+ {% endfor %}
30
+
31
+ {% endfor %}