Arslan1997 commited on
Commit
f1515e9
·
1 Parent(s): f886e66

made it faster and added color picker

Browse files
app/SAMPLE_DATA_README.md DELETED
@@ -1,141 +0,0 @@
1
- # Sample Housing Dataset - Comprehensive Real Estate Analytics
2
-
3
- ## Overview
4
- This enhanced sample dataset contains **500 realistic property listings** with **33 features** designed to enable comprehensive real estate market analysis and insightful dashboard creation.
5
-
6
- ## Dataset Features
7
-
8
- ### Basic Property Information (9 columns)
9
- - **price**: Property sale price ($361K - $5.08M)
10
- - **property_type**: Single Family, Condo, Townhouse, or Multi-Family
11
- - **neighborhood**: Downtown, Suburbs, Waterfront, Historic District, or Business District
12
- - **bedrooms**: Number of bedrooms (1-6)
13
- - **bathrooms**: Number of bathrooms (1.0-4.5)
14
- - **sqft_living**: Interior living space in square feet (800-5,500)
15
- - **sqft_lot**: Lot size in square feet (2,000-20,000)
16
- - **floors**: Number of floors (1.0-3.0)
17
- - **year_built**: Year the property was built (1950-2023)
18
-
19
- ### Property Characteristics (7 columns)
20
- - **year_renovated**: Year of last major renovation (0 if never renovated)
21
- - **property_age**: Age of the property in years
22
- - **waterfront**: Boolean - waterfront property (12% have this premium feature)
23
- - **view_quality**: View rating from 0-4 (higher is better)
24
- - **condition**: Overall condition rating 1-5
25
- - **grade**: Construction quality grade 3-12 (7-8 is average)
26
- - **price_segment**: Categorized as Budget, Mid-Range, Premium, or Luxury
27
-
28
- ### Amenities & Features (4 columns)
29
- - **parking_spaces**: Number of parking spaces (0-4)
30
- - **has_pool**: Boolean - property has a swimming pool (15%)
31
- - **has_fireplace**: Boolean - property has fireplace(s) (35%)
32
- - **has_basement**: Boolean - property has a basement (45%)
33
-
34
- ### Neighborhood Quality (3 columns)
35
- - **school_rating**: School district rating (3-10, higher is better)
36
- - **crime_index**: Neighborhood crime index (15-85, lower is better)
37
- - **walkability_score**: Walkability score (30-100, higher is better)
38
-
39
- ### Market Metrics (3 columns)
40
- - **price_per_sqft**: Price per square foot (calculated)
41
- - **days_on_market**: Days the property was listed (1-180)
42
- - **hoa_fees**: Monthly HOA fees ($0-$800, ~40% of properties)
43
-
44
- ### Temporal Data (4 columns)
45
- - **sale_date**: Date of sale (2022-01-01 to 2024-01-01)
46
- - **sale_month**: Month of sale (1-12)
47
- - **sale_quarter**: Quarter of sale (Q1-Q4)
48
- - **sale_year**: Year of sale (2022-2023)
49
-
50
- ### Location Data (3 columns)
51
- - **zipcode**: ZIP code (10 different Seattle-area ZIP codes)
52
- - **lat**: Latitude coordinate
53
- - **long**: Longitude coordinate
54
-
55
- ## Key Features for Analysis
56
-
57
- ### 1. Price Drivers
58
- The dataset includes realistic relationships between price and features:
59
- - **Waterfront premium**: +30% average
60
- - **View quality**: +8% per rating point
61
- - **Property type**: Single Family and Multi-Family command premiums
62
- - **Neighborhood**: Waterfront (+25%) and Downtown (+15%) areas are most expensive
63
- - **School ratings**: Better schools correlate with higher prices
64
- - **Recent renovations**: +12% for properties renovated after 2015
65
-
66
- ### 2. Market Segmentation
67
- Properties are naturally segmented into:
68
- - **Budget**: < $750K (25%)
69
- - **Mid-Range**: $750K - $1.2M (30%)
70
- - **Premium**: $1.2M - $1.8M (25%)
71
- - **Luxury**: > $1.8M (20%)
72
-
73
- ### 3. Temporal Trends
74
- - Sales data spans 2 years (2022-2024)
75
- - Enables quarter-over-quarter and year-over-year analysis
76
- - Market velocity metrics through "days_on_market"
77
-
78
- ### 4. Geographic Analysis
79
- - 10 different ZIP codes representing diverse neighborhoods
80
- - Lat/long coordinates for mapping and spatial analysis
81
- - Neighborhood-level characteristics (crime, walkability, schools)
82
-
83
- ## Suggested Analysis & Visualizations
84
-
85
- ### 1. Price Distribution & Trends
86
- - Price distribution by property type and neighborhood
87
- - Price trends over time (quarterly/yearly)
88
- - Price per square foot analysis
89
-
90
- ### 2. Feature Impact Analysis
91
- - Impact of bedrooms, bathrooms on price
92
- - Premium analysis for waterfront, pool, renovations
93
- - School rating vs. price correlation
94
-
95
- ### 3. Market Velocity
96
- - Days on market by price segment
97
- - Seasonal patterns in sales activity
98
- - HOA fees impact on marketability
99
-
100
- ### 4. Geographic Insights
101
- - Price heatmaps by location
102
- - Neighborhood quality metrics visualization
103
- - ZIP code-level market analysis
104
-
105
- ### 5. Property Characteristics
106
- - Condition vs. grade distribution
107
- - Age distribution and renovation patterns
108
- - Amenity combinations most common in different price ranges
109
-
110
- ### 6. Comparative Analysis
111
- - Property type performance
112
- - Neighborhood comparisons (crime, schools, walkability)
113
- - Market segment characteristics
114
-
115
- ## Default Dashboard Query
116
- The sample data comes with a pre-configured query that generates a comprehensive dashboard showcasing:
117
- 1. KPI cards for key metrics (avg price, total listings)
118
- 2. Price distribution by property type and neighborhood
119
- 3. Quarterly price trends showing market evolution
120
- 4. Feature impact analysis (bedrooms, waterfront, schools)
121
- 5. Geographic price visualization
122
- 6. Condition vs. grade matrix
123
- 7. Market velocity by price segment
124
-
125
- ## Data Quality
126
- - **No missing values**: All records are complete
127
- - **Realistic relationships**: Price adjustments based on actual market factors
128
- - **Diverse distribution**: Balanced representation across categories
129
- - **Time-series ready**: Proper date handling for temporal analysis
130
- - **GIS-ready**: Includes coordinates for mapping
131
-
132
- ## Use Cases
133
- This dataset is ideal for demonstrating:
134
- - Real estate market analysis
135
- - Predictive pricing models
136
- - Market segmentation strategies
137
- - Investment opportunity identification
138
- - Neighborhood analysis and comparison
139
- - Temporal market trend analysis
140
- - Geographic price pattern detection
141
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/main.py CHANGED
@@ -28,6 +28,7 @@ from .routes.forms import router as forms_router
28
  from .routes.responses import router as responses_router
29
  from .routes.analytics import router as analytics_router
30
  from .routes.users import router as users_router
 
31
 
32
  app = FastAPI(title="Backend", version="0.1.0")
33
 
@@ -107,6 +108,7 @@ app.include_router(forms_router)
107
  app.include_router(responses_router)
108
  app.include_router(analytics_router)
109
  app.include_router(users_router)
 
110
 
111
  # Initialize DB
112
  if os.getenv("AUTO_MIGRATE", "1") == "1":
@@ -123,4 +125,3 @@ if os.getenv("AUTO_MIGRATE", "1") == "1":
123
  except Exception as e:
124
  logging.error(f"Failed to initialize subscription plans: {e}")
125
 
126
-
 
28
  from .routes.responses import router as responses_router
29
  from .routes.analytics import router as analytics_router
30
  from .routes.users import router as users_router
31
+ from .routes.uploads import router as uploads_router
32
 
33
  app = FastAPI(title="Backend", version="0.1.0")
34
 
 
108
  app.include_router(responses_router)
109
  app.include_router(analytics_router)
110
  app.include_router(users_router)
111
+ app.include_router(uploads_router)
112
 
113
  # Initialize DB
114
  if os.getenv("AUTO_MIGRATE", "1") == "1":
 
125
  except Exception as e:
126
  logging.error(f"Failed to initialize subscription plans: {e}")
127
 
 
app/models.py CHANGED
@@ -83,14 +83,11 @@ class User(Base):
83
  created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
84
 
85
  subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="user")
86
- datasets: Mapped[list["Dataset"]] = relationship(back_populates="user")
87
  credits: Mapped["UserCredits"] = relationship(back_populates="user", uselist=False)
88
  credit_transactions: Mapped[list["CreditTransaction"]] = relationship(back_populates="user")
89
- dashboard_queries: Mapped[list["DashboardQuery"]] = relationship(back_populates="user", cascade="all, delete-orphan")
90
- chat_messages: Mapped[list["ChatMessage"]] = relationship(back_populates="user", cascade="all, delete-orphan")
91
- public_dashboards: Mapped[list["PublicDashboard"]] = relationship(back_populates="user", cascade="all, delete-orphan")
92
  forms: Mapped[list["Form"]] = relationship(back_populates="user", cascade="all, delete-orphan")
93
  public_forms: Mapped[list["PublicForm"]] = relationship(back_populates="user", cascade="all, delete-orphan")
 
94
 
95
 
96
  class Subscription(Base):
@@ -112,106 +109,6 @@ class Subscription(Base):
112
  plan: Mapped["SubscriptionPlan"] = relationship(back_populates="subscriptions")
113
 
114
 
115
- class Dataset(Base):
116
- __tablename__ = "datasets"
117
-
118
- id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
119
- user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
120
- dataset_id: Mapped[str] = mapped_column(String(100), unique=True, index=True)
121
-
122
- # Basic metadata
123
- filename: Mapped[str] = mapped_column(String(255))
124
- row_count: Mapped[int] = mapped_column(Integer)
125
- column_count: Mapped[int] = mapped_column(Integer)
126
- file_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
127
-
128
- # Dataset context from DSPy (rich description)
129
- context: Mapped[str | None] = mapped_column(Text, nullable=True)
130
- context_generated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
131
-
132
- # Column metadata as JSON
133
- columns_info: Mapped[dict | None] = mapped_column(JSON, nullable=True)
134
- # Store: {"columns": [...], "dtypes": {...}, "statistics": {...}}
135
-
136
- # Timestamps
137
- created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
138
- updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
139
-
140
- # Status tracking
141
- context_status: Mapped[str] = mapped_column(String(50), default="pending")
142
- # Values: "pending", "generating", "completed", "failed"
143
-
144
- user: Mapped[User] = relationship(back_populates="datasets")
145
- dashboard_queries: Mapped[list["DashboardQuery"]] = relationship(back_populates="dataset", cascade="all, delete-orphan")
146
- chat_messages: Mapped[list["ChatMessage"]] = relationship(back_populates="dataset", cascade="all, delete-orphan")
147
- public_dashboards: Mapped[list["PublicDashboard"]] = relationship(back_populates="dataset", cascade="all, delete-orphan")
148
-
149
-
150
- class DashboardQuery(Base):
151
- __tablename__ = "dashboard_queries"
152
-
153
- id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
154
- dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
155
- user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
156
-
157
- query: Mapped[str] = mapped_column(Text) # User's request
158
- query_type: Mapped[str] = mapped_column(String(50)) # "analyze", "edit", "add"
159
- dashboard_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
160
- charts_data: Mapped[list[dict] | None] = mapped_column(JSON, nullable=True) # Array of chart objects with code, figure, etc.
161
- background_color: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#ffffff") # Hex color code
162
- text_color: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#1a1a1a") # Hex color code for text
163
- created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
164
-
165
- dataset: Mapped["Dataset"] = relationship(back_populates="dashboard_queries")
166
- user: Mapped["User"] = relationship(back_populates="dashboard_queries")
167
-
168
-
169
- class ChatMessage(Base):
170
- __tablename__ = "chat_messages"
171
-
172
- id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
173
- dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
174
- user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
175
-
176
- role: Mapped[str] = mapped_column(String(20)) # "user", "assistant"
177
- content: Mapped[str] = mapped_column(Text)
178
- query_type: Mapped[str | None] = mapped_column(String(50), nullable=True) # "edit", "add", "data_analysis", "general_qa", "need_clarity", etc.
179
- code: Mapped[str | None] = mapped_column(Text, nullable=True) # Executable code if applicable
180
- chart_index: Mapped[int | None] = mapped_column(Integer, nullable=True) # If related to a chart
181
- created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
182
-
183
- dataset: Mapped["Dataset"] = relationship(back_populates="chat_messages")
184
- user: Mapped["User"] = relationship(back_populates="chat_messages")
185
-
186
-
187
- class PublicDashboard(Base):
188
- __tablename__ = "public_dashboards"
189
-
190
- id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
191
- dataset_id: Mapped[int] = mapped_column(ForeignKey("datasets.id"), index=True)
192
- user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
193
-
194
- share_token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
195
- figures_data: Mapped[list[dict] | None] = mapped_column(JSON, nullable=True) # Array of chart figures
196
- dashboard_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
197
- background_color: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#ffffff") # Hex color code
198
- text_color: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#1a1a1a") # Hex color code for text
199
- background_opacity: Mapped[float | None] = mapped_column(Float, nullable=True, default=1.0) # Background opacity
200
- use_gradient: Mapped[bool | None] = mapped_column(Boolean, nullable=True, default=False) # Use gradient background
201
- gradient_color_2: Mapped[str | None] = mapped_column(String(7), nullable=True, default="#e5e7eb") # Second gradient color
202
- container_colors: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Container-specific colors
203
- chart_colors: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Chart trace colors
204
- chart_opacities: Mapped[dict | None] = mapped_column(JSON, nullable=True) # Chart trace opacities
205
- apply_to_containers: Mapped[bool | None] = mapped_column(Boolean, nullable=True, default=True) # Apply colors to all containers
206
- is_public: Mapped[bool] = mapped_column(Boolean, default=True)
207
- expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
208
- created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
209
- updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
210
-
211
- dataset: Mapped["Dataset"] = relationship(back_populates="public_dashboards")
212
- user: Mapped["User"] = relationship(back_populates="public_dashboards")
213
-
214
-
215
  class SubscriptionPlan(Base):
216
  __tablename__ = "subscription_plans"
217
 
@@ -297,6 +194,7 @@ class Form(Base):
297
  chat_messages_form: Mapped[list["ChatMessageForm"]] = relationship(back_populates="form", cascade="all, delete-orphan")
298
  versions: Mapped[list["FormVersion"]] = relationship(back_populates="form", cascade="all, delete-orphan")
299
  analytics_events: Mapped[list["FormAnalyticsEvent"]] = relationship(back_populates="form", cascade="all, delete-orphan")
 
300
 
301
 
302
  class FormQuestion(Base):
@@ -322,6 +220,7 @@ class FormQuestion(Base):
322
  target_rules: Mapped[list["ConditionalRule"]] = relationship(foreign_keys="ConditionalRule.target_question_id", back_populates="target_question", cascade="all, delete-orphan")
323
  answers: Mapped[list["ResponseAnswer"]] = relationship(back_populates="question", cascade="all, delete-orphan")
324
  analytics_events: Mapped[list["FormAnalyticsEvent"]] = relationship(back_populates="question", cascade="all, delete-orphan")
 
325
 
326
 
327
  class ConditionalRule(Base):
@@ -381,6 +280,7 @@ class FormResponse(Base):
381
  form: Mapped[Form] = relationship(back_populates="responses")
382
  answers: Mapped[list["ResponseAnswer"]] = relationship(back_populates="response", cascade="all, delete-orphan")
383
  analytics_events: Mapped[list["FormAnalyticsEvent"]] = relationship(back_populates="submission", cascade="all, delete-orphan")
 
384
 
385
 
386
  class ResponseAnswer(Base):
@@ -400,6 +300,32 @@ class ResponseAnswer(Base):
400
  question: Mapped[FormQuestion] = relationship(back_populates="answers")
401
 
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  class PublicForm(Base):
404
  """Public shareable forms"""
405
  __tablename__ = "public_forms"
@@ -538,4 +464,3 @@ class WebhookConfig(Base):
538
  # Relationships
539
  form: Mapped[Form] = relationship()
540
  user: Mapped[User] = relationship()
541
-
 
83
  created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
84
 
85
  subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="user")
 
86
  credits: Mapped["UserCredits"] = relationship(back_populates="user", uselist=False)
87
  credit_transactions: Mapped[list["CreditTransaction"]] = relationship(back_populates="user")
 
 
 
88
  forms: Mapped[list["Form"]] = relationship(back_populates="user", cascade="all, delete-orphan")
89
  public_forms: Mapped[list["PublicForm"]] = relationship(back_populates="user", cascade="all, delete-orphan")
90
+ form_uploads: Mapped[list["FormUpload"]] = relationship(back_populates="user", cascade="all, delete-orphan")
91
 
92
 
93
  class Subscription(Base):
 
109
  plan: Mapped["SubscriptionPlan"] = relationship(back_populates="subscriptions")
110
 
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  class SubscriptionPlan(Base):
113
  __tablename__ = "subscription_plans"
114
 
 
194
  chat_messages_form: Mapped[list["ChatMessageForm"]] = relationship(back_populates="form", cascade="all, delete-orphan")
195
  versions: Mapped[list["FormVersion"]] = relationship(back_populates="form", cascade="all, delete-orphan")
196
  analytics_events: Mapped[list["FormAnalyticsEvent"]] = relationship(back_populates="form", cascade="all, delete-orphan")
197
+ uploads: Mapped[list["FormUpload"]] = relationship(back_populates="form", cascade="all, delete-orphan")
198
 
199
 
200
  class FormQuestion(Base):
 
220
  target_rules: Mapped[list["ConditionalRule"]] = relationship(foreign_keys="ConditionalRule.target_question_id", back_populates="target_question", cascade="all, delete-orphan")
221
  answers: Mapped[list["ResponseAnswer"]] = relationship(back_populates="question", cascade="all, delete-orphan")
222
  analytics_events: Mapped[list["FormAnalyticsEvent"]] = relationship(back_populates="question", cascade="all, delete-orphan")
223
+ uploads: Mapped[list["FormUpload"]] = relationship(back_populates="question", cascade="all, delete-orphan")
224
 
225
 
226
  class ConditionalRule(Base):
 
280
  form: Mapped[Form] = relationship(back_populates="responses")
281
  answers: Mapped[list["ResponseAnswer"]] = relationship(back_populates="response", cascade="all, delete-orphan")
282
  analytics_events: Mapped[list["FormAnalyticsEvent"]] = relationship(back_populates="submission", cascade="all, delete-orphan")
283
+ uploads: Mapped[list["FormUpload"]] = relationship(back_populates="response", cascade="all, delete-orphan")
284
 
285
 
286
  class ResponseAnswer(Base):
 
300
  question: Mapped[FormQuestion] = relationship(back_populates="answers")
301
 
302
 
303
+ class FormUpload(Base):
304
+ """Uploaded files for form responses"""
305
+ __tablename__ = "form_uploads"
306
+
307
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
308
+ user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
309
+ form_id: Mapped[int] = mapped_column(ForeignKey("forms.id"), index=True)
310
+ form_response_id: Mapped[int | None] = mapped_column(ForeignKey("form_responses.id"), nullable=True, index=True)
311
+ form_question_id: Mapped[int] = mapped_column(ForeignKey("form_questions.id"), index=True)
312
+
313
+ status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
314
+ original_filename: Mapped[str] = mapped_column(String(512))
315
+ content_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
316
+ size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
317
+ s3_key: Mapped[str] = mapped_column(String(1024), unique=True, index=True)
318
+
319
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
320
+ uploaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
321
+ attached_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
322
+
323
+ user: Mapped[User] = relationship(back_populates="form_uploads")
324
+ form: Mapped[Form] = relationship(back_populates="uploads")
325
+ response: Mapped["FormResponse"] = relationship(back_populates="uploads")
326
+ question: Mapped[FormQuestion] = relationship(back_populates="uploads")
327
+
328
+
329
  class PublicForm(Base):
330
  """Public shareable forms"""
331
  __tablename__ = "public_forms"
 
464
  # Relationships
465
  form: Mapped[Form] = relationship()
466
  user: Mapped[User] = relationship()
 
app/routes/auth.py CHANGED
@@ -45,7 +45,7 @@ def get_current_user_info(
45
  ):
46
  """Get current authenticated user's profile information"""
47
  # Get user's subscription info
48
- from ..models import Subscription, Dataset
49
  from datetime import datetime, timezone
50
 
51
  subscription = db.query(Subscription).filter(
@@ -62,13 +62,13 @@ def get_current_user_info(
62
  "created_at": subscription.created_at.isoformat()
63
  }
64
 
65
- # Calculate dashboards created this month
66
  now = datetime.now(timezone.utc)
67
  start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
68
 
69
- dashboards_this_month = db.query(Dataset).filter(
70
- Dataset.user_id == current_user.id,
71
- Dataset.created_at >= start_of_month
72
  ).count()
73
 
74
  return {
@@ -79,7 +79,7 @@ def get_current_user_info(
79
  "provider": current_user.provider,
80
  "is_active": current_user.is_active,
81
  "created_at": current_user.created_at.isoformat(),
82
- "dashboards_this_month": dashboards_this_month,
83
  "subscription": subscription_info or {
84
  "tier": "free",
85
  "status": "inactive",
 
45
  ):
46
  """Get current authenticated user's profile information"""
47
  # Get user's subscription info
48
+ from ..models import Subscription, Form
49
  from datetime import datetime, timezone
50
 
51
  subscription = db.query(Subscription).filter(
 
62
  "created_at": subscription.created_at.isoformat()
63
  }
64
 
65
+ # Calculate forms created this month
66
  now = datetime.now(timezone.utc)
67
  start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
68
 
69
+ forms_this_month = db.query(Form).filter(
70
+ Form.user_id == current_user.id,
71
+ Form.created_at >= start_of_month
72
  ).count()
73
 
74
  return {
 
79
  "provider": current_user.provider,
80
  "is_active": current_user.is_active,
81
  "created_at": current_user.created_at.isoformat(),
82
+ "forms_this_month": forms_this_month,
83
  "subscription": subscription_info or {
84
  "tier": "free",
85
  "status": "inactive",
app/routes/plans.py CHANGED
@@ -15,8 +15,6 @@ router = APIRouter(prefix="/api/plans", tags=["plans"])
15
 
16
  class PlanFeatures(BaseModel):
17
  """Plan features model"""
18
- max_datasets: int | None = None
19
- max_file_size_mb: int | None = None
20
  export_formats: List[str] | None = None
21
  priority_support: bool | None = None
22
  custom_branding: bool | None = None
 
15
 
16
  class PlanFeatures(BaseModel):
17
  """Plan features model"""
 
 
18
  export_formats: List[str] | None = None
19
  priority_support: bool | None = None
20
  custom_branding: bool | None = None
app/routes/responses.py CHANGED
@@ -16,7 +16,7 @@ from datetime import datetime
16
 
17
  from ..core.db import get_db
18
  from ..core.security import get_current_user
19
- from ..models import User, Form, FormQuestion, FormResponse, ResponseAnswer, PublicForm
20
  from ..schemas.form import (
21
  SubmissionCreate, SubmissionResponse,
22
  FormResponseDetail, FormResponsesList, ResponseAnswerDetail,
@@ -26,6 +26,7 @@ from ..schemas.validation import PartialSubmissionCreate, AutoSaveResponse
26
  from ..services.validation_service import validation_service
27
  from ..services.analytics_service import analytics_service
28
  from ..services.webhook_service import webhook_service
 
29
  from ..middleware.rate_limiter import rate_limiter
30
 
31
  import logging
@@ -35,6 +36,116 @@ logger = logging.getLogger(__name__)
35
  router = APIRouter(prefix="/api", tags=["responses"])
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  @router.post("/public/forms/{token}/submit", response_model=SubmissionResponse)
39
  async def submit_form(
40
  token: str,
@@ -78,23 +189,83 @@ async def submit_form(
78
  client_ip = request.client.host if request.client else None
79
 
80
  try:
81
- # Create form response
82
- form_response = FormResponse(
83
- form_id=form.id,
84
- ip_address=client_ip,
85
- submission_metadata=submission.metadata or {}
86
- )
87
- db.add(form_response)
88
- db.flush()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
- # Create answers
91
  for answer_data in submission.answers:
92
- answer = ResponseAnswer(
93
- form_response_id=form_response.id,
94
- form_question_id=answer_data.question_id,
95
- answer_value=answer_data.answer_value.model_dump()
 
 
 
 
96
  )
97
- db.add(answer)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  db.commit()
100
  db.refresh(form_response)
@@ -106,6 +277,9 @@ async def submit_form(
106
  message=public_form.custom_thank_you_message or "Thank you for your submission!"
107
  )
108
 
 
 
 
109
  except Exception as e:
110
  db.rollback()
111
  logger.error(f"Form submission failed: {e}")
@@ -171,15 +345,25 @@ async def autosave_submission(
171
  ResponseAnswer.form_question_id == answer_data.get('question_id')
172
  ).first()
173
 
 
 
 
 
 
 
 
 
 
 
174
  if existing_answer:
175
  # Update existing answer
176
- existing_answer.answer_value = answer_data.get('answer_value', {})
177
  else:
178
  # Create new answer
179
  new_answer = ResponseAnswer(
180
  form_response_id=existing_response.id,
181
  form_question_id=answer_data.get('question_id'),
182
- answer_value=answer_data.get('answer_value', {})
183
  )
184
  db.add(new_answer)
185
 
@@ -205,10 +389,19 @@ async def autosave_submission(
205
 
206
  # Create answers
207
  for answer_data in submission.answers:
 
 
 
 
 
 
 
 
 
208
  answer = ResponseAnswer(
209
  form_response_id=form_response.id,
210
  form_question_id=answer_data.get('question_id'),
211
- answer_value=answer_data.get('answer_value', {})
212
  )
213
  db.add(answer)
214
 
@@ -230,6 +423,9 @@ async def autosave_submission(
230
  message="Auto-saved successfully"
231
  )
232
 
 
 
 
233
  except Exception as e:
234
  db.rollback()
235
  logger.error(f"Auto-save failed: {e}")
@@ -314,6 +510,16 @@ async def get_form_responses(
314
  FormResponse.form_id == form_id
315
  ).order_by(desc(FormResponse.submitted_at)).limit(limit).offset(offset).all()
316
 
 
 
 
 
 
 
 
 
 
 
317
  # Build detailed response list
318
  response_details = []
319
  for response in responses:
@@ -324,16 +530,22 @@ async def get_form_responses(
324
  ).first()
325
 
326
  if question:
 
 
 
 
 
327
  answers.append(ResponseAnswerDetail(
328
  question_id=question.id,
329
  question_text=question.question_text,
330
  question_type=question.question_type,
331
- answer_value=answer.answer_value or {}
332
  ))
333
 
334
  response_details.append(FormResponseDetail(
335
  id=response.id,
336
  form_id=response.form_id,
 
337
  submitted_at=response.submitted_at,
338
  ip_address=response.ip_address,
339
  answers=answers
@@ -378,17 +590,31 @@ async def get_single_response(
378
 
379
  # Build answer details
380
  answers = []
 
 
 
 
 
 
 
 
 
381
  for answer in response.answers:
382
  question = db.query(FormQuestion).filter(
383
  FormQuestion.id == answer.form_question_id
384
  ).first()
385
 
386
  if question:
 
 
 
 
 
387
  answers.append(ResponseAnswerDetail(
388
  question_id=question.id,
389
  question_text=question.question_text,
390
  question_type=question.question_type,
391
- answer_value=answer.answer_value or {}
392
  ))
393
 
394
  return FormResponseDetail(
@@ -403,6 +629,7 @@ async def get_single_response(
403
  @router.get("/forms/{form_id}/responses/export/csv")
404
  async def export_responses_csv(
405
  form_id: int,
 
406
  current_user: User = Depends(get_current_user),
407
  db: Session = Depends(get_db)
408
  ):
@@ -431,15 +658,40 @@ async def export_responses_csv(
431
  FormResponse.form_id == form_id
432
  ).order_by(desc(FormResponse.submitted_at)).all()
433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  # Create CSV in memory
435
  output = io.StringIO()
436
  writer = csv.writer(output)
437
 
438
  # Write headers
439
  headers = ["Response ID", "Submitted At", "IP Address"]
440
- headers.extend([q.question_text for q in questions])
 
 
 
441
  writer.writerow(headers)
442
 
 
 
443
  # Write data rows
444
  for response in responses:
445
  row = [
@@ -457,6 +709,12 @@ async def export_responses_csv(
457
  # Add answers in question order
458
  for question in questions:
459
  answer_value = answer_map.get(question.id, {})
 
 
 
 
 
 
460
 
461
  # Format answer based on type
462
  if isinstance(answer_value, dict):
@@ -471,10 +729,42 @@ async def export_responses_csv(
471
  row.append(answer_value["date"])
472
  elif "rating" in answer_value:
473
  row.append(str(answer_value["rating"]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  else:
475
  row.append(json.dumps(answer_value))
 
 
476
  else:
477
  row.append(str(answer_value))
 
 
478
 
479
  writer.writerow(row)
480
 
@@ -493,6 +783,7 @@ async def export_responses_csv(
493
  @router.get("/forms/{form_id}/responses/export/json")
494
  async def export_responses_json(
495
  form_id: int,
 
496
  current_user: User = Depends(get_current_user),
497
  db: Session = Depends(get_db)
498
  ):
@@ -516,6 +807,18 @@ async def export_responses_json(
516
  FormResponse.form_id == form_id
517
  ).order_by(desc(FormResponse.submitted_at)).all()
518
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  # Build export data
520
  export_data = {
521
  "form_id": form_id,
@@ -539,11 +842,30 @@ async def export_responses_json(
539
  ).first()
540
 
541
  if question:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  response_data["answers"].append({
543
  "question_id": question.id,
544
  "question_text": question.question_text,
545
  "question_type": question.question_type.value,
546
- "answer_value": answer.answer_value
547
  })
548
 
549
  export_data["responses"].append(response_data)
@@ -591,4 +913,3 @@ async def delete_response(
591
  db.commit()
592
 
593
  return None
594
-
 
16
 
17
  from ..core.db import get_db
18
  from ..core.security import get_current_user
19
+ from ..models import User, Form, FormQuestion, FormResponse, ResponseAnswer, PublicForm, FormUpload, QuestionType
20
  from ..schemas.form import (
21
  SubmissionCreate, SubmissionResponse,
22
  FormResponseDetail, FormResponsesList, ResponseAnswerDetail,
 
26
  from ..services.validation_service import validation_service
27
  from ..services.analytics_service import analytics_service
28
  from ..services.webhook_service import webhook_service
29
+ from ..services.s3_service import s3_service
30
  from ..middleware.rate_limiter import rate_limiter
31
 
32
  import logging
 
36
  router = APIRouter(prefix="/api", tags=["responses"])
37
 
38
 
39
+ def _extract_upload_ids(answer_value: dict) -> List[int]:
40
+ if not isinstance(answer_value, dict):
41
+ return []
42
+ files = answer_value.get("files") or []
43
+ upload_ids = []
44
+ for item in files:
45
+ upload_id = None
46
+ if isinstance(item, dict):
47
+ upload_id = item.get("upload_id")
48
+ elif isinstance(item, int):
49
+ upload_id = item
50
+ elif isinstance(item, str) and item.isdigit():
51
+ upload_id = int(item)
52
+ if isinstance(upload_id, int):
53
+ upload_ids.append(upload_id)
54
+ return upload_ids
55
+
56
+
57
+ def _attach_uploads(
58
+ db: Session,
59
+ upload_ids: List[int],
60
+ form_id: int,
61
+ response_id: int,
62
+ question_id: int
63
+ ) -> None:
64
+ if not upload_ids:
65
+ return
66
+ if not question_id:
67
+ return
68
+ try:
69
+ question_id = int(question_id)
70
+ except (TypeError, ValueError):
71
+ return
72
+ uploads = db.query(FormUpload).filter(FormUpload.id.in_(upload_ids)).all()
73
+ upload_map = {upload.id: upload for upload in uploads}
74
+
75
+ for upload_id in upload_ids:
76
+ upload = upload_map.get(upload_id)
77
+ if not upload:
78
+ raise HTTPException(
79
+ status_code=status.HTTP_400_BAD_REQUEST,
80
+ detail="Invalid upload reference"
81
+ )
82
+ if upload.form_id != form_id or upload.form_question_id != question_id:
83
+ raise HTTPException(
84
+ status_code=status.HTTP_400_BAD_REQUEST,
85
+ detail="Upload does not match form or question"
86
+ )
87
+ if upload.status not in ["pending", "uploaded", "attached"]:
88
+ raise HTTPException(
89
+ status_code=status.HTTP_400_BAD_REQUEST,
90
+ detail="Upload is not ready"
91
+ )
92
+
93
+ upload.form_response_id = response_id
94
+ upload.status = "attached"
95
+ upload.attached_at = datetime.utcnow()
96
+
97
+
98
+ def _enrich_answer_files(
99
+ answer_value: dict,
100
+ upload_map: dict,
101
+ include_download_url: bool = True
102
+ ) -> dict:
103
+ if not isinstance(answer_value, dict):
104
+ return answer_value
105
+ files = answer_value.get("files") or []
106
+ if not files:
107
+ return answer_value
108
+
109
+ enriched = []
110
+ for item in files:
111
+ upload_id = None
112
+ if isinstance(item, dict):
113
+ upload_id = item.get("upload_id")
114
+ elif isinstance(item, int):
115
+ upload_id = item
116
+ elif isinstance(item, str) and item.isdigit():
117
+ upload_id = int(item)
118
+
119
+ if not isinstance(upload_id, int):
120
+ continue
121
+
122
+ upload = upload_map.get(upload_id)
123
+ if not upload:
124
+ continue
125
+
126
+ file_entry = {
127
+ "upload_id": upload.id,
128
+ "filename": upload.original_filename,
129
+ "content_type": upload.content_type,
130
+ "size_bytes": upload.size_bytes,
131
+ "s3_key": upload.s3_key
132
+ }
133
+
134
+ if include_download_url:
135
+ try:
136
+ file_entry["download_url"] = s3_service.generate_presigned_download_url(
137
+ key=upload.s3_key,
138
+ filename=upload.original_filename
139
+ )
140
+ except Exception:
141
+ pass
142
+
143
+ enriched.append(file_entry)
144
+
145
+ updated = dict(answer_value)
146
+ updated["files"] = enriched
147
+ return updated
148
+
149
  @router.post("/public/forms/{token}/submit", response_model=SubmissionResponse)
150
  async def submit_form(
151
  token: str,
 
189
  client_ip = request.client.host if request.client else None
190
 
191
  try:
192
+ # Try to reuse existing in-progress response for this session (if provided)
193
+ form_response = None
194
+ if submission.session_id:
195
+ form_response = db.query(FormResponse).filter(
196
+ FormResponse.form_id == form.id,
197
+ FormResponse.session_id == submission.session_id,
198
+ FormResponse.status.in_(["in_progress", "partial"])
199
+ ).first()
200
+
201
+ if form_response:
202
+ form_response.last_updated_at = datetime.utcnow()
203
+ form_response.submitted_at = datetime.utcnow()
204
+ form_response.ip_address = client_ip
205
+ form_response.utm_source = submission.utm_source
206
+ form_response.utm_medium = submission.utm_medium
207
+ form_response.utm_campaign = submission.utm_campaign
208
+ if submission.metadata:
209
+ form_response.submission_metadata = submission.metadata
210
+ else:
211
+ # Create new form response
212
+ form_response = FormResponse(
213
+ form_id=form.id,
214
+ session_id=submission.session_id,
215
+ status="complete",
216
+ form_version=submission.form_version,
217
+ started_at=datetime.utcnow(),
218
+ submitted_at=datetime.utcnow(),
219
+ ip_address=client_ip,
220
+ utm_source=submission.utm_source,
221
+ utm_medium=submission.utm_medium,
222
+ utm_campaign=submission.utm_campaign,
223
+ submission_metadata=submission.metadata or {}
224
+ )
225
+ db.add(form_response)
226
+ db.flush()
227
 
228
+ # Create/update answers
229
  for answer_data in submission.answers:
230
+ answer_value = answer_data.answer_value.model_dump()
231
+ upload_ids = _extract_upload_ids(answer_value)
232
+ _attach_uploads(
233
+ db=db,
234
+ upload_ids=upload_ids,
235
+ form_id=form.id,
236
+ response_id=form_response.id,
237
+ question_id=answer_data.question_id
238
  )
239
+
240
+ existing_answer = db.query(ResponseAnswer).filter(
241
+ ResponseAnswer.form_response_id == form_response.id,
242
+ ResponseAnswer.form_question_id == answer_data.question_id
243
+ ).first()
244
+
245
+ if existing_answer:
246
+ existing_answer.answer_value = answer_value
247
+ else:
248
+ answer = ResponseAnswer(
249
+ form_response_id=form_response.id,
250
+ form_question_id=answer_data.question_id,
251
+ answer_value=answer_value
252
+ )
253
+ db.add(answer)
254
+
255
+ # Validate and set status based on whether all required fields are answered
256
+ validation_result = validation_service.validate_submission(
257
+ db=db,
258
+ form_id=form.id,
259
+ answers=[
260
+ {"question_id": a.question_id, "answer_value": a.answer_value.model_dump()}
261
+ for a in submission.answers
262
+ ],
263
+ is_complete=True,
264
+ form_version=submission.form_version
265
+ )
266
+
267
+ # Set status: "complete" if all required fields answered, "partial" otherwise
268
+ form_response.status = "complete" if validation_result.is_valid else "partial"
269
 
270
  db.commit()
271
  db.refresh(form_response)
 
277
  message=public_form.custom_thank_you_message or "Thank you for your submission!"
278
  )
279
 
280
+ except HTTPException:
281
+ db.rollback()
282
+ raise
283
  except Exception as e:
284
  db.rollback()
285
  logger.error(f"Form submission failed: {e}")
 
345
  ResponseAnswer.form_question_id == answer_data.get('question_id')
346
  ).first()
347
 
348
+ answer_value = answer_data.get('answer_value', {})
349
+ upload_ids = _extract_upload_ids(answer_value)
350
+ _attach_uploads(
351
+ db=db,
352
+ upload_ids=upload_ids,
353
+ form_id=form.id,
354
+ response_id=existing_response.id,
355
+ question_id=answer_data.get('question_id')
356
+ )
357
+
358
  if existing_answer:
359
  # Update existing answer
360
+ existing_answer.answer_value = answer_value
361
  else:
362
  # Create new answer
363
  new_answer = ResponseAnswer(
364
  form_response_id=existing_response.id,
365
  form_question_id=answer_data.get('question_id'),
366
+ answer_value=answer_value
367
  )
368
  db.add(new_answer)
369
 
 
389
 
390
  # Create answers
391
  for answer_data in submission.answers:
392
+ answer_value = answer_data.get('answer_value', {})
393
+ upload_ids = _extract_upload_ids(answer_value)
394
+ _attach_uploads(
395
+ db=db,
396
+ upload_ids=upload_ids,
397
+ form_id=form.id,
398
+ response_id=form_response.id,
399
+ question_id=answer_data.get('question_id')
400
+ )
401
  answer = ResponseAnswer(
402
  form_response_id=form_response.id,
403
  form_question_id=answer_data.get('question_id'),
404
+ answer_value=answer_value
405
  )
406
  db.add(answer)
407
 
 
423
  message="Auto-saved successfully"
424
  )
425
 
426
+ except HTTPException:
427
+ db.rollback()
428
+ raise
429
  except Exception as e:
430
  db.rollback()
431
  logger.error(f"Auto-save failed: {e}")
 
510
  FormResponse.form_id == form_id
511
  ).order_by(desc(FormResponse.submitted_at)).limit(limit).offset(offset).all()
512
 
513
+ upload_ids = set()
514
+ for response in responses:
515
+ for answer in response.answers:
516
+ upload_ids.update(_extract_upload_ids(answer.answer_value or {}))
517
+
518
+ upload_map = {}
519
+ if upload_ids:
520
+ uploads = db.query(FormUpload).filter(FormUpload.id.in_(upload_ids)).all()
521
+ upload_map = {upload.id: upload for upload in uploads}
522
+
523
  # Build detailed response list
524
  response_details = []
525
  for response in responses:
 
530
  ).first()
531
 
532
  if question:
533
+ answer_value = _enrich_answer_files(
534
+ answer.answer_value or {},
535
+ upload_map,
536
+ include_download_url=True
537
+ )
538
  answers.append(ResponseAnswerDetail(
539
  question_id=question.id,
540
  question_text=question.question_text,
541
  question_type=question.question_type,
542
+ answer_value=answer_value
543
  ))
544
 
545
  response_details.append(FormResponseDetail(
546
  id=response.id,
547
  form_id=response.form_id,
548
+ status=response.status,
549
  submitted_at=response.submitted_at,
550
  ip_address=response.ip_address,
551
  answers=answers
 
590
 
591
  # Build answer details
592
  answers = []
593
+ upload_ids = set()
594
+ for answer in response.answers:
595
+ upload_ids.update(_extract_upload_ids(answer.answer_value or {}))
596
+
597
+ upload_map = {}
598
+ if upload_ids:
599
+ uploads = db.query(FormUpload).filter(FormUpload.id.in_(upload_ids)).all()
600
+ upload_map = {upload.id: upload for upload in uploads}
601
+
602
  for answer in response.answers:
603
  question = db.query(FormQuestion).filter(
604
  FormQuestion.id == answer.form_question_id
605
  ).first()
606
 
607
  if question:
608
+ answer_value = _enrich_answer_files(
609
+ answer.answer_value or {},
610
+ upload_map,
611
+ include_download_url=True
612
+ )
613
  answers.append(ResponseAnswerDetail(
614
  question_id=question.id,
615
  question_text=question.question_text,
616
  question_type=question.question_type,
617
+ answer_value=answer_value
618
  ))
619
 
620
  return FormResponseDetail(
 
629
  @router.get("/forms/{form_id}/responses/export/csv")
630
  async def export_responses_csv(
631
  form_id: int,
632
+ request: Request,
633
  current_user: User = Depends(get_current_user),
634
  db: Session = Depends(get_db)
635
  ):
 
658
  FormResponse.form_id == form_id
659
  ).order_by(desc(FormResponse.submitted_at)).all()
660
 
661
+ upload_ids = set()
662
+ for response in responses:
663
+ for answer in response.answers:
664
+ upload_ids.update(_extract_upload_ids(answer.answer_value or {}))
665
+
666
+ upload_map = {}
667
+ if upload_ids:
668
+ uploads = db.query(FormUpload).filter(FormUpload.id.in_(upload_ids)).all()
669
+ upload_map = {upload.id: upload for upload in uploads}
670
+
671
+ upload_ids = set()
672
+ for response in responses:
673
+ for answer in response.answers:
674
+ upload_ids.update(_extract_upload_ids(answer.answer_value or {}))
675
+
676
+ upload_map = {}
677
+ if upload_ids:
678
+ uploads = db.query(FormUpload).filter(FormUpload.id.in_(upload_ids)).all()
679
+ upload_map = {upload.id: upload for upload in uploads}
680
+
681
  # Create CSV in memory
682
  output = io.StringIO()
683
  writer = csv.writer(output)
684
 
685
  # Write headers
686
  headers = ["Response ID", "Submitted At", "IP Address"]
687
+ for question in questions:
688
+ headers.append(question.question_text)
689
+ if question.question_type == QuestionType.FILE_UPLOAD:
690
+ headers.append(f"{question.question_text} (download_url)")
691
  writer.writerow(headers)
692
 
693
+ base_url = str(request.base_url).rstrip("/")
694
+
695
  # Write data rows
696
  for response in responses:
697
  row = [
 
709
  # Add answers in question order
710
  for question in questions:
711
  answer_value = answer_map.get(question.id, {})
712
+ if isinstance(answer_value, dict):
713
+ answer_value = _enrich_answer_files(
714
+ answer_value,
715
+ upload_map,
716
+ include_download_url=True
717
+ )
718
 
719
  # Format answer based on type
720
  if isinstance(answer_value, dict):
 
729
  row.append(answer_value["date"])
730
  elif "rating" in answer_value:
731
  row.append(str(answer_value["rating"]))
732
+ elif "files" in answer_value and answer_value["files"]:
733
+ file_labels = []
734
+ file_links = []
735
+ for file_item in answer_value["files"]:
736
+ if not isinstance(file_item, dict):
737
+ file_labels.append(str(file_item))
738
+ file_links.append(str(file_item))
739
+ continue
740
+ file_labels.append(
741
+ file_item.get("download_url")
742
+ or file_item.get("filename")
743
+ or file_item.get("original_filename")
744
+ or file_item.get("s3_key")
745
+ or str(file_item.get("upload_id", ""))
746
+ )
747
+ if file_item.get("download_url"):
748
+ file_links.append(file_item["download_url"])
749
+ elif file_item.get("upload_id"):
750
+ file_links.append(
751
+ f"{base_url}/api/forms/{form_id}/uploads/{file_item['upload_id']}/download"
752
+ )
753
+ row.append(", ".join([label for label in file_labels if label]))
754
+ if question.question_type == QuestionType.FILE_UPLOAD:
755
+ row.append(", ".join([link for link in file_links if link]))
756
+ elif "file_url" in answer_value:
757
+ row.append(answer_value["file_url"])
758
+ if question.question_type == QuestionType.FILE_UPLOAD:
759
+ row.append(answer_value["file_url"])
760
  else:
761
  row.append(json.dumps(answer_value))
762
+ if question.question_type == QuestionType.FILE_UPLOAD:
763
+ row.append("")
764
  else:
765
  row.append(str(answer_value))
766
+ if question.question_type == QuestionType.FILE_UPLOAD:
767
+ row.append("")
768
 
769
  writer.writerow(row)
770
 
 
783
  @router.get("/forms/{form_id}/responses/export/json")
784
  async def export_responses_json(
785
  form_id: int,
786
+ request: Request,
787
  current_user: User = Depends(get_current_user),
788
  db: Session = Depends(get_db)
789
  ):
 
807
  FormResponse.form_id == form_id
808
  ).order_by(desc(FormResponse.submitted_at)).all()
809
 
810
+ upload_ids = set()
811
+ for response in responses:
812
+ for answer in response.answers:
813
+ upload_ids.update(_extract_upload_ids(answer.answer_value or {}))
814
+
815
+ upload_map = {}
816
+ if upload_ids:
817
+ uploads = db.query(FormUpload).filter(FormUpload.id.in_(upload_ids)).all()
818
+ upload_map = {upload.id: upload for upload in uploads}
819
+
820
+ base_url = str(request.base_url).rstrip("/")
821
+
822
  # Build export data
823
  export_data = {
824
  "form_id": form_id,
 
842
  ).first()
843
 
844
  if question:
845
+ answer_value = _enrich_answer_files(
846
+ answer.answer_value or {},
847
+ upload_map,
848
+ include_download_url=True
849
+ )
850
+ if isinstance(answer_value, dict) and "files" in answer_value:
851
+ updated_files = []
852
+ for file_item in answer_value.get("files") or []:
853
+ if not isinstance(file_item, dict):
854
+ updated_files.append(file_item)
855
+ continue
856
+ if not file_item.get("download_url") and file_item.get("upload_id"):
857
+ file_item = dict(file_item)
858
+ file_item["download_url"] = (
859
+ f"{base_url}/api/forms/{form_id}/uploads/{file_item['upload_id']}/download"
860
+ )
861
+ updated_files.append(file_item)
862
+ answer_value = dict(answer_value)
863
+ answer_value["files"] = updated_files
864
  response_data["answers"].append({
865
  "question_id": question.id,
866
  "question_text": question.question_text,
867
  "question_type": question.question_type.value,
868
+ "answer_value": answer_value
869
  })
870
 
871
  export_data["responses"].append(response_data)
 
913
  db.commit()
914
 
915
  return None
 
app/routes/uploads.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Uploads API Routes
3
+ ==================
4
+
5
+ Presigned S3 uploads for public form submissions.
6
+ """
7
+
8
+ import os
9
+ from datetime import datetime
10
+ from fastapi import APIRouter, Depends, HTTPException, status
11
+ from sqlalchemy.orm import Session
12
+
13
+ from ..core.db import get_db
14
+ from ..core.security import get_current_user
15
+ from ..models import Form, FormQuestion, FormUpload, PublicForm, QuestionType, User
16
+ from ..schemas.uploads import (
17
+ UploadCreateRequest,
18
+ UploadCreateResponse,
19
+ UploadCompleteRequest,
20
+ UploadCompleteResponse
21
+ )
22
+ from ..services.s3_service import s3_service
23
+
24
+ router = APIRouter(prefix="/api", tags=["uploads"])
25
+
26
+
27
+ @router.post("/public/forms/{token}/uploads", response_model=UploadCreateResponse)
28
+ async def create_public_upload(
29
+ token: str,
30
+ payload: UploadCreateRequest,
31
+ db: Session = Depends(get_db)
32
+ ):
33
+ """Create a presigned upload URL for a public form submission."""
34
+ public_form = db.query(PublicForm).filter(
35
+ PublicForm.share_token == token,
36
+ PublicForm.is_public == True
37
+ ).first()
38
+
39
+ if not public_form:
40
+ raise HTTPException(
41
+ status_code=status.HTTP_404_NOT_FOUND,
42
+ detail="Form not found or no longer accepting responses"
43
+ )
44
+
45
+ if public_form.expires_at and public_form.expires_at < datetime.utcnow():
46
+ raise HTTPException(
47
+ status_code=status.HTTP_410_GONE,
48
+ detail="This form has expired"
49
+ )
50
+
51
+ form = db.query(Form).filter(Form.id == public_form.form_id).first()
52
+ if not form:
53
+ raise HTTPException(
54
+ status_code=status.HTTP_404_NOT_FOUND,
55
+ detail="Form not found"
56
+ )
57
+
58
+ question = db.query(FormQuestion).filter(
59
+ FormQuestion.id == payload.question_id,
60
+ FormQuestion.form_id == form.id
61
+ ).first()
62
+
63
+ if not question:
64
+ raise HTTPException(
65
+ status_code=status.HTTP_404_NOT_FOUND,
66
+ detail="Question not found"
67
+ )
68
+
69
+ if question.question_type != QuestionType.FILE_UPLOAD:
70
+ raise HTTPException(
71
+ status_code=status.HTTP_400_BAD_REQUEST,
72
+ detail="Question does not accept file uploads"
73
+ )
74
+
75
+ settings = question.settings or {}
76
+ max_size = settings.get("max_file_size")
77
+ if max_size and payload.size_bytes and payload.size_bytes > max_size:
78
+ raise HTTPException(
79
+ status_code=status.HTTP_400_BAD_REQUEST,
80
+ detail="File exceeds maximum allowed size"
81
+ )
82
+
83
+ allowed_types = settings.get("file_types") or []
84
+ if allowed_types:
85
+ ext = os.path.splitext(payload.filename)[1].lower()
86
+ allowed = {t.lower() for t in allowed_types}
87
+ if ext and ext not in allowed:
88
+ raise HTTPException(
89
+ status_code=status.HTTP_400_BAD_REQUEST,
90
+ detail="File type not allowed"
91
+ )
92
+
93
+ upload = FormUpload(
94
+ user_id=public_form.user_id,
95
+ form_id=form.id,
96
+ form_question_id=question.id,
97
+ original_filename=payload.filename,
98
+ content_type=payload.content_type,
99
+ size_bytes=payload.size_bytes,
100
+ status="pending",
101
+ s3_key=""
102
+ )
103
+ db.add(upload)
104
+ db.flush()
105
+
106
+ s3_key = s3_service.build_key(form.id, form.title, upload.id, payload.filename)
107
+ upload.s3_key = s3_key
108
+ db.commit()
109
+
110
+ try:
111
+ upload_url = s3_service.generate_presigned_upload_url(
112
+ key=s3_key,
113
+ content_type=payload.content_type
114
+ )
115
+ except Exception:
116
+ raise HTTPException(
117
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
118
+ detail="S3 upload is not configured"
119
+ )
120
+
121
+ return UploadCreateResponse(
122
+ upload_id=upload.id,
123
+ upload_url=upload_url,
124
+ s3_key=s3_key,
125
+ expires_in=900,
126
+ original_filename=payload.filename
127
+ )
128
+
129
+
130
+ @router.post("/public/forms/{token}/uploads/{upload_id}/complete", response_model=UploadCompleteResponse)
131
+ async def complete_public_upload(
132
+ token: str,
133
+ upload_id: int,
134
+ payload: UploadCompleteRequest,
135
+ db: Session = Depends(get_db)
136
+ ):
137
+ """Mark a public upload as completed."""
138
+ public_form = db.query(PublicForm).filter(
139
+ PublicForm.share_token == token,
140
+ PublicForm.is_public == True
141
+ ).first()
142
+
143
+ if not public_form:
144
+ raise HTTPException(
145
+ status_code=status.HTTP_404_NOT_FOUND,
146
+ detail="Form not found"
147
+ )
148
+
149
+ upload = db.query(FormUpload).filter(
150
+ FormUpload.id == upload_id,
151
+ FormUpload.form_id == public_form.form_id
152
+ ).first()
153
+
154
+ if not upload:
155
+ raise HTTPException(
156
+ status_code=status.HTTP_404_NOT_FOUND,
157
+ detail="Upload not found"
158
+ )
159
+
160
+ if payload.content_type:
161
+ upload.content_type = payload.content_type
162
+ if payload.size_bytes is not None:
163
+ upload.size_bytes = payload.size_bytes
164
+
165
+ upload.status = "uploaded"
166
+ upload.uploaded_at = datetime.utcnow()
167
+ db.commit()
168
+
169
+ return UploadCompleteResponse(success=True)
170
+
171
+
172
+ @router.get("/forms/{form_id}/uploads/{upload_id}/download")
173
+ async def download_upload(
174
+ form_id: int,
175
+ upload_id: int,
176
+ current_user: User = Depends(get_current_user),
177
+ db: Session = Depends(get_db)
178
+ ):
179
+ """Generate a presigned download URL for a file upload (owner only)."""
180
+ form = db.query(Form).filter(
181
+ Form.id == form_id,
182
+ Form.user_id == current_user.id
183
+ ).first()
184
+
185
+ if not form:
186
+ raise HTTPException(
187
+ status_code=status.HTTP_404_NOT_FOUND,
188
+ detail="Form not found"
189
+ )
190
+
191
+ upload = db.query(FormUpload).filter(
192
+ FormUpload.id == upload_id,
193
+ FormUpload.form_id == form_id
194
+ ).first()
195
+
196
+ if not upload:
197
+ raise HTTPException(
198
+ status_code=status.HTTP_404_NOT_FOUND,
199
+ detail="Upload not found"
200
+ )
201
+
202
+ try:
203
+ download_url = s3_service.generate_presigned_download_url(
204
+ key=upload.s3_key,
205
+ filename=upload.original_filename
206
+ )
207
+ except Exception:
208
+ raise HTTPException(
209
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
210
+ detail="S3 download is not configured"
211
+ )
212
+
213
+ return {"download_url": download_url}
app/schemas/chat.py DELETED
@@ -1,34 +0,0 @@
1
- from pydantic import BaseModel
2
- from typing import Optional, Dict, Any, Literal
3
-
4
-
5
- class ChatRequest(BaseModel):
6
- message: str
7
- dataset_id: Optional[str] = None
8
- fig_data: Optional[Dict[str, Any]] = None
9
- plotly_code: Optional[str] = None
10
-
11
-
12
- class ChatResponse(BaseModel):
13
- reply: str
14
- user: str | None = None
15
- matched_chart: Optional[Dict[str, Any]] = None
16
- code_type: Optional[Literal['plotly_edit', 'analysis', 'add_chart_query']] = None
17
- executable_code: Optional[str] = None
18
- query_type: Optional[str] = None # The detected query type (for showing action buttons on need_clarity)
19
-
20
-
21
- class FixVisualizationRequest(BaseModel):
22
- plotly_code: str
23
- error_message: str
24
- dataset_id: Optional[str] = None
25
-
26
-
27
- class RetryCodeRequest(BaseModel):
28
- user_query: str
29
- dataset_id: str
30
- code_type: str # 'plotly_edit' or 'analysis'
31
- plotly_code: Optional[str] = None # Only for plotly_edit
32
- data_context: str
33
-
34
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/schemas/form.py CHANGED
@@ -153,6 +153,7 @@ class AnswerValue(BaseModel):
153
  date: Optional[str] = None # For date/time
154
  choices: Optional[List[str]] = None # For multiple choice, checkboxes
155
  file_url: Optional[str] = None # For file uploads
 
156
  rating: Optional[int] = None # For ratings
157
  signature: Optional[str] = None # Base64 signature image
158
  wallet_address: Optional[str] = None # For wallet connect
@@ -172,6 +173,11 @@ class SubmissionCreate(BaseModel):
172
  """Schema for submitting a form response"""
173
  answers: List[SubmitAnswer]
174
  metadata: Optional[Dict[str, Any]] = None
 
 
 
 
 
175
 
176
 
177
  class SubmissionResponse(BaseModel):
@@ -200,6 +206,7 @@ class FormResponseDetail(BaseModel):
200
  """Detailed view of a single form response"""
201
  id: int
202
  form_id: int
 
203
  submitted_at: datetime
204
  ip_address: Optional[str]
205
  answers: List[ResponseAnswerDetail]
 
153
  date: Optional[str] = None # For date/time
154
  choices: Optional[List[str]] = None # For multiple choice, checkboxes
155
  file_url: Optional[str] = None # For file uploads
156
+ files: Optional[List[Dict[str, Any]]] = None # For file uploads
157
  rating: Optional[int] = None # For ratings
158
  signature: Optional[str] = None # Base64 signature image
159
  wallet_address: Optional[str] = None # For wallet connect
 
173
  """Schema for submitting a form response"""
174
  answers: List[SubmitAnswer]
175
  metadata: Optional[Dict[str, Any]] = None
176
+ session_id: Optional[str] = None
177
+ form_version: Optional[int] = None
178
+ utm_source: Optional[str] = None
179
+ utm_medium: Optional[str] = None
180
+ utm_campaign: Optional[str] = None
181
 
182
 
183
  class SubmissionResponse(BaseModel):
 
206
  """Detailed view of a single form response"""
207
  id: int
208
  form_id: int
209
+ status: Optional[str] = None
210
  submitted_at: datetime
211
  ip_address: Optional[str]
212
  answers: List[ResponseAnswerDetail]
app/schemas/uploads.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional
3
+
4
+
5
+ class UploadCreateRequest(BaseModel):
6
+ question_id: int = Field(..., ge=1)
7
+ filename: str = Field(..., min_length=1, max_length=512)
8
+ content_type: Optional[str] = None
9
+ size_bytes: Optional[int] = Field(default=None, ge=0)
10
+
11
+
12
+ class UploadCreateResponse(BaseModel):
13
+ upload_id: int
14
+ upload_url: str
15
+ s3_key: str
16
+ expires_in: int
17
+ original_filename: str
18
+
19
+
20
+ class UploadCompleteRequest(BaseModel):
21
+ content_type: Optional[str] = None
22
+ size_bytes: Optional[int] = Field(default=None, ge=0)
23
+
24
+
25
+ class UploadCompleteResponse(BaseModel):
26
+ success: bool
app/services/agents.py CHANGED
@@ -106,7 +106,7 @@ class FormPlannerSignature(dspy.Signature):
106
  6. Choose appropriate component types based on the data being collected
107
  7. Mark essential components as required
108
  8. Order components logically (general to specific)
109
- 9. Include 3-5 components typically
110
  10. Add conditional logic where it makes sense (e.g., show follow-up based on previous answer)
111
  11. Generate component_ids like "comp_1", "comp_2", etc.
112
  12. **PREFER STRUCTURED INPUTS** over text when possible (use checkboxes, dropdowns, ratings instead of open text)
 
106
  6. Choose appropriate component types based on the data being collected
107
  7. Mark essential components as required
108
  8. Order components logically (general to specific)
109
+ 9. **INCLUDE AT LEAST 5 COMPONENTS** - Forms should have a minimum of 5 questions/components. You can reuse the same component type multiple times if appropriate (e.g., multiple short_answer fields for different information)
110
  10. Add conditional logic where it makes sense (e.g., show follow-up based on previous answer)
111
  11. Generate component_ids like "comp_1", "comp_2", etc.
112
  12. **PREFER STRUCTURED INPUTS** over text when possible (use checkboxes, dropdowns, ratings instead of open text)
app/services/export_service.py CHANGED
@@ -445,6 +445,21 @@ class ExportService:
445
  if "ranked_items" in answer_value and answer_value["ranked_items"]:
446
  return ", ".join(answer_value["ranked_items"])
447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  # File upload
449
  if "file_url" in answer_value and answer_value["file_url"]:
450
  return answer_value["file_url"]
 
445
  if "ranked_items" in answer_value and answer_value["ranked_items"]:
446
  return ", ".join(answer_value["ranked_items"])
447
 
448
+ # File uploads
449
+ if "files" in answer_value and answer_value["files"]:
450
+ file_labels = []
451
+ for file_item in answer_value["files"]:
452
+ if not isinstance(file_item, dict):
453
+ file_labels.append(str(file_item))
454
+ continue
455
+ file_labels.append(
456
+ file_item.get("filename")
457
+ or file_item.get("original_filename")
458
+ or file_item.get("s3_key")
459
+ or str(file_item.get("upload_id", ""))
460
+ )
461
+ return ", ".join([label for label in file_labels if label])
462
+
463
  # File upload
464
  if "file_url" in answer_value and answer_value["file_url"]:
465
  return answer_value["file_url"]
app/services/plan_service.py CHANGED
@@ -215,9 +215,7 @@ class PlanService:
215
  "stripe_price_id_yearly": os.getenv("STRIPE_FREE_PRICE_YEARLY_ID"),
216
  "stripe_product_id": os.getenv("STRIPE_FREE_PRODUCT_ID"),
217
  "features": {
218
- "max_datasets": 3,
219
- "max_file_size_mb": 10,
220
- "export_formats": ["png", "csv"]
221
  },
222
  "sort_order": 0
223
  },
@@ -233,9 +231,7 @@ class PlanService:
233
  "stripe_price_id_yearly": os.getenv("STRIPE_PRO_PRICE_YEARLY_ID"),
234
  "stripe_product_id": os.getenv("STRIPE_PRO_PRODUCT_ID"),
235
  "features": {
236
- "max_datasets": 50,
237
- "max_file_size_mb": 100,
238
- "export_formats": ["png", "csv", "pdf", "xlsx"],
239
  "priority_support": True
240
  },
241
  "sort_order": 1
@@ -252,9 +248,7 @@ class PlanService:
252
  "stripe_price_id_yearly": os.getenv("STRIPE_ULTRA_PRICE_YEARLY_ID"),
253
  "stripe_product_id": os.getenv("STRIPE_ULTRA_PRODUCT_ID"),
254
  "features": {
255
- "max_datasets": -1, # Unlimited
256
- "max_file_size_mb": 500,
257
- "export_formats": ["png", "csv", "pdf", "xlsx", "json"],
258
  "priority_support": True,
259
  "custom_branding": True,
260
  "api_access": True
 
215
  "stripe_price_id_yearly": os.getenv("STRIPE_FREE_PRICE_YEARLY_ID"),
216
  "stripe_product_id": os.getenv("STRIPE_FREE_PRODUCT_ID"),
217
  "features": {
218
+ "export_formats": ["csv", "json"]
 
 
219
  },
220
  "sort_order": 0
221
  },
 
231
  "stripe_price_id_yearly": os.getenv("STRIPE_PRO_PRICE_YEARLY_ID"),
232
  "stripe_product_id": os.getenv("STRIPE_PRO_PRODUCT_ID"),
233
  "features": {
234
+ "export_formats": ["csv", "json", "pdf"],
 
 
235
  "priority_support": True
236
  },
237
  "sort_order": 1
 
248
  "stripe_price_id_yearly": os.getenv("STRIPE_ULTRA_PRICE_YEARLY_ID"),
249
  "stripe_product_id": os.getenv("STRIPE_ULTRA_PRODUCT_ID"),
250
  "features": {
251
+ "export_formats": ["csv", "json", "pdf"],
 
 
252
  "priority_support": True,
253
  "custom_branding": True,
254
  "api_access": True
app/services/s3_service.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from typing import Optional
4
+ import boto3
5
+
6
+
7
+ class S3Service:
8
+ """S3 utility for presigned uploads/downloads and key generation."""
9
+
10
+ def __init__(self):
11
+ self.bucket = os.getenv("S3_BUCKET_NAME")
12
+
13
+ region = os.getenv("AWS_REGION")
14
+ access_key = os.getenv("AWS_ACCESS_KEY_ID")
15
+ secret_key = os.getenv("AWS_SECRET_ACCESS_KEY")
16
+
17
+ self.client = boto3.client(
18
+ "s3",
19
+ region_name=region,
20
+ aws_access_key_id=access_key,
21
+ aws_secret_access_key=secret_key
22
+ )
23
+
24
+ app_env = os.getenv("APP_ENV", "dev")
25
+ self.prefix = os.getenv("S3_PREFIX", f"autoform/{app_env}").strip("/")
26
+
27
+ def build_key(self, form_id: int, form_title: str, upload_id: int, filename: str) -> str:
28
+ form_slug = self._slugify(form_title)
29
+ filename_safe = self._sanitize_filename(filename)
30
+
31
+ folder = f"f{form_id}"
32
+ if form_slug:
33
+ folder = f"{folder}-{form_slug}"
34
+
35
+ return f"{self.prefix}/{folder}/{upload_id}__{filename_safe}"
36
+
37
+ def generate_presigned_upload_url(
38
+ self,
39
+ key: str,
40
+ content_type: Optional[str],
41
+ expires_in: int = 900
42
+ ) -> str:
43
+ if not self.bucket:
44
+ raise ValueError("S3_BUCKET_NAME is not set")
45
+ params = {
46
+ "Bucket": self.bucket,
47
+ "Key": key
48
+ }
49
+
50
+ if content_type:
51
+ params["ContentType"] = content_type
52
+
53
+ return self.client.generate_presigned_url(
54
+ "put_object",
55
+ Params=params,
56
+ ExpiresIn=expires_in
57
+ )
58
+
59
+ def generate_presigned_download_url(
60
+ self,
61
+ key: str,
62
+ filename: Optional[str],
63
+ expires_in: int = 900
64
+ ) -> str:
65
+ if not self.bucket:
66
+ raise ValueError("S3_BUCKET_NAME is not set")
67
+ params = {
68
+ "Bucket": self.bucket,
69
+ "Key": key
70
+ }
71
+
72
+ if filename:
73
+ safe_name = self._sanitize_filename(filename)
74
+ params["ResponseContentDisposition"] = f'attachment; filename="{safe_name}"'
75
+
76
+ return self.client.generate_presigned_url(
77
+ "get_object",
78
+ Params=params,
79
+ ExpiresIn=expires_in
80
+ )
81
+
82
+ def _slugify(self, value: str) -> str:
83
+ if not value:
84
+ return ""
85
+
86
+ value = value.lower()
87
+ value = re.sub(r"[^a-z0-9]+", "-", value)
88
+ value = value.strip("-")
89
+ return value[:60]
90
+
91
+ def _sanitize_filename(self, filename: str) -> str:
92
+ base = os.path.basename(filename or "")
93
+ base = base.replace(" ", "_")
94
+ base = re.sub(r"[^A-Za-z0-9._-]", "", base)
95
+ return base[:120] or "file"
96
+
97
+
98
+ s3_service = S3Service()
app/services/validation_service.py CHANGED
@@ -77,13 +77,19 @@ class ValidationService:
77
 
78
  # Create answer map for quick lookup
79
  answer_map = {ans.get('question_id'): ans.get('answer_value', {}) for ans in answers}
 
80
 
81
  # Validate each question
82
  for question in form.questions:
83
  answer_value = answer_map.get(question.id)
84
 
85
  # Check required fields (only if is_complete)
86
- if is_complete and question.required and not self._has_value(answer_value):
 
 
 
 
 
87
  errors.append(FieldValidationError(
88
  question_id=question.id,
89
  field="value",
@@ -106,6 +112,74 @@ class ValidationService:
106
  errors=errors,
107
  warnings=warnings
108
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
  def validate_field(
111
  self,
@@ -318,13 +392,16 @@ class ValidationService:
318
  return False
319
 
320
  # Check all possible value fields
321
- text = answer_value.get('text', '').strip()
 
322
  number = answer_value.get('number')
323
  date = answer_value.get('date')
324
- choices = answer_value.get('choices', [])
325
  rating = answer_value.get('rating')
 
 
326
 
327
- return bool(text or number is not None or date or choices or rating is not None)
328
 
329
  def generate_validation_token(
330
  self,
 
77
 
78
  # Create answer map for quick lookup
79
  answer_map = {ans.get('question_id'): ans.get('answer_value', {}) for ans in answers}
80
+ visible_question_ids = self._get_visible_question_ids(form, answer_map)
81
 
82
  # Validate each question
83
  for question in form.questions:
84
  answer_value = answer_map.get(question.id)
85
 
86
  # Check required fields (only if is_complete)
87
+ if (
88
+ is_complete
89
+ and question.required
90
+ and question.id in visible_question_ids
91
+ and not self._has_value(answer_value)
92
+ ):
93
  errors.append(FieldValidationError(
94
  question_id=question.id,
95
  field="value",
 
112
  errors=errors,
113
  warnings=warnings
114
  )
115
+
116
+ def _get_visible_question_ids(
117
+ self,
118
+ form: Form,
119
+ answer_map: Dict[int, Dict[str, Any]]
120
+ ) -> set[int]:
121
+ """Determine which questions are visible based on conditional rules."""
122
+ visible = {q.id for q in form.questions}
123
+ if not getattr(form, "conditional_rules", None):
124
+ return visible
125
+
126
+ for rule in form.conditional_rules:
127
+ trigger_answer = answer_map.get(rule.trigger_question_id, {})
128
+ condition_met = self._is_condition_met(trigger_answer, rule.condition_type, rule.condition_value)
129
+
130
+ if condition_met:
131
+ if rule.action == "show":
132
+ visible.add(rule.target_question_id)
133
+ elif rule.action == "hide":
134
+ visible.discard(rule.target_question_id)
135
+ else:
136
+ if rule.action == "show":
137
+ visible.discard(rule.target_question_id)
138
+
139
+ return visible
140
+
141
+ def _is_condition_met(
142
+ self,
143
+ answer_value: Dict[str, Any],
144
+ condition_type: str,
145
+ condition_value: Optional[str]
146
+ ) -> bool:
147
+ """Evaluate conditional logic against an answer value."""
148
+ if not isinstance(answer_value, dict):
149
+ return False
150
+
151
+ text = answer_value.get("text")
152
+ number = answer_value.get("number")
153
+ choices = answer_value.get("choices") or []
154
+
155
+ if condition_type == "equals":
156
+ if isinstance(text, str) and text == condition_value:
157
+ return True
158
+ if number is not None and str(number) == condition_value:
159
+ return True
160
+ if choices and condition_value in choices:
161
+ return True
162
+ return False
163
+
164
+ if condition_type == "not_equals":
165
+ if isinstance(text, str):
166
+ return text != condition_value
167
+ if number is not None:
168
+ return str(number) != condition_value
169
+ if choices:
170
+ return condition_value not in choices
171
+ return True
172
+
173
+ if condition_type == "contains":
174
+ return isinstance(text, str) and condition_value is not None and condition_value in text
175
+
176
+ if condition_type == "is_not_empty":
177
+ return self._has_value(answer_value)
178
+
179
+ if condition_type == "is_empty":
180
+ return not self._has_value(answer_value)
181
+
182
+ return False
183
 
184
  def validate_field(
185
  self,
 
392
  return False
393
 
394
  # Check all possible value fields
395
+ raw_text = answer_value.get('text')
396
+ text = raw_text.strip() if isinstance(raw_text, str) else ""
397
  number = answer_value.get('number')
398
  date = answer_value.get('date')
399
+ choices = answer_value.get('choices') or []
400
  rating = answer_value.get('rating')
401
+ files = answer_value.get('files') or []
402
+ file_url = answer_value.get('file_url')
403
 
404
+ return bool(text or number is not None or date or choices or rating is not None or files or file_url)
405
 
406
  def generate_validation_token(
407
  self,
app/system.ipynb DELETED
@@ -1,950 +0,0 @@
1
- {
2
- "cells": [
3
- {
4
- "cell_type": "code",
5
- "execution_count": 1,
6
- "id": "c74bd6dd",
7
- "metadata": {},
8
- "outputs": [
9
- {
10
- "name": "stderr",
11
- "output_type": "stream",
12
- "text": [
13
- "c:\\Users\\arsla\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\tqdm\\auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n",
14
- " from .autonotebook import tqdm as notebook_tqdm\n"
15
- ]
16
- }
17
- ],
18
- "source": [
19
- "import dspy\n",
20
- "import pandas as pd"
21
- ]
22
- },
23
- {
24
- "cell_type": "code",
25
- "execution_count": null,
26
- "id": "40c180c8",
27
- "metadata": {},
28
- "outputs": [],
29
- "source": [
30
- "\n",
31
- "\n"
32
- ]
33
- },
34
- {
35
- "cell_type": "code",
36
- "execution_count": 5,
37
- "id": "360310bc",
38
- "metadata": {},
39
- "outputs": [],
40
- "source": [
41
- "# styling_instructions\n",
42
- "\n",
43
- "dspy.configure(lm=dspy.LM(\"openai/gpt-4o-mini\", max_tokens=4500 ))"
44
- ]
45
- },
46
- {
47
- "cell_type": "code",
48
- "execution_count": null,
49
- "id": "e9920b70",
50
- "metadata": {},
51
- "outputs": [],
52
- "source": [
53
- "\n",
54
- "class plan_to_d3(dspy.Signature):\n",
55
- " \"\"\" \"\"\"\n",
56
- " plan = dspy.InputField(desc=\"Planner instructions around what they want to visualize\")\n",
57
- " dataset_context = dspy.InputField(desc=\"Information about the dataset, columns, stats\")\n",
58
- " styling_instructions = dspy.InputField(desc=\"Styling instructions for the charts / dashboard\")\n",
59
- " d3_js_code = dspy.OutputField(desc=\"d3.js code that the js frontend can run to visualize\")\n",
60
- "\n",
61
- "\n",
62
- "# query_enhancer = dspy.Predict(\"original_query, dataset_context ->chart_categories:List[Literal['line_charts','bar_charts','histograms','pie_charts','tabular_and_generic_charts','no_chart']]\")\n",
63
- "\n",
64
- "plan_to_d3_sign = dspy.Predict(plan_to_d3)\n",
65
- "\n",
66
- "styling_instructions = [\n",
67
- " {\n",
68
- " \"category\": \"line_charts\",\n",
69
- " \"description\": \"Used to visualize trends and changes over time, often with multiple series.\",\n",
70
- " \"styling\": {\n",
71
- " \"theme\": \"light\", # replaces 'template'\n",
72
- " \"axes\": {\n",
73
- " \"stroke_width\": 0.2,\n",
74
- " \"grid_stroke_width\": 1,\n",
75
- " \"grid_color\": \"#e0e0e0\"\n",
76
- " },\n",
77
- " \"title\": {\n",
78
- " \"bold_html\": True,\n",
79
- " \"include\": True\n",
80
- " },\n",
81
- " \"colors\": \"use color scale (d3.schemeCategory10 or interpolated if >10 lines)\",\n",
82
- " \"annotations\": [\"min\", \"max\"],\n",
83
- " \"number_format\": {\n",
84
- " \"apply_k_m\": True,\n",
85
- " \"thresholds\": {\"K\": 1000, \"M\": 100000},\n",
86
- " \"percentage_decimals\": 2,\n",
87
- " \"percentage_sign\": True\n",
88
- " },\n",
89
- " \"svg_defaults\": {\"height\": 600, \"width\": 800, \"margin\": {\"top\": 50, \"right\": 50, \"bottom\": 50, \"left\": 60}}\n",
90
- " }\n",
91
- " },\n",
92
- " {\n",
93
- " \"category\": \"bar_charts\",\n",
94
- " \"description\": \"Useful for comparing discrete categories or groups with bars representing values.\",\n",
95
- " \"styling\": {\n",
96
- " \"theme\": \"light\",\n",
97
- " \"axes\": {\n",
98
- " \"stroke_width\": 0.2,\n",
99
- " \"grid_stroke_width\": 1\n",
100
- " },\n",
101
- " \"title\": {\"bold_html\": True, \"include\": True},\n",
102
- " \"annotations\": [\"bar values\"],\n",
103
- " \"bar_style\": {\"padding\": 0.1, \"corner_radius\": 3},\n",
104
- " \"number_format\": {\n",
105
- " \"apply_k_m\": True,\n",
106
- " \"thresholds\": {\"K\": 1000, \"M\": 100000},\n",
107
- " \"percentage_decimals\": 2,\n",
108
- " \"percentage_sign\": True\n",
109
- " },\n",
110
- " \"svg_defaults\": {\"height\": 600, \"width\": 800}\n",
111
- " }\n",
112
- " },\n",
113
- " {\n",
114
- " \"category\": \"histograms\",\n",
115
- " \"description\": \"Display the distribution of a data set, useful for returns or frequency distributions.\",\n",
116
- " \"styling\": {\n",
117
- " \"theme\": \"light\",\n",
118
- " \"bin_count\": 50,\n",
119
- " \"axes\": {\"stroke_width\": 0.2, \"grid_stroke_width\": 1},\n",
120
- " \"title\": {\"bold_html\": True, \"include\": True},\n",
121
- " \"annotations\": [\"x values\"],\n",
122
- " \"bar_style\": {\"opacity\": 0.8, \"corner_radius\": 2},\n",
123
- " \"number_format\": {\n",
124
- " \"apply_k_m\": True,\n",
125
- " \"thresholds\": {\"K\": 1000, \"M\": 100000},\n",
126
- " \"percentage_decimals\": 2,\n",
127
- " \"percentage_sign\": True\n",
128
- " },\n",
129
- " \"svg_defaults\": {\"height\": 600, \"width\": 800}\n",
130
- " }\n",
131
- " },\n",
132
- " {\n",
133
- " \"category\": \"pie_charts\",\n",
134
- " \"description\": \"Show composition or parts of a whole with slices representing categories.\",\n",
135
- " \"styling\": {\n",
136
- " \"theme\": \"light\",\n",
137
- " \"top_categories_to_show\": 10,\n",
138
- " \"bundle_rest_as\": \"Others\",\n",
139
- " \"title\": {\"bold_html\": True, \"include\": True},\n",
140
- " \"pie_style\": {\n",
141
- " \"inner_radius_ratio\": 0.0, # 0 for pie, >0 for donut\n",
142
- " \"pad_angle\": 0.02\n",
143
- " },\n",
144
- " \"annotations\": [\"percentage labels\"],\n",
145
- " \"color_scheme\": \"d3.schemeCategory10\",\n",
146
- " \"number_format\": {\n",
147
- " \"apply_k_m\": True,\n",
148
- " \"thresholds\": {\"K\": 1000, \"M\": 100000},\n",
149
- " \"percentage_decimals\": 2,\n",
150
- " \"percentage_sign\": True\n",
151
- " },\n",
152
- " \"svg_defaults\": {\"height\": 600, \"width\": 800}\n",
153
- " }\n",
154
- " },\n",
155
- " {\n",
156
- " \"category\": \"tabular_and_generic_charts\",\n",
157
- " \"description\": \"Applies to charts where number formatting needs flexibility, including mixed or raw data.\",\n",
158
- " \"styling\": {\n",
159
- " \"theme\": \"light\",\n",
160
- " \"axes\": {\"stroke_width\": 0.2, \"grid_stroke_width\": 1},\n",
161
- " \"title\": {\"bold_html\": True, \"include\": True},\n",
162
- " \"annotations\": [\"x values\"],\n",
163
- " \"number_format\": {\n",
164
- " \"apply_k_m\": True,\n",
165
- " \"thresholds\": {\"K\": 1000, \"M\": 100000},\n",
166
- " \"exclude_if_commas_present\": True,\n",
167
- " \"exclude_if_not_numeric\": True,\n",
168
- " \"percentage_decimals\": 2,\n",
169
- " \"percentage_sign\": True\n",
170
- " },\n",
171
- " \"svg_defaults\": {\"height\": 600, \"width\": 800}\n",
172
- " }\n",
173
- " },\n",
174
- " {\n",
175
- " \"category\": \"heat_maps\",\n",
176
- " \"description\": \"Show data density or intensity using color scales on a matrix or grid.\",\n",
177
- " \"styling\": {\n",
178
- " \"theme\": \"light\",\n",
179
- " \"axes\": {\n",
180
- " \"stroke_color\": \"black\",\n",
181
- " \"stroke_width\": 0.2,\n",
182
- " \"grid_stroke_width\": 1\n",
183
- " },\n",
184
- " \"color_scale\": \"d3.interpolateViridis\",\n",
185
- " \"title\": {\"bold_html\": True, \"include\": True},\n",
186
- " \"svg_defaults\": {\"height\": 600, \"width\": 800}\n",
187
- " }\n",
188
- " },\n",
189
- " {\n",
190
- " \"category\": \"histogram_distribution\",\n",
191
- " \"description\": \"Specialized histogram for return distributions with opacity control.\",\n",
192
- " \"styling\": {\n",
193
- " \"theme\": \"light\",\n",
194
- " \"bar_style\": {\"opacity\": 0.75},\n",
195
- " \"axes\": {\n",
196
- " \"grid_stroke_width\": 1\n",
197
- " },\n",
198
- " \"title\": {\"bold_html\": True, \"include\": True},\n",
199
- " \"svg_defaults\": {\"height\": 600, \"width\": 800}\n",
200
- " }\n",
201
- " }\n",
202
- "]\n",
203
- "\n",
204
- "# styling_instructions = [str(chart_dict).replace('\\n','') for chart_dict in styling_instructions]\n"
205
- ]
206
- },
207
- {
208
- "cell_type": "code",
209
- "execution_count": null,
210
- "id": "d859fae6",
211
- "metadata": {},
212
- "outputs": [],
213
- "source": [
214
- "\n",
215
- "class plan_to_d3(dspy.Signature):\n",
216
- " \"\"\" \"\"\"\n",
217
- " plan = dspy.InputField(desc=\"Planner instructions around what they want to visualize\")\n",
218
- " dataset_context = dspy.InputField(desc=\"Information about the dataset, columns, stats\")\n",
219
- " styling_instructions = dspy.InputField(desc=\"Styling instructions for the charts / dashboard\")\n",
220
- " d3_js_code = dspy.OutputField(desc=\"d3.js code that the js frontend can run to visualize\")\n",
221
- "\n",
222
- "\n",
223
- "# query_enhancer = dspy.Predict(\"original_query, dataset_context ->chart_categories:List[Literal['line_charts','bar_charts','histograms','pie_charts','tabular_and_generic_charts','no_chart']]\")\n",
224
- "\n",
225
- "plan_to_d3_sign = dspy.Predict(plan_to_d3)\n",
226
- "\n",
227
- "styling_instructions = [\n",
228
- " {\n",
229
- " \"category\": \"line_charts\",\n",
230
- " \"description\": \"Used to visualize trends and changes over time, often with multiple series.\",\n",
231
- " \"styling\": {\n",
232
- " \"theme\": \"light\", # replaces 'template'\n",
233
- " \"axes\": {\n",
234
- " \"stroke_width\": 0.2,\n",
235
- " \"grid_stroke_width\": 1,\n",
236
- " \"grid_color\": \"#e0e0e0\"\n",
237
- " },\n",
238
- " \"title\": {\n",
239
- " \"bold_html\": True,\n",
240
- " \"include\": True\n",
241
- " },\n",
242
- " \"colors\": \"use color scale (d3.schemeCategory10 or interpolated if >10 lines)\",\n",
243
- " \"annotations\": [\"min\", \"max\"],\n",
244
- " \"number_format\": {\n",
245
- " \"apply_k_m\": True,\n",
246
- " \"thresholds\": {\"K\": 1000, \"M\": 100000},\n",
247
- " \"percentage_decimals\": 2,\n",
248
- " \"percentage_sign\": True\n",
249
- " },\n",
250
- " \"svg_defaults\": {\"height\": 600, \"width\": 800, \"margin\": {\"top\": 50, \"right\": 50, \"bottom\": 50, \"left\": 60}}\n",
251
- " }\n",
252
- " },\n",
253
- " {\n",
254
- " \"category\": \"bar_charts\",\n",
255
- " \"description\": \"Useful for comparing discrete categories or groups with bars representing values.\",\n",
256
- " \"styling\": {\n",
257
- " \"theme\": \"light\",\n",
258
- " \"axes\": {\n",
259
- " \"stroke_width\": 0.2,\n",
260
- " \"grid_stroke_width\": 1\n",
261
- " },\n",
262
- " \"title\": {\"bold_html\": True, \"include\": True},\n",
263
- " \"annotations\": [\"bar values\"],\n",
264
- " \"bar_style\": {\"padding\": 0.1, \"corner_radius\": 3},\n",
265
- " \"number_format\": {\n",
266
- " \"apply_k_m\": True,\n",
267
- " \"thresholds\": {\"K\": 1000, \"M\": 100000},\n",
268
- " \"percentage_decimals\": 2,\n",
269
- " \"percentage_sign\": True\n",
270
- " },\n",
271
- " \"svg_defaults\": {\"height\": 600, \"width\": 800}\n",
272
- " }\n",
273
- " },\n",
274
- " {\n",
275
- " \"category\": \"histograms\",\n",
276
- " \"description\": \"Display the distribution of a data set, useful for returns or frequency distributions.\",\n",
277
- " \"styling\": {\n",
278
- " \"theme\": \"light\",\n",
279
- " \"bin_count\": 50,\n",
280
- " \"axes\": {\"stroke_width\": 0.2, \"grid_stroke_width\": 1},\n",
281
- " \"title\": {\"bold_html\": True, \"include\": True},\n",
282
- " \"annotations\": [\"x values\"],\n",
283
- " \"bar_style\": {\"opacity\": 0.8, \"corner_radius\": 2},\n",
284
- " \"number_format\": {\n",
285
- " \"apply_k_m\": True,\n",
286
- " \"thresholds\": {\"K\": 1000, \"M\": 100000},\n",
287
- " \"percentage_decimals\": 2,\n",
288
- " \"percentage_sign\": True\n",
289
- " },\n",
290
- " \"svg_defaults\": {\"height\": 600, \"width\": 800}\n",
291
- " }\n",
292
- " },\n",
293
- " {\n",
294
- " \"category\": \"pie_charts\",\n",
295
- " \"description\": \"Show composition or parts of a whole with slices representing categories.\",\n",
296
- " \"styling\": {\n",
297
- " \"theme\": \"light\",\n",
298
- " \"top_categories_to_show\": 10,\n",
299
- " \"bundle_rest_as\": \"Others\",\n",
300
- " \"title\": {\"bold_html\": True, \"include\": True},\n",
301
- " \"pie_style\": {\n",
302
- " \"inner_radius_ratio\": 0.0, # 0 for pie, >0 for donut\n",
303
- " \"pad_angle\": 0.02\n",
304
- " },\n",
305
- " \"annotations\": [\"percentage labels\"],\n",
306
- " \"color_scheme\": \"d3.schemeCategory10\",\n",
307
- " \"number_format\": {\n",
308
- " \"apply_k_m\": True,\n",
309
- " \"thresholds\": {\"K\": 1000, \"M\": 100000},\n",
310
- " \"percentage_decimals\": 2,\n",
311
- " \"percentage_sign\": True\n",
312
- " },\n",
313
- " \"svg_defaults\": {\"height\": 600, \"width\": 800}\n",
314
- " }\n",
315
- " },\n",
316
- " {\n",
317
- " \"category\": \"tabular_and_generic_charts\",\n",
318
- " \"description\": \"Applies to charts where number formatting needs flexibility, including mixed or raw data.\",\n",
319
- " \"styling\": {\n",
320
- " \"theme\": \"light\",\n",
321
- " \"axes\": {\"stroke_width\": 0.2, \"grid_stroke_width\": 1},\n",
322
- " \"title\": {\"bold_html\": True, \"include\": True},\n",
323
- " \"annotations\": [\"x values\"],\n",
324
- " \"number_format\": {\n",
325
- " \"apply_k_m\": True,\n",
326
- " \"thresholds\": {\"K\": 1000, \"M\": 100000},\n",
327
- " \"exclude_if_commas_present\": True,\n",
328
- " \"exclude_if_not_numeric\": True,\n",
329
- " \"percentage_decimals\": 2,\n",
330
- " \"percentage_sign\": True\n",
331
- " },\n",
332
- " \"svg_defaults\": {\"height\": 600, \"width\": 800}\n",
333
- " }\n",
334
- " },\n",
335
- " {\n",
336
- " \"category\": \"heat_maps\",\n",
337
- " \"description\": \"Show data density or intensity using color scales on a matrix or grid.\",\n",
338
- " \"styling\": {\n",
339
- " \"theme\": \"light\",\n",
340
- " \"axes\": {\n",
341
- " \"stroke_color\": \"black\",\n",
342
- " \"stroke_width\": 0.2,\n",
343
- " \"grid_stroke_width\": 1\n",
344
- " },\n",
345
- " \"color_scale\": \"d3.interpolateViridis\",\n",
346
- " \"title\": {\"bold_html\": True, \"include\": True},\n",
347
- " \"svg_defaults\": {\"height\": 600, \"width\": 800}\n",
348
- " }\n",
349
- " },\n",
350
- " {\n",
351
- " \"category\": \"histogram_distribution\",\n",
352
- " \"description\": \"Specialized histogram for return distributions with opacity control.\",\n",
353
- " \"styling\": {\n",
354
- " \"theme\": \"light\",\n",
355
- " \"bar_style\": {\"opacity\": 0.75},\n",
356
- " \"axes\": {\n",
357
- " \"grid_stroke_width\": 1\n",
358
- " },\n",
359
- " \"title\": {\"bold_html\": True, \"include\": True},\n",
360
- " \"svg_defaults\": {\"height\": 600, \"width\": 800}\n",
361
- " }\n",
362
- " }\n",
363
- "]\n",
364
- "\n",
365
- "# styling_instructions = [str(chart_dict).replace('\\n','') for chart_dict in styling_instructions]\n",
366
- "\n",
367
- "\n",
368
- "class GenerateVisualizationPlan(dspy.Signature):\n",
369
- " \"\"\"\n",
370
- " Generate a visualization or analytical plan from a natural language query.\n",
371
- " The plan should include the reasoning steps and the names of charts\n",
372
- " (chosen from known chart types).\n",
373
- " \"\"\"\n",
374
- "\n",
375
- " query = dspy.InputField(\n",
376
- " desc=\"User's natural language query or instruction, e.g., 'Compare monthly revenue trends across regions.'\"\n",
377
- " )\n",
378
- " dataset_context = dspy.InputField(desc=\"Dataset information, columns, types etc\")\n",
379
- " plan = dspy.OutputField(\n",
380
- " desc=(\n",
381
- " \"Step-by-step plan describing what to compute or visualize, \"\n",
382
- " \"including which chart types to use. \"\n",
383
- " \"Chart names must be chosen from: \"\n",
384
- " \"[line_charts, bar_charts, histograms, pie_charts, \"\n",
385
- " \"tabular_and_generic_charts, heat_maps, histogram_distribution].\"\n",
386
- " )\n",
387
- " )\n",
388
- "\n",
389
- " relevant_query = dspy.OutputField(\n",
390
- " desc=\"Boolean flag — True if the query is not about visualizations,dashboarding, design, data analysis etc.\",\n",
391
- " type=bool\n",
392
- " )\n",
393
- "\n",
394
- "\n",
395
- "fail = \"\"\"// D3.js: No visualization generated.\n",
396
- "// Reason: The query was not about analysis/visualization or the data is invalid.\n",
397
- "d3.select(\"body\")\n",
398
- " .append(\"div\")\n",
399
- " .style(\"color\", \"red\")\n",
400
- " .style(\"font-size\", \"1.2em\")\n",
401
- " .style(\"margin\", \"2em\")\n",
402
- " .text(\"No visualization generated: The query was not about analysis/visualization or the data is invalid.\");\"\"\"\n",
403
- "\n",
404
- "class d3_module(dspy.Module):\n",
405
- " def __init__(self, styling_instructions, fail):\n",
406
- " self.styling_instructions = styling_instructions\n",
407
- " self.planner = dspy.Predict(GenerateVisualizationPlan)\n",
408
- " self.plan_to_d3_sign = dspy.Predict(plan_to_d3)\n",
409
- " self.fail = fail\n",
410
- " def forward(self,query,dataset_context):\n",
411
- " plan = self.planner(query=query, dataset_context=dataset_context)\n",
412
- "\n",
413
- " # print(plan)\n",
414
- " if 'False' in str(plan.relevant_query):\n",
415
- " \n",
416
- " styling = []\n",
417
- " for c in self.styling_instructions:\n",
418
- " if c.get(\"category\") in plan.plan:\n",
419
- " styling.append(c)\n",
420
- " d3_code = self.plan_to_d3_sign(plan=plan.plan, dataset_context = dataset_context, styling_instructions = str(styling) )\n",
421
- "\n",
422
- " return d3_code.d3_js_code\n",
423
- " else:\n",
424
- " return self.fail"
425
- ]
426
- },
427
- {
428
- "cell_type": "code",
429
- "execution_count": null,
430
- "id": "31f9133c",
431
- "metadata": {},
432
- "outputs": [],
433
- "source": [
434
- "class d3_module(dspy.Module):\n",
435
- " def __init__(self, styling_instructions, fail):\n",
436
- " self.styling_instructions = styling_instructions\n",
437
- " self.planner = dspy.Predict(GenerateVisualizationPlan)\n",
438
- " self.plan_to_d3_sign = dspy.Predict(plan_to_d3)\n",
439
- " self.fail = fail\n",
440
- " def forward(self,query,dataset_context):\n",
441
- " plan = self.planner(query=query, dataset_context=dataset_context)\n",
442
- "\n",
443
- " # print(plan)\n",
444
- " if 'False' in str(plan.relevant_query):\n",
445
- " \n",
446
- " styling = []\n",
447
- " for c in self.styling_instructions:\n",
448
- " if c.get(\"category\") in plan.plan:\n",
449
- " styling.append(c)\n",
450
- " d3_code = self.plan_to_d3_sign(plan=plan.plan, dataset_context = dataset_context, styling_instructions = str(styling) )\n",
451
- "\n",
452
- " return d3_code.d3_js_code\n",
453
- " else:\n",
454
- " return self.fail\n",
455
- " "
456
- ]
457
- },
458
- {
459
- "cell_type": "code",
460
- "execution_count": 43,
461
- "id": "646fbe11",
462
- "metadata": {},
463
- "outputs": [],
464
- "source": [
465
- "import pandas as pd\n",
466
- "import numpy as np\n",
467
- "\n",
468
- "# Create a more realistic fake housing dataset DataFrame\n",
469
- "np.random.seed(42)\n",
470
- "n_samples = 20\n",
471
- "\n",
472
- "# Bedrooms: Most homes have 2-4 bedrooms, with fewer at the extremes\n",
473
- "bedrooms = np.random.choice([1, 2, 3, 4, 5], size=n_samples, p=[0.05, 0.25, 0.45, 0.2, 0.05])\n",
474
- "\n",
475
- "# Bathrooms: Correlated with bedrooms, but with some noise\n",
476
- "bathrooms = np.clip(\n",
477
- " np.random.normal(loc=bedrooms - 0.5 + np.random.uniform(0, 1, n_samples), scale=0.3), 1, 4\n",
478
- ")\n",
479
- "bathrooms = np.round(bathrooms, 1)\n",
480
- "\n",
481
- "# Living area: log-normal for positive skew, correlated with bedrooms\n",
482
- "sqft_living = np.random.lognormal(mean=7.5 + 0.15 * bedrooms, sigma=0.2, size=n_samples).astype(int)\n",
483
- "\n",
484
- "# Lot size: log-normal, larger than living area, more variance\n",
485
- "sqft_lot = np.random.lognormal(mean=8.5 + 0.1 * bedrooms, sigma=0.35, size=n_samples).astype(int)\n",
486
- "\n",
487
- "# Floors: Most homes have 1 or 2 floors, rarely 3\n",
488
- "floors = np.random.choice([1.0, 1.5, 2.0, 2.5, 3.0], size=n_samples, p=[0.4, 0.15, 0.35, 0.07, 0.03])\n",
489
- "\n",
490
- "# Waterfront: Only a small fraction are waterfront\n",
491
- "waterfront = np.random.choice([True, False], size=n_samples, p=[0.1, 0.9])\n",
492
- "\n",
493
- "# View: Most homes have 0-1, few have 2-4\n",
494
- "view = np.random.choice([0, 1, 2, 3, 4], size=n_samples, p=[0.5, 0.3, 0.1, 0.07, 0.03])\n",
495
- "\n",
496
- "# Condition: Most homes are in good condition (3-4)\n",
497
- "condition = np.random.choice([1, 2, 3, 4, 5], size=n_samples, p=[0.05, 0.15, 0.5, 0.25, 0.05])\n",
498
- "\n",
499
- "# Grade: Most homes are 6-10, few at extremes\n",
500
- "grade = np.random.choice(range(1, 14), size=n_samples, p=[0.01, 0.01, 0.03, 0.05, 0.08, 0.12, 0.18, 0.18, 0.15, 0.10, 0.05, 0.02, 0.02])\n",
501
- "\n",
502
- "# Year built: Most homes built after 1970, some older\n",
503
- "yr_built = np.random.choice(\n",
504
- " np.concatenate([np.arange(1950, 1980), np.arange(1980, 2000), np.arange(2000, 2023)]),\n",
505
- " size=n_samples,\n",
506
- " p=np.concatenate([\n",
507
- " np.repeat(0.01, 30), # 1950-1979\n",
508
- " np.repeat(0.025, 20), # 1980-1999\n",
509
- " np.repeat(0.025, 23) # 2000-2022\n",
510
- " ]) / (0.01*30 + 0.025*43)\n",
511
- ")\n",
512
- "\n",
513
- "# Zipcode: Weighted to favor some zipcodes\n",
514
- "zipcodes = ['98101', '98102', '98103', '98104', '98105']\n",
515
- "zipcode = np.random.choice(zipcodes, size=n_samples, p=[0.2, 0.15, 0.35, 0.15, 0.15])\n",
516
- "\n",
517
- "# Latitude and longitude: Centered around Seattle, with small noise\n",
518
- "lat = np.round(np.random.normal(loc=47.62, scale=0.04, size=n_samples), 6)\n",
519
- "long = np.round(np.random.normal(loc=-122.32, scale=0.04, size=n_samples), 6)\n",
520
- "\n",
521
- "# Price: Correlated with living area, grade, and waterfront, plus noise\n",
522
- "base_price = 100000 + sqft_living * 350 + sqft_lot * 2\n",
523
- "grade_adj = (grade - 7) * 20000\n",
524
- "waterfront_adj = np.where(waterfront, 150000, 0)\n",
525
- "view_adj = view * 10000\n",
526
- "price_noise = np.random.normal(0, 40000, n_samples)\n",
527
- "price = base_price + grade_adj + waterfront_adj + view_adj + price_noise\n",
528
- "price = np.round(price, -3)\n",
529
- "\n",
530
- "housing_df = pd.DataFrame({\n",
531
- " \"price\": price,\n",
532
- " \"bedrooms\": bedrooms,\n",
533
- " \"bathrooms\": bathrooms,\n",
534
- " \"sqft_living\": sqft_living,\n",
535
- " \"sqft_lot\": sqft_lot,\n",
536
- " \"floors\": floors,\n",
537
- " \"waterfront\": waterfront,\n",
538
- " \"view\": view,\n",
539
- " \"condition\": condition,\n",
540
- " \"grade\": grade,\n",
541
- " \"yr_built\": yr_built,\n",
542
- " \"zipcode\": zipcode,\n",
543
- " \"lat\": lat,\n",
544
- " \"long\": long\n",
545
- "})\n",
546
- "\n",
547
- "housing_df.to_csv(\"housing_sample.csv\")\n",
548
- "\n",
549
- "\n",
550
- "housing_dataset_context = \"\"\"\n",
551
- "Dataset Description:\n",
552
- "This dataset contains information about residential houses, including their sale prices and various features that may influence price. The data is commonly used for housing market analysis and price prediction.\n",
553
- "\n",
554
- "Columns:\n",
555
- "- price (float): Sale price of the house in USD.\n",
556
- "- bedrooms (int): Number of bedrooms in the house.\n",
557
- "- bathrooms (float): Number of bathrooms in the house.\n",
558
- "- sqft_living (int): Living area in square feet.\n",
559
- "- sqft_lot (int): Lot size in square feet.\n",
560
- "- floors (float): Number of floors in the house.\n",
561
- "- waterfront (bool): Whether the house has a waterfront view (True/False).\n",
562
- "- view (int): Quality of the view from the house, rated from 0 (none) to 4 (excellent).\n",
563
- "- condition (int): Condition of the house, rated from 1 (poor) to 5 (excellent).\n",
564
- "- grade (int): Overall grade of the house, from 1 (low) to 13 (high).\n",
565
- "- yr_built (int): Year the house was built.\n",
566
- "- zipcode (str): Postal code where the house is located.\n",
567
- "- lat (float): Latitude coordinate of the house.\n",
568
- "- long (float): Longitude coordinate of the house.\n",
569
- "\"\"\"\n",
570
- "\n"
571
- ]
572
- },
573
- {
574
- "cell_type": "markdown",
575
- "id": "44036d01",
576
- "metadata": {},
577
- "source": []
578
- },
579
- {
580
- "cell_type": "code",
581
- "execution_count": 40,
582
- "id": "7f2e778d",
583
- "metadata": {},
584
- "outputs": [],
585
- "source": [
586
- "predictor = d3_module(styling_instructions=styling_instructions, fail=fail)\n",
587
- "\n",
588
- "response = predictor(query=\"Build me dashboard for the housing dataset, visualize all the variables\", dataset_context=housing_dataset_context )"
589
- ]
590
- },
591
- {
592
- "cell_type": "code",
593
- "execution_count": 42,
594
- "id": "477d0009",
595
- "metadata": {},
596
- "outputs": [
597
- {
598
- "name": "stdout",
599
- "output_type": "stream",
600
- "text": [
601
- "```javascript\n",
602
- "// D3.js code for visualizing the dataset as per the plan\n",
603
- "\n",
604
- "// 1. Tabular and Generic Chart for Summary Statistics\n",
605
- "function createSummaryTable(data) {\n",
606
- " const summaryStats = d3.select(\"#summary-table\")\n",
607
- " .append(\"table\")\n",
608
- " .attr(\"class\", \"summary-table\");\n",
609
- "\n",
610
- " const headers = [\"Variable\", \"Mean\", \"Median\", \"Min\", \"Max\", \"Std Dev\"];\n",
611
- " summaryStats.append(\"thead\").append(\"tr\")\n",
612
- " .selectAll(\"th\")\n",
613
- " .data(headers)\n",
614
- " .enter()\n",
615
- " .append(\"th\")\n",
616
- " .text(d => d);\n",
617
- "\n",
618
- " const tbody = summaryStats.append(\"tbody\");\n",
619
- " const numericalVars = [\"price\", \"bedrooms\", \"bathrooms\", \"sqft_living\", \"sqft_lot\", \"floors\", \"view\", \"condition\", \"grade\", \"yr_built\"];\n",
620
- " \n",
621
- " numericalVars.forEach(varName => {\n",
622
- " const stats = calculateStats(data, varName); // Assume calculateStats is a function that returns mean, median, min, max, std dev\n",
623
- " tbody.append(\"tr\")\n",
624
- " .selectAll(\"td\")\n",
625
- " .data([varName, stats.mean, stats.median, stats.min, stats.max, stats.stdDev])\n",
626
- " .enter()\n",
627
- " .append(\"td\")\n",
628
- " .text(d => d);\n",
629
- " });\n",
630
- "}\n",
631
- "\n",
632
- "// 2. Histograms for Sale Price, Bedrooms, Bathrooms, and Living Area\n",
633
- "function createHistogram(data, variable) {\n",
634
- " const margin = {top: 20, right: 30, bottom: 30, left: 40},\n",
635
- " width = 800 - margin.left - margin.right,\n",
636
- " height = 600 - margin.top - margin.bottom;\n",
637
- "\n",
638
- " const svg = d3.select(`#histogram-${variable}`)\n",
639
- " .append(\"svg\")\n",
640
- " .attr(\"width\", width + margin.left + margin.right)\n",
641
- " .attr(\"height\", height + margin.top + margin.bottom)\n",
642
- " .append(\"g\")\n",
643
- " .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n",
644
- "\n",
645
- " const x = d3.scaleLinear()\n",
646
- " .domain(d3.extent(data, d => d[variable]))\n",
647
- " .range([0, width]);\n",
648
- "\n",
649
- " const histogram = d3.histogram()\n",
650
- " .value(d => d[variable])\n",
651
- " .domain(x.domain())\n",
652
- " .thresholds(x.ticks(50));\n",
653
- "\n",
654
- " const bins = histogram(data);\n",
655
- "\n",
656
- " const y = d3.scaleLinear()\n",
657
- " .domain([0, d3.max(bins, d => d.length)])\n",
658
- " .range([height, 0]);\n",
659
- "\n",
660
- " svg.selectAll(\"rect\")\n",
661
- " .data(bins)\n",
662
- " .enter().append(\"rect\")\n",
663
- " .attr(\"x\", 1)\n",
664
- " .attr(\"transform\", d => `translate(${x(d.x0)},${y(d.length)})`)\n",
665
- " .attr(\"width\", d => x(d.x1) - x(d.x0) - 1)\n",
666
- " .attr(\"height\", d => height - y(d.length))\n",
667
- " .style(\"fill\", \"steelblue\");\n",
668
- "\n",
669
- " svg.append(\"g\")\n",
670
- " .attr(\"class\", \"x axis\")\n",
671
- " .attr(\"transform\", `translate(0,${height})`)\n",
672
- " .call(d3.axisBottom(x));\n",
673
- "\n",
674
- " svg.append(\"g\")\n",
675
- " .attr(\"class\", \"y axis\")\n",
676
- " .call(d3.axisLeft(y));\n",
677
- "}\n",
678
- "\n",
679
- "// 3. Bar Charts for Average Sale Price by Bedrooms, Bathrooms, and Floors\n",
680
- "function createBarChart(data, variable) {\n",
681
- " const margin = {top: 20, right: 30, bottom: 30, left: 40},\n",
682
- " width = 800 - margin.left - margin.right,\n",
683
- " height = 600 - margin.top - margin.bottom;\n",
684
- "\n",
685
- " const svg = d3.select(`#bar-chart-${variable}`)\n",
686
- " .append(\"svg\")\n",
687
- " .attr(\"width\", width + margin.left + margin.right)\n",
688
- " .attr(\"height\", height + margin.top + margin.bottom)\n",
689
- " .append(\"g\")\n",
690
- " .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n",
691
- "\n",
692
- " const avgPriceData = d3.nest()\n",
693
- " .key(d => d[variable])\n",
694
- " .rollup(v => d3.mean(v, d => d.price))\n",
695
- " .entries(data);\n",
696
- "\n",
697
- " const x = d3.scaleBand()\n",
698
- " .domain(avgPriceData.map(d => d.key))\n",
699
- " .range([0, width])\n",
700
- " .padding(0.1);\n",
701
- "\n",
702
- " const y = d3.scaleLinear()\n",
703
- " .domain([0, d3.max(avgPriceData, d => d.value)])\n",
704
- " .range([height, 0]);\n",
705
- "\n",
706
- " svg.selectAll(\".bar\")\n",
707
- " .data(avgPriceData)\n",
708
- " .enter().append(\"rect\")\n",
709
- " .attr(\"class\", \"bar\")\n",
710
- " .attr(\"x\", d => x(d.key))\n",
711
- " .attr(\"y\", d => y(d.value))\n",
712
- " .attr(\"width\", x.bandwidth())\n",
713
- " .attr(\"height\", d => height - y(d.value))\n",
714
- " .style(\"fill\", \"steelblue\");\n",
715
- "\n",
716
- " svg.append(\"g\")\n",
717
- " .attr(\"class\", \"x axis\")\n",
718
- " .attr(\"transform\", `translate(0,${height})`)\n",
719
- " .call(d3.axisBottom(x));\n",
720
- "\n",
721
- " svg.append(\"g\")\n",
722
- " .attr(\"class\", \"y axis\")\n",
723
- " .call(d3.axisLeft(y));\n",
724
- "}\n",
725
- "\n",
726
- "// 4. Pie Charts for Waterfront View Proportions\n",
727
- "function createPieChart(data) {\n",
728
- " const width = 800, height = 600, radius = Math.min(width, height) / 2;\n",
729
- "\n",
730
- " const svg = d3.select(\"#pie-chart\")\n",
731
- " .append(\"svg\")\n",
732
- " .attr(\"width\", width)\n",
733
- " .attr(\"height\", height)\n",
734
- " .append(\"g\")\n",
735
- " .attr(\"transform\", `translate(${width / 2},${height / 2})`);\n",
736
- "\n",
737
- " const pie = d3.pie()\n",
738
- " .value(d => d.count);\n",
739
- "\n",
740
- " const dataCount = [\n",
741
- " {label: \"Waterfront\", count: d3.sum(data, d => d.waterfront ? 1 : 0)},\n",
742
- " {label: \"No Waterfront\", count: d3.sum(data, d => !d.waterfront ? 1 : 0)}\n",
743
- " ];\n",
744
- "\n",
745
- " const arc = d3.arc()\n",
746
- " .innerRadius(0)\n",
747
- " .outerRadius(radius);\n",
748
- "\n",
749
- " const color = d3.scaleOrdinal(d3.schemeCategory10);\n",
750
- "\n",
751
- " const arcs = svg.selectAll(\".arc\")\n",
752
- " .data(pie(dataCount))\n",
753
- " .enter().append(\"g\")\n",
754
- " .attr(\"class\", \"arc\");\n",
755
- "\n",
756
- " arcs.append(\"path\")\n",
757
- " .attr(\"d\", arc)\n",
758
- " .style(\"fill\", (d, i) => color(i));\n",
759
- "\n",
760
- " arcs.append(\"text\")\n",
761
- " .attr(\"transform\", d => `translate(${arc.centroid(d)})`)\n",
762
- " .attr(\"dy\", \".35em\")\n",
763
- " .text(d => d.data.label);\n",
764
- "}\n",
765
- "\n",
766
- "// 5. Heat Map for Correlation\n",
767
- "function createHeatMap(data) {\n",
768
- " const margin = {top: 20, right: 30, bottom: 30, left: 40},\n",
769
- " width = 800 - margin.left - margin.right,\n",
770
- " height = 600 - margin.top - margin.bottom;\n",
771
- "\n",
772
- " const svg = d3.select(\"#heat-map\")\n",
773
- " .append(\"svg\")\n",
774
- " .attr(\"width\", width + margin.left + margin.right)\n",
775
- " .attr(\"height\", height + margin.top + margin.bottom)\n",
776
- " .append(\"g\")\n",
777
- " .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n",
778
- "\n",
779
- " const correlationMatrix = calculateCorrelation(data); // Assume calculateCorrelation returns a matrix of correlations\n",
780
- "\n",
781
- " const x = d3.scaleBand()\n",
782
- " .domain(correlationMatrix.columns)\n",
783
- " .range([0, width])\n",
784
- " .padding(0.01);\n",
785
- "\n",
786
- " const y = d3.scaleBand()\n",
787
- " .domain(correlationMatrix.rows)\n",
788
- " .range([height, 0])\n",
789
- " .padding(0.01);\n",
790
- "\n",
791
- " const colorScale = d3.scaleSequential(d3.interpolateRdYlBu)\n",
792
- " .domain([-1, 1]);\n",
793
- "\n",
794
- " svg.selectAll(\"rect\")\n",
795
- " .data(correlationMatrix.data)\n",
796
- " .enter().append(\"rect\")\n",
797
- " .attr(\"x\", d => x(d.column))\n",
798
- " .attr(\"y\", d => y(d.row))\n",
799
- " .attr(\"width\", x.bandwidth())\n",
800
- " .attr(\"height\", y.bandwidth())\n",
801
- " .style(\"fill\", d => colorScale(d.value));\n",
802
- "\n",
803
- " svg.append(\"g\")\n",
804
- " .attr(\"class\", \"x axis\")\n",
805
- " .attr(\"transform\", `translate(0,${height})`)\n",
806
- " .call(d3.axisBottom(x));\n",
807
- "\n",
808
- " svg.append(\"g\")\n",
809
- " .attr(\"class\", \"y axis\")\n",
810
- " .call(d3.axisLeft(y));\n",
811
- "}\n",
812
- "\n",
813
- "// 6. Line Charts for Average Sale Price by Year Built\n",
814
- "function createLineChart(data) {\n",
815
- " const margin = {top: 20, right: 30, bottom: 30, left: 40},\n",
816
- " width = 800 - margin.left - margin.right,\n",
817
- " height = 600 - margin.top - margin.bottom;\n",
818
- "\n",
819
- " const svg = d3.select(\"#line-chart\")\n",
820
- " .append(\"svg\")\n",
821
- " .attr(\"width\", width + margin.left + margin.right)\n",
822
- " .attr(\"height\", height + margin.top + margin.bottom)\n",
823
- " .append(\"g\")\n",
824
- " .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n",
825
- "\n",
826
- " const avgPriceByYear = d3.nest()\n",
827
- " .key(d => d.yr_built)\n",
828
- " .rollup(v => d3.mean(v, d => d.price))\n",
829
- " .entries(data);\n",
830
- "\n",
831
- " const x = d3.scaleLinear()\n",
832
- " .domain(d3.extent(avgPriceByYear, d => d.key))\n",
833
- " .range([0, width]);\n",
834
- "\n",
835
- " const y = d3.scaleLinear()\n",
836
- " .domain([0, d3.max(avgPriceByYear, d => d.value)])\n",
837
- " .range([height, 0]);\n",
838
- "\n",
839
- " const line = d3.line()\n",
840
- " .x(d => x(d.key))\n",
841
- " .y(d => y(d.value));\n",
842
- "\n",
843
- " svg.append(\"path\")\n",
844
- " .datum(avgPriceByYear)\n",
845
- " .attr(\"fill\", \"none\")\n",
846
- " .attr(\"stroke\", \"steelblue\")\n",
847
- " .attr(\"stroke-width\", 1.5)\n",
848
- " .attr(\"d\", line);\n",
849
- "\n",
850
- " svg.append(\"g\")\n",
851
- " .attr(\"class\", \"x axis\")\n",
852
- " .attr(\"transform\", `translate(0,${height})`)\n",
853
- " .call(d3.axisBottom(x));\n",
854
- "\n",
855
- " svg.append(\"g\")\n",
856
- " .attr(\"class\", \"y axis\")\n",
857
- " .call(d3.axisLeft(y));\n",
858
- "}\n",
859
- "\n",
860
- "// 7. Scatter Plot for sqft_living vs Price\n",
861
- "function createScatterPlot(data) {\n",
862
- " const margin = {top: 20, right: 30, bottom: 30, left: 40},\n",
863
- " width = 800 - margin.left - margin.right,\n",
864
- " height = 600 - margin.top - margin.bottom;\n",
865
- "\n",
866
- " const svg = d3.select(\"#scatter-plot\")\n",
867
- " .append(\"svg\")\n",
868
- " .attr(\"width\", width + margin.left + margin.right)\n",
869
- " .attr(\"height\", height + margin.top + margin.bottom)\n",
870
- " .append(\"g\")\n",
871
- " .attr(\"transform\", `translate(${margin.left},${margin.top})`);\n",
872
- "\n",
873
- " const x = d3.scaleLinear()\n",
874
- " .domain(d3.extent(data, d => d.sqft_living))\n",
875
- " .range([0, width]);\n",
876
- "\n",
877
- " const y = d3.scaleLinear()\n",
878
- " .domain(d3.extent(data, d => d.price))\n",
879
- " .range([height, 0]);\n",
880
- "\n",
881
- " svg.selectAll(\"circle\")\n",
882
- " .data(data)\n",
883
- " .enter().append(\"circle\")\n",
884
- " .attr(\"cx\", d => x(d.sqft_living))\n",
885
- " .attr(\"cy\", d => y(d.price))\n",
886
- " .attr(\"r\", 3)\n",
887
- " .style(\"fill\", \"steelblue\");\n",
888
- "\n",
889
- " svg.append(\"g\")\n",
890
- " .attr(\"class\", \"x axis\")\n",
891
- " .attr(\"transform\", `translate(0,${height})`)\n",
892
- " .call(d3.axisBottom(x));\n",
893
- "\n",
894
- " svg.append(\"g\")\n",
895
- " .attr(\"class\", \"y axis\")\n",
896
- " .call(d3.axisLeft(y));\n",
897
- "}\n",
898
- "\n",
899
- "// Call the functions with the dataset\n",
900
- "createSummaryTable(dataset);\n",
901
- "createHistogram(dataset, 'price');\n",
902
- "createHistogram(dataset, 'bedrooms');\n",
903
- "createHistogram(dataset, 'bathrooms');\n",
904
- "createHistogram(dataset, 'sqft_living');\n",
905
- "createBarChart(dataset, 'bedrooms');\n",
906
- "createBarChart(dataset, 'bathrooms');\n",
907
- "createBarChart(dataset, 'floors');\n",
908
- "createPieChart(dataset);\n",
909
- "createHeatMap(dataset);\n",
910
- "createLineChart(dataset);\n",
911
- "createScatterPlot(dataset);\n",
912
- "```\n"
913
- ]
914
- }
915
- ],
916
- "source": [
917
- "print(response)"
918
- ]
919
- },
920
- {
921
- "cell_type": "code",
922
- "execution_count": null,
923
- "id": "df811674",
924
- "metadata": {},
925
- "outputs": [],
926
- "source": []
927
- }
928
- ],
929
- "metadata": {
930
- "kernelspec": {
931
- "display_name": "Python 3",
932
- "language": "python",
933
- "name": "python3"
934
- },
935
- "language_info": {
936
- "codemirror_mode": {
937
- "name": "ipython",
938
- "version": 3
939
- },
940
- "file_extension": ".py",
941
- "mimetype": "text/x-python",
942
- "name": "python",
943
- "nbconvert_exporter": "python",
944
- "pygments_lexer": "ipython3",
945
- "version": "3.13.5"
946
- }
947
- },
948
- "nbformat": 4,
949
- "nbformat_minor": 5
950
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -21,6 +21,7 @@ chardet==5.2.0
21
  openpyxl==3.1.2
22
  xlrd==2.0.1
23
  numpy==2.1.2
 
24
  python-multipart==0.0.20
25
  tabulate==0.9.0
26
  plotly==6.3.1
@@ -29,4 +30,3 @@ reportlab==4.0.7
29
  geoip2==4.8.0
30
  httpx==0.27.0
31
 
32
-
 
21
  openpyxl==3.1.2
22
  xlrd==2.0.1
23
  numpy==2.1.2
24
+ boto3==1.34.162
25
  python-multipart==0.0.20
26
  tabulate==0.9.0
27
  plotly==6.3.1
 
30
  geoip2==4.8.0
31
  httpx==0.27.0
32