diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..b1acebe909a50a7d1ae6a5fb99d00812e3a90170 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +WORKDIR /code + +# Install system libraries needed for database drivers +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install dependencies in binary mode to avoid compiler blocks +COPY ./requirements.txt /code/requirements.txt +RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt --only-binary :all: + +# Copy the rest of the application files +COPY . /code + +# Hugging Face Spaces routes internal traffic to port 7860 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000000000000000000000000000000000000..08f1d9f87820b013575caa6bbb23b084e3364683 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..78a4aba74cd117351355a6d5e718934b42b16d7a --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# ConstructHire app package diff --git a/app/__pycache__/__init__.cpython-314.pyc b/app/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5fa75fc1938e376f609f70cf2592d777bf5df736 Binary files /dev/null and b/app/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/__pycache__/main.cpython-314.pyc b/app/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd12dba78c08f9acba9d7d9c68126017ef0fbdfd Binary files /dev/null and b/app/__pycache__/main.cpython-314.pyc differ diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..842f557ad4ac28ca42df3450b07258f71af92828 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +# Core configuration and security diff --git a/app/core/__pycache__/__init__.cpython-314.pyc b/app/core/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..234b156cf708c22f6c05c1dca04ecce0ae83a910 Binary files /dev/null and b/app/core/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/core/__pycache__/config.cpython-314.pyc b/app/core/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7e38727cd930fc7bb053f4a13181efe2a6b8a54 Binary files /dev/null and b/app/core/__pycache__/config.cpython-314.pyc differ diff --git a/app/core/__pycache__/database.cpython-314.pyc b/app/core/__pycache__/database.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7937484b553d0aedb52963318271c058eb8a0f75 Binary files /dev/null and b/app/core/__pycache__/database.cpython-314.pyc differ diff --git a/app/core/__pycache__/security.cpython-314.pyc b/app/core/__pycache__/security.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c93229bb85e9be036b7c9cafbb3768b72bac501 Binary files /dev/null and b/app/core/__pycache__/security.cpython-314.pyc differ diff --git a/app/core/__pycache__/seeding.cpython-314.pyc b/app/core/__pycache__/seeding.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..888f7acfbc8a943416e14bb68ed0e19c66291f43 Binary files /dev/null and b/app/core/__pycache__/seeding.cpython-314.pyc differ diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..76a6c7db3e15f9d673946bb065373c5d95617935 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,20 @@ +import os +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + PROJECT_NAME: str = "ConstructHire API" + API_V1_STR: str = "/api" + + # Security config + # In production, this must be a secure random hex string read from environment + SECRET_KEY: str = os.getenv("SECRET_KEY", "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7") + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days for BCA viva convenience + + # Database settings + DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/constructhire") + + class Config: + case_sensitive = True + +settings = Settings() diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000000000000000000000000000000000000..fd668293e42154ade42eaef9c2451b6c4661fefd --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,29 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +# Database URL configuration +# For local development, default to SQLite fallback. +# For production/staging, it will read PostgreSQL from environment variables. +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./constructhire.db") + +# Support SQLite configuration parameters +if DATABASE_URL.startswith("sqlite"): + engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +else: + # Use pool_pre_ping=True to prevent stale connection errors with Supabase/Render + engine = create_engine(DATABASE_URL, pool_pre_ping=True) + + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +def get_db(): + """ + Database session generator to be used as a FastAPI dependency. + Yields a database session and ensures it is closed after the request is finished. + """ + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000000000000000000000000000000000000..58c5e3151363fbfc51986debf2dda6e46d505742 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,35 @@ +import bcrypt +from datetime import datetime, timedelta +from typing import Any, Union +from jose import jwt +from backend.app.core.config import settings + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify if a plain text password matches its hashed bcrypt value.""" + if not hashed_password: + return False + try: + return bcrypt.checkpw( + plain_password.encode("utf-8"), + hashed_password.encode("utf-8") + ) + except Exception: + return False + +def get_password_hash(password: str) -> str: + """Generate a secure bcrypt hash of a plain text password.""" + # Generate salt and hash the password + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password.encode("utf-8"), salt) + return hashed.decode("utf-8") + +def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str: + """Generate a JWT access token for a subject (usually user phone).""" + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt diff --git a/app/core/seeding.py b/app/core/seeding.py new file mode 100644 index 0000000000000000000000000000000000000000..55737da7e31cd8f3ba867e811e24486df674283c --- /dev/null +++ b/app/core/seeding.py @@ -0,0 +1,191 @@ +from sqlalchemy.orm import Session +from backend.app.models.users import User, WorkerProfile, CustomerProfile +from backend.app.models.jobs import Job +from backend.app.models.bookings import Booking +from backend.app.models.wallet import WalletTransaction +from backend.app.models.roster import RosterWorker +from backend.app.models.alerts import AdminAlert +from backend.app.core.security import get_password_hash + +def seed_db(db: Session): + """Pre-populates the database with initial viva seed data if empty.""" + # Check if DB is already seeded + if db.query(User).first() is not None: + print("Database already seeded.") + return False + + print("Seeding database...") + + # 1. Create Default Admin + admin = User( + name="Chandan Admin", + phone="9999999999", + role="admin", + password_hash=get_password_hash("admin123") + ) + db.add(admin) + + # 2. Create Default Customer + customer = User( + name="Harsh", + phone="9876543210", + role="customer", + password_hash=get_password_hash("pass123") + ) + db.add(customer) + db.flush() + + cust_profile = CustomerProfile(user_id=customer.id, wallet_balance=8200) + db.add(cust_profile) + + # 3. Create Default Mediator + mediator = User( + name="Rafiq Thekedar", + phone="9876599999", + role="mediator", + password_hash=get_password_hash("pass123") + ) + db.add(mediator) + db.flush() + + med_cust_profile = CustomerProfile(user_id=mediator.id, wallet_balance=5000) + db.add(med_cust_profile) + + # 4. Create Seed Workers + workers_data = [ + ("Ramesh Kumar", "9876500001", "Mason", 650, 4.8, 126, 97, 67, 34, True, True), + ("Imran Ali", "9876500002", "Electrician", 800, 4.7, 88, 95, 35, 45, True, True), + ("Sita Devi", "9876500003", "Painter", 700, 4.9, 112, 99, 58, 72, True, True), + ("Babulal Meena", "9876500004", "Helper", 500, 4.4, 46, 91, 26, 67, True, False), + ("Karan Singh", "9876500005", "Carpenter", 750, 4.6, 73, 92, 74, 58, False, True), + ("Mohan Lal", "9876500006", "Plumber", 720, 4.5, 65, 93, 46, 28, True, True), + ] + + workers = [] + for name, phone, skill, rate, rating, completed, completion, x, y, online, verified in workers_data: + user = User( + name=name, + phone=phone, + role="worker", + password_hash=get_password_hash("pass123") + ) + db.add(user) + db.flush() + + # Calculate distance + import math + dist = round(math.sqrt((x - 50)**2 + (y - 50)**2) / 15, 1) or 1.2 + + prof = WorkerProfile( + user_id=user.id, + skill=skill, + rate=rate, + rating=rating, + distance=dist, + online=online, + verified=verified, + completed_jobs=completed, + completion_rate=completion, + map_x=x, + map_y=y + ) + db.add(prof) + workers.append(user) + + db.flush() + + # 5. Create Seed Jobs + jobs_data = [ + ("Mason", "Mansarovar, Jaipur", 1300, "POSTED", "Boundary wall repair and cement finishing."), + ("Painter", "Vaishali Nagar", 2800, "ACCEPTED", "Two rooms wall putty and primer."), + ("Electrician", "Malviya Nagar", 900, "IN_PROGRESS", "Switch board repair and fan installation."), + ] + + jobs = [] + for skill, loc, budget, status, desc in jobs_data: + job = Job( + customer_id=customer.id, + skill_required=skill, + location=loc, + budget=budget, + description=desc, + status=status + ) + db.add(job) + jobs.append(job) + + db.flush() + + # 6. Create Seed Bookings + bookings_data = [ + ("B-2401", "Boundary wall repair", workers[0].id, 1300, "ACCEPTED", jobs[0].id), + ("B-2402", "Switch board repair", workers[1].id, 900, "IN_PROGRESS", jobs[2].id), + ] + + for code, title, worker_id, amount, status, job_id in bookings_data: + booking = Booking( + code=code, + customer_id=customer.id, + worker_id=worker_id, + amount=amount, + status=status, + job_id=job_id + ) + db.add(booking) + + db.flush() + + # 7. Create Seed Wallet Transactions + tx_data = [ + ("Wallet top-up via UPI", 5000, "credit"), + ("Escrow hold for B-2401", -1300, "hold"), + ("Escrow hold for B-2402", -900, "hold"), + ("Refund from cancelled booking", 600, "credit"), + ] + + for label, amount, tx_type in tx_data: + tx = WalletTransaction( + user_id=customer.id, + label=label, + amount=amount, + type=tx_type + ) + db.add(tx) + + # 8. Create Seed Roster Workers + roster_data = [ + ("Rafiq", "Helper", "IVR only", "Available", 1200), + ("Sunita", "Painter", "Verified", "On job", 820), + ("Dinesh", "Mason", "Verified", "Available", 1440), + ] + + for name, skill, phone_status, status, commission in roster_data: + roster_worker = RosterWorker( + mediator_id=mediator.id, + name=name, + skill=skill, + phone_status=phone_status, + status=status, + commission=commission + ) + db.add(roster_worker) + + # 9. Create Seed Admin Alerts + alerts_data = [ + ("Dispute", "B-2397 quality issue pending admin review", "red"), + ("Fraud", "Duplicate Aadhaar hash detected for two accounts", "orange"), + ("IVR", "23 non-smartphone workers received daily job alerts", "gray"), + ] + + for type_, text, severity in alerts_data: + alert = AdminAlert( + type=type_, + text=text, + severity=severity, + status="active" + ) + db.add(alert) + + db.commit() + print("Database seeded successfully!") + return True diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..e296bcf502940bb1b9165d882dbce0c8b7350bd9 --- /dev/null +++ b/app/main.py @@ -0,0 +1,81 @@ +import os +from fastapi import FastAPI, Depends, APIRouter +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.orm import Session + +from backend.app.core.config import settings +from backend.app.core.database import SessionLocal, engine, get_db +from backend.app.core.seeding import seed_db +from backend.app.models.base import Base + +# Import Routers +from backend.app.routers import ( + auth_router, + workers_router, + jobs_router, + bookings_router, + wallet_router, + roster_router, + admin_router +) + +# Initialize database tables +# This is a safe fallback; in production migrations are preferred, +# but for the BCA viva, ensuring tables exist automatically is a lifesaver. +Base.metadata.create_all(bind=engine) + +# Trigger Database Seeding on startup +db = SessionLocal() +try: + seed_db(db) +finally: + db.close() + +app = FastAPI( + title=settings.PROJECT_NAME, + description="ConstructHire location-aware labor marketplace backend", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc" +) + +# CORS configuration +# Allow local Vite dev server and production netlify deployments +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # For BCA viva simplicity; in strict prod specify origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Register API Routers +api_router = APIRouter(prefix="/api") +api_router.include_router(auth_router) +api_router.include_router(workers_router) +api_router.include_router(jobs_router) +api_router.include_router(bookings_router) +api_router.include_router(wallet_router) +api_router.include_router(roster_router) +api_router.include_router(admin_router) + +app.include_router(api_router) + +@app.get("/") +def read_root(): + """Root endpoint to check backend status.""" + return { + "status": "online", + "service": settings.PROJECT_NAME, + "docs": "/docs", + "api_prefix": settings.API_V1_STR + } + +@app.post("/api/admin/reset-db") +def reset_database(db: Session = Depends(get_db)): + """Convenience endpoint to clear and re-seed the database with demo values.""" + # Drop all tables and recreate them + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + seed_db(db) + return {"message": "Database successfully reset and re-seeded."} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..37ba5e4bc06477d994b82d89e7dff3267eb6dfb8 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,24 @@ +from backend.app.models.base import Base, BaseDBModel +from backend.app.models.users import User, WorkerProfile, CustomerProfile +from backend.app.models.jobs import Job +from backend.app.models.bookings import Booking +from backend.app.models.wallet import WalletTransaction +from backend.app.models.reviews import Review +from backend.app.models.notifications import Notification +from backend.app.models.roster import RosterWorker +from backend.app.models.alerts import AdminAlert + +__all__ = [ + "Base", + "BaseDBModel", + "User", + "WorkerProfile", + "CustomerProfile", + "Job", + "Booking", + "WalletTransaction", + "Review", + "Notification", + "RosterWorker", + "AdminAlert", +] diff --git a/app/models/__pycache__/__init__.cpython-314.pyc b/app/models/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d564cd9c214f1a89449e1c9ab5aa0b33d419e0b8 Binary files /dev/null and b/app/models/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/models/__pycache__/alerts.cpython-314.pyc b/app/models/__pycache__/alerts.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..17d7939aadc281ea451791dafff8ce99e172eaa0 Binary files /dev/null and b/app/models/__pycache__/alerts.cpython-314.pyc differ diff --git a/app/models/__pycache__/base.cpython-314.pyc b/app/models/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b9551b084307b89d3751b12c4d3dd4c6f474f597 Binary files /dev/null and b/app/models/__pycache__/base.cpython-314.pyc differ diff --git a/app/models/__pycache__/bookings.cpython-314.pyc b/app/models/__pycache__/bookings.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1444c44cb3d8b077c1fce6a8e3f9a8b928d616a1 Binary files /dev/null and b/app/models/__pycache__/bookings.cpython-314.pyc differ diff --git a/app/models/__pycache__/jobs.cpython-314.pyc b/app/models/__pycache__/jobs.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9713d16493e221faf93de097cc48a56a14ebcebf Binary files /dev/null and b/app/models/__pycache__/jobs.cpython-314.pyc differ diff --git a/app/models/__pycache__/notifications.cpython-314.pyc b/app/models/__pycache__/notifications.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f1ff611fafe61c12118afc803ed637e215efb694 Binary files /dev/null and b/app/models/__pycache__/notifications.cpython-314.pyc differ diff --git a/app/models/__pycache__/reviews.cpython-314.pyc b/app/models/__pycache__/reviews.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7597d317e5460818d14c3c28593f4ade10cd01ac Binary files /dev/null and b/app/models/__pycache__/reviews.cpython-314.pyc differ diff --git a/app/models/__pycache__/roster.cpython-314.pyc b/app/models/__pycache__/roster.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4adf29db788b9707c7a13bf5aece8eca5df52fe5 Binary files /dev/null and b/app/models/__pycache__/roster.cpython-314.pyc differ diff --git a/app/models/__pycache__/users.cpython-314.pyc b/app/models/__pycache__/users.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d963dd632a659ffbd55a178c0232665f0968116b Binary files /dev/null and b/app/models/__pycache__/users.cpython-314.pyc differ diff --git a/app/models/__pycache__/wallet.cpython-314.pyc b/app/models/__pycache__/wallet.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40e308afcd912197c3a9d7b3478f2e46a78ae6de Binary files /dev/null and b/app/models/__pycache__/wallet.cpython-314.pyc differ diff --git a/app/models/alerts.py b/app/models/alerts.py new file mode 100644 index 0000000000000000000000000000000000000000..ce7ad3091d5bac79cabe0b8ae2cf2e3fc34b7977 --- /dev/null +++ b/app/models/alerts.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, String +from backend.app.models.base import BaseDBModel + +class AdminAlert(BaseDBModel): + __tablename__ = "admin_alerts" + + type = Column(String, nullable=False, index=True) # Dispute, Fraud, IVR + text = Column(String, nullable=False) + severity = Column(String, nullable=False) # red, orange, gray + status = Column(String, nullable=False, default="active", index=True) # active, resolved diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000000000000000000000000000000000000..626552aebc3bc17fa468cbea2bd03127f2977624 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,14 @@ +import uuid +from datetime import datetime +from sqlalchemy import Column, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + +class BaseDBModel(Base): + __abstract__ = True + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) diff --git a/app/models/bookings.py b/app/models/bookings.py new file mode 100644 index 0000000000000000000000000000000000000000..df2027ab82ef275efd09bccd1f925995ff0df829 --- /dev/null +++ b/app/models/bookings.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, String, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseDBModel + +class Booking(BaseDBModel): + __tablename__ = "bookings" + + code = Column(String, unique=True, index=True, nullable=False) # e.g. B-2401 + job_id = Column(UUID(as_uuid=True), ForeignKey("jobs.id", ondelete="SET NULL"), nullable=True) + customer_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + worker_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + amount = Column(Integer, nullable=False) + status = Column(String, nullable=False, default="ACCEPTED", index=True) # ACCEPTED, IN_PROGRESS, COMPLETED, RATED, DISPUTED + + # Relationships + job = relationship("Job", back_populates="bookings") + customer = relationship("User", foreign_keys=[customer_id]) + worker = relationship("User", foreign_keys=[worker_id]) + reviews = relationship("Review", back_populates="booking", cascade="all, delete-orphan") diff --git a/app/models/jobs.py b/app/models/jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..c5b13314216a0145fade7f225dc4381dd97c0c5b --- /dev/null +++ b/app/models/jobs.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, String, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseDBModel + +class Job(BaseDBModel): + __tablename__ = "jobs" + + customer_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + skill_required = Column(String, index=True, nullable=False) + location = Column(String, nullable=False) + budget = Column(Integer, nullable=False) + description = Column(String, nullable=False) + status = Column(String, nullable=False, default="POSTED", index=True) # POSTED, ACCEPTED, IN_PROGRESS, COMPLETED, CANCELLED + + # Relationships + bookings = relationship("Booking", back_populates="job", cascade="all, delete-orphan") diff --git a/app/models/notifications.py b/app/models/notifications.py new file mode 100644 index 0000000000000000000000000000000000000000..8283e9771324e06ce694f302c6679609993cf2cd --- /dev/null +++ b/app/models/notifications.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, String, Boolean, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseDBModel + +class Notification(BaseDBModel): + __tablename__ = "notifications" + + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + title = Column(String, nullable=False) + message = Column(String, nullable=False) + read = Column(Boolean, nullable=False, default=False, index=True) + + # Relationships + user = relationship("User", back_populates="notifications") diff --git a/app/models/reviews.py b/app/models/reviews.py new file mode 100644 index 0000000000000000000000000000000000000000..fd525535f9473ff57886a5a14173b572b340376f --- /dev/null +++ b/app/models/reviews.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, String, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseDBModel + +class Review(BaseDBModel): + __tablename__ = "reviews" + + booking_id = Column(UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="CASCADE"), index=True, nullable=False) + reviewer_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + reviewee_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + rating = Column(Integer, nullable=False) # 1 to 5 + comment = Column(String, nullable=True) + + # Relationships + booking = relationship("Booking", back_populates="reviews") + reviewer = relationship("User", foreign_keys=[reviewer_id]) + reviewee = relationship("User", foreign_keys=[reviewee_id]) +class Review_Table_Init: + pass diff --git a/app/models/roster.py b/app/models/roster.py new file mode 100644 index 0000000000000000000000000000000000000000..6b93789f0efc30ce2ab74426152424da1228945c --- /dev/null +++ b/app/models/roster.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, String, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseDBModel + +class RosterWorker(BaseDBModel): + __tablename__ = "roster_workers" + + mediator_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + name = Column(String, nullable=False) + skill = Column(String, nullable=False) + phone_status = Column(String, nullable=False) # e.g. "IVR only", "Verified" + status = Column(String, nullable=False) # e.g. "Available", "On job" + commission = Column(Integer, nullable=False, default=0) + + # Relationships + mediator = relationship("User", back_populates="roster_workers") diff --git a/app/models/users.py b/app/models/users.py new file mode 100644 index 0000000000000000000000000000000000000000..623030681d32acab1dcd913860c3b44fdb72c0da --- /dev/null +++ b/app/models/users.py @@ -0,0 +1,48 @@ +from sqlalchemy import Column, String, Integer, Float, Boolean, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseDBModel + +class User(BaseDBModel): + __tablename__ = "users" + + name = Column(String, nullable=False) + phone = Column(String, unique=True, index=True, nullable=False) + role = Column(String, index=True, nullable=False) # admin, customer, worker, mediator + password_hash = Column(String, nullable=True) # can be null for demo OTP bypass + city = Column(String, nullable=False, default="Jaipur") + is_active = Column(Boolean, nullable=False, default=True) + + # Relationships + worker_profile = relationship("WorkerProfile", back_populates="user", uselist=False, cascade="all, delete-orphan") + customer_profile = relationship("CustomerProfile", back_populates="user", uselist=False, cascade="all, delete-orphan") + notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan") + wallet_transactions = relationship("WalletTransaction", back_populates="user", cascade="all, delete-orphan") + roster_workers = relationship("RosterWorker", back_populates="mediator", cascade="all, delete-orphan") + +class WorkerProfile(BaseDBModel): + __tablename__ = "worker_profiles" + + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True, nullable=False) + skill = Column(String, index=True, nullable=False) # Mason, Electrician, Painter, etc. + rate = Column(Integer, nullable=False) # Daily wage in INR + rating = Column(Float, nullable=False, default=4.5) + distance = Column(Float, nullable=False, default=1.5) # Simulated distance in km + online = Column(Boolean, nullable=False, default=True) + verified = Column(Boolean, nullable=False, default=False) # Aadhaar verified + completed_jobs = Column(Integer, nullable=False, default=0) + completion_rate = Column(Integer, nullable=False, default=90) # percentage + map_x = Column(Integer, nullable=False, default=50) # Coordinate x on simulated map + map_y = Column(Integer, nullable=False, default=50) # Coordinate y on simulated map + + # Relationships + user = relationship("User", back_populates="worker_profile") + +class CustomerProfile(BaseDBModel): + __tablename__ = "customer_profiles" + + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True, nullable=False) + wallet_balance = Column(Integer, nullable=False, default=8200) + + # Relationships + user = relationship("User", back_populates="customer_profile") diff --git a/app/models/wallet.py b/app/models/wallet.py new file mode 100644 index 0000000000000000000000000000000000000000..85406ffe8441f46be16fd4166844a26a12344433 --- /dev/null +++ b/app/models/wallet.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, String, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from backend.app.models.base import BaseDBModel + +class WalletTransaction(BaseDBModel): + __tablename__ = "wallet_transactions" + + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False) + booking_id = Column(UUID(as_uuid=True), ForeignKey("bookings.id", ondelete="SET NULL"), nullable=True) + label = Column(String, nullable=False) # e.g. "Wallet top-up via UPI", "Escrow hold for B-2401" + amount = Column(Integer, nullable=False) # positive for credits/refunds, negative for debits/holds + type = Column(String, nullable=False, index=True) # credit, hold, release, refund + + # Relationships + user = relationship("User", back_populates="wallet_transactions") + booking = relationship("Booking") diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e0e95af278d664f7aa8599a25ed272c6c36fba64 --- /dev/null +++ b/app/repositories/__init__.py @@ -0,0 +1,15 @@ +from backend.app.repositories.users import UserRepository +from backend.app.repositories.jobs import JobRepository +from backend.app.repositories.bookings import BookingRepository +from backend.app.repositories.wallet import WalletRepository +from backend.app.repositories.roster import RosterRepository +from backend.app.repositories.alerts import AlertRepository + +__all__ = [ + "UserRepository", + "JobRepository", + "BookingRepository", + "WalletRepository", + "RosterRepository", + "AlertRepository", +] diff --git a/app/repositories/__pycache__/__init__.cpython-314.pyc b/app/repositories/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1560d2c72c03360ee15e1c5c89899640b10543ff Binary files /dev/null and b/app/repositories/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/repositories/__pycache__/alerts.cpython-314.pyc b/app/repositories/__pycache__/alerts.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9765f1b7305c25d8583f18010fa4511caec86b7c Binary files /dev/null and b/app/repositories/__pycache__/alerts.cpython-314.pyc differ diff --git a/app/repositories/__pycache__/bookings.cpython-314.pyc b/app/repositories/__pycache__/bookings.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d685b029449e9518762248ca9b5408c35b691612 Binary files /dev/null and b/app/repositories/__pycache__/bookings.cpython-314.pyc differ diff --git a/app/repositories/__pycache__/jobs.cpython-314.pyc b/app/repositories/__pycache__/jobs.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..82091189381d09e2aa8d4fcee6119ab213a4ffb4 Binary files /dev/null and b/app/repositories/__pycache__/jobs.cpython-314.pyc differ diff --git a/app/repositories/__pycache__/roster.cpython-314.pyc b/app/repositories/__pycache__/roster.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a471a9aa0e74e7c8c4f4042fe3c12fc7a058e1b Binary files /dev/null and b/app/repositories/__pycache__/roster.cpython-314.pyc differ diff --git a/app/repositories/__pycache__/users.cpython-314.pyc b/app/repositories/__pycache__/users.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..443e208e56456e8ea65380bbd886ae659aae3119 Binary files /dev/null and b/app/repositories/__pycache__/users.cpython-314.pyc differ diff --git a/app/repositories/__pycache__/wallet.cpython-314.pyc b/app/repositories/__pycache__/wallet.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc7aa7790d3879f394c7842caf4198602a2c8032 Binary files /dev/null and b/app/repositories/__pycache__/wallet.cpython-314.pyc differ diff --git a/app/repositories/alerts.py b/app/repositories/alerts.py new file mode 100644 index 0000000000000000000000000000000000000000..815f017a67dd8e5deebed39b8b51a0ad34214b15 --- /dev/null +++ b/app/repositories/alerts.py @@ -0,0 +1,34 @@ +import uuid +from typing import List, Optional +from sqlalchemy.orm import Session +from backend.app.models.alerts import AdminAlert + +class AlertRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, type_: str, text: str, severity: str = "gray") -> AdminAlert: + alert = AdminAlert( + type=type_, + text=text, + severity=severity, + status="active" + ) + self.db.add(alert) + self.db.commit() + self.db.refresh(alert) + return alert + + def get_active(self) -> List[AdminAlert]: + return self.db.query(AdminAlert).filter(AdminAlert.status == "active").order_by(AdminAlert.created_at.asc()).all() + + def get_by_id(self, alert_id: uuid.UUID) -> Optional[AdminAlert]: + return self.db.query(AdminAlert).filter(AdminAlert.id == alert_id).first() + + def resolve(self, alert_id: uuid.UUID) -> Optional[AdminAlert]: + alert = self.get_by_id(alert_id) + if alert: + alert.status = "resolved" + self.db.commit() + self.db.refresh(alert) + return alert diff --git a/app/repositories/bookings.py b/app/repositories/bookings.py new file mode 100644 index 0000000000000000000000000000000000000000..685c96ce8f9ad18a7a22f1780c562e50c73ba505 --- /dev/null +++ b/app/repositories/bookings.py @@ -0,0 +1,63 @@ +import uuid +from typing import List, Optional +from sqlalchemy.orm import Session +from backend.app.models.bookings import Booking +from backend.app.models.reviews import Review + +class BookingRepository: + def __init__(self, db: Session): + self.db = db + + def get_by_id(self, booking_id: uuid.UUID) -> Optional[Booking]: + return self.db.query(Booking).filter(Booking.id == booking_id).first() + + def get_by_code(self, code: str) -> Optional[Booking]: + return self.db.query(Booking).filter(Booking.code == code).first() + + def create(self, customer_id: uuid.UUID, worker_id: uuid.UUID, amount: int, job_id: Optional[uuid.UUID] = None) -> Booking: + # Generate code (B-24XX) + count = self.db.query(Booking).count() + code = f"B-{2401 + count}" + + booking = Booking( + code=code, + customer_id=customer_id, + worker_id=worker_id, + job_id=job_id, + amount=amount, + status="ACCEPTED" + ) + self.db.add(booking) + self.db.commit() + self.db.refresh(booking) + return booking + + def get_by_customer(self, customer_id: uuid.UUID) -> List[Booking]: + return self.db.query(Booking).filter(Booking.customer_id == customer_id).order_by(Booking.created_at.desc()).all() + + def get_by_worker(self, worker_id: uuid.UUID) -> List[Booking]: + return self.db.query(Booking).filter(Booking.worker_id == worker_id).order_by(Booking.created_at.desc()).all() + + def get_all(self) -> List[Booking]: + return self.db.query(Booking).order_by(Booking.created_at.desc()).all() + + def update_status(self, booking_id: uuid.UUID, status: str) -> Optional[Booking]: + booking = self.get_by_id(booking_id) + if booking: + booking.status = status + self.db.commit() + self.db.refresh(booking) + return booking + + def add_review(self, booking_id: uuid.UUID, reviewer_id: uuid.UUID, reviewee_id: uuid.UUID, rating: int, comment: Optional[str] = None) -> Review: + review = Review( + booking_id=booking_id, + reviewer_id=reviewer_id, + reviewee_id=reviewee_id, + rating=rating, + comment=comment + ) + self.db.add(review) + self.db.commit() + self.db.refresh(review) + return review diff --git a/app/repositories/jobs.py b/app/repositories/jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..85feca84b8296bde2ffa646019c851ad8dafba6a --- /dev/null +++ b/app/repositories/jobs.py @@ -0,0 +1,42 @@ +import uuid +from typing import List, Optional +from sqlalchemy.orm import Session +from backend.app.models.jobs import Job + +class JobRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, customer_id: uuid.UUID, skill_required: str, location: str, budget: int, description: str) -> Job: + job = Job( + customer_id=customer_id, + skill_required=skill_required, + location=location, + budget=budget, + description=description, + status="POSTED" + ) + self.db.add(job) + self.db.commit() + self.db.refresh(job) + return job + + def get_by_id(self, job_id: uuid.UUID) -> Optional[Job]: + return self.db.query(Job).filter(Job.id == job_id).first() + + def get_all(self, skill: Optional[str] = None) -> List[Job]: + query = self.db.query(Job) + if skill and skill != "All": + query = query.filter(Job.skill_required == skill) + return query.order_by(Job.created_at.desc()).all() + + def get_by_customer(self, customer_id: uuid.UUID) -> List[Job]: + return self.db.query(Job).filter(Job.customer_id == customer_id).order_by(Job.created_at.desc()).all() + + def update_status(self, job_id: uuid.UUID, status: str) -> Optional[Job]: + job = self.get_by_id(job_id) + if job: + job.status = status + self.db.commit() + self.db.refresh(job) + return job diff --git a/app/repositories/roster.py b/app/repositories/roster.py new file mode 100644 index 0000000000000000000000000000000000000000..f1590b280c8f24298fb7e04d88cd66de7e916043 --- /dev/null +++ b/app/repositories/roster.py @@ -0,0 +1,44 @@ +import uuid +from typing import List, Optional +from sqlalchemy.orm import Session +from backend.app.models.roster import RosterWorker + +class RosterRepository: + def __init__(self, db: Session): + self.db = db + + def create(self, mediator_id: uuid.UUID, name: str, skill: str, phone_status: str = "IVR only", status: str = "Available", commission: int = 0) -> RosterWorker: + worker = RosterWorker( + mediator_id=mediator_id, + name=name, + skill=skill, + phone_status=phone_status, + status=status, + commission=commission + ) + self.db.add(worker) + self.db.commit() + self.db.refresh(worker) + return worker + + def get_by_mediator(self, mediator_id: uuid.UUID) -> List[RosterWorker]: + return self.db.query(RosterWorker).filter(RosterWorker.mediator_id == mediator_id).order_by(RosterWorker.created_at.desc()).all() + + def get_by_id(self, worker_id: uuid.UUID) -> Optional[RosterWorker]: + return self.db.query(RosterWorker).filter(RosterWorker.id == worker_id).first() + + def update_status(self, worker_id: uuid.UUID, status: str) -> Optional[RosterWorker]: + worker = self.get_by_id(worker_id) + if worker: + worker.status = status + self.db.commit() + self.db.refresh(worker) + return worker + + def add_commission(self, worker_id: uuid.UUID, amount: int) -> Optional[RosterWorker]: + worker = self.get_by_id(worker_id) + if worker: + worker.commission += amount + self.db.commit() + self.db.refresh(worker) + return worker diff --git a/app/repositories/users.py b/app/repositories/users.py new file mode 100644 index 0000000000000000000000000000000000000000..beccd840087b26c23e7be91561a97d61f0ff6faf --- /dev/null +++ b/app/repositories/users.py @@ -0,0 +1,94 @@ +import uuid +from typing import List, Optional +from sqlalchemy.orm import Session +from backend.app.models.users import User, WorkerProfile, CustomerProfile + +class UserRepository: + def __init__(self, db: Session): + self.db = db + + def get_by_id(self, user_id: uuid.UUID) -> Optional[User]: + return self.db.query(User).filter(User.id == user_id).first() + + def get_by_phone(self, phone: str) -> Optional[User]: + return self.db.query(User).filter(User.phone == phone).first() + + def create_customer(self, name: str, phone: str, city: str = "Jaipur") -> User: + user = User(name=name, phone=phone, role="customer", city=city) + self.db.add(user) + self.db.flush() # get user id + + customer_profile = CustomerProfile(user_id=user.id, wallet_balance=8200) + self.db.add(customer_profile) + self.db.commit() + self.db.refresh(user) + return user + + def create_worker( + self, + name: str, + phone: str, + skill: str, + rate: int, + city: str = "Jaipur", + map_x: int = 50, + map_y: int = 50 + ) -> User: + user = User(name=name, phone=phone, role="worker", city=city) + self.db.add(user) + self.db.flush() + + # Calculate simulated distance from customer (center 50, 50) + import math + dist = round(math.sqrt((map_x - 50)**2 + (map_y - 50)**2) / 15, 1) or 1.2 + + worker_profile = WorkerProfile( + user_id=user.id, + skill=skill, + rate=rate, + distance=dist, + map_x=map_x, + map_y=map_y + ) + self.db.add(worker_profile) + self.db.commit() + self.db.refresh(user) + return user + + def get_all_users(self) -> List[User]: + return self.db.query(User).all() + + def get_all_workers( + self, + skill: Optional[str] = None, + radius: Optional[float] = None, + online_only: bool = False + ) -> List[User]: + query = self.db.query(User).join(WorkerProfile).filter(User.role == "worker") + + if skill and skill != "All": + query = query.filter(WorkerProfile.skill == skill) + + if online_only: + query = query.filter(WorkerProfile.online == True) + + if radius: + query = query.filter(WorkerProfile.distance <= radius) + + return query.all() + + def update_user_status(self, user_id: uuid.UUID, is_active: bool) -> Optional[User]: + user = self.get_by_id(user_id) + if user: + user.is_active = is_active + self.db.commit() + self.db.refresh(user) + return user + + def delete_user(self, user_id: uuid.UUID) -> bool: + user = self.get_by_id(user_id) + if user: + self.db.delete(user) + self.db.commit() + return True + return False diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py new file mode 100644 index 0000000000000000000000000000000000000000..c7248d043573fd3e2237d7fa58481d3a46824885 --- /dev/null +++ b/app/repositories/wallet.py @@ -0,0 +1,48 @@ +import uuid +from typing import List, Optional +from sqlalchemy.orm import Session +from backend.app.models.wallet import WalletTransaction +from backend.app.models.users import CustomerProfile, User + +class WalletRepository: + def __init__(self, db: Session): + self.db = db + + def get_customer_profile(self, user_id: uuid.UUID) -> Optional[CustomerProfile]: + return self.db.query(CustomerProfile).filter(CustomerProfile.user_id == user_id).first() + + def update_balance(self, user_id: uuid.UUID, amount_change: int) -> int: + """Update customer wallet balance and return the new balance.""" + profile = self.get_customer_profile(user_id) + if not profile: + # Create a customer profile dynamically if it doesn't exist + profile = CustomerProfile(user_id=user_id, wallet_balance=0) + self.db.add(profile) + self.db.flush() + + profile.wallet_balance += amount_change + self.db.commit() + return profile.wallet_balance + + def add_transaction( + self, + user_id: uuid.UUID, + label: str, + amount: int, + type_: str, + booking_id: Optional[uuid.UUID] = None + ) -> WalletTransaction: + tx = WalletTransaction( + user_id=user_id, + booking_id=booking_id, + label=label, + amount=amount, + type=type_ + ) + self.db.add(tx) + self.db.commit() + self.db.refresh(tx) + return tx + + def get_user_transactions(self, user_id: uuid.UUID) -> List[WalletTransaction]: + return self.db.query(WalletTransaction).filter(WalletTransaction.user_id == user_id).order_by(WalletTransaction.created_at.desc()).all() diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e405381fe910189ae1e2d56d6cb7760c95dc6f92 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1,17 @@ +from backend.app.routers.auth import router as auth_router +from backend.app.routers.workers import router as workers_router +from backend.app.routers.jobs import router as jobs_router +from backend.app.routers.bookings import router as bookings_router +from backend.app.routers.wallet import router as wallet_router +from backend.app.routers.roster import router as roster_router +from backend.app.routers.admin import router as admin_router + +__all__ = [ + "auth_router", + "workers_router", + "jobs_router", + "bookings_router", + "wallet_router", + "roster_router", + "admin_router", +] diff --git a/app/routers/__pycache__/__init__.cpython-314.pyc b/app/routers/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..32b5647fc97b6fe1c566ab13b458665f010ccebb Binary files /dev/null and b/app/routers/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/routers/__pycache__/admin.cpython-314.pyc b/app/routers/__pycache__/admin.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1b43754bd56372806643ebe2c022401b658e7cd Binary files /dev/null and b/app/routers/__pycache__/admin.cpython-314.pyc differ diff --git a/app/routers/__pycache__/auth.cpython-314.pyc b/app/routers/__pycache__/auth.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b6ee0db4cdd151b3ece3ad2bbb427d01bf4bf7c4 Binary files /dev/null and b/app/routers/__pycache__/auth.cpython-314.pyc differ diff --git a/app/routers/__pycache__/bookings.cpython-314.pyc b/app/routers/__pycache__/bookings.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..212214b151063afe4a944f5b53ee189817f316fe Binary files /dev/null and b/app/routers/__pycache__/bookings.cpython-314.pyc differ diff --git a/app/routers/__pycache__/jobs.cpython-314.pyc b/app/routers/__pycache__/jobs.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..22005857cb23e5deb37fe9254cd59215815a48ba Binary files /dev/null and b/app/routers/__pycache__/jobs.cpython-314.pyc differ diff --git a/app/routers/__pycache__/roster.cpython-314.pyc b/app/routers/__pycache__/roster.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2bcaebad30a1fda5e77a183ae1fb531b9a1f05f3 Binary files /dev/null and b/app/routers/__pycache__/roster.cpython-314.pyc differ diff --git a/app/routers/__pycache__/wallet.cpython-314.pyc b/app/routers/__pycache__/wallet.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5c9522c97e80eb891837302fdac83d2856dc6f8 Binary files /dev/null and b/app/routers/__pycache__/wallet.cpython-314.pyc differ diff --git a/app/routers/__pycache__/workers.cpython-314.pyc b/app/routers/__pycache__/workers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2efb4ce715b49e11c5b924c715d99930e4297b2e Binary files /dev/null and b/app/routers/__pycache__/workers.cpython-314.pyc differ diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..d710127f762dcca7d64c5a911432e33a447aaee6 --- /dev/null +++ b/app/routers/admin.py @@ -0,0 +1,153 @@ +from typing import List +import uuid +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from sqlalchemy import func +from backend.app.core.database import get_db +from backend.app.routers.auth import get_current_user +from backend.app.repositories.users import UserRepository +from backend.app.repositories.alerts import AlertRepository +from backend.app.repositories.bookings import BookingRepository +from backend.app.services.bookings import BookingService +from backend.app.schemas.admin import UserAdminView, AdminAlertResponse, AdminAnalytics +from backend.app.models.users import User, WorkerProfile +from backend.app.models.jobs import Job +from backend.app.models.bookings import Booking +from backend.app.models.alerts import AdminAlert + +router = APIRouter(prefix="/admin", tags=["admin"]) + +def check_admin(current_user: User): + """Utility to restrict access to Admins only.""" + if current_user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only administrators can access this endpoint." + ) + +@router.get("/users", response_model=List[UserAdminView]) +def get_users( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + List all registered platform users. (Admins only). + """ + check_admin(current_user) + user_repo = UserRepository(db) + return user_repo.get_all_users() + +@router.patch("/users/{user_id}/status", response_model=UserAdminView) +def toggle_user_status( + user_id: uuid.UUID, + is_active: bool, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Suspend or activate a user account. (Admins only). + """ + check_admin(current_user) + user_repo = UserRepository(db) + user = user_repo.update_user_status(user_id, is_active) + if not user: + raise HTTPException(status_code=404, detail="User not found.") + return user + +@router.delete("/users/{user_id}", status_code=status.HTTP_200_OK) +def delete_user( + user_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Permanently delete a user account from the system. (Admins only). + """ + check_admin(current_user) + user_repo = UserRepository(db) + success = user_repo.delete_user(user_id) + if not success: + raise HTTPException(status_code=404, detail="User not found.") + return {"message": "User deleted successfully."} + +@router.get("/alerts", response_model=List[AdminAlertResponse]) +def get_alerts( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + List active fraud, dispute, and IVR platform warnings. (Admins only). + """ + check_admin(current_user) + alert_repo = AlertRepository(db) + return alert_repo.get_active() + +@router.post("/alerts/{alert_id}/resolve", response_model=AdminAlertResponse) +def resolve_alert( + alert_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Mark a warning alert as resolved, removing it from the admin review queue. (Admins only). + """ + check_admin(current_user) + alert_repo = AlertRepository(db) + + # Check if alert is a Dispute and needs resolving + alert = alert_repo.get_by_id(alert_id) + if alert and alert.type == "Dispute": + # Resolve dispute by refunding customer as a mock action + # Extract booking code from alert text if possible + import re + match = re.search(r"B-\d+", alert.text) + if match: + code = match.group(0) + booking_repo = BookingRepository(db) + booking = booking_repo.get_by_code(code) + if booking and booking.status == "DISPUTED": + booking_service = BookingService(db) + booking_service.refund_booking(booking.id) + + resolved = alert_repo.resolve(alert_id) + if not resolved: + raise HTTPException(status_code=404, detail="Alert not found.") + return resolved + +@router.get("/analytics", response_model=AdminAnalytics) +def get_analytics( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Calculate platform performance indicators and metrics in real-time. (Admins only). + """ + check_admin(current_user) + + workers_count = db.query(User).filter(User.role == "worker").count() + customers_count = db.query(User).filter(User.role == "customer").count() + jobs_count = db.query(Job).count() + bookings = db.query(Booking).all() + bookings_count = len(bookings) + + # Sum booking values + gmv = sum(b.amount for b in bookings) + platform_commission = sum(round(b.amount * 0.05) for b in bookings if b.status in ("COMPLETED", "RATED")) + + # Alert counts + alerts = db.query(AdminAlert).filter(AdminAlert.status == "active").all() + disputes = sum(1 for a in alerts if a.type == "Dispute") + frauds = sum(1 for a in alerts if a.type == "Fraud") + ivrs = sum(1 for a in alerts if a.type == "IVR") + + return AdminAnalytics( + total_workers=workers_count, + total_customers=customers_count, + total_jobs=jobs_count, + active_bookings=bookings_count, + gmv=gmv, + platform_commission=platform_commission, + disputes_count=disputes, + fraud_alerts_count=frauds, + ivr_alerts_count=ivrs + ) diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..46b0d5a848a4fa278ba7277b3080cbc04cf2e9cb --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Depends, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session +from backend.app.core.database import get_db +from backend.app.services.auth import AuthService +from backend.app.schemas.auth import UserRegister, UserLogin, UserResponse, Token +from backend.app.models.users import User + +router = APIRouter(prefix="/auth", tags=["auth"]) + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) + +def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User: + """Dependency to retrieve the currently logged in user using their JWT token.""" + from fastapi import HTTPException + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated. Missing bearer token." + ) + auth_service = AuthService(db) + return auth_service.get_current_user_by_token(token) + +@router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def signup(data: UserRegister, db: Session = Depends(get_db)): + """Register a new customer or worker account.""" + auth_service = AuthService(db) + return auth_service.signup(data) + +@router.post("/login", response_model=Token) +def login(data: UserLogin, db: Session = Depends(get_db)): + """Verify phone + OTP (demo otp: 123456) and return JWT credentials.""" + auth_service = AuthService(db) + return auth_service.login(data) + +@router.get("/me", response_model=UserResponse) +def get_me(current_user: User = Depends(get_current_user)): + """Fetch profile details of the currently authenticated user.""" + return current_user diff --git a/app/routers/bookings.py b/app/routers/bookings.py new file mode 100644 index 0000000000000000000000000000000000000000..445812b1bcc4a4ce017d890ee0886dc7d1eb468e --- /dev/null +++ b/app/routers/bookings.py @@ -0,0 +1,133 @@ +from typing import List +import uuid +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from backend.app.core.database import get_db +from backend.app.routers.auth import get_current_user +from backend.app.services.bookings import BookingService +from backend.app.repositories.users import UserRepository +from backend.app.schemas.bookings import BookingCreate, BookingStatusUpdate, BookingReviewCreate, BookingResponse +from backend.app.models.users import User + +router = APIRouter(prefix="/bookings", tags=["bookings"]) + +def map_booking_response(booking, db: Session) -> BookingResponse: + """Helper to map a database Booking model to a detailed BookingResponse Pydantic schema.""" + user_repo = UserRepository(db) + customer = user_repo.get_by_id(booking.customer_id) + worker = user_repo.get_by_id(booking.worker_id) + + job_title = "Direct Hire" + if booking.job: + job_title = f"{booking.job.skill_required} at {booking.job.location}" + + return BookingResponse( + id=booking.id, + code=booking.code, + job_id=booking.job_id, + customer_id=booking.customer_id, + worker_id=booking.worker_id, + amount=booking.amount, + status=booking.status, + created_at=booking.created_at, + customer_name=customer.name if customer else "Customer", + worker_name=worker.name if worker else "Worker", + job_title=job_title + ) + +@router.post("", response_model=BookingResponse, status_code=status.HTTP_201_CREATED) +def book_worker( + data: BookingCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Create a new booking and place booking funds in escrow hold. (Customers only). + """ + if current_user.role not in ("customer", "admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only customers can book workers." + ) + + booking_service = BookingService(db) + booking = booking_service.create_booking( + customer_id=current_user.id, + worker_id=data.worker_id, + job_id=data.job_id + ) + return map_booking_response(booking, db) + +@router.get("", response_model=List[BookingResponse]) +def list_bookings( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + List bookings associated with the currently authenticated user. + """ + booking_service = BookingService(db) + if current_user.role == "worker": + bookings = booking_service.booking_repo.get_by_worker(current_user.id) + elif current_user.role == "customer": + bookings = booking_service.booking_repo.get_by_customer(current_user.id) + else: # admin/mediator see all + bookings = booking_service.booking_repo.get_all() + + return [map_booking_response(b, db) for b in bookings] + +@router.patch("/{booking_id}/status", response_model=BookingResponse) +def update_booking_status( + booking_id: uuid.UUID, + data: BookingStatusUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Advance the booking status (e.g. ACCEPTED -> IN_PROGRESS -> COMPLETED). + Triggers escrow payout release upon completion. + """ + booking_service = BookingService(db) + booking = booking_service.advance_booking_status( + booking_id=booking_id, + new_status=data.status, + actor_id=current_user.id + ) + return map_booking_response(booking, db) + +@router.post("/{booking_id}/dispute", response_model=BookingResponse) +def dispute_booking( + booking_id: uuid.UUID, + reason: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Mark a booking as disputed. Places a warning in the Admin review queue. + """ + booking_service = BookingService(db) + booking = booking_service.dispute_booking( + booking_id=booking_id, + reason=reason, + actor_id=current_user.id + ) + return map_booking_response(booking, db) + +@router.post("/{booking_id}/review", status_code=status.HTTP_201_CREATED) +def review_booking( + booking_id: uuid.UUID, + data: BookingReviewCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Submit a star rating and comment for a worker. Recalculates worker rating profile. + """ + booking_service = BookingService(db) + booking_service.submit_review( + booking_id=booking_id, + rating=data.rating, + comment=data.comment, + reviewer_id=current_user.id + ) + return {"message": "Review submitted successfully."} diff --git a/app/routers/jobs.py b/app/routers/jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..8a09b776472ae63d0ac26f1af3381d64c232f0f3 --- /dev/null +++ b/app/routers/jobs.py @@ -0,0 +1,58 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from backend.app.core.database import get_db +from backend.app.routers.auth import get_current_user +from backend.app.repositories.jobs import JobRepository +from backend.app.schemas.jobs import JobCreate, JobResponse +from backend.app.models.users import User + +router = APIRouter(prefix="/jobs", tags=["jobs"]) + +@router.post("", response_model=JobResponse, status_code=status.HTTP_201_CREATED) +def post_job( + data: JobCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Post a new daily labor job description. (Customers only). + """ + if current_user.role not in ("customer", "admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only customers can post jobs." + ) + + job_repo = JobRepository(db) + return job_repo.create( + customer_id=current_user.id, + skill_required=data.skill_required, + location=data.location, + budget=data.budget, + description=data.description + ) + +@router.get("", response_model=List[JobResponse]) +def list_jobs(skill: Optional[str] = None, db: Session = Depends(get_db)): + """ + List open daily labor jobs. + """ + job_repo = JobRepository(db) + return job_repo.get_all(skill=skill) + +@router.get("/my", response_model=List[JobResponse]) +def list_my_jobs( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + List jobs posted by the currently logged in customer. + """ + job_repo = JobRepository(db) + if current_user.role == "worker": + # Workers see jobs matched to their skill + skill = current_user.worker_profile.skill if current_user.worker_profile else None + return job_repo.get_all(skill=skill) + + return job_repo.get_by_customer(current_user.id) diff --git a/app/routers/roster.py b/app/routers/roster.py new file mode 100644 index 0000000000000000000000000000000000000000..6f61cd808e89254735a64780768a11579818c274 --- /dev/null +++ b/app/routers/roster.py @@ -0,0 +1,53 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from backend.app.core.database import get_db +from backend.app.routers.auth import get_current_user +from backend.app.repositories.roster import RosterRepository +from backend.app.schemas.admin import RosterWorkerCreate, RosterWorkerResponse +from backend.app.models.users import User + +router = APIRouter(prefix="/roster", tags=["roster"]) + +@router.post("", response_model=RosterWorkerResponse, status_code=status.HTTP_201_CREATED) +def add_roster_worker( + data: RosterWorkerCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Add a new offline worker to the mediator's managed roster list. (Mediators/Thekedars only). + """ + if current_user.role not in ("mediator", "admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only mediators can manage a worker roster." + ) + + roster_repo = RosterRepository(db) + return roster_repo.create( + mediator_id=current_user.id, + name=data.name, + skill=data.skill, + phone_status=data.phone_status, + status=data.status, + commission=data.commission + ) + +@router.get("", response_model=List[RosterWorkerResponse]) +def get_roster( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Fetch the roster list of workers managed by this mediator. + """ + roster_repo = RosterRepository(db) + if current_user.role not in ("mediator", "admin"): + # For demo purposes, we will return a generic mock list if not mediator, but let's restrict or allow admin + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only mediators can access roster logs." + ) + + return roster_repo.get_by_mediator(current_user.id) diff --git a/app/routers/wallet.py b/app/routers/wallet.py new file mode 100644 index 0000000000000000000000000000000000000000..1a83de26586e96a67887aab9c6164f5d894201b7 --- /dev/null +++ b/app/routers/wallet.py @@ -0,0 +1,70 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from backend.app.core.database import get_db +from backend.app.routers.auth import get_current_user +from backend.app.repositories.wallet import WalletRepository +from backend.app.schemas.wallet import WalletTopup, WalletTransactionResponse +from backend.app.models.users import User + +router = APIRouter(prefix="/wallet", tags=["wallet"]) + +@router.post("/topup") +def topup_wallet( + data: WalletTopup, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Simulate a UPI/Razorpay wallet top-up, crediting the customer's balance. + """ + wallet_repo = WalletRepository(db) + + # Increase wallet balance + new_balance = wallet_repo.update_balance(current_user.id, data.amount) + + # Record transaction ledger log + wallet_repo.add_transaction( + user_id=current_user.id, + label="Wallet top-up via UPI", + amount=data.amount, + type_="credit" + ) + + return { + "message": f"Successfully recharged wallet by {data.amount} INR.", + "wallet_balance": new_balance + } + +@router.get("/transactions", response_model=List[WalletTransactionResponse]) +def get_transactions( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Retrieve the immutable ledger history for the current user. + """ + wallet_repo = WalletRepository(db) + return wallet_repo.get_user_transactions(current_user.id) + +@router.get("/balance") +def get_balance( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """ + Retrieve the current wallet balance of the authenticated user. + """ + wallet_repo = WalletRepository(db) + + if current_user.role == "customer": + profile = wallet_repo.get_customer_profile(current_user.id) + balance = profile.wallet_balance if profile else 8200 + elif current_user.role == "worker": + # Workers can have earnings in their profile wallet balance for viva demo convenience + profile = wallet_repo.get_customer_profile(current_user.id) + balance = profile.wallet_balance if profile else 0 + else: + balance = 0 + + return {"wallet_balance": balance} diff --git a/app/routers/workers.py b/app/routers/workers.py new file mode 100644 index 0000000000000000000000000000000000000000..e82ce7abc28ced822fb81653f22cabbc01fcc2bf --- /dev/null +++ b/app/routers/workers.py @@ -0,0 +1,94 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from backend.app.core.database import get_db +from backend.app.repositories.users import UserRepository +from backend.app.schemas.auth import WorkerSearchResult + +router = APIRouter(prefix="/workers", tags=["workers"]) + +def calculate_ai_score( + rating: float, + completion_rate: int, + distance: float, + online: bool, + verified: bool +) -> int: + """ + Simulated AI ranking score calculation. + Formula: rating * 7 + completion_rate * 0.25 + online (25 pts) + verified (15 pts) + distance (max 25 - distance * 3) + """ + online_score = 25 if online else 0 + verified_score = 15 if verified else 0 + distance_score = max(0.0, 25.0 - float(distance) * 3.0) + + score = (float(rating) * 7.0) + (int(completion_rate) * 0.25) + online_score + verified_score + distance_score + return round(score) + +@router.get("", response_model=List[WorkerSearchResult]) +def search_workers( + skill: Optional[str] = Query("All", description="Filter by worker skill type"), + radius: Optional[float] = Query(5.0, description="Max search radius in kilometers"), + online: Optional[str] = Query("yes", description="Filter: 'yes' for online only, 'all' for all"), + q: Optional[str] = Query("", alias="query", description="Search by name or skill query text"), + db: Session = Depends(get_db) +): + """ + Search and rank construction workers using the location-aware AI ranking algorithm. + """ + user_repo = UserRepository(db) + + # Resolve online boolean filter + online_only = (online == "yes") + + # Query database workers matching filters + workers = user_repo.get_all_workers( + skill=skill, + radius=radius, + online_only=online_only + ) + + results = [] + text_filter = q.strip().lower() + + for user in workers: + prof = user.worker_profile + if not prof: + continue + + # Text query filtering (by name or skill) + if text_filter and text_filter not in f"{user.name} {prof.skill}".lower(): + continue + + # Compute dynamic match score + score = calculate_ai_score( + rating=prof.rating, + completion_rate=prof.completion_rate, + distance=prof.distance, + online=prof.online, + verified=prof.verified + ) + + results.append( + WorkerSearchResult( + id=user.id, + name=user.name, + phone=user.phone, + city=user.city, + skill=prof.skill, + rate=prof.rate, + rating=prof.rating, + distance=prof.distance, + online=prof.online, + verified=prof.verified, + completed_jobs=prof.completed_jobs, + completion_rate=prof.completion_rate, + map_x=prof.map_x, + map_y=prof.map_y, + ai_score=score + ) + ) + + # Sort results in descending order of AI Match Score + results.sort(key=lambda w: w.ai_score, reverse=True) + return results diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..00b7b4b14471d64ce26171ba236734c466a16c5c --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,30 @@ +from backend.app.schemas.auth import Token, TokenData, UserLogin, UserRegister, UserResponse, WorkerProfileResponse, CustomerProfileResponse, WorkerSearchResult +from backend.app.schemas.jobs import JobCreate, JobResponse +from backend.app.schemas.bookings import BookingCreate, BookingStatusUpdate, BookingReviewCreate, BookingResponse +from backend.app.schemas.wallet import WalletTopup, WalletTransactionResponse +from backend.app.schemas.admin import RosterWorkerCreate, RosterWorkerResponse, AdminAlertResponse, UserAdminView, AdminAnalytics + +__all__ = [ + "Token", + "TokenData", + "UserLogin", + "UserRegister", + "UserResponse", + "WorkerProfileResponse", + "CustomerProfileResponse", + "WorkerSearchResult", + "JobCreate", + "JobResponse", + "BookingCreate", + "BookingStatusUpdate", + "BookingReviewCreate", + "BookingResponse", + "WalletTopup", + "WalletTransactionResponse", + "RosterWorkerCreate", + "RosterWorkerResponse", + "AdminAlertResponse", + "UserAdminView", + "AdminAnalytics", +] + diff --git a/app/schemas/__pycache__/__init__.cpython-314.pyc b/app/schemas/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4df698ee3e456cad618a95edb31a216b0249d520 Binary files /dev/null and b/app/schemas/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/schemas/__pycache__/admin.cpython-314.pyc b/app/schemas/__pycache__/admin.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c5f52bf93f82b6f093e95ea35438d38badca66d Binary files /dev/null and b/app/schemas/__pycache__/admin.cpython-314.pyc differ diff --git a/app/schemas/__pycache__/auth.cpython-314.pyc b/app/schemas/__pycache__/auth.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..93ee03df402ea47964438be8aefae217828c0c77 Binary files /dev/null and b/app/schemas/__pycache__/auth.cpython-314.pyc differ diff --git a/app/schemas/__pycache__/bookings.cpython-314.pyc b/app/schemas/__pycache__/bookings.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f9c10a084f75894310e6179c1e0daf391f5344dc Binary files /dev/null and b/app/schemas/__pycache__/bookings.cpython-314.pyc differ diff --git a/app/schemas/__pycache__/jobs.cpython-314.pyc b/app/schemas/__pycache__/jobs.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d0639dc2e4c966bb219d6213b55a8b81d0eafc73 Binary files /dev/null and b/app/schemas/__pycache__/jobs.cpython-314.pyc differ diff --git a/app/schemas/__pycache__/wallet.cpython-314.pyc b/app/schemas/__pycache__/wallet.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0afcadaaf14baa63cf16ed43b2e1f246fd6c6e92 Binary files /dev/null and b/app/schemas/__pycache__/wallet.cpython-314.pyc differ diff --git a/app/schemas/admin.py b/app/schemas/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..5e456f5615313f08e1145959a95cbd20a113d13f --- /dev/null +++ b/app/schemas/admin.py @@ -0,0 +1,61 @@ +import uuid +from datetime import datetime +from typing import List, Optional +from pydantic import BaseModel, Field + +class RosterWorkerCreate(BaseModel): + name: str = Field(..., min_length=2) + skill: str = Field(..., min_length=2) + phone_status: str = Field("IVR only", pattern="^(IVR only|Verified)$") + status: str = Field("Available", pattern="^(Available|On job)$") + commission: int = Field(0, ge=0) + +class RosterWorkerResponse(BaseModel): + id: uuid.UUID + mediator_id: uuid.UUID + name: str + skill: str + phone_status: str + status: str + commission: int + created_at: datetime + + class Config: + from_attributes = True + +class AdminAlertResponse(BaseModel): + id: uuid.UUID + type: str # Dispute, Fraud, IVR + text: str + severity: str # red, orange, gray + status: str # active, resolved + created_at: datetime + + class Config: + from_attributes = True + +class UserAdminView(BaseModel): + id: uuid.UUID + name: str + phone: str + role: str + city: str + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + +class AdminAnalytics(BaseModel): + total_workers: int + total_customers: int + total_jobs: int + active_bookings: int + gmv: int + platform_commission: int + disputes_count: int + fraud_alerts_count: int + ivr_alerts_count: int + + class Config: + from_attributes = True diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..e9f83056900c829557e41772536a5ae28ed5bf67 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,82 @@ +import uuid +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + +class Token(BaseModel): + access_token: str + token_type: str + role: str + +class TokenData(BaseModel): + phone: Optional[str] = None + +class UserLogin(BaseModel): + phone: str = Field(..., min_length=10, max_length=10, pattern=r"^\d{10}$") + otp: str = Field(..., min_length=6, max_length=6) + role: str = Field(..., pattern="^(customer|worker|mediator|admin)$") + +class UserRegister(BaseModel): + phone: str = Field(..., min_length=10, max_length=10, pattern=r"^\d{10}$") + name: str = Field(..., min_length=2) + role: str = Field(..., pattern="^(customer|worker|mediator|admin)$") + city: str = Field("Jaipur", min_length=2) + password: Optional[str] = None + + # Worker-specific fields (optional during registration, required if role is worker) + skill: Optional[str] = None + rate: Optional[int] = None + +class WorkerProfileResponse(BaseModel): + skill: str + rate: int + rating: float + distance: float + online: bool + verified: bool + completed_jobs: int + completion_rate: int + map_x: int + map_y: int + + class Config: + from_attributes = True + +class CustomerProfileResponse(BaseModel): + wallet_balance: int + + class Config: + from_attributes = True + +class UserResponse(BaseModel): + id: uuid.UUID + phone: str + name: str + role: str + city: str + is_active: bool + created_at: datetime + + worker_profile: Optional[WorkerProfileResponse] = None + customer_profile: Optional[CustomerProfileResponse] = None + + class Config: + from_attributes = True + +class WorkerSearchResult(BaseModel): + id: uuid.UUID + name: str + phone: str + city: str + skill: str + rate: int + rating: float + distance: float + online: bool + verified: bool + completed_jobs: int + completion_rate: int + map_x: int + map_y: int + ai_score: int + diff --git a/app/schemas/bookings.py b/app/schemas/bookings.py new file mode 100644 index 0000000000000000000000000000000000000000..69dac9ab0855f73994df3dc53a851c40e0dc850d --- /dev/null +++ b/app/schemas/bookings.py @@ -0,0 +1,34 @@ +import uuid +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + +class BookingCreate(BaseModel): + worker_id: uuid.UUID + job_id: Optional[uuid.UUID] = None + amount: Optional[int] = None # direct booking amount is worker rate, job booking is job budget + +class BookingStatusUpdate(BaseModel): + status: str = Field(..., pattern="^(ACCEPTED|IN_PROGRESS|COMPLETED|RATED|DISPUTED)$") + +class BookingReviewCreate(BaseModel): + rating: int = Field(..., ge=1, le=5) + comment: Optional[str] = None + +class BookingResponse(BaseModel): + id: uuid.UUID + code: str + job_id: Optional[uuid.UUID] = None + customer_id: uuid.UUID + worker_id: uuid.UUID + amount: int + status: str + created_at: datetime + + # Optional names for frontend convenience + customer_name: Optional[str] = None + worker_name: Optional[str] = None + job_title: Optional[str] = None + + class Config: + from_attributes = True diff --git a/app/schemas/jobs.py b/app/schemas/jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..1fcbed227ccc8fb6eea5eb6b9500e11f671a6708 --- /dev/null +++ b/app/schemas/jobs.py @@ -0,0 +1,23 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel, Field + +class JobCreate(BaseModel): + skill_required: str = Field(..., min_length=2) + location: str = Field(..., min_length=3) + budget: int = Field(..., gt=0) + description: str = Field(..., min_length=5) + +class JobResponse(BaseModel): + id: uuid.UUID + customer_id: uuid.UUID + skill_required: str + location: str + budget: int + description: str + status: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/app/schemas/wallet.py b/app/schemas/wallet.py new file mode 100644 index 0000000000000000000000000000000000000000..0593c87e558e610dab94d48e0fd608723aa5c3ab --- /dev/null +++ b/app/schemas/wallet.py @@ -0,0 +1,19 @@ +import uuid +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + +class WalletTopup(BaseModel): + amount: int = Field(..., gt=0) + +class WalletTransactionResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + booking_id: Optional[uuid.UUID] = None + label: str + amount: int + type: str # credit, hold, release, refund + created_at: datetime + + class Config: + from_attributes = True diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..135f2ae7f55c034032649b492670325c307f5880 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,7 @@ +from backend.app.services.auth import AuthService +from backend.app.services.bookings import BookingService + +__all__ = [ + "AuthService", + "BookingService", +] diff --git a/app/services/__pycache__/__init__.cpython-314.pyc b/app/services/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ce71d5147e47b691428f7dd015d6c3869b6c057b Binary files /dev/null and b/app/services/__pycache__/__init__.cpython-314.pyc differ diff --git a/app/services/__pycache__/auth.cpython-314.pyc b/app/services/__pycache__/auth.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..19483490d85bfabf7caaebb0a6fd135dbacd57d7 Binary files /dev/null and b/app/services/__pycache__/auth.cpython-314.pyc differ diff --git a/app/services/__pycache__/bookings.cpython-314.pyc b/app/services/__pycache__/bookings.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2607f59ab6edc5b63af302319981053570f4336c Binary files /dev/null and b/app/services/__pycache__/bookings.cpython-314.pyc differ diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..91e34fcc431b01c18a725b8c9b0178eafcc8a1cb --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,146 @@ +from typing import Optional +from sqlalchemy.orm import Session +from fastapi import HTTPException, status +from backend.app.core.security import get_password_hash, verify_password, create_access_token +from backend.app.repositories.users import UserRepository +from backend.app.schemas.auth import UserRegister, UserLogin +from backend.app.models.users import User + +class AuthService: + def __init__(self, db: Session): + self.db = db + self.user_repo = UserRepository(db) + + def signup(self, data: UserRegister) -> User: + """Register a new customer or worker.""" + # Check if phone already exists + existing_user = self.user_repo.get_by_phone(data.phone) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A user with this phone number is already registered." + ) + + # Hash password if provided + hashed_password = None + if data.password: + hashed_password = get_password_hash(data.password) + + if data.role == "worker": + # For workers, ensure rate and skill are provided + if not data.skill or data.rate is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Workers must provide a skill and a daily rate." + ) + + # Create worker and worker profile + user = self.user_repo.create_worker( + name=data.name, + phone=data.phone, + skill=data.skill, + rate=data.rate, + city=data.city + ) + # Set hashed password if provided + if hashed_password: + user.password_hash = hashed_password + self.db.commit() + elif data.role in ("customer", "mediator", "admin"): + # Create customer/mediator/admin + user = self.user_repo.create_customer( + name=data.name, + phone=data.phone, + city=data.city + ) + # Override default role if not customer + user.role = data.role + if hashed_password: + user.password_hash = hashed_password + self.db.commit() + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid registration role: {data.role}" + ) + + return user + + def login(self, data: UserLogin) -> dict: + """Authenticate user and return a JWT access token.""" + user = self.user_repo.get_by_phone(data.phone) + + # Bypass for demo OTP 123456 + if data.otp == "123456": + if not user: + # Dynamically create the user for demo convenience if they don't exist + if data.role == "worker": + user = self.user_repo.create_worker( + name="Ramesh Kumar", + phone=data.phone, + skill="Mason", + rate=650 + ) + else: + user = self.user_repo.create_customer( + name="Harsh", + phone=data.phone + ) + user.role = data.role + self.db.commit() + else: + # Update role if user log in with a different role in the demo selector + user.role = data.role + self.db.commit() + else: + # If not using demo OTP, check password/credentials + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials. Use demo OTP 123456." + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User account is suspended. Contact admin." + ) + + # Create access token + token = create_access_token(subject=user.phone) + return { + "access_token": token, + "token_type": "bearer", + "role": user.role + } + + def get_current_user_by_token(self, token: str) -> User: + """Decode JWT token and get the user.""" + from jose import jwt, JWTError + from backend.app.core.config import settings + + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + phone: str = payload.get("sub") + if phone is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials token signature." + ) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token. Please log in again." + ) + + user = self.user_repo.get_by_phone(phone) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found." + ) + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Account is suspended." + ) + return user diff --git a/app/services/bookings.py b/app/services/bookings.py new file mode 100644 index 0000000000000000000000000000000000000000..ac31f6035d6afb720c0a9332c51572e81f8a57f8 --- /dev/null +++ b/app/services/bookings.py @@ -0,0 +1,219 @@ +import uuid +from typing import Optional +from sqlalchemy.orm import Session +from fastapi import HTTPException, status +from backend.app.repositories.bookings import BookingRepository +from backend.app.repositories.wallet import WalletRepository +from backend.app.repositories.users import UserRepository +from backend.app.repositories.alerts import AlertRepository +from backend.app.models.bookings import Booking +from backend.app.models.reviews import Review + +class BookingService: + def __init__(self, db: Session): + self.db = db + self.booking_repo = BookingRepository(db) + self.wallet_repo = WalletRepository(db) + self.user_repo = UserRepository(db) + self.alert_repo = AlertRepository(db) + + def create_booking(self, customer_id: uuid.UUID, worker_id: uuid.UUID, job_id: Optional[uuid.UUID] = None) -> Booking: + """Create a booking, placing the amount in escrow hold.""" + worker = self.user_repo.get_by_id(worker_id) + if not worker or worker.role != "worker" or not worker.worker_profile: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Worker profile not found." + ) + + customer_profile = self.wallet_repo.get_customer_profile(customer_id) + wallet_balance = customer_profile.wallet_balance if customer_profile else 0 + + # Determine amount: direct booking uses daily rate + amount = worker.worker_profile.rate + + if wallet_balance < amount: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Insufficient wallet balance ({wallet_balance} INR). Escrow hold requires {amount} INR. Please top up." + ) + + # Deduct wallet balance from customer + self.wallet_repo.update_balance(customer_id, -amount) + + # Create booking + booking = self.booking_repo.create( + customer_id=customer_id, + worker_id=worker_id, + amount=amount, + job_id=job_id + ) + + # Record escrow hold in transaction ledger + self.wallet_repo.add_transaction( + user_id=customer_id, + booking_id=booking.id, + label=f"Escrow hold for booking {booking.code}", + amount=-amount, + type_="hold" + ) + + # Update job status if linked to a job post + if job_id: + from backend.app.repositories.jobs import JobRepository + JobRepository(self.db).update_status(job_id, "ACCEPTED") + + return booking + + def advance_booking_status(self, booking_id: uuid.UUID, new_status: str, actor_id: uuid.UUID) -> Booking: + """Advance the booking lifecycle state machine.""" + booking = self.booking_repo.get_by_id(booking_id) + if not booking: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Booking not found." + ) + + # Verify access: only customer, worker, or admin can update status + if actor_id not in (booking.customer_id, booking.worker_id): + actor = self.user_repo.get_by_id(actor_id) + if not actor or actor.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You are not authorized to update this booking." + ) + + valid_states = ["POSTED", "ACCEPTED", "IN_PROGRESS", "COMPLETED", "RATED"] + + # State machine validations + current_status = booking.status + if current_status == "COMPLETED" and new_status not in ("RATED", "DISPUTED"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Completed bookings can only be Rated or Disputed." + ) + + # Update status + self.booking_repo.update_status(booking_id, new_status) + + # Business logic for completion: release payout to worker wallet + if new_status == "COMPLETED" and current_status != "COMPLETED": + # Calculate worker payout: 95% of booking amount (5% platform commission) + commission = round(booking.amount * 0.05) + payout = booking.amount - commission + + # Release payout to worker + # For simplicity in demo, we record this in the worker's wallet or ledger transaction + self.wallet_repo.update_balance(booking.worker_id, payout) + + # Record release ledger transactions + self.wallet_repo.add_transaction( + user_id=booking.worker_id, + booking_id=booking.id, + label=f"Completed job payout for {booking.code} (95% release)", + amount=payout, + type_="release" + ) + + # Record platform fee + self.wallet_repo.add_transaction( + user_id=booking.customer_id, + booking_id=booking.id, + label=f"Escrow release for {booking.code} (5% platform fee deducted)", + amount=-booking.amount, + type_="release" + ) + + # Update worker profiles jobs counter + worker = self.user_repo.get_by_id(booking.worker_id) + if worker and worker.worker_profile: + worker.worker_profile.completed_jobs += 1 + self.db.commit() + + # Update linked job status + if booking.job_id: + from backend.app.repositories.jobs import JobRepository + job_status = "IN_PROGRESS" if new_status == "IN_PROGRESS" else "COMPLETED" if new_status in ("COMPLETED", "RATED") else "ACCEPTED" + JobRepository(self.db).update_status(booking.job_id, job_status) + + return booking + + def dispute_booking(self, booking_id: uuid.UUID, reason: str, actor_id: uuid.UUID) -> Booking: + """File a dispute for a booking, alerting the Admin queue.""" + booking = self.booking_repo.get_by_id(booking_id) + if not booking: + raise HTTPException(status_code=404, detail="Booking not found.") + + if actor_id not in (booking.customer_id, booking.worker_id): + raise HTTPException(status_code=403, detail="Not authorized.") + + # Update booking status + self.booking_repo.update_status(booking_id, "DISPUTED") + + # Generate admin alert + self.alert_repo.create( + type_="Dispute", + text=f"Dispute filed on {booking.code} by customer. Reason: {reason}", + severity="red" + ) + + return booking + + def refund_booking(self, booking_id: uuid.UUID) -> Booking: + """Admin action: Resolve dispute by refunding the customer.""" + booking = self.booking_repo.get_by_id(booking_id) + if not booking: + raise HTTPException(status_code=404, detail="Booking not found.") + + # Refund amount to customer wallet + self.wallet_repo.update_balance(booking.customer_id, booking.amount) + + # Log refund in ledger + self.wallet_repo.add_transaction( + user_id=booking.customer_id, + booking_id=booking.id, + label=f"Refund for cancelled booking {booking.code}", + amount=booking.amount, + type_="refund" + ) + + # Update status + self.booking_repo.update_status(booking_id, "CANCELLED") + + if booking.job_id: + from backend.app.repositories.jobs import JobRepository + JobRepository(self.db).update_status(booking.job_id, "POSTED") + + return booking + + def submit_review(self, booking_id: uuid.UUID, rating: int, comment: Optional[str], reviewer_id: uuid.UUID) -> Review: + """Rate a booking worker and update their average rating profile.""" + booking = self.booking_repo.get_by_id(booking_id) + if not booking: + raise HTTPException(status_code=404, detail="Booking not found.") + + if reviewer_id != booking.customer_id: + raise HTTPException(status_code=403, detail="Only customers can rate their bookings.") + + # Write review + review = self.booking_repo.add_review( + booking_id=booking_id, + reviewer_id=reviewer_id, + reviewee_id=booking.worker_id, + rating=rating, + comment=comment + ) + + # Advance status to rated + self.booking_repo.update_status(booking_id, "RATED") + + # Update worker rating in profile + worker = self.user_repo.get_by_id(booking.worker_id) + if worker and worker.worker_profile: + # Calculate average rating + reviews = self.db.query(Review).filter(Review.reviewee_id == booking.worker_id).all() + avg_rating = sum(r.rating for r in reviews) / len(reviews) + worker.worker_profile.rating = round(avg_rating, 2) + self.db.commit() + + return review diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000000000000000000000000000000000000..98e4f9c44effe479ed38c66ba922e7bcc672916f --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/__pycache__/env.cpython-314.pyc b/migrations/__pycache__/env.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc94595557ed7f0628748f538267feaf9eb6c818 Binary files /dev/null and b/migrations/__pycache__/env.cpython-314.pyc differ diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000000000000000000000000000000000000..bc6634f4af2e54754f424a6e2ee2fa58acfe5f78 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,61 @@ +import sys +from pathlib import Path +from logging.config import fileConfig + +from sqlalchemy import pool +from alembic import context + +# Add the project root to sys.path so we can import the backend package +root_path = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(root_path)) + +# Import database models and settings +from backend.app.models import Base +from backend.app.core.database import DATABASE_URL + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +target_metadata = Base.metadata + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = DATABASE_URL + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + # Create the engine dynamically from our DATABASE_URL + from sqlalchemy import create_engine + connectable = create_engine(DATABASE_URL, poolclass=pool.NullPool) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() + diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000000000000000000000000000000000000..11016301e749297acb67822efc7974ee53c905c6 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/9dc0f589f21c_initial_schema.py b/migrations/versions/9dc0f589f21c_initial_schema.py new file mode 100644 index 0000000000000000000000000000000000000000..59aaf62be1f98c9669c59ef34076b3231e2ecfbf --- /dev/null +++ b/migrations/versions/9dc0f589f21c_initial_schema.py @@ -0,0 +1,252 @@ +"""Initial schema + +Revision ID: 9dc0f589f21c +Revises: +Create Date: 2026-06-17 20:38:02.834335 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9dc0f589f21c' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('admin_alerts', + sa.Column('type', sa.String(), nullable=False), + sa.Column('text', sa.String(), nullable=False), + sa.Column('severity', sa.String(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_admin_alerts_created_at'), 'admin_alerts', ['created_at'], unique=False) + op.create_index(op.f('ix_admin_alerts_id'), 'admin_alerts', ['id'], unique=False) + op.create_index(op.f('ix_admin_alerts_status'), 'admin_alerts', ['status'], unique=False) + op.create_index(op.f('ix_admin_alerts_type'), 'admin_alerts', ['type'], unique=False) + op.create_table('users', + sa.Column('name', sa.String(), nullable=False), + sa.Column('phone', sa.String(), nullable=False), + sa.Column('role', sa.String(), nullable=False), + sa.Column('password_hash', sa.String(), nullable=True), + sa.Column('city', sa.String(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_created_at'), 'users', ['created_at'], unique=False) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_index(op.f('ix_users_phone'), 'users', ['phone'], unique=True) + op.create_index(op.f('ix_users_role'), 'users', ['role'], unique=False) + op.create_table('customer_profiles', + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('wallet_balance', sa.Integer(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_customer_profiles_created_at'), 'customer_profiles', ['created_at'], unique=False) + op.create_index(op.f('ix_customer_profiles_id'), 'customer_profiles', ['id'], unique=False) + op.create_index(op.f('ix_customer_profiles_user_id'), 'customer_profiles', ['user_id'], unique=True) + op.create_table('jobs', + sa.Column('customer_id', sa.UUID(), nullable=False), + sa.Column('skill_required', sa.String(), nullable=False), + sa.Column('location', sa.String(), nullable=False), + sa.Column('budget', sa.Integer(), nullable=False), + sa.Column('description', sa.String(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['customer_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_jobs_created_at'), 'jobs', ['created_at'], unique=False) + op.create_index(op.f('ix_jobs_customer_id'), 'jobs', ['customer_id'], unique=False) + op.create_index(op.f('ix_jobs_id'), 'jobs', ['id'], unique=False) + op.create_index(op.f('ix_jobs_skill_required'), 'jobs', ['skill_required'], unique=False) + op.create_index(op.f('ix_jobs_status'), 'jobs', ['status'], unique=False) + op.create_table('notifications', + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('message', sa.String(), nullable=False), + sa.Column('read', sa.Boolean(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_notifications_created_at'), 'notifications', ['created_at'], unique=False) + op.create_index(op.f('ix_notifications_id'), 'notifications', ['id'], unique=False) + op.create_index(op.f('ix_notifications_read'), 'notifications', ['read'], unique=False) + op.create_index(op.f('ix_notifications_user_id'), 'notifications', ['user_id'], unique=False) + op.create_table('roster_workers', + sa.Column('mediator_id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('skill', sa.String(), nullable=False), + sa.Column('phone_status', sa.String(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('commission', sa.Integer(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['mediator_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_roster_workers_created_at'), 'roster_workers', ['created_at'], unique=False) + op.create_index(op.f('ix_roster_workers_id'), 'roster_workers', ['id'], unique=False) + op.create_index(op.f('ix_roster_workers_mediator_id'), 'roster_workers', ['mediator_id'], unique=False) + op.create_table('worker_profiles', + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('skill', sa.String(), nullable=False), + sa.Column('rate', sa.Integer(), nullable=False), + sa.Column('rating', sa.Float(), nullable=False), + sa.Column('distance', sa.Float(), nullable=False), + sa.Column('online', sa.Boolean(), nullable=False), + sa.Column('verified', sa.Boolean(), nullable=False), + sa.Column('completed_jobs', sa.Integer(), nullable=False), + sa.Column('completion_rate', sa.Integer(), nullable=False), + sa.Column('map_x', sa.Integer(), nullable=False), + sa.Column('map_y', sa.Integer(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_worker_profiles_created_at'), 'worker_profiles', ['created_at'], unique=False) + op.create_index(op.f('ix_worker_profiles_id'), 'worker_profiles', ['id'], unique=False) + op.create_index(op.f('ix_worker_profiles_skill'), 'worker_profiles', ['skill'], unique=False) + op.create_index(op.f('ix_worker_profiles_user_id'), 'worker_profiles', ['user_id'], unique=True) + op.create_table('bookings', + sa.Column('code', sa.String(), nullable=False), + sa.Column('job_id', sa.UUID(), nullable=True), + sa.Column('customer_id', sa.UUID(), nullable=False), + sa.Column('worker_id', sa.UUID(), nullable=False), + sa.Column('amount', sa.Integer(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['customer_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['job_id'], ['jobs.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['worker_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_bookings_code'), 'bookings', ['code'], unique=True) + op.create_index(op.f('ix_bookings_created_at'), 'bookings', ['created_at'], unique=False) + op.create_index(op.f('ix_bookings_customer_id'), 'bookings', ['customer_id'], unique=False) + op.create_index(op.f('ix_bookings_id'), 'bookings', ['id'], unique=False) + op.create_index(op.f('ix_bookings_status'), 'bookings', ['status'], unique=False) + op.create_index(op.f('ix_bookings_worker_id'), 'bookings', ['worker_id'], unique=False) + op.create_table('reviews', + sa.Column('booking_id', sa.UUID(), nullable=False), + sa.Column('reviewer_id', sa.UUID(), nullable=False), + sa.Column('reviewee_id', sa.UUID(), nullable=False), + sa.Column('rating', sa.Integer(), nullable=False), + sa.Column('comment', sa.String(), nullable=True), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['reviewee_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['reviewer_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_reviews_booking_id'), 'reviews', ['booking_id'], unique=False) + op.create_index(op.f('ix_reviews_created_at'), 'reviews', ['created_at'], unique=False) + op.create_index(op.f('ix_reviews_id'), 'reviews', ['id'], unique=False) + op.create_index(op.f('ix_reviews_reviewee_id'), 'reviews', ['reviewee_id'], unique=False) + op.create_index(op.f('ix_reviews_reviewer_id'), 'reviews', ['reviewer_id'], unique=False) + op.create_table('wallet_transactions', + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('booking_id', sa.UUID(), nullable=True), + sa.Column('label', sa.String(), nullable=False), + sa.Column('amount', sa.Integer(), nullable=False), + sa.Column('type', sa.String(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_wallet_transactions_created_at'), 'wallet_transactions', ['created_at'], unique=False) + op.create_index(op.f('ix_wallet_transactions_id'), 'wallet_transactions', ['id'], unique=False) + op.create_index(op.f('ix_wallet_transactions_type'), 'wallet_transactions', ['type'], unique=False) + op.create_index(op.f('ix_wallet_transactions_user_id'), 'wallet_transactions', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_wallet_transactions_user_id'), table_name='wallet_transactions') + op.drop_index(op.f('ix_wallet_transactions_type'), table_name='wallet_transactions') + op.drop_index(op.f('ix_wallet_transactions_id'), table_name='wallet_transactions') + op.drop_index(op.f('ix_wallet_transactions_created_at'), table_name='wallet_transactions') + op.drop_table('wallet_transactions') + op.drop_index(op.f('ix_reviews_reviewer_id'), table_name='reviews') + op.drop_index(op.f('ix_reviews_reviewee_id'), table_name='reviews') + op.drop_index(op.f('ix_reviews_id'), table_name='reviews') + op.drop_index(op.f('ix_reviews_created_at'), table_name='reviews') + op.drop_index(op.f('ix_reviews_booking_id'), table_name='reviews') + op.drop_table('reviews') + op.drop_index(op.f('ix_bookings_worker_id'), table_name='bookings') + op.drop_index(op.f('ix_bookings_status'), table_name='bookings') + op.drop_index(op.f('ix_bookings_id'), table_name='bookings') + op.drop_index(op.f('ix_bookings_customer_id'), table_name='bookings') + op.drop_index(op.f('ix_bookings_created_at'), table_name='bookings') + op.drop_index(op.f('ix_bookings_code'), table_name='bookings') + op.drop_table('bookings') + op.drop_index(op.f('ix_worker_profiles_user_id'), table_name='worker_profiles') + op.drop_index(op.f('ix_worker_profiles_skill'), table_name='worker_profiles') + op.drop_index(op.f('ix_worker_profiles_id'), table_name='worker_profiles') + op.drop_index(op.f('ix_worker_profiles_created_at'), table_name='worker_profiles') + op.drop_table('worker_profiles') + op.drop_index(op.f('ix_roster_workers_mediator_id'), table_name='roster_workers') + op.drop_index(op.f('ix_roster_workers_id'), table_name='roster_workers') + op.drop_index(op.f('ix_roster_workers_created_at'), table_name='roster_workers') + op.drop_table('roster_workers') + op.drop_index(op.f('ix_notifications_user_id'), table_name='notifications') + op.drop_index(op.f('ix_notifications_read'), table_name='notifications') + op.drop_index(op.f('ix_notifications_id'), table_name='notifications') + op.drop_index(op.f('ix_notifications_created_at'), table_name='notifications') + op.drop_table('notifications') + op.drop_index(op.f('ix_jobs_status'), table_name='jobs') + op.drop_index(op.f('ix_jobs_skill_required'), table_name='jobs') + op.drop_index(op.f('ix_jobs_id'), table_name='jobs') + op.drop_index(op.f('ix_jobs_customer_id'), table_name='jobs') + op.drop_index(op.f('ix_jobs_created_at'), table_name='jobs') + op.drop_table('jobs') + op.drop_index(op.f('ix_customer_profiles_user_id'), table_name='customer_profiles') + op.drop_index(op.f('ix_customer_profiles_id'), table_name='customer_profiles') + op.drop_index(op.f('ix_customer_profiles_created_at'), table_name='customer_profiles') + op.drop_table('customer_profiles') + op.drop_index(op.f('ix_users_role'), table_name='users') + op.drop_index(op.f('ix_users_phone'), table_name='users') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_created_at'), table_name='users') + op.drop_table('users') + op.drop_index(op.f('ix_admin_alerts_type'), table_name='admin_alerts') + op.drop_index(op.f('ix_admin_alerts_status'), table_name='admin_alerts') + op.drop_index(op.f('ix_admin_alerts_id'), table_name='admin_alerts') + op.drop_index(op.f('ix_admin_alerts_created_at'), table_name='admin_alerts') + op.drop_table('admin_alerts') + # ### end Alembic commands ### diff --git a/migrations/versions/__pycache__/9dc0f589f21c_initial_schema.cpython-314.pyc b/migrations/versions/__pycache__/9dc0f589f21c_initial_schema.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51cb60f8f5405c2b172a5397ac8a8855315e0cd9 Binary files /dev/null and b/migrations/versions/__pycache__/9dc0f589f21c_initial_schema.cpython-314.pyc differ diff --git a/requirements.txt b/requirements.txt index b2790a691c07d06f5447942029db76e7f7f2d9db..5dc9e990792cb0675998df735ca4ce019d601d21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,12 @@ -flask -werkzeug -pillow -gradio \ No newline at end of file +fastapi>=0.111.0 +uvicorn[standard]>=0.30.1 +sqlalchemy>=2.0.30 +psycopg2-binary>=2.9.9 +pydantic>=2.10.0 +pydantic-settings>=2.3.1 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +python-multipart>=0.0.9 +alembic>=1.13.1 +pytest>=8.2.2 +httpx>=0.27.0 diff --git a/test.db b/test.db new file mode 100644 index 0000000000000000000000000000000000000000..69f58a252396e0eb3f785a04bea00ee3c34b97ed Binary files /dev/null and b/test.db differ diff --git a/tests/__pycache__/conftest.cpython-314-pytest-9.1.0.pyc b/tests/__pycache__/conftest.cpython-314-pytest-9.1.0.pyc new file mode 100644 index 0000000000000000000000000000000000000000..64e9efaa6a88a0ffc8238a49ae68205703bc552b Binary files /dev/null and b/tests/__pycache__/conftest.cpython-314-pytest-9.1.0.pyc differ diff --git a/tests/__pycache__/test_auth.cpython-314-pytest-9.1.0.pyc b/tests/__pycache__/test_auth.cpython-314-pytest-9.1.0.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd3310198057fa2af9515fba297bb4c053a9cc61 Binary files /dev/null and b/tests/__pycache__/test_auth.cpython-314-pytest-9.1.0.pyc differ diff --git a/tests/__pycache__/test_jobs_bookings.cpython-314-pytest-9.1.0.pyc b/tests/__pycache__/test_jobs_bookings.cpython-314-pytest-9.1.0.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1328bbc5892719422260d548492114148867c4d1 Binary files /dev/null and b/tests/__pycache__/test_jobs_bookings.cpython-314-pytest-9.1.0.pyc differ diff --git a/tests/__pycache__/test_wallet.cpython-314-pytest-9.1.0.pyc b/tests/__pycache__/test_wallet.cpython-314-pytest-9.1.0.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eeb339dc78ee89968adf8bc7cb22eefdda7853c9 Binary files /dev/null and b/tests/__pycache__/test_wallet.cpython-314-pytest-9.1.0.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..b7a503ba3ae99c9c5368d9266fc906aea48a89b0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,56 @@ +import pytest +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from fastapi.testclient import TestClient + +# Force settings to use sqlite test database +os.environ["DATABASE_URL"] = "sqlite:///./test.db" + +from backend.app.main import app +from backend.app.core.database import get_db +from backend.app.models.base import Base + +# Create test engine and session +SQLALCHEMY_DATABASE_URL = "sqlite:///./test_api.db" +engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +@pytest.fixture(scope="session", autouse=True) +def setup_db(): + # Drop and recreate tables + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + yield + # Clean up test database file + Base.metadata.drop_all(bind=engine) + if os.path.exists("./test_api.db"): + try: + os.remove("./test_api.db") + except PermissionError: + pass + +@pytest.fixture +def db_session(): + connection = engine.connect() + transaction = connection.begin() + session = TestingSessionLocal(bind=connection) + + yield session + + session.close() + transaction.rollback() + connection.close() + +@pytest.fixture +def client(db_session): + def override_get_db(): + try: + yield db_session + finally: + pass + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..debb418105f119c97e9bac4fe9294bfaedd8f201 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,113 @@ +import pytest + +def test_signup_customer(client): + response = client.post( + "/api/auth/signup", + json={ + "name": "Jane Doe", + "phone": "9876543211", + "role": "customer", + "city": "Jaipur" + } + ) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Jane Doe" + assert data["phone"] == "9876543211" + assert data["role"] == "customer" + assert "customer_profile" in data + assert data["customer_profile"]["wallet_balance"] == 8200 + +def test_signup_worker_fails_without_skill_or_rate(client): + response = client.post( + "/api/auth/signup", + json={ + "name": "Ramu", + "phone": "9876543212", + "role": "worker", + "city": "Jaipur" + } + ) + assert response.status_code == 400 + assert "detail" in response.json() + +def test_signup_worker_success(client): + response = client.post( + "/api/auth/signup", + json={ + "name": "Ramu Mason", + "phone": "9876543212", + "role": "worker", + "city": "Jaipur", + "skill": "Mason", + "rate": 500 + } + ) + assert response.status_code == 201 + data = response.json() + assert data["role"] == "worker" + assert "worker_profile" in data + assert data["worker_profile"]["skill"] == "Mason" + assert data["worker_profile"]["rate"] == 500 + +def test_login_demo_bypass_otp(client): + # Log in as a customer with phone and demo OTP + response = client.post( + "/api/auth/login", + json={ + "phone": "9876543213", + "role": "customer", + "otp": "123456" + } + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["role"] == "customer" + +def test_login_demo_bypass_otp_worker(client): + response = client.post( + "/api/auth/login", + json={ + "phone": "9876543214", + "role": "worker", + "otp": "123456" + } + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["role"] == "worker" + +def test_login_invalid_otp_fails(client): + response = client.post( + "/api/auth/login", + json={ + "phone": "9876543213", + "role": "customer", + "otp": "999999" + } + ) + assert response.status_code == 401 + +def test_fetch_me_profile_success(client): + # 1. Login to get token + login_response = client.post( + "/api/auth/login", + json={ + "phone": "9876543215", + "role": "customer", + "otp": "123456" + } + ) + token = login_response.json()["access_token"] + + # 2. Access me endpoint + response = client.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {token}"} + ) + assert response.status_code == 200 + data = response.json() + assert data["phone"] == "9876543215" + assert data["role"] == "customer" diff --git a/tests/test_jobs_bookings.py b/tests/test_jobs_bookings.py new file mode 100644 index 0000000000000000000000000000000000000000..d1717926cdc45ab154fc5d15cc939d8f406991a6 --- /dev/null +++ b/tests/test_jobs_bookings.py @@ -0,0 +1,204 @@ +import pytest + +def test_search_workers_sorting(client): + # Register customer to use search + client.post( + "/api/auth/signup", + json={ + "name": "Customer Search", + "phone": "9876543201", + "role": "customer", + "city": "Jaipur" + } + ) + # Register multiple workers with different rates and ratings to check sorting + client.post( + "/api/auth/signup", + json={ + "name": "Worker Low Rating", + "phone": "9876543202", + "role": "worker", + "city": "Jaipur", + "skill": "Mason", + "rate": 400 + } + ) + client.post( + "/api/auth/signup", + json={ + "name": "Worker High Rating", + "phone": "9876543203", + "role": "worker", + "city": "Jaipur", + "skill": "Mason", + "rate": 600 + } + ) + + # Call search endpoint + response = client.get("/api/workers", params={"skill": "Mason"}) + assert response.status_code == 200 + workers = response.json() + assert len(workers) >= 2 + + # Assert sorted by ai_score descending + scores = [w["ai_score"] for w in workers] + assert scores == sorted(scores, reverse=True) + +def test_post_and_list_jobs(client): + # Signup customer + signup_res = client.post( + "/api/auth/signup", + json={ + "name": "Customer Job", + "phone": "9876543204", + "role": "customer", + "city": "Jaipur" + } + ) + # Login to get token + login_res = client.post( + "/api/auth/login", + json={ + "phone": "9876543204", + "role": "customer", + "otp": "123456" + } + ) + token = login_res.json()["access_token"] + + # Post Job + job_res = client.post( + "/api/jobs", + json={ + "skill_required": "Electrician", + "location": "Mansarovar", + "budget": 800, + "description": "Install new circuit breaker box." + }, + headers={"Authorization": f"Bearer {token}"} + ) + assert job_res.status_code == 201 + job_data = job_res.json() + assert job_data["skill_required"] == "Electrician" + assert job_data["budget"] == 800 + + # List Jobs + list_res = client.get("/api/jobs") + assert list_res.status_code == 200 + jobs = list_res.json() + assert any(j["id"] == job_data["id"] for j in jobs) + +def test_booking_lifecycle_escrow(client): + # 1. Signup customer & worker + cust_res = client.post( + "/api/auth/signup", + json={ + "name": "Escrow Customer", + "phone": "9876543205", + "role": "customer", + "city": "Jaipur" + } + ) + work_res = client.post( + "/api/auth/signup", + json={ + "name": "Escrow Worker", + "phone": "9876543206", + "role": "worker", + "city": "Jaipur", + "skill": "Plumber", + "rate": 600 + } + ) + worker_id = work_res.json()["id"] + + # 2. Login customer + cust_login = client.post( + "/api/auth/login", + json={ + "phone": "9876543205", + "role": "customer", + "otp": "123456" + } + ) + cust_token = cust_login.json()["access_token"] + + # 3. Topup customer wallet + topup_res = client.post( + "/api/wallet/topup", + json={"amount": 1000}, + headers={"Authorization": f"Bearer {cust_token}"} + ) + assert topup_res.status_code == 200 + + # 4. Create booking + booking_res = client.post( + "/api/bookings", + json={"worker_id": worker_id}, + headers={"Authorization": f"Bearer {cust_token}"} + ) + assert booking_res.status_code == 201 + booking = booking_res.json() + assert booking["status"] == "ACCEPTED" + assert booking["amount"] == 600 + + # 5. Check customer balance is debited (8200 + 1000 - 600 = 8600) + bal_res = client.get( + "/api/wallet/balance", + headers={"Authorization": f"Bearer {cust_token}"} + ) + assert bal_res.json()["wallet_balance"] == 8600 + + # 6. Login worker to accept the booking + work_login = client.post( + "/api/auth/login", + json={ + "phone": "9876543206", + "role": "worker", + "otp": "123456" + } + ) + work_token = work_login.json()["access_token"] + + # Worker accepts booking + accept_res = client.patch( + f"/api/bookings/{booking['id']}/status", + json={"status": "ACCEPTED"}, + headers={"Authorization": f"Bearer {work_token}"} + ) + assert accept_res.status_code == 200 + assert accept_res.json()["status"] == "ACCEPTED" + + # Worker starts work + start_res = client.patch( + f"/api/bookings/{booking['id']}/status", + json={"status": "IN_PROGRESS"}, + headers={"Authorization": f"Bearer {work_token}"} + ) + assert start_res.status_code == 200 + assert start_res.json()["status"] == "IN_PROGRESS" + + # Worker completes work + complete_res = client.patch( + f"/api/bookings/{booking['id']}/status", + json={"status": "COMPLETED"}, + headers={"Authorization": f"Bearer {work_token}"} + ) + assert complete_res.status_code == 200 + assert complete_res.json()["status"] == "COMPLETED" + + # 7. Check worker balance receives 95% of 600 = 570 INR + work_bal = client.get( + "/api/wallet/balance", + headers={"Authorization": f"Bearer {work_token}"} + ) + # The default mock config might set worker profile wallet_balance. Let's make sure it increased by 570. + # We can also check the transaction log of the worker. + ledger_res = client.get( + "/api/wallet/transactions", + headers={"Authorization": f"Bearer {work_token}"} + ) + assert ledger_res.status_code == 200 + transactions = ledger_res.json() + assert any("payout" in t["label"].lower() and t["amount"] == 570 for t in transactions) diff --git a/tests/test_wallet.py b/tests/test_wallet.py new file mode 100644 index 0000000000000000000000000000000000000000..ae21d2cfd570e249928c49a132909abf754fcac9 --- /dev/null +++ b/tests/test_wallet.py @@ -0,0 +1,53 @@ +import pytest + +def test_wallet_ledger_audit(client): + # Register customer + client.post( + "/api/auth/signup", + json={ + "name": "Audit Customer", + "phone": "9876543209", + "role": "customer", + "city": "Jaipur" + } + ) + + # Login customer + login_res = client.post( + "/api/auth/login", + json={ + "phone": "9876543209", + "role": "customer", + "otp": "123456" + } + ) + token = login_res.json()["access_token"] + + # Retrieve initial transactions + res_initial = client.get( + "/api/wallet/transactions", + headers={"Authorization": f"Bearer {token}"} + ) + assert res_initial.status_code == 200 + assert len(res_initial.json()) == 0 + + # Execute a wallet topup + topup_res = client.post( + "/api/wallet/topup", + json={"amount": 1500}, + headers={"Authorization": f"Bearer {token}"} + ) + assert topup_res.status_code == 200 + assert topup_res.json()["wallet_balance"] == 9700 + + # Verify transactions ledger log + res_after = client.get( + "/api/wallet/transactions", + headers={"Authorization": f"Bearer {token}"} + ) + assert res_after.status_code == 200 + txs = res_after.json() + assert len(txs) == 1 + assert txs[0]["amount"] == 1500 + assert txs[0]["type"] == "credit" + assert "top-up" in txs[0]["label"].lower()