diff --git a/README.md b/README.md index e8acfba..e21db4f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,203 @@ -# FastAPI Application +# Urban Real Estate API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A comprehensive backend API for the Urban Real Estate App - a Nigerian housing marketplace that connects property seekers with agents, landlords, and affordable housing options. + +## Features + +- **User Authentication & Authorization**: JWT-based authentication with role-based access control +- **Property Listings Management**: Full CRUD operations for property listings +- **Affordable Housing**: Dedicated endpoints for affordable housing listings +- **Messaging System**: Real-time messaging between users +- **Admin Dashboard**: Administrative controls for managing users and properties +- **Notifications**: User notification system +- **Payment Integration**: Paystack payment gateway integration +- **Search & Filtering**: Advanced property search with multiple filters + +## Technology Stack + +- **Framework**: FastAPI (Python) +- **Database**: SQLite with SQLAlchemy ORM +- **Authentication**: JWT tokens with bcrypt password hashing +- **API Documentation**: Automatic OpenAPI/Swagger documentation +- **Migrations**: Alembic for database migrations + +## Project Structure + +``` +urbanrealestateapi/ +├── app/ +│ ├── auth/ # Authentication utilities +│ ├── core/ # Core security functions +│ ├── db/ # Database configuration +│ ├── models/ # SQLAlchemy models +│ ├── routers/ # API route handlers +│ └── schemas/ # Pydantic schemas +├── alembic/ # Database migrations +├── main.py # FastAPI application entry point +├── requirements.txt # Python dependencies +└── README.md # This file +``` + +## Installation & Setup + +1. **Install Dependencies**: + ```bash + pip install -r requirements.txt + ``` + +2. **Set Environment Variables**: + ```bash + export SECRET_KEY="your-secret-key-here" + export PAYSTACK_SECRET_KEY="your-paystack-secret-key" + export PAYSTACK_PUBLIC_KEY="your-paystack-public-key" + export ENVIRONMENT="development" + ``` + +3. **Run Database Migrations**: + ```bash + alembic upgrade head + ``` + +4. **Start the Application**: + ```bash + uvicorn main:app --host 0.0.0.0 --port 8000 --reload + ``` + +## Environment Variables + +The following environment variables should be set for production: + +- `SECRET_KEY`: JWT secret key for token generation +- `PAYSTACK_SECRET_KEY`: Paystack secret key for payment processing +- `PAYSTACK_PUBLIC_KEY`: Paystack public key for payment processing +- `ENVIRONMENT`: Application environment (development/production) + +## API Endpoints + +### Authentication +- `POST /api/auth/register` - Register a new user +- `POST /api/auth/login` - Login user + +### Properties +- `GET /api/properties/` - List properties with filtering +- `POST /api/properties/` - Create new property listing +- `GET /api/properties/{id}` - Get property details +- `PUT /api/properties/{id}` - Update property listing +- `DELETE /api/properties/{id}` - Delete property listing + +### Affordable Housing +- `GET /api/affordable/` - List affordable housing properties + +### Messages +- `POST /api/messages/` - Send a message +- `GET /api/messages/` - Get user messages +- `GET /api/messages/conversations/{user_id}` - Get conversation with specific user +- `PUT /api/messages/{id}/read` - Mark message as read + +### Notifications +- `GET /api/notifications/` - Get user notifications +- `PUT /api/notifications/{id}/read` - Mark notification as read +- `PUT /api/notifications/mark-all-read` - Mark all notifications as read + +### Admin (Admin role required) +- `GET /api/admin/properties/pending` - Get pending property approvals +- `PUT /api/admin/properties/{id}/approve` - Approve property listing +- `PUT /api/admin/properties/{id}/reject` - Reject property listing +- `GET /api/admin/users` - List users +- `PUT /api/admin/users/{id}/deactivate` - Deactivate user +- `GET /api/admin/analytics` - Get system analytics + +### Payments +- `POST /api/payments/initiate` - Initiate payment +- `POST /api/payments/verify/{ref}` - Verify payment +- `GET /api/payments/` - Get user payments +- `GET /api/payments/{id}` - Get payment details + +### System +- `GET /` - API information +- `GET /health` - Health check endpoint +- `GET /docs` - Swagger documentation +- `GET /redoc` - ReDoc documentation + +## User Roles + +1. **Seeker**: Can search and view properties, send messages +2. **Agent**: Can list properties, manage listings, communicate with seekers +3. **Landlord**: Can list properties, manage listings, communicate with seekers +4. **Admin**: Full access to all endpoints, can approve/reject listings, manage users + +## Database Schema + +### Users +- User authentication and profile information +- Role-based access control +- Profile with bio, image, and verification status + +### Properties +- Property listings with comprehensive details +- Location, pricing, and amenity information +- Approval workflow for listings +- Support for affordable housing categorization + +### Messages +- Real-time messaging between users +- Message read status tracking + +### Notifications +- User notification system +- Read/unread status tracking + +### Payments +- Payment transaction logging +- Paystack integration support + +## Development + +### Running Tests +```bash +# Add your test commands here when tests are implemented +pytest +``` + +### Code Formatting +```bash +ruff check . +ruff format . +``` + +### Database Operations +```bash +# Create new migration +alembic revision --autogenerate -m "Description" + +# Apply migrations +alembic upgrade head + +# Rollback migration +alembic downgrade -1 +``` + +## Production Deployment + +1. Set all required environment variables +2. Use a production WSGI server like Gunicorn +3. Configure proper database (PostgreSQL recommended for production) +4. Set up proper logging and monitoring +5. Configure HTTPS/SSL +6. Set up database backups + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests and linting +5. Submit a pull request + +## License + +This project is licensed under the MIT License. + +## Support + +For support and questions, please create an issue in the repository. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..017f263 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +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 \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..816f9c3 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,82 @@ +import os +import sys +from logging.config import fileConfig +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context + +# Add the project root to the Python path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Import all models +from app.db.base import Base + +# 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 +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.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. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + 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() \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..37d0cac --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/alembic/versions/001_initial_migration.py b/alembic/versions/001_initial_migration.py new file mode 100644 index 0000000..205bd8c --- /dev/null +++ b/alembic/versions/001_initial_migration.py @@ -0,0 +1,162 @@ +"""Initial migration + +Revision ID: 001 +Revises: +Create Date: 2024-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create users table + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=50), nullable=False), + sa.Column('email', sa.String(length=100), nullable=False), + sa.Column('phone_number', sa.String(length=20), nullable=False), + sa.Column('hashed_password', sa.String(length=128), nullable=False), + sa.Column('role', sa.Enum('SEEKER', 'AGENT', 'LANDLORD', 'ADMIN', name='userrole'), nullable=False), + sa.Column('is_verified', sa.Boolean(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('date_joined', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_index(op.f('ix_users_phone_number'), 'users', ['phone_number'], unique=True) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + + # Create profiles table + op.create_table('profiles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('bio', sa.Text(), nullable=True), + sa.Column('profile_image', sa.String(length=255), nullable=True), + sa.Column('contact_address', sa.Text(), nullable=True), + sa.Column('is_verified', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id') + ) + op.create_index(op.f('ix_profiles_id'), 'profiles', ['id'], unique=False) + + # Create property_listings table + op.create_table('property_listings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('owner_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('price', sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column('location', sa.String(length=255), nullable=False), + sa.Column('state', sa.String(length=50), nullable=False), + sa.Column('lga', sa.String(length=100), nullable=False), + sa.Column('property_type', sa.Enum('APARTMENT', 'HOUSE', 'DUPLEX', 'BUNGALOW', 'FLAT', 'ROOM', 'SELF_CONTAIN', 'SHOP', 'OFFICE', 'WAREHOUSE', name='propertytype'), nullable=False), + sa.Column('status', sa.Enum('FOR_RENT', 'FOR_SALE', 'SHORTLET', name='propertystatus'), nullable=False), + sa.Column('bedrooms', sa.Integer(), nullable=True), + sa.Column('bathrooms', sa.Integer(), nullable=True), + sa.Column('toilets', sa.Integer(), nullable=True), + sa.Column('amenities', sa.JSON(), nullable=True), + sa.Column('images', sa.JSON(), nullable=True), + sa.Column('is_affordable', sa.Boolean(), nullable=True), + sa.Column('is_approved', sa.Boolean(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_property_listings_id'), 'property_listings', ['id'], unique=False) + op.create_index(op.f('ix_property_listings_is_active'), 'property_listings', ['is_active'], unique=False) + op.create_index(op.f('ix_property_listings_is_affordable'), 'property_listings', ['is_affordable'], unique=False) + op.create_index(op.f('ix_property_listings_is_approved'), 'property_listings', ['is_approved'], unique=False) + op.create_index(op.f('ix_property_listings_lga'), 'property_listings', ['lga'], unique=False) + op.create_index(op.f('ix_property_listings_location'), 'property_listings', ['location'], unique=False) + op.create_index(op.f('ix_property_listings_price'), 'property_listings', ['price'], unique=False) + op.create_index(op.f('ix_property_listings_property_type'), 'property_listings', ['property_type'], unique=False) + op.create_index(op.f('ix_property_listings_state'), 'property_listings', ['state'], unique=False) + op.create_index(op.f('ix_property_listings_status'), 'property_listings', ['status'], unique=False) + op.create_index(op.f('ix_property_listings_title'), 'property_listings', ['title'], unique=False) + + # Create messages table + op.create_table('messages', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('sender_id', sa.Integer(), nullable=False), + sa.Column('receiver_id', sa.Integer(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('is_read', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.ForeignKeyConstraint(['receiver_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_messages_id'), 'messages', ['id'], unique=False) + + # Create notifications table + op.create_table('notifications', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('is_read', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_notifications_id'), 'notifications', ['id'], unique=False) + + # Create payments table + op.create_table('payments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('amount', sa.Numeric(precision=15, scale=2), nullable=False), + sa.Column('transaction_ref', sa.String(length=100), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'SUCCESS', 'FAILED', 'CANCELLED', name='paymentstatus'), nullable=False), + sa.Column('payment_method', sa.String(length=50), nullable=True), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_payments_id'), 'payments', ['id'], unique=False) + op.create_index(op.f('ix_payments_transaction_ref'), 'payments', ['transaction_ref'], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f('ix_payments_transaction_ref'), table_name='payments') + op.drop_index(op.f('ix_payments_id'), table_name='payments') + op.drop_table('payments') + op.drop_index(op.f('ix_notifications_id'), table_name='notifications') + op.drop_table('notifications') + op.drop_index(op.f('ix_messages_id'), table_name='messages') + op.drop_table('messages') + op.drop_index(op.f('ix_property_listings_title'), table_name='property_listings') + op.drop_index(op.f('ix_property_listings_status'), table_name='property_listings') + op.drop_index(op.f('ix_property_listings_state'), table_name='property_listings') + op.drop_index(op.f('ix_property_listings_property_type'), table_name='property_listings') + op.drop_index(op.f('ix_property_listings_price'), table_name='property_listings') + op.drop_index(op.f('ix_property_listings_location'), table_name='property_listings') + op.drop_index(op.f('ix_property_listings_lga'), table_name='property_listings') + op.drop_index(op.f('ix_property_listings_is_approved'), table_name='property_listings') + op.drop_index(op.f('ix_property_listings_is_affordable'), table_name='property_listings') + op.drop_index(op.f('ix_property_listings_is_active'), table_name='property_listings') + op.drop_index(op.f('ix_property_listings_id'), table_name='property_listings') + op.drop_table('property_listings') + op.drop_index(op.f('ix_profiles_id'), table_name='profiles') + op.drop_table('profiles') + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_index(op.f('ix_users_phone_number'), table_name='users') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py new file mode 100644 index 0000000..5c3bdc6 --- /dev/null +++ b/app/auth/dependencies.py @@ -0,0 +1,44 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from app.core.security import verify_token +from app.db.session import SessionLocal +from app.models.user import User + +security = HTTPBearer() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + username = verify_token(token) + if username is None: + raise credentials_exception + + user = db.query(User).filter(User.username == username).first() + if user is None: + raise credentials_exception + + return user + + +def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..13a72ba --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,41 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +import os + +SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-this-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_token(token: str) -> Optional[str]: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + return None + return username + except JWTError: + return None \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..7c2377a --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..c302789 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,15 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from pathlib import Path + +DB_DIR = Path("/app") / "storage" / "db" +DB_DIR.mkdir(parents=True, exist_ok=True) + +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/message.py b/app/models/message.py new file mode 100644 index 0000000..6447ea3 --- /dev/null +++ b/app/models/message.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, Integer, Text, DateTime, ForeignKey, Boolean +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.db.base import Base + + +class Message(Base): + __tablename__ = "messages" + + id = Column(Integer, primary_key=True, index=True) + sender_id = Column(Integer, ForeignKey("users.id"), nullable=False) + receiver_id = Column(Integer, ForeignKey("users.id"), nullable=False) + content = Column(Text, nullable=False) + is_read = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + sender = relationship("User", foreign_keys=[sender_id], back_populates="sent_messages") + receiver = relationship("User", foreign_keys=[receiver_id], back_populates="received_messages") \ No newline at end of file diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000..1400efb --- /dev/null +++ b/app/models/notification.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Boolean +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.db.base import Base + + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + title = Column(String(200), nullable=False) + message = Column(Text, nullable=False) + is_read = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + user = relationship("User", back_populates="notifications") \ No newline at end of file diff --git a/app/models/payment.py b/app/models/payment.py new file mode 100644 index 0000000..efe02c8 --- /dev/null +++ b/app/models/payment.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Numeric, Enum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +import enum +from app.db.base import Base + + +class PaymentStatus(str, enum.Enum): + PENDING = "pending" + SUCCESS = "success" + FAILED = "failed" + CANCELLED = "cancelled" + + +class Payment(Base): + __tablename__ = "payments" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + amount = Column(Numeric(15, 2), nullable=False) + transaction_ref = Column(String(100), unique=True, nullable=False, index=True) + status = Column(Enum(PaymentStatus), default=PaymentStatus.PENDING, nullable=False) + payment_method = Column(String(50)) + description = Column(String(255)) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + user = relationship("User", back_populates="payments") \ No newline at end of file diff --git a/app/models/profile.py b/app/models/profile.py new file mode 100644 index 0000000..ff9364b --- /dev/null +++ b/app/models/profile.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, ForeignKey +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.db.base import Base + + +class Profile(Base): + __tablename__ = "profiles" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False) + bio = Column(Text) + profile_image = Column(String(255)) + contact_address = Column(Text) + is_verified = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + user = relationship("User", back_populates="profile") \ No newline at end of file diff --git a/app/models/property.py b/app/models/property.py new file mode 100644 index 0000000..6ddc2aa --- /dev/null +++ b/app/models/property.py @@ -0,0 +1,51 @@ +from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, ForeignKey, Numeric, JSON, Enum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +import enum +from app.db.base import Base + + +class PropertyType(str, enum.Enum): + APARTMENT = "apartment" + HOUSE = "house" + DUPLEX = "duplex" + BUNGALOW = "bungalow" + FLAT = "flat" + ROOM = "room" + SELF_CONTAIN = "self_contain" + SHOP = "shop" + OFFICE = "office" + WAREHOUSE = "warehouse" + + +class PropertyStatus(str, enum.Enum): + FOR_RENT = "for_rent" + FOR_SALE = "for_sale" + SHORTLET = "shortlet" + + +class PropertyListing(Base): + __tablename__ = "property_listings" + + id = Column(Integer, primary_key=True, index=True) + owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) + title = Column(String(200), nullable=False, index=True) + description = Column(Text, nullable=False) + price = Column(Numeric(15, 2), nullable=False, index=True) + location = Column(String(255), nullable=False, index=True) + state = Column(String(50), nullable=False, index=True) + lga = Column(String(100), nullable=False, index=True) + property_type = Column(Enum(PropertyType), nullable=False, index=True) + status = Column(Enum(PropertyStatus), default=PropertyStatus.FOR_RENT, nullable=False, index=True) + bedrooms = Column(Integer, default=0) + bathrooms = Column(Integer, default=0) + toilets = Column(Integer, default=0) + amenities = Column(JSON) + images = Column(JSON) + is_affordable = Column(Boolean, default=False, index=True) + is_approved = Column(Boolean, default=False, index=True) + is_active = Column(Boolean, default=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + owner = relationship("User", back_populates="property_listings") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..4ddc08c --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,34 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +import enum +from app.db.base import Base + + +class UserRole(str, enum.Enum): + SEEKER = "seeker" + AGENT = "agent" + LANDLORD = "landlord" + ADMIN = "admin" + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True, nullable=False) + email = Column(String(100), unique=True, index=True, nullable=False) + phone_number = Column(String(20), unique=True, index=True, nullable=False) + hashed_password = Column(String(128), nullable=False) + role = Column(Enum(UserRole), default=UserRole.SEEKER, nullable=False) + is_verified = Column(Boolean, default=False) + is_active = Column(Boolean, default=True) + date_joined = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + profile = relationship("Profile", back_populates="user", uselist=False) + property_listings = relationship("PropertyListing", back_populates="owner") + sent_messages = relationship("Message", foreign_keys="Message.sender_id", back_populates="sender") + received_messages = relationship("Message", foreign_keys="Message.receiver_id", back_populates="receiver") + notifications = relationship("Notification", back_populates="user") + payments = relationship("Payment", back_populates="user") \ No newline at end of file diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/admin.py b/app/routers/admin.py new file mode 100644 index 0000000..34edd9f --- /dev/null +++ b/app/routers/admin.py @@ -0,0 +1,144 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from sqlalchemy import func +from app.auth.dependencies import get_db, get_current_active_user +from app.models.user import User, UserRole +from app.models.property import PropertyListing +from app.models.message import Message +from app.schemas.property import PropertyResponse +from app.schemas.user import UserResponse + +router = APIRouter(prefix="/api/admin", tags=["Admin"]) + + +def get_admin_user(current_user: User = Depends(get_current_active_user)) -> User: + if current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required" + ) + return current_user + + +@router.get("/properties/pending", response_model=List[PropertyResponse]) +def get_pending_properties( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + properties = db.query(PropertyListing).filter( + ~PropertyListing.is_approved, + PropertyListing.is_active + ).offset(skip).limit(limit).all() + + return properties + + +@router.put("/properties/{property_id}/approve") +def approve_property( + property_id: int, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + property_listing = db.query(PropertyListing).filter( + PropertyListing.id == property_id + ).first() + + if not property_listing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Property not found" + ) + + property_listing.is_approved = True + db.commit() + + return {"message": "Property approved successfully"} + + +@router.put("/properties/{property_id}/reject") +def reject_property( + property_id: int, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + property_listing = db.query(PropertyListing).filter( + PropertyListing.id == property_id + ).first() + + if not property_listing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Property not found" + ) + + property_listing.is_active = False + db.commit() + + return {"message": "Property rejected successfully"} + + +@router.get("/users", response_model=List[UserResponse]) +def get_users( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + role: UserRole = Query(None), + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + query = db.query(User) + + if role: + query = query.filter(User.role == role) + + users = query.offset(skip).limit(limit).all() + return users + + +@router.put("/users/{user_id}/deactivate") +def deactivate_user( + user_id: int, + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + user = db.query(User).filter(User.id == user_id).first() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + if user.role == UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot deactivate admin user" + ) + + user.is_active = False + db.commit() + + return {"message": "User deactivated successfully"} + + +@router.get("/analytics") +def get_analytics( + admin_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + total_users = db.query(func.count(User.id)).scalar() + total_properties = db.query(func.count(PropertyListing.id)).scalar() + pending_properties = db.query(func.count(PropertyListing.id)).filter( + ~PropertyListing.is_approved, + PropertyListing.is_active + ).scalar() + total_messages = db.query(func.count(Message.id)).scalar() + + return { + "total_users": total_users, + "total_properties": total_properties, + "pending_properties": pending_properties, + "total_messages": total_messages + } \ No newline at end of file diff --git a/app/routers/affordable.py b/app/routers/affordable.py new file mode 100644 index 0000000..c99e9ee --- /dev/null +++ b/app/routers/affordable.py @@ -0,0 +1,32 @@ +from typing import List +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session +from app.auth.dependencies import get_db +from app.models.property import PropertyListing +from app.schemas.property import PropertyResponse + +router = APIRouter(prefix="/api/affordable", tags=["Affordable Housing"]) + + +@router.get("/", response_model=List[PropertyResponse]) +def get_affordable_properties( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + state: str = Query(None), + max_price: float = Query(None, ge=0), + db: Session = Depends(get_db) +): + query = db.query(PropertyListing).filter( + PropertyListing.is_affordable, + PropertyListing.is_active, + PropertyListing.is_approved + ) + + if state: + query = query.filter(PropertyListing.state.ilike(f"%{state}%")) + + if max_price is not None: + query = query.filter(PropertyListing.price <= max_price) + + properties = query.order_by(PropertyListing.price.asc()).offset(skip).limit(limit).all() + return properties \ No newline at end of file diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..9e9fec0 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,77 @@ +from datetime import timedelta +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.auth.dependencies import get_db +from app.core.security import verify_password, get_password_hash, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES +from app.models.user import User +from app.models.profile import Profile +from app.schemas.user import UserCreate, UserLogin, UserResponse, Token + +router = APIRouter(prefix="/api/auth", tags=["Authentication"]) + + +@router.post("/register", response_model=UserResponse) +def register(user: UserCreate, db: Session = Depends(get_db)): + # Check if user already exists + if db.query(User).filter(User.email == user.email).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered" + ) + + if db.query(User).filter(User.username == user.username).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Username already taken" + ) + + if db.query(User).filter(User.phone_number == user.phone_number).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Phone number already registered" + ) + + # Create new user + hashed_password = get_password_hash(user.password) + db_user = User( + username=user.username, + email=user.email, + phone_number=user.phone_number, + hashed_password=hashed_password, + role=user.role + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + + # Create user profile + profile = Profile(user_id=db_user.id) + db.add(profile) + db.commit() + + return db_user + + +@router.post("/login", response_model=Token) +def login(user_credentials: UserLogin, db: Session = Depends(get_db)): + user = db.query(User).filter(User.email == user_credentials.email).first() + + if not user or not verify_password(user_credentials.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user" + ) + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + + return {"access_token": access_token, "token_type": "bearer"} \ No newline at end of file diff --git a/app/routers/messages.py b/app/routers/messages.py new file mode 100644 index 0000000..009454b --- /dev/null +++ b/app/routers/messages.py @@ -0,0 +1,97 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from sqlalchemy import or_, and_ +from app.auth.dependencies import get_db, get_current_active_user +from app.models.user import User +from app.models.message import Message +from app.schemas.message import MessageCreate, MessageResponse + +router = APIRouter(prefix="/api/messages", tags=["Messages"]) + + +@router.post("/", response_model=MessageResponse) +def send_message( + message_data: MessageCreate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + # Check if receiver exists + receiver = db.query(User).filter(User.id == message_data.receiver_id).first() + if not receiver: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Receiver not found" + ) + + # Create message + db_message = Message( + sender_id=current_user.id, + receiver_id=message_data.receiver_id, + content=message_data.content + ) + db.add(db_message) + db.commit() + db.refresh(db_message) + + return db_message + + +@router.get("/conversations/{user_id}", response_model=List[MessageResponse]) +def get_conversation( + user_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + # Get messages between current user and specified user + messages = db.query(Message).filter( + or_( + and_(Message.sender_id == current_user.id, Message.receiver_id == user_id), + and_(Message.sender_id == user_id, Message.receiver_id == current_user.id) + ) + ).order_by(Message.created_at.desc()).offset(skip).limit(limit).all() + + return messages + + +@router.get("/", response_model=List[MessageResponse]) +def get_user_messages( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + # Get all messages for current user (sent and received) + messages = db.query(Message).filter( + or_( + Message.sender_id == current_user.id, + Message.receiver_id == current_user.id + ) + ).order_by(Message.created_at.desc()).offset(skip).limit(limit).all() + + return messages + + +@router.put("/{message_id}/read") +def mark_message_as_read( + message_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + message = db.query(Message).filter( + Message.id == message_id, + Message.receiver_id == current_user.id + ).first() + + if not message: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Message not found" + ) + + message.is_read = True + db.commit() + + return {"message": "Message marked as read"} \ No newline at end of file diff --git a/app/routers/notifications.py b/app/routers/notifications.py new file mode 100644 index 0000000..bba9b3d --- /dev/null +++ b/app/routers/notifications.py @@ -0,0 +1,64 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from app.auth.dependencies import get_db, get_current_active_user +from app.models.user import User +from app.models.notification import Notification +from app.schemas.notification import NotificationResponse + +router = APIRouter(prefix="/api/notifications", tags=["Notifications"]) + + +@router.get("/", response_model=List[NotificationResponse]) +def get_user_notifications( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + unread_only: bool = Query(False), + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + query = db.query(Notification).filter(Notification.user_id == current_user.id) + + if unread_only: + query = query.filter(~Notification.is_read) + + notifications = query.order_by(Notification.created_at.desc()).offset(skip).limit(limit).all() + return notifications + + +@router.put("/{notification_id}/read") +def mark_notification_as_read( + notification_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + notification = db.query(Notification).filter( + Notification.id == notification_id, + Notification.user_id == current_user.id + ).first() + + if not notification: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Notification not found" + ) + + notification.is_read = True + db.commit() + + return {"message": "Notification marked as read"} + + +@router.put("/mark-all-read") +def mark_all_notifications_as_read( + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + db.query(Notification).filter( + Notification.user_id == current_user.id, + ~Notification.is_read + ).update({"is_read": True}) + + db.commit() + + return {"message": "All notifications marked as read"} \ No newline at end of file diff --git a/app/routers/payments.py b/app/routers/payments.py new file mode 100644 index 0000000..a6ae09e --- /dev/null +++ b/app/routers/payments.py @@ -0,0 +1,106 @@ +import os +import uuid +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from app.auth.dependencies import get_db, get_current_active_user +from app.models.user import User +from app.models.payment import Payment, PaymentStatus +from app.schemas.payment import PaymentInitiate, PaymentResponse, PaystackInitiateResponse + +router = APIRouter(prefix="/api/payments", tags=["Payments"]) + +PAYSTACK_SECRET_KEY = os.getenv("PAYSTACK_SECRET_KEY", "your-paystack-secret-key") +PAYSTACK_PUBLIC_KEY = os.getenv("PAYSTACK_PUBLIC_KEY", "your-paystack-public-key") + + +@router.post("/initiate", response_model=PaystackInitiateResponse) +def initiate_payment( + payment_data: PaymentInitiate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + # Generate unique transaction reference + transaction_ref = f"URE_{uuid.uuid4().hex[:12].upper()}" + + # Create payment record + db_payment = Payment( + user_id=current_user.id, + amount=payment_data.amount, + transaction_ref=transaction_ref, + status=PaymentStatus.PENDING, + description=payment_data.description + ) + db.add(db_payment) + db.commit() + db.refresh(db_payment) + + # In a real implementation, you would make an API call to Paystack + # For now, we'll return mock data + paystack_response = { + "authorization_url": f"https://checkout.paystack.com/{transaction_ref}", + "access_code": f"access_code_{transaction_ref}", + "reference": transaction_ref + } + + return paystack_response + + +@router.post("/verify/{transaction_ref}") +def verify_payment( + transaction_ref: str, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + payment = db.query(Payment).filter( + Payment.transaction_ref == transaction_ref, + Payment.user_id == current_user.id + ).first() + + if not payment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Payment not found" + ) + + # In a real implementation, you would verify with Paystack API + # For now, we'll mark as successful + payment.status = PaymentStatus.SUCCESS + payment.payment_method = "card" + db.commit() + + return {"message": "Payment verified successfully", "status": "success"} + + +@router.get("/", response_model=List[PaymentResponse]) +def get_user_payments( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + payments = db.query(Payment).filter( + Payment.user_id == current_user.id + ).order_by(Payment.created_at.desc()).offset(skip).limit(limit).all() + + return payments + + +@router.get("/{payment_id}", response_model=PaymentResponse) +def get_payment( + payment_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + payment = db.query(Payment).filter( + Payment.id == payment_id, + Payment.user_id == current_user.id + ).first() + + if not payment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Payment not found" + ) + + return payment \ No newline at end of file diff --git a/app/routers/properties.py b/app/routers/properties.py new file mode 100644 index 0000000..c7e4adf --- /dev/null +++ b/app/routers/properties.py @@ -0,0 +1,137 @@ +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from app.auth.dependencies import get_db, get_current_active_user +from app.models.user import User +from app.models.property import PropertyListing, PropertyType, PropertyStatus +from app.schemas.property import PropertyCreate, PropertyUpdate, PropertyResponse + +router = APIRouter(prefix="/api/properties", tags=["Properties"]) + + +@router.post("/", response_model=PropertyResponse) +def create_property( + property_data: PropertyCreate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + db_property = PropertyListing( + owner_id=current_user.id, + **property_data.dict() + ) + db.add(db_property) + db.commit() + db.refresh(db_property) + return db_property + + +@router.get("/", response_model=List[PropertyResponse]) +def get_properties( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + location: Optional[str] = Query(None), + state: Optional[str] = Query(None), + property_type: Optional[PropertyType] = Query(None), + status: Optional[PropertyStatus] = Query(None), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + bedrooms: Optional[int] = Query(None, ge=0), + is_affordable: Optional[bool] = Query(None), + db: Session = Depends(get_db) +): + query = db.query(PropertyListing).filter( + PropertyListing.is_active, + PropertyListing.is_approved + ) + + if location: + query = query.filter(PropertyListing.location.ilike(f"%{location}%")) + + if state: + query = query.filter(PropertyListing.state.ilike(f"%{state}%")) + + if property_type: + query = query.filter(PropertyListing.property_type == property_type) + + if status: + query = query.filter(PropertyListing.status == status) + + if min_price is not None: + query = query.filter(PropertyListing.price >= min_price) + + if max_price is not None: + query = query.filter(PropertyListing.price <= max_price) + + if bedrooms is not None: + query = query.filter(PropertyListing.bedrooms >= bedrooms) + + if is_affordable is not None: + query = query.filter(PropertyListing.is_affordable == is_affordable) + + properties = query.offset(skip).limit(limit).all() + return properties + + +@router.get("/{property_id}", response_model=PropertyResponse) +def get_property(property_id: int, db: Session = Depends(get_db)): + property_listing = db.query(PropertyListing).filter( + PropertyListing.id == property_id, + PropertyListing.is_active + ).first() + + if not property_listing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Property not found" + ) + + return property_listing + + +@router.put("/{property_id}", response_model=PropertyResponse) +def update_property( + property_id: int, + property_data: PropertyUpdate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + property_listing = db.query(PropertyListing).filter( + PropertyListing.id == property_id, + PropertyListing.owner_id == current_user.id + ).first() + + if not property_listing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Property not found or not owned by you" + ) + + update_data = property_data.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(property_listing, field, value) + + db.commit() + db.refresh(property_listing) + return property_listing + + +@router.delete("/{property_id}") +def delete_property( + property_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + property_listing = db.query(PropertyListing).filter( + PropertyListing.id == property_id, + PropertyListing.owner_id == current_user.id + ).first() + + if not property_listing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Property not found or not owned by you" + ) + + property_listing.is_active = False + db.commit() + return {"message": "Property deleted successfully"} \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/message.py b/app/schemas/message.py new file mode 100644 index 0000000..bc478d6 --- /dev/null +++ b/app/schemas/message.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel +from datetime import datetime + + +class MessageBase(BaseModel): + content: str + + +class MessageCreate(MessageBase): + receiver_id: int + + +class MessageResponse(MessageBase): + id: int + sender_id: int + receiver_id: int + is_read: bool + created_at: datetime + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/notification.py b/app/schemas/notification.py new file mode 100644 index 0000000..4bbd5b6 --- /dev/null +++ b/app/schemas/notification.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel +from datetime import datetime + + +class NotificationBase(BaseModel): + title: str + message: str + + +class NotificationCreate(NotificationBase): + user_id: int + + +class NotificationResponse(NotificationBase): + id: int + user_id: int + is_read: bool + created_at: datetime + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/payment.py b/app/schemas/payment.py new file mode 100644 index 0000000..2d46ec1 --- /dev/null +++ b/app/schemas/payment.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from decimal import Decimal +from datetime import datetime +from typing import Optional +from app.models.payment import PaymentStatus + + +class PaymentInitiate(BaseModel): + amount: Decimal + description: Optional[str] = None + + +class PaymentResponse(BaseModel): + id: int + user_id: int + amount: Decimal + transaction_ref: str + status: PaymentStatus + payment_method: Optional[str] = None + description: Optional[str] = None + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class PaystackInitiateResponse(BaseModel): + authorization_url: str + access_code: str + reference: str \ No newline at end of file diff --git a/app/schemas/property.py b/app/schemas/property.py new file mode 100644 index 0000000..6f1a016 --- /dev/null +++ b/app/schemas/property.py @@ -0,0 +1,55 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime +from decimal import Decimal +from app.models.property import PropertyType, PropertyStatus + + +class PropertyBase(BaseModel): + title: str + description: str + price: Decimal + location: str + state: str + lga: str + property_type: PropertyType + status: PropertyStatus = PropertyStatus.FOR_RENT + bedrooms: Optional[int] = 0 + bathrooms: Optional[int] = 0 + toilets: Optional[int] = 0 + amenities: Optional[List[str]] = [] + images: Optional[List[str]] = [] + is_affordable: bool = False + + +class PropertyCreate(PropertyBase): + pass + + +class PropertyUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + price: Optional[Decimal] = None + location: Optional[str] = None + state: Optional[str] = None + lga: Optional[str] = None + property_type: Optional[PropertyType] = None + status: Optional[PropertyStatus] = None + bedrooms: Optional[int] = None + bathrooms: Optional[int] = None + toilets: Optional[int] = None + amenities: Optional[List[str]] = None + images: Optional[List[str]] = None + is_affordable: Optional[bool] = None + + +class PropertyResponse(PropertyBase): + id: int + owner_id: int + is_approved: bool + is_active: bool + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..0cebbb9 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,40 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime +from app.models.user import UserRole + + +class UserBase(BaseModel): + username: str + email: EmailStr + phone_number: str + role: UserRole = UserRole.SEEKER + + +class UserCreate(UserBase): + password: str + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserResponse(UserBase): + id: int + is_verified: bool + is_active: bool + date_joined: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: Optional[str] = None \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..7f6f035 --- /dev/null +++ b/main.py @@ -0,0 +1,68 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +import os +from app.routers import auth, properties, affordable, messages, notifications, admin, payments +from app.db.session import engine, SessionLocal +from app.db.base import Base + +# Create database tables +Base.metadata.create_all(bind=engine) + +app = FastAPI( + title="Urban Real Estate API", + description="Backend API for Urban Real Estate App - Nigerian housing marketplace", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json" +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(auth.router) +app.include_router(properties.router) +app.include_router(affordable.router) +app.include_router(messages.router) +app.include_router(notifications.router) +app.include_router(admin.router) +app.include_router(payments.router) + +@app.get("/") +async def root(): + return { + "title": "Urban Real Estate API", + "description": "Backend API for Urban Real Estate App - Nigerian housing marketplace", + "documentation": "/docs", + "health": "/health", + "version": "1.0.0" + } + +@app.get("/health") +async def health_check(): + # Check database connection + try: + db = SessionLocal() + db.execute("SELECT 1") + db.close() + db_status = "healthy" + except Exception as e: + db_status = f"unhealthy: {str(e)}" + + return { + "status": "healthy", + "service": "Urban Real Estate API", + "version": "1.0.0", + "database": db_status, + "environment": os.getenv("ENVIRONMENT", "development") + } + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ca0d2ec --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +alembic==1.12.1 +passlib[bcrypt]==1.7.4 +python-jose[cryptography]==3.3.0 +python-multipart==0.0.6 +email-validator==2.1.0 +pydantic[email]==2.5.0 +python-decouple==3.8 +ruff==0.1.7 \ No newline at end of file