-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
179 lines (147 loc) · 6.05 KB
/
Copy pathmain.py
File metadata and controls
179 lines (147 loc) · 6.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
import csv
import io
from decimal import Decimal, InvalidOperation
from fastapi import FastAPI, UploadFile, File, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from database import engine, get_db, Base
from models import Employee
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Employee CSV Upload API")
# ---------------------------------------------------------------------------
# CORS – allow all origins so the plain HTML frontend can reach the API
# ---------------------------------------------------------------------------
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Serve the frontend at /
app.mount("/frontend", StaticFiles(directory="frontend", html=True), name="frontend")
# ---------------------------------------------------------------------------
# Required columns for the CSV
# ---------------------------------------------------------------------------
REQUIRED_COLUMNS = {"emp_id", "name", "age", "department", "salary", "city", "skills", "is_active"}
TRUE_VALUES = {"true", "yes", "1", "t"}
FALSE_VALUES = {"false", "no", "0", "f"}
def parse_boolean(value: str, row: int, field: str) -> bool:
"""Parse a string boolean value. Raises ValueError on invalid input."""
v = value.strip().lower()
if v in TRUE_VALUES:
return True
if v in FALSE_VALUES:
return False
raise ValueError(f"Row {row}: '{field}' must be a boolean (TRUE/FALSE), got '{value}'")
def parse_skills(value: str) -> list[str]:
"""Parse a comma-separated skills string into a list of non-empty strings."""
skills = [s.strip() for s in value.split(",") if s.strip()]
return skills
# ---------------------------------------------------------------------------
# Upload endpoint
# ---------------------------------------------------------------------------
@app.post("/api/upload")
async def upload_csv(file: UploadFile = File(...), db: Session = Depends(get_db)):
# Validate file type
if not file.filename.lower().endswith(".csv"):
raise HTTPException(status_code=400, detail="Only .csv files are accepted.")
content = await file.read()
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(status_code=400, detail="File must be UTF-8 encoded.")
reader = csv.DictReader(io.StringIO(text))
# Validate headers
if reader.fieldnames is None:
raise HTTPException(status_code=400, detail="CSV file is empty or has no headers.")
actual_columns = {col.strip() for col in reader.fieldnames}
missing_headers = REQUIRED_COLUMNS - actual_columns
if missing_headers:
raise HTTPException(
status_code=400,
detail=f"CSV is missing required columns: {', '.join(sorted(missing_headers))}",
)
errors: list[dict] = []
employees: list[Employee] = []
seen_emp_ids: dict[int, int] = {} # emp_id -> first row number
for row_num, raw_row in enumerate(reader, start=2): # row 1 is headers
row = {k.strip(): (v.strip() if v else "") for k, v in raw_row.items()}
row_errors: list[str] = []
# Check for missing / empty required columns
for col in REQUIRED_COLUMNS:
if not row.get(col):
row_errors.append(f"'{col}' is missing or empty")
if row_errors:
errors.append({"row": row_num, "errors": row_errors})
continue
# --- emp_id ---
emp_id = None
try:
emp_id = int(row["emp_id"])
except ValueError:
row_errors.append(f"'emp_id' must be an integer, got '{row['emp_id']}'")
# --- age ---
age = None
try:
age = int(row["age"])
if age <= 0:
row_errors.append(f"'age' must be a positive integer, got '{row['age']}'")
except ValueError:
row_errors.append(f"'age' must be an integer, got '{row['age']}'")
# --- salary ---
salary = None
try:
salary = Decimal(row["salary"])
if salary < 0:
row_errors.append(f"'salary' must be a non-negative number, got '{row['salary']}'")
except InvalidOperation:
row_errors.append(f"'salary' must be a numeric value, got '{row['salary']}'")
# --- is_active ---
is_active = None
try:
is_active = parse_boolean(row["is_active"], row_num, "is_active")
except ValueError as e:
row_errors.append(str(e))
# --- skills ---
skills = parse_skills(row["skills"])
if not skills:
row_errors.append("'skills' must contain at least one skill")
# --- duplicate emp_id check ---
if emp_id is not None:
if emp_id in seen_emp_ids:
row_errors.append(
f"'emp_id' {emp_id} is a duplicate (first seen at row {seen_emp_ids[emp_id]})"
)
else:
seen_emp_ids[emp_id] = row_num
if row_errors:
errors.append({"row": row_num, "errors": row_errors})
else:
employees.append(
Employee(
emp_id=emp_id,
name=row["name"],
age=age,
department=row["department"],
salary=salary,
city=row["city"],
skills=skills,
is_active=is_active,
)
)
# If any validation errors, return 400 without touching the DB
if errors:
raise HTTPException(status_code=400, detail=errors)
# Bulk insert
try:
db.add_all(employees)
db.commit()
except Exception as exc:
db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {str(exc)}")
return {
"message": f"Successfully uploaded {len(employees)} employee record(s).",
"inserted": len(employees),
}