From d8ad41ede23e53f505f94841277a7f99349ef9e0 Mon Sep 17 00:00:00 2001 From: Santiago Montoya Date: Tue, 1 Jul 2025 19:41:48 +0000 Subject: [PATCH] feat(endpoints): endpoints for receiving lead forms data tested and running --- migrations/versions/8939202cfc94_.py | 48 +++++++++++++++ migrations/versions/972faf56c454_.py | 42 +++++++++++++ src/api/admin.py | 4 +- src/api/commands.py | 11 ++-- src/api/models.py | 20 +++--- src/api/routes.py | 91 ++++++++++++++++++++++++++-- src/front/pages/Demo.jsx | 43 ------------- src/front/pages/Single.jsx | 36 ----------- 8 files changed, 196 insertions(+), 99 deletions(-) create mode 100644 migrations/versions/8939202cfc94_.py create mode 100644 migrations/versions/972faf56c454_.py delete mode 100644 src/front/pages/Demo.jsx delete mode 100644 src/front/pages/Single.jsx diff --git a/migrations/versions/8939202cfc94_.py b/migrations/versions/8939202cfc94_.py new file mode 100644 index 0000000000..61ac5d16e7 --- /dev/null +++ b/migrations/versions/8939202cfc94_.py @@ -0,0 +1,48 @@ +"""empty message + +Revision ID: 8939202cfc94 +Revises: 972faf56c454 +Create Date: 2025-07-01 19:01:24.422826 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8939202cfc94' +down_revision = '972faf56c454' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('lead', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('email', sa.String(length=120), nullable=False), + sa.Column('phone', sa.String(length=15), nullable=False), + sa.Column('company', sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('phone') + ) + op.drop_table('user') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('email', sa.VARCHAR(length=120), autoincrement=False, nullable=False), + sa.Column('name', sa.VARCHAR(length=120), autoincrement=False, nullable=False), + sa.Column('phone', sa.VARCHAR(length=15), autoincrement=False, nullable=False), + sa.Column('company', sa.VARCHAR(length=50), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name='user_pkey'), + sa.UniqueConstraint('email', name='user_email_key'), + sa.UniqueConstraint('phone', name='user_phone_key') + ) + op.drop_table('lead') + # ### end Alembic commands ### diff --git a/migrations/versions/972faf56c454_.py b/migrations/versions/972faf56c454_.py new file mode 100644 index 0000000000..924b5e1948 --- /dev/null +++ b/migrations/versions/972faf56c454_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 972faf56c454 +Revises: 0763d677d453 +Create Date: 2025-07-01 16:24:18.129115 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '972faf56c454' +down_revision = '0763d677d453' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('name', sa.String(length=120), nullable=False)) + batch_op.add_column(sa.Column('phone', sa.String(length=15), nullable=False)) + batch_op.add_column(sa.Column('company', sa.String(length=50), nullable=True)) + batch_op.create_unique_constraint(None, ['phone']) + batch_op.drop_column('password') + batch_op.drop_column('is_active') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False)) + batch_op.add_column(sa.Column('password', sa.VARCHAR(), autoincrement=False, nullable=False)) + batch_op.drop_constraint(None, type_='unique') + batch_op.drop_column('company') + batch_op.drop_column('phone') + batch_op.drop_column('name') + + # ### end Alembic commands ### diff --git a/src/api/admin.py b/src/api/admin.py index 3eecb64140..d312ff7990 100644 --- a/src/api/admin.py +++ b/src/api/admin.py @@ -1,7 +1,7 @@ import os from flask_admin import Admin -from .models import db, User +from .models import db, Lead from flask_admin.contrib.sqla import ModelView def setup_admin(app): @@ -11,7 +11,7 @@ def setup_admin(app): # Add your models here, for example this is how we add a the User model to the admin - admin.add_view(ModelView(User, db.session)) + admin.add_view(ModelView(Lead, db.session)) # You can duplicate that line to add mew models # admin.add_view(ModelView(YourModelName, db.session)) \ No newline at end of file diff --git a/src/api/commands.py b/src/api/commands.py index 19806164d3..59c538f626 100644 --- a/src/api/commands.py +++ b/src/api/commands.py @@ -1,21 +1,22 @@ import click -from api.models import db, User +from api.models import db, Lead """ In this file, you can add as many commands as you want using the @app.cli.command decorator Flask commands are usefull to run cronjobs or tasks outside of the API but sill in integration with youy database, for example: Import the price of bitcoin every night as 12am """ + + def setup_commands(app): - """ This is an example command "insert-test-users" that you can run from the command line by typing: $ flask insert-test-users 5 Note: 5 is the number of users to add """ - @app.cli.command("insert-test-users") # name of our command - @click.argument("count") # argument of out command + @app.cli.command("insert-test-users") # name of our command + @click.argument("count") # argument of out command def insert_test_users(count): print("Creating test users") for x in range(1, int(count) + 1): @@ -31,4 +32,4 @@ def insert_test_users(count): @app.cli.command("insert-test-data") def insert_test_data(): - pass \ No newline at end of file + pass diff --git a/src/api/models.py b/src/api/models.py index da515f6a1a..68dc2100fd 100644 --- a/src/api/models.py +++ b/src/api/models.py @@ -4,16 +4,22 @@ db = SQLAlchemy() -class User(db.Model): - id: Mapped[int] = mapped_column(primary_key=True) - email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) - password: Mapped[str] = mapped_column(nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean(), nullable=False) +class Lead(db.Model): + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column( + String(120), unique=False, nullable=False) + email: Mapped[str] = mapped_column( + String(120), unique=True, nullable=False) + phone: Mapped[str] = mapped_column(String(15), unique=True, nullable=False) + company: Mapped[str] = mapped_column( + String(50), unique=False, nullable=True) def serialize(self): return { "id": self.id, + "name": self.name, "email": self.email, - # do not serialize the password, its a security breach - } \ No newline at end of file + "phone": self.phone, + "company": self.company + } diff --git a/src/api/routes.py b/src/api/routes.py index 029589a3a1..cea2cc5941 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -2,9 +2,10 @@ This module takes care of starting the API Server, Loading the DB and Adding the endpoints """ from flask import Flask, request, jsonify, url_for, Blueprint -from api.models import db, User +from api.models import db, Lead from api.utils import generate_sitemap, APIException from flask_cors import CORS +from sqlalchemy.exc import IntegrityError api = Blueprint('api', __name__) @@ -12,11 +13,89 @@ CORS(api) -@api.route('/hello', methods=['POST', 'GET']) -def handle_hello(): +@api.route('/leads', methods=['GET']) +def get_leads(): + all_leads = Lead.query.all() + serialized_leads = [lead.serialize() for lead in all_leads] - response_body = { - "message": "Hello! I'm a message that came from the backend, check the network tab on the google inspector and you will see the GET request" + return jsonify(serialized_leads), 200 + + +def validate_lead_data(data): + errors = {} + + name = data.get("name") + email = data.get("email") + phone = data.get("phone") + company = data.get("company") + + if not name or not name.strip(): + errors["name"] = "Name field is required." + + if not email or not email.strip(): + errors["email"] = "Email field is required." + elif "@" not in email or "." not in email or len(email) < 5: + errors["email"] = "Invalid email format." + + if not phone or not phone.strip(): + errors["phone"] = "Phone field is required" + elif len(phone) < 9: + errors["phone"] = "Phone number must have at least nine digits" + + return errors, { + "name": name.strip() if name else None, + "email": email.strip() if email else None, + "phone": phone.strip() if phone else None, + "company": company.strip() if company else None } - return jsonify(response_body), 200 + +@api.route('/contact', methods=['POST']) +def add_lead(): + lead_data = request.get_json() + + if not lead_data: + return jsonify({"message": "Invalid JSON or empty request body"}), 400 + + validation_errors, clean_data = validate_lead_data(lead_data) + + if validation_errors: + return jsonify({ + "status": "error", + "message": "Validation failed", + "errors": validation_errors + }), 400 + + try: + new_lead = Lead( + name=clean_data["name"], + email=clean_data["email"], + phone=clean_data["phone"], + company=clean_data["company"] + ) + + db.session.add(new_lead) + db.session.commit() + + return jsonify({ + "message": "Lead received successfully!", + "lead_id": new_lead.id, + "lead": new_lead.serialize() + }), 201 + + except IntegrityError as e: + db.session.rollback() + print(f"Registration error (Duplicate): {e}") + if "lead_email_key" in str(e): + return jsonify({ + "status": "error", + "message": "Validation failed", + "errors": {"email": "This email is already registered"} + }), 409 + else: + return jsonify({"message": "An integrity error ocurred"}), 400 + + except Exception as e: + db.session.rollback() + print(f"Registration error (General): {e}") + return jsonify({"message": "Unable to process your request at this time"}), 500 diff --git a/src/front/pages/Demo.jsx b/src/front/pages/Demo.jsx deleted file mode 100644 index 34250a45b7..0000000000 --- a/src/front/pages/Demo.jsx +++ /dev/null @@ -1,43 +0,0 @@ -// Import necessary components from react-router-dom and other parts of the application. -import { Link } from "react-router-dom"; -import useGlobalReducer from "../hooks/useGlobalReducer"; // Custom hook for accessing the global state. - -export const Demo = () => { - // Access the global state and dispatch function using the useGlobalReducer hook. - const { store, dispatch } = useGlobalReducer() - - return ( -
- -
- - - - -
- ); -}; diff --git a/src/front/pages/Single.jsx b/src/front/pages/Single.jsx deleted file mode 100644 index 407b01c47f..0000000000 --- a/src/front/pages/Single.jsx +++ /dev/null @@ -1,36 +0,0 @@ -// Import necessary hooks and components from react-router-dom and other libraries. -import { Link, useParams } from "react-router-dom"; // To use link for navigation and useParams to get URL parameters -import PropTypes from "prop-types"; // To define prop types for this component -import useGlobalReducer from "../hooks/useGlobalReducer"; // Import a custom hook for accessing the global state - -// Define and export the Single component which displays individual item details. -export const Single = props => { - // Access the global state using the custom hook. - const { store } = useGlobalReducer() - - // Retrieve the 'theId' URL parameter using useParams hook. - const { theId } = useParams() - const singleTodo = store.todos.find(todo => todo.id === parseInt(theId)); - - return ( -
- {/* Display the title of the todo element dynamically retrieved from the store using theId. */} -

Todo: {singleTodo?.title}

-
{/* A horizontal rule for visual separation. */} - - {/* A Link component acts as an anchor tag but is used for client-side routing to prevent page reloads. */} - - - Back home - - f -
- ); -}; - -// Use PropTypes to validate the props passed to this component, ensuring reliable behavior. -Single.propTypes = { - // Although 'match' prop is defined here, it is not used in the component. - // Consider removing or using it as needed. - match: PropTypes.object -};