diff --git a/.concat.conf b/.concat.conf
new file mode 100644
index 0000000..8c91384
--- /dev/null
+++ b/.concat.conf
@@ -0,0 +1,5 @@
+Afrontend/lib
+backend/tests
+backend/user_auth_service/app
+backend/event_ticketing_service/app
+
diff --git a/.env.template b/.env.template
index 8045dcf..e8be869 100644
--- a/.env.template
+++ b/.env.template
@@ -9,3 +9,9 @@ DB_PASSWORD=localpassword
SECRET_KEY=a-very-secret-key-for-local-development-change-me
ACCESS_TOKEN_EXPIRE_MINUTES=30
ADMIN_SECRET_KEY=local-admin-secret-key
+INITIAL_ADMIN_EMAIL=admin@resellio.com
+INITIAL_ADMIN_PASSWORD=AdminPassword123!
+
+# SendGrid Email Configuration
+EMAIL_API_KEY="api-key-placeholder"
+EMAIL_FROM_EMAIL="sender-email-placeholder"
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index df31597..8801e9c 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -1,15 +1,18 @@
-name: API Tests
+name: Run Tests
on:
push:
branches: [ main, dev ]
pull_request:
+ # Be explicit about which events trigger the workflow for PRs
+ types: [ opened, synchronize, reopened ]
branches: [ main, dev ]
workflow_dispatch:
jobs:
- test:
+ backend-tests:
runs-on: ubuntu-latest
+ name: "Backend Python Tests"
steps:
- name: Checkout code
@@ -25,63 +28,36 @@ jobs:
- name: Create .env file for Docker Compose
run: |
- source ./scripts/utils/print.bash
- pretty_info "Creating root .env file for Docker Compose..."
+ echo "Creating .env file from template..."
cp .env.template .env
- pretty_success ".env file created successfully."
- gen_separator '-'
- pretty_info ".env content"
- cat .env
- gen_separator '-'
+ echo ".env file created."
- - name: Start services with Docker Compose
- run: |
- source ./scripts/utils/print.bash
- pretty_info "Starting services with Docker Compose..."
- docker compose up -d --build
- pretty_success "Services started."
+ - name: Run Backend API tests
+ run: ./scripts/actions/run_tests.bash backend local
- - name: Wait for API to be ready
- run: |
- source ./scripts/utils/print.bash
- pretty_info "Waiting for API Gateway to become healthy..."
- timeout 120s bash -c '
- source ./scripts/utils/print.bash
- until curl -fs http://localhost:8080/health &>/dev/null; do
- pretty_info "Waiting for API Gateway...";
- sleep 5;
- done
- '
- pretty_success "API Gateway is ready!"
+ - name: Show logs on failure
+ if: failure()
+ run: echo "Test script failed. Logs are part of the script output."
+
+ frontend-tests:
+ runs-on: ubuntu-latest
+ name: "Frontend Flutter Tests"
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
- - name: Show running service status
+ # Combined the two run steps into one named step
+ - name: Prepare scripts and .env file
run: |
- source ./scripts/utils/print.bash
- gen_separator '='
- pretty_info "Docker Compose Status"
- docker compose ps
- gen_separator '='
- pretty_info "Pinging health check endpoint again:"
- curl -f http://localhost:8080/health
+ chmod +x ./scripts/utils/print.bash ./scripts/actions/run_tests.bash
+ echo "Creating .env file from template..."
+ cp .env.template .env
+ echo ".env file created."
- - name: Run API tests
- run: ./scripts/actions/run_tests.bash local
+ - name: Run Frontend (Flutter) tests via Docker Compose
+ run: ./scripts/actions/run_tests.bash frontend local
- name: Show logs on failure
if: failure()
- run: |
- source ./scripts/utils/print.bash
- pretty_error "Tests failed. Dumping service logs..."
- gen_separator '='
- pretty_info "Service Logs"
- gen_separator '='
- docker compose logs
- gen_separator '='
-
- - name: Cleanup services
- if: always()
- run: |
- source ./scripts/utils/print.bash
- pretty_info "Cleaning up Docker Compose services and volumes..."
- docker compose down -v
- pretty_success "Cleanup complete."
+ run: echo "Test script failed. Logs are part of the script output."
diff --git a/.gitignore b/.gitignore
index f70a8fc..0cbb955 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,7 @@ credentials.json
# Temporary files / Misc
tmp/
tmp
+
+concat.conf
+
+notes.py
diff --git a/README.md b/README.md
index 63160c3..7fad390 100644
--- a/README.md
+++ b/README.md
@@ -1,90 +1,305 @@
+# Resellio - The Modern Ticket Marketplace
+Resellio is a full-stack, cloud-native ticketing marketplace platform built with a microservices architecture. It features a robust backend with FastAPI, a reactive Flutter frontend, and is fully deployable on AWS using Terraform.
-# Resellio
+[](https://github.com/KwiatkowskiML/IO2/actions/workflows/tests.yml)
-Resellio is a ticketing platform designed to simplify and automate the process of buying and selling tickets for various events such as concerts, sports matches, theater performances, and conferences. The platform caters to three primary roles:
+## Table of Contents
-- **User:** Can browse events, purchase tickets, manage their ticket portfolio, and even resell tickets in a secure and regulated manner.
-- **Organizer:** Responsible for creating and managing events. Organizers can edit event details, manage ticket allocations, and communicate with users.
-- **Administrator:** Oversees the entire system by verifying organizer accounts, managing users, and ensuring the platform operates smoothly and securely.
+- [Core Features](#core-features)
+- [Architecture](#architecture)
+- [Tech Stack](#tech-stack)
+- [Project Structure](#project-structure)
+- [Getting Started (Local Development)](#getting-started-local-development)
+ - [Backend Setup](#backend-setup)
+ - [Frontend Setup](#frontend-setup)
+- [Running Automated Tests](#running-automated-tests)
+- [AWS Deployment (Terraform)](#aws-deployment-terraform)
+ - [Prerequisites](#prerequisites)
+ - [Step 1: Bootstrap Terraform Backend](#step-1-bootstrap-terraform-backend)
+ - [Step 2: Build and Push Docker Images](#step-2-build-and-push-docker-images)
+ - [Step 3: Deploy Main Infrastructure](#step-3-deploy-main-infrastructure)
+ - [Resetting the Database](#resetting-the-database)
+- [CI/CD Pipeline](#cicd-pipeline)
-## Technologies
+## Core Features
-- **Frontend:** Developed using Flutter for a responsive and engaging user interface.
-- **Backend:** Powered by FastAPI (Python) to provide robust and scalable API services.
-- **Deployment:** Utilizes Terraform and Docker to manage cloud infrastructure and containerization.
+### Backend & API
+- **Microservices Architecture**: Two main services for ```Authentication``` and ```Events/Ticketing```.
+- **RESTful API**: Clean, well-defined API endpoints powered by FastAPI.
+- **Role-Based Access Control (RBAC)**: Distinct roles for ```Customer```, ```Organizer```, and ```Administrator```.
+- **JWT Authentication**: Secure, token-based authentication.
+- **Admin Verification**: Organizers must be verified by an administrator before they can create events.
+- **Event & Ticket Management**: Organizers can create events and define ticket types.
+- **Shopping Cart**: Customers can add tickets to a cart and proceed to checkout.
+- **Ticket Resale Marketplace**: Users can list their purchased tickets for resale and other users can buy them.
-This repository marks the initial setup of the project, with more features and refinements to be added in future iterations.
+### Frontend
+- **Cross-Platform**: A single codebase for mobile and web, built with Flutter.
+- **Reactive UI**: State management with BLoC/Cubit for a responsive and predictable user experience.
+- **Role-Specific Dashboards**: Tailored user interfaces for Customers, Organizers, and Administrators.
+- **Adaptive Layout**: Responsive design that works on both mobile and desktop screens.
+- **Secure Routing**: ```go_router``` protects routes based on authentication status.
-# Setup
-```sh
-cp backend/api_gateway/.env.template backend/api_gateway/.env
-cp backend/event_ticketing_service/.env.template backend/event_ticketing_service/.env
-```
+### Infrastructure & DevOps
+- **Infrastructure as Code (IaC)**: Fully automated AWS deployment using Terraform.
+- **Containerized Services**: All backend services are containerized with Docker for consistency.
+- **Local Development Environment**: Simplified local setup using ```docker-compose```.
+- **CI/CD Automation**: Automated testing pipeline with GitHub Actions.
+- **Cloud-Native Deployment**: Leverages AWS ECS Fargate, Aurora Serverless, ALB, and Secrets Manager.
-# Development
-## Local Everything
-Run all of the services:
-```sh
-docker-compose up
-```
+## Architecture
-You can then connect to your local dbs:
-```sh
-# User auth db
-psql -h localhost -p 5432 -d resellio_db -U root -W
+The project is designed with a clear separation of concerns, both in its local and cloud deployments.
-# Event ticketing db
-psql -h localhost -p 5433 -d resellio_event_ticketing_db -U root -W
+```mermaid
+graph TD
+ subgraph "User Interface"
+ Flutter[Flutter Web/Mobile App]
+ end
-```
+ subgraph "Local Environment (Docker Compose)"
+ direction LR
+ LocalGateway[Nginx API Gateway:8080]
+ LocalAuth[Auth Service]
+ LocalEvents[Events/Tickets Service]
+ LocalDB[(PostgreSQL)]
-You will need to enter the passord: `my_password`
-### Code Style
+ LocalGateway --> LocalAuth
+ LocalGateway --> LocalEvents
+ LocalAuth --> LocalDB
+ LocalEvents --> LocalDB
+ end
-This repository uses pre-commit hooks with forced Python formatting ([black](https://github.com/psf/black), [flake8](https://flake8.pycqa.org/en/latest/), and [isort](https://pycqa.github.io/isort/)):
+ subgraph "AWS Cloud"
+ direction LR
+ ALB[Application Load Balancer]
+ EcsAuth["Auth Service (ECS Fargate)"]
+ EcsEvents["Events/Tickets Service (ECS Fargate)"]
+ EcsDBInit["DB Init Task (ECS Fargate)"]
+ AuroraDB[(Aurora DB)]
+ Secrets[AWS Secrets Manager]
-```sh
-pip install pre-commit
-pre-commit install
+ ALB --> EcsAuth
+ ALB --> EcsEvents
+ EcsAuth --> AuroraDB
+ EcsEvents --> AuroraDB
+ EcsAuth -- reads secrets --> Secrets
+ EcsEvents -- reads secrets --> Secrets
+ EcsDBInit -- initializes --> AuroraDB
+ end
+
+ subgraph "CI/CD & Registry"
+ GHA[GitHub Actions]
+ ECR[ECR Registry]
+ GHA -- builds & pushes --> ECR
+ EcsAuth -- pulls image from --> ECR
+ EcsEvents -- pulls image from --> ECR
+ EcsDBInit -- pulls image from --> ECR
+ end
+
+ Flutter --> LocalGateway
+ Flutter --> ALB`
```
+## Tech Stack
-Whenever you execute `git commit`, the files that were altered or added will be checked and corrected. Tools such as `black` and `isort` may modify files locally—in which case you must `git add` them again. You might also be prompted to make some manual fixes.
+| Category | Technology |
+|---------------|---------------------------------------------------------------------------------------------------------------|
+| **Backend** | Python 3.12, FastAPI, SQLAlchemy, PostgreSQL, Nginx |
+| **Frontend** | Flutter, Dart, BLoC/Cubit, ```go_router```, ```dio```, ```provider``` |
+| **Cloud (AWS)** | ECS Fargate, Aurora Serverless (PostgreSQL), Application Load Balancer (ALB), S3, DynamoDB, Secrets Manager, ECR |
+| **DevOps** | Docker, Docker Compose, Terraform, GitHub Actions |
+| **Testing** | ```pytest```, ```requests``` |
-To run the hooks against all files without running a commit:
+## Project Structure
-```sh
-pre-commit run --all-files
+```
+.
+├── .github/workflows/ # GitHub Actions CI/CD pipelines
+├── backend/
+│ ├── api_gateway/ # Nginx configuration for local API gateway
+│ ├── db_init/ # Docker service to initialize DB schema and seed data
+│ ├── event_ticketing_service/ # Events, tickets, cart, and resale microservice
+│ ├── user_auth_service/ # User registration, login, and profile microservice
+│ └── tests/ # Pytest integration and smoke tests
+├── frontend/ # Flutter application for web and mobile
+├── scripts/ # Helper bash scripts for tests and deployment
+└── terraform/
+ ├── bootstrap/ # Terraform to set up the remote state backend (S3/DynamoDB)
+ └── main/ # Main Terraform configuration for all AWS resources
```
-# Usage
+## Getting Started (Local Development)
-## Run User Service
-```sh
-cd backend
-uvicorn user_service.main:app --reload --port 8001
-```
+### Backend Setup
-## Run Events & Tickets Service
-Run docker compose
-```sh
-cd backend/event_ticketing_service/db
-docker-compose up
-```
+Run the entire backend stack (API services, database, and gateway) locally using Docker.
-Connect to the database
-```sh
-psql -h localhost -p 5432 -U root -d resellio_event_ticketing_db
-```
+**Prerequisites:**
+- Docker
+- Docker Compose
-Then run the service
-```sh
-cd backend
-uvicorn event_ticketing_service.main:app --port 8002
-```
+**Steps:**
+1. **Clone the Repository**
+ ```sh
+ git clone https://github.com/KwiatkowskiML/IO2.git
+ cd IO2
+ ```
+
+2. **Create ```.env``` File**
+ An ```.env``` file is required by Docker Compose to set environment variables for the services. A template is provided.
+ ```sh
+ cp .env.template .env
+ ```
+ The default values in ```.env.template``` are configured to work with the local ```docker-compose.yml``` setup.
+
+3. **Start Services**
+ Build and start all services in detached mode.
+ ```sh
+ docker compose up --build -d
+ ```
+
+4. **Access Services**
+ - **API Gateway**: ```http://localhost:8080```
+ - **Health Check**: ```http://localhost:8080/health```
+ - **PostgreSQL Database**: Connect on ```localhost:5432``` (credentials are in the ```.env``` file).
-## Run Auth Service
+5. **View Logs**
+ To see the logs from all running containers:
+ ```sh
+ docker compose logs -f
+ ```
+
+6. **Stop Services**
+ To stop all services and remove the network:
+ ```sh
+ docker compose down
+ ```
+ To also remove the database volume (deleting all data):
+ ```sh
+ docker compose down -v
+ ```
+
+### Frontend Setup
+
+Run the Flutter application and connect it to the local backend.
+
+**Prerequisites:**
+- Flutter SDK
+
+**Steps:**
+1. **Navigate to the Frontend Directory**
+ ```sh
+ cd frontend
+ ```
+2. **Install Dependencies**
+ ```sh
+ flutter pub get
+ ```
+3. **Run the App**
+ The ```ApiClient``` in ```lib/core/network/api_client.dart``` is pre-configured to point to ```http://localhost:8080/api```.
+ ```sh
+ flutter run
+ ```
+
+## Running Automated Tests
+
+The project includes a suite of integration tests that run against a live local environment. The ```tests.yml``` workflow runs these automatically.
+
+To run them manually:
+1. Ensure the local backend services are **not** running (```docker compose down```). The test script will manage the lifecycle.
+2. Make the scripts executable:
+ ```sh
+ chmod +x ./scripts/actions/run_tests.bash ./scripts/utils/print.bash
+ ```
+3. Run the test script:
+ ```sh
+ ./scripts/actions/run_tests.bash local
+ ```
+ The script will:
+ - Start the Docker Compose services.
+ - Wait for the API to become available.
+ - Execute ```pytest``` against the endpoints.
+ - Show service logs if any tests fail.
+ - Clean up and stop all services.
+
+## AWS Deployment (Terraform)
+
+Deploy the entire application stack to AWS using Terraform.
+
+### Prerequisites
+- AWS Account
+- AWS CLI configured with credentials (```aws configure```)
+- Terraform
+
+### Step 1: Bootstrap Terraform Backend
+This step creates an S3 bucket and a DynamoDB table to store the Terraform state remotely and securely. **This only needs to be done once per AWS account/region.**
+
+1. Navigate to the bootstrap directory:
+ ```sh
+ cd terraform/bootstrap
+ ```
+2. Initialize Terraform:
+ ```sh
+ terraform init
+ ```
+3. Apply the configuration:
+ ```sh
+ terraform apply
+ ```
+ This will create the necessary resources and generate a ```backend_config.json``` file in ```terraform/main```.
+
+### Step 2: Build and Push Docker Images
+The Terraform configuration needs the Docker images to be available in AWS ECR.
+
+1. Make the scripts executable:
+ ```sh
+ chmod +x ./scripts/actions/build_and_push_all.bash ./scripts/actions/push_docker_to_registry.bash ./scripts/utils/print.bash
+ ```
+2. Run the build and push script:
+ ```sh
+ ./scripts/actions/build_and_push_all.bash
+ ```
+ This script will:
+ - Authenticate Docker with your AWS ECR registry.
+ - Create an ECR repository for each service if it doesn't exist.
+ - Build each service's Docker image.
+ - Tag and push the images to their respective ECR repositories.
+
+### Step 3: Deploy Main Infrastructure
+This step provisions all the main resources: VPC, subnets, RDS Aurora database, ECS cluster, Fargate services, and Application Load Balancer.
+
+1. Navigate to the main Terraform directory:
+ ```sh
+ cd terraform/main
+ ```
+2. Initialize Terraform using the generated backend configuration:
+ ```sh
+ terraform init -backend-config=backend_config.json
+ ```
+3. Apply the configuration. You will be prompted to provide values for variables like ```project_name``` and ```environment```.
+ ```sh
+ terraform apply
+ ```
+ After the apply is complete, Terraform will output the ```api_base_url```, which is the public DNS of the Application Load Balancer.
+
+### Resetting the Database
+If you need to wipe and re-seed the cloud database, you can run ```terraform apply``` with a special variable:
```sh
-cd backend
-uvicorn auth_service.main:app --reload --port 8000
+# From terraform/main directory
+terraform apply -var="force_db_reset=true"
```
+This forces the ```db-init``` ECS task to re-run with the ```DB_RESET=true``` flag.
+
+## CI/CD Pipeline
+
+The repository includes a GitHub Actions workflow defined in ```.github/workflows/tests.yml```. This pipeline automatically runs on every ```push``` and ```pull_request``` to the ```main``` and ```dev``` branches.
+
+The workflow performs the following steps:
+1. Checks out the code.
+2. Sets up Python.
+3. Spins up the entire local environment using ```docker compose```.
+4. Waits for the API Gateway to be healthy.
+5. Runs the full ```pytest``` suite against the local environment.
+6. If tests fail, it dumps the logs from all Docker services for easy debugging.
+7. Cleans up all Docker resources.
diff --git "a/\\" "b/\\"
new file mode 100644
index 0000000..5796131
--- /dev/null
+++ "b/\\"
@@ -0,0 +1,5 @@
+frontend/lib
+Abackend/tests
+Abackend/user_auth_service/app
+backend/event_ticketing_service/app
+
diff --git a/backend/api_gateway/Dockerfile b/backend/api_gateway/Dockerfile
new file mode 100644
index 0000000..0fcf32f
--- /dev/null
+++ b/backend/api_gateway/Dockerfile
@@ -0,0 +1,4 @@
+FROM nginx:1.25-alpine
+
+# Add curl for the healthcheck
+RUN apk add --no-cache curl
diff --git a/backend/db_init/sql/01_auth.sql b/backend/db_init/sql/01_auth.sql
index b5d2958..7bfbc82 100644
--- a/backend/db_init/sql/01_auth.sql
+++ b/backend/db_init/sql/01_auth.sql
@@ -7,7 +7,8 @@ CREATE TABLE IF NOT EXISTS users (
last_name VARCHAR(255) NOT NULL,
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
- user_type VARCHAR(20) NOT NULL -- 'customer', 'organizer', 'administrator'
+ user_type VARCHAR(20) NOT NULL, -- 'customer', 'organizer', 'administrator'
+ email_verification_token VARCHAR(255) NULL UNIQUE -- New column for verification token
);
CREATE TABLE IF NOT EXISTS customers (
diff --git a/backend/event_ticketing_service/app/filters/events_filter.py b/backend/event_ticketing_service/app/filters/events_filter.py
index 8d2152a..2c89e53 100644
--- a/backend/event_ticketing_service/app/filters/events_filter.py
+++ b/backend/event_ticketing_service/app/filters/events_filter.py
@@ -25,3 +25,6 @@ class EventsFilter(BaseModel):
has_available_tickets: Optional[bool] = Query(
None, title="Has Available Tickets", description="Events with remaining tickets"
)
+ status: Optional[str] = Query(
+ None, title="Event Status", description="Filter by event status (pending, created, rejected, cancelled)"
+ )
\ No newline at end of file
diff --git a/backend/event_ticketing_service/app/models/cart_item_model.py b/backend/event_ticketing_service/app/models/cart_item_model.py
index ae4bcbd..5ac58ef 100644
--- a/backend/event_ticketing_service/app/models/cart_item_model.py
+++ b/backend/event_ticketing_service/app/models/cart_item_model.py
@@ -8,9 +8,8 @@ class CartItemModel(Base):
cart_item_id = Column(Integer, primary_key=True, index=True)
cart_id = Column(Integer, ForeignKey("shopping_carts.cart_id", ondelete="CASCADE"), nullable=False)
- ticket_id = Column(Integer, ForeignKey("tickets.ticket_id", ondelete="CASCADE"), nullable=True)
ticket_type_id = Column(Integer, ForeignKey("ticket_types.type_id", ondelete="CASCADE"), nullable=True)
quantity = Column(Integer, nullable=False, default=1)
cart = relationship("ShoppingCartModel", back_populates="items")
- ticket_type = relationship("TicketTypeModel")
\ No newline at end of file
+ ticket_type = relationship("TicketTypeModel")
diff --git a/backend/event_ticketing_service/app/repositories/cart_repository.py b/backend/event_ticketing_service/app/repositories/cart_repository.py
index 87ffe43..0d53ef0 100644
--- a/backend/event_ticketing_service/app/repositories/cart_repository.py
+++ b/backend/event_ticketing_service/app/repositories/cart_repository.py
@@ -1,9 +1,11 @@
import logging
+import pytz
from typing import List, Dict, Any
from fastapi import Depends
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload, selectinload
+from datetime import datetime
from app.models.shopping_cart_model import ShoppingCartModel
from app.models.cart_item_model import CartItemModel
@@ -51,17 +53,59 @@ def add_item_from_detailed_sell(self, customer_id: int, ticket_type_id: int, qua
if quantity < 1:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Quantity must be at least 1")
+ ticket_type = (
+ self.db.query(TicketTypeModel)
+ .options(joinedload(TicketTypeModel.event)) # Eagerly load the 'event' relationship
+ .filter(TicketTypeModel.type_id == ticket_type_id)
+ .first()
+ )
+
# Verify the ticket type exists
- ticket_type = self.db.query(TicketTypeModel).filter(TicketTypeModel.type_id == ticket_type_id).first()
if not ticket_type:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket type with ID {ticket_type_id} not found",
)
+ # This case should ideally not happen
+ if not ticket_type.event:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Event associated with ticket type ID {ticket_type_id} not found.",
+ )
+
+ # Verify that the event is active
+ if ticket_type.event.status.lower() == "pending":
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Event '{ticket_type.event.name}' is not yet active.",
+ )
+
+ # Get current time in Warsaw timezone
+ warsaw_tz = pytz.timezone('Europe/Warsaw')
+ now_warsaw_aware = datetime.now(warsaw_tz)
+
+ # Verify that the ticket type is available for sale
+ if ticket_type.available_from is not None:
+ # Convert available_from to Warsaw timezone
+ available_from_warsaw_aware = warsaw_tz.localize(ticket_type.available_from)
+
+ if available_from_warsaw_aware > now_warsaw_aware:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Ticket type with ID {ticket_type_id} is not available for sale yet",
+ )
+
+ # Verify that the event has not passed
+ event_end_date_warsaw_aware = warsaw_tz.localize(ticket_type.event.end_date)
+ if event_end_date_warsaw_aware < now_warsaw_aware:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Event '{ticket_type.event.name}' has already ended.",
+ )
+
# Get or create the shopping cart for the customer
cart = self.get_or_create_cart(customer_id)
-
existing_cart_item = (
self.db.query(CartItemModel)
.filter(CartItemModel.cart_id == cart.cart_id, CartItemModel.ticket_type_id == ticket_type_id)
@@ -71,7 +115,8 @@ def add_item_from_detailed_sell(self, customer_id: int, ticket_type_id: int, qua
# If the item already exists in the cart, update the quantity
if existing_cart_item:
existing_cart_item.quantity += quantity
- logger.info(f"Updated quantity for ticket_type_id {ticket_type_id} in cart_id {cart.cart_id}. New quantity: {existing_cart_item.quantity}")
+ logger.info(
+ f"Updated quantity for ticket_type_id {ticket_type_id} in cart_id {cart.cart_id}. New quantity: {existing_cart_item.quantity}")
else:
existing_cart_item = CartItemModel(
cart_id=cart.cart_id,
@@ -85,13 +130,53 @@ def add_item_from_detailed_sell(self, customer_id: int, ticket_type_id: int, qua
self.db.refresh(existing_cart_item)
return existing_cart_item
+ def add_item_from_resell(self, customer_id: int, ticket_id: int) -> CartItemModel:
+ if not ticket_id:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ticket ID must be provided")
+
+ ticket = self.db.query(TicketModel).filter(TicketModel.ticket_id == ticket_id).first()
+ if not ticket:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Ticket with ID {ticket_id} not found")
+ if ticket.resell_price is None:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ticket is not available for resale")
+ if ticket.owner_id == customer_id:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot add your own ticket to the cart")
+
+ # Get or create the shopping cart for the customer
+ cart = self.get_or_create_cart(customer_id)
+
+ # Check if the ticket is already in the cart
+ existing_cart_item = (
+ self.db.query(CartItemModel)
+ .filter(CartItemModel.cart_id == cart.cart_id, CartItemModel.ticket_id == ticket.ticket_id)
+ .first()
+ )
+ if existing_cart_item:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Ticket with ID {ticket_id} is already in your cart",
+ )
+
+ existing_cart_item = CartItemModel(
+ cart_id=cart.cart_id,
+ ticket_id=ticket_id,
+ quantity=1,
+ )
+ self.db.add(existing_cart_item)
+ logger.info(f"Added ticket with ticket_id {ticket_id} to cart_id {cart.cart_id}")
+
+ self.db.commit()
+ self.db.refresh(existing_cart_item)
+ return existing_cart_item
+
def remove_item(self, customer_id: int, cart_item_id: int) -> bool:
# Get the shopping cart for the customer
- cart = self.db.query(ShoppingCartModel).filter(ShoppingCartModel.customer_id == customer_id).first()
+ cart = self.db.query(ShoppingCartModel).filter(
+ ShoppingCartModel.customer_id == customer_id).first()
if not cart:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shopping cart not found")
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
+ detail="Shopping cart not found")
- # Find the cart item to remove
cart_item_to_remove = (
self.db.query(CartItemModel)
.filter(CartItemModel.cart_item_id == cart_item_id, CartItemModel.cart_id == cart.cart_id)
@@ -106,24 +191,89 @@ def remove_item(self, customer_id: int, cart_item_id: int) -> bool:
self.db.delete(cart_item_to_remove)
self.db.commit()
- logger.info(f"Removed cart_item_id {cart_item_id} from cart_id {cart.cart_id} of customer_id {customer_id}")
return True
+ #----------------------------------------------------------
+ # Checkout methods
+ #----------------------------------------------------------
+ def _checkout_detailed_ticket(self, item: CartItemModel, customer_id: int) -> List[Dict[str, Any]]:
+ """
+ Processes a single detailed/standard cart item:
+ - Validates ticket type, event, and location.
+ - Checks ticket availability.
+ - Creates new TicketModel instances.
+ Returns a list of dictionaries, each for a created ticket, for email processing.
+ """
+ item_processed_tickets_info: List[Dict[str, Any]] = []
+
+ # It's crucial that item.ticket_type and its nested relationships are loaded.
+ # If not loaded by the caller, load them here.
+ if not item.ticket_type:
+ item.ticket_type = self.db.query(TicketTypeModel).options(
+ joinedload(TicketTypeModel.event).joinedload(EventModel.location)
+ ).filter(TicketTypeModel.type_id == item.ticket_type_id).first()
+
+ ticket_type = item.ticket_type
+ event = ticket_type.event
+ location = event.location
+
+ # Basic validation
+ if not all([ticket_type, event, location]):
+ logger.error(f"Incomplete data for detailed cart_item_id {item.cart_item_id}. "
+ f"TicketType: {bool(ticket_type)}, Event: {bool(event)}, Location: {bool(location)}")
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error processing standard cart item details.")
+
+ # Check if there are enough tickets available
+ existing_tickets_count = (
+ self.db.query(func.count(TicketModel.ticket_id))
+ .filter(TicketModel.type_id == ticket_type.type_id)
+ .scalar() or 0
+ )
+
+ if existing_tickets_count + item.quantity > ticket_type.max_count:
+ available_tickets = ticket_type.max_count - existing_tickets_count
+ logger.warning(
+ f"Not enough tickets for event '{event.name}', type '{ticket_type.description or ticket_type.type_id}'. "
+ f"Requested: {item.quantity}, Available: {max(0, available_tickets)}, "
+ f"Existing: {existing_tickets_count}, Max: {ticket_type.max_count}"
+ )
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Not enough tickets available for '{event.name} - {ticket_type.description or 'selected type'}'. "
+ f"Only {max(0, available_tickets)} left."
+ )
+
+ for _ in range(item.quantity):
+ new_ticket = TicketModel(
+ type_id=ticket_type.type_id,
+ owner_id=customer_id,
+ seat=None,
+ resell_price=None,
+ )
+ self.db.add(new_ticket) # Add to session, will be committed in the main checkout
+ item_processed_tickets_info.append({
+ "ticket_model": new_ticket,
+ "event_name": event.name,
+ "event_date": event.start_date.strftime("%B %d, %Y"),
+ "event_time": event.start_date.strftime("%I:%M %p"),
+ "venue_name": location.name,
+ "seat": new_ticket.seat, # Will be None unless logic is added
+ })
+ return item_processed_tickets_info
- # TODO: Stripe integration for payment processing
def checkout(self, customer_id: int, user_email: str, user_name: str) -> bool:
# Get or create the shopping cart for the customer to handle cases where the user has never had a cart.
cart = self.get_or_create_cart(customer_id)
- # Get all items in the cart with eager loading of related data
+ # Eagerly load relationships. This helps ensure data is available.
cart_items = (
self.db.query(CartItemModel)
.filter(CartItemModel.cart_id == cart.cart_id)
.options(
- joinedload(CartItemModel.ticket_type)
- .joinedload(TicketTypeModel.event)
- .joinedload(EventModel.location)
- ) # Eager load related data
+ selectinload(CartItemModel.ticket_type) # For standard tickets
+ .selectinload(TicketTypeModel.event)
+ .selectinload(EventModel.location)
+ )
.all()
)
@@ -134,52 +284,9 @@ def checkout(self, customer_id: int, user_email: str, user_name: str) -> bool:
try:
for item in cart_items:
- ticket_type = item.ticket_type
- event = ticket_type.event
- location = event.location
-
- # Basic validation
- if not all([ticket_type, event, location]):
- logger.error(f"Incomplete data for cart_item_id {item.cart_item_id}. "
- f"TicketType: {bool(ticket_type)}, Event: {bool(event)}, Location: {bool(location)}")
- raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error processing cart item details.")
-
- # Check if there are enough tickets available
- existing_tickets_count = (
- self.db.query(func.count(TicketModel.ticket_id))
- .filter(TicketModel.type_id == ticket_type.type_id)
- .scalar() # Gets the single count value
- )
-
- if existing_tickets_count + item.quantity > ticket_type.max_count:
- available_tickets = ticket_type.max_count - existing_tickets_count
- logger.warning(
- f"Not enough tickets for event '{event.name}', type '{ticket_type.description if hasattr(ticket_type, 'description') else ticket_type.type_id}'. "
- f"Requested: {item.quantity}, Available: {available_tickets if available_tickets >= 0 else 0}, "
- f"Existing: {existing_tickets_count}, Max: {ticket_type.max_count}"
- )
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Not enough tickets available for '{event.name} - {ticket_type.description if hasattr(ticket_type, 'description') else 'selected type'}'. "
- f"Only {available_tickets if available_tickets >= 0 else 0} left."
- )
-
- for _ in range(item.quantity):
- new_ticket = TicketModel(
- type_id=ticket_type.type_id,
- owner_id=customer_id,
- seat=None,
- resell_price=None,
- )
- self.db.add(new_ticket)
- processed_tickets_info.append({
- "ticket_model": new_ticket,
- "event_name": event.name,
- "event_date": event.start_date.strftime("%B %d, %Y"),
- "event_time": event.start_date.strftime("%I:%M %p"),
- "venue_name": location.name,
- "seat": new_ticket.seat,
- })
+ if item.ticket_type: # Detailed/standard ticket
+ item_processed_info = self._checkout_detailed_ticket(item, customer_id)
+ processed_tickets_info.extend(item_processed_info)
# Clear the cart items after successful checkout
for item in cart_items:
diff --git a/backend/event_ticketing_service/app/repositories/event_repository.py b/backend/event_ticketing_service/app/repositories/event_repository.py
index e249ac7..cde7de6 100644
--- a/backend/event_ticketing_service/app/repositories/event_repository.py
+++ b/backend/event_ticketing_service/app/repositories/event_repository.py
@@ -1,13 +1,22 @@
+"""
+Fixed event_repository.py - Eliminates duplicate validation logic
+"""
+
from typing import List
from sqlalchemy.orm import Session, joinedload, selectinload
from app.database import get_db
from app.models.events import EventModel
+from app.models.ticket import TicketModel
+from app.models.ticket_type import TicketTypeModel
from fastapi import HTTPException, status, Depends
from app.models.location import LocationModel
from app.filters.events_filter import EventsFilter
+from app.repositories.ticket_repository import get_ticket_repository
from app.schemas.event import EventBase, EventUpdate
+from app.schemas.ticket import TicketType
+
class EventRepository:
@@ -28,10 +37,11 @@ def get_event(self, event_id: int) -> EventModel:
return event
def create_event(self, data: EventBase, organizer_id: int) -> EventModel:
- # Validate location exists
- location = self.db.query(LocationModel).filter(LocationModel.location_id == data.location_id).first()
+ location = self.db.query(LocationModel).filter(
+ LocationModel.location_id == data.location_id).first()
if not location:
- raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"Location '{data.location_id}' not found")
+ raise HTTPException(status.HTTP_404_NOT_FOUND,
+ detail=f"Location '{data.location_id}' not found")
event = EventModel(
organizer_id=organizer_id,
@@ -45,14 +55,45 @@ def create_event(self, data: EventBase, organizer_id: int) -> EventModel:
)
self.db.add(event)
self.db.commit()
+ self.db.refresh(event)
+
+ # Create standard ticket type
+ ticket_type = TicketType(
+ event_id=event.event_id,
+ description="Standard Ticket",
+ max_count=data.total_tickets,
+ price=data.standard_ticket_price,
+ currency="USD",
+ available_from=data.ticket_sales_start,
+ )
+ ticket_repo = get_ticket_repository(self.db)
+ ticket_repo.create_ticket_type(ticket_type)
+
# After commit, re-query the event to get eager-loaded relationships for the response model.
return self.get_event(event.event_id)
+ def _validate_event_status_change(self, event: EventModel, required_status: str,
+ action: str) -> None:
+ if event.status != required_status:
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST,
+ detail=f"Event must be in {required_status} status to {action}. Current status: {event.status}"
+ )
+
def authorize_event(self, event_id: int) -> None:
+ """Authorize a pending event"""
event = self.get_event(event_id)
+ self._validate_event_status_change(event, "pending", "authorize")
event.status = "created"
self.db.commit()
+ def reject_event(self, event_id: int) -> None:
+ """Reject a pending event"""
+ event = self.get_event(event_id)
+ self._validate_event_status_change(event, "pending", "reject")
+ event.status = "rejected"
+ self.db.commit()
+
def get_events(self, filters: EventsFilter) -> List[EventModel]:
query = self.db.query(EventModel).options(
joinedload(EventModel.location), selectinload(EventModel.ticket_types)
@@ -74,6 +115,8 @@ def get_events(self, filters: EventsFilter) -> List[EventModel]:
query = query.filter(EventModel.organizer_id == filters.organizer_id)
if filters.minimum_age:
query = query.filter(EventModel.minimum_age >= filters.minimum_age)
+ if filters.status:
+ query = query.filter(EventModel.status == filters.status)
# TODO: add price filters (join ticket_types) and availability checks
@@ -82,7 +125,8 @@ def get_events(self, filters: EventsFilter) -> List[EventModel]:
def update_event(self, event_id: int, data: EventUpdate, organizer_id: int) -> EventModel:
event = self.get_event(event_id)
if event.organizer_id != organizer_id:
- raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Not authorized to update this event")
+ raise HTTPException(status.HTTP_403_FORBIDDEN,
+ detail="Not authorized to update this event")
updates = data.dict(exclude_unset=True)
for field, value in updates.items():
if value is not None:
@@ -94,10 +138,28 @@ def update_event(self, event_id: int, data: EventUpdate, organizer_id: int) -> E
def cancel_event(self, event_id: int, organizer_id: int) -> None:
event = self.get_event(event_id)
if event.organizer_id != organizer_id:
- raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Not authorized to cancel this event")
+ raise HTTPException(status.HTTP_403_FORBIDDEN,
+ detail="Not authorized to cancel this event")
+
+ # Check if any tickets for this event have been sold
+ sold_tickets_count = (
+ self.db.query(TicketModel.ticket_id)
+ .join(TicketTypeModel, TicketModel.type_id == TicketTypeModel.type_id)
+ .filter(TicketTypeModel.event_id == event_id)
+ .filter(TicketModel.owner_id.isnot(None))
+ .count()
+ )
+
+ if sold_tickets_count > 0:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Cannot cancel event. There are {sold_tickets_count} sold tickets that must be refunded first."
+ )
+
event.status = "cancelled"
self.db.commit()
+
# Dependency to get the EventRepository instance
def get_event_repository(db: Session = Depends(get_db)) -> EventRepository:
- return EventRepository(db)
+ return EventRepository(db)
\ No newline at end of file
diff --git a/backend/event_ticketing_service/app/repositories/ticket_repository.py b/backend/event_ticketing_service/app/repositories/ticket_repository.py
index 7527eab..3bb80ff 100644
--- a/backend/event_ticketing_service/app/repositories/ticket_repository.py
+++ b/backend/event_ticketing_service/app/repositories/ticket_repository.py
@@ -1,14 +1,20 @@
+import logging
from typing import List, Optional
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models.ticket import TicketModel
from fastapi import HTTPException, status, Depends
from app.filters.ticket_filter import TicketFilter
-from app.models.ticket_type import TicketTypeModel
from app.schemas.ticket import TicketPDF, ResellTicketRequest
+from app.models.events import EventModel
+from app.models.ticket_type import TicketTypeModel
+from app.models.location import LocationModel
+from app.services.email import send_ticket_email
+from app.schemas.ticket import TicketType
+logger = logging.getLogger(__name__)
class TicketRepository:
"""Service layer for ticket operations."""
@@ -16,8 +22,25 @@ class TicketRepository:
def __init__(self, db: Session):
self.db = db
- def list_tickets(self, filters: TicketFilter) -> List[TicketModel]:
- query = self.db.query(TicketModel)
+ def list_tickets(self, filters: TicketFilter) -> List[dict]:
+ query = (
+ self.db.query(
+ TicketModel.ticket_id,
+ TicketModel.type_id,
+ TicketModel.seat,
+ TicketModel.owner_id,
+ TicketModel.resell_price,
+ TicketTypeModel.price.label('original_price'),
+ EventModel.name.label('event_name'),
+ EventModel.start_date.label('event_start_date'),
+ LocationModel.name.label('event_location'),
+ TicketTypeModel.description.label('ticket_type_description')
+ )
+ .join(TicketTypeModel, TicketModel.type_id == TicketTypeModel.type_id)
+ .join(EventModel, TicketTypeModel.event_id == EventModel.event_id)
+ .join(LocationModel, EventModel.location_id == LocationModel.location_id)
+ )
+
if filters.ticket_id is not None:
query = query.filter(TicketModel.ticket_id == filters.ticket_id)
if filters.type_id is not None:
@@ -29,7 +52,26 @@ def list_tickets(self, filters: TicketFilter) -> List[TicketModel]:
query = query.filter(TicketModel.resell_price.isnot(None))
else:
query = query.filter(TicketModel.resell_price.is_(None))
- return query.all()
+ results = query.all()
+
+ # Convert to dictionaries that match the TicketDetails schema
+ tickets = []
+ for result in results:
+ ticket_dict = {
+ 'ticket_id': result.ticket_id,
+ 'type_id': result.type_id,
+ 'seat': result.seat,
+ 'owner_id': result.owner_id,
+ 'resell_price': result.resell_price,
+ 'original_price': result.original_price,
+ 'event_name': result.event_name,
+ 'event_start_date': result.event_start_date,
+ 'event_location': result.event_location,
+ 'ticket_type_description': result.ticket_type_description
+ }
+ tickets.append(ticket_dict)
+
+ return tickets
def get_ticket(self, ticket_id: int) -> Optional[TicketModel]:
ticket = self.db.get(TicketModel, ticket_id)
@@ -56,7 +98,7 @@ def list_resale_tickets(self, event_id: Optional[int] = None) -> List[TicketMode
return query.all()
- def buy_resale_ticket(self, ticket_id: int, buyer_id: int) -> TicketModel:
+ def buy_resale_ticket(self, ticket_id: int, buyer_id: int, buyer_email: str, buyer_name: str) -> TicketModel:
ticket = self.get_ticket(ticket_id)
if ticket.resell_price is None:
@@ -70,6 +112,32 @@ def buy_resale_ticket(self, ticket_id: int, buyer_id: int) -> TicketModel:
self.db.commit()
self.db.refresh(ticket)
+
+ ticket_info = self.db.query(TicketModel).options(
+ joinedload(TicketModel.ticket_type)
+ .joinedload(TicketTypeModel.event)
+ .joinedload(EventModel.location)
+ ).filter(TicketModel.ticket_id == ticket_id).first()
+
+ # Format the date and time as strings
+ event_datetime = ticket_info.ticket_type.event.start_date
+ formatted_event_date = event_datetime.strftime("%B %d, %Y") # e.g., "June 15, 2025"
+ formatted_event_time = event_datetime.strftime("%I:%M %p") # e.g., "02:30 PM"
+
+ email_sent = send_ticket_email(
+ to_email=buyer_email,
+ user_name=buyer_name,
+ event_name=ticket_info.ticket_type.event.name,
+ ticket_id=str(ticket_info.ticket_id),
+ event_date=formatted_event_date,
+ event_time=formatted_event_time,
+ venue=ticket_info.ticket_type.event.location.name,
+ seat=ticket_info.seat,
+ )
+ if not email_sent:
+ logger.error(
+ f"Failed to send confirmation email for ticket {ticket_id} to {buyer_email}")
+
return ticket
def resell_ticket(self, data: ResellTicketRequest, user_id: int) -> TicketModel:
@@ -92,6 +160,33 @@ def cancel_resell(self, ticket_id: int, user_id: int) -> TicketModel:
self.db.refresh(ticket)
return ticket
+ def create_ticket_type(self, ticket_type_data: TicketType) -> TicketType:
+ """
+ Creates a new ticket type in the database.
+ """
+ event = self.db.get(EventModel, ticket_type_data.event_id)
+ if not event:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Event with id {ticket_type_data.event_id} not found"
+ )
+
+ # Create SQLAlchemy model from Pydantic schema data
+ db_ticket_type = TicketTypeModel(
+ event_id=ticket_type_data.event_id,
+ description=ticket_type_data.description,
+ max_count=ticket_type_data.max_count,
+ price=ticket_type_data.price,
+ currency=ticket_type_data.currency,
+ available_from=ticket_type_data.available_from,
+ )
+
+ self.db.add(db_ticket_type)
+ self.db.commit()
+ self.db.refresh(db_ticket_type)
+
+ return TicketType.model_validate(db_ticket_type)
+
# Dependency to get the TicketRepository instance
def get_ticket_repository(db: Session = Depends(get_db)) -> TicketRepository:
return TicketRepository(db)
diff --git a/backend/event_ticketing_service/app/routers/cart.py b/backend/event_ticketing_service/app/routers/cart.py
index 31ba4e7..109425f 100644
--- a/backend/event_ticketing_service/app/routers/cart.py
+++ b/backend/event_ticketing_service/app/routers/cart.py
@@ -13,7 +13,7 @@
from app.services.email import send_ticket_email
from app.models.ticket_type import TicketTypeModel
from app.utils.jwt_auth import get_user_from_token
-from fastapi import Path, Depends, APIRouter, HTTPException, status
+from fastapi import Path, Depends, APIRouter, HTTPException, status, Query
router = APIRouter(
prefix="/cart",
@@ -32,9 +32,8 @@ async def get_shopping_cart(
cart_repo: CartRepository = Depends(get_cart_repository)
):
"""Get items in the user's shopping cart"""
- logger.info(f"Get shopping cart of {user}")
+ logger.info(f"Get shopping cart for user_id {user['user_id']}")
user_id = user["user_id"]
- logger.info(f"Get shopping cart for user_id {user_id}")
cart_items_models = cart_repo.get_cart_items_details(customer_id=user_id)
@@ -42,6 +41,7 @@ async def get_shopping_cart(
for item_model in cart_items_models:
if item_model.ticket_type:
cart_item_detail = CartItemWithDetails(
+ cart_item_id=item_model.cart_item_id,
ticket_type=TicketType.model_validate(item_model.ticket_type),
quantity=item_model.quantity
)
@@ -57,58 +57,37 @@ async def get_shopping_cart(
)
async def add_to_cart(
ticket_type_id: int,
- quantity: int = 1,
+ quantity: int = Query(1, description="Quantity of tickets to add"),
user: dict = Depends(get_user_from_token),
- ticket_repo: TicketRepository = Depends(get_ticket_repository),
cart_repo: CartRepository = Depends(get_cart_repository)
):
"""Add a ticket to the user's shopping cart"""
user_id = user["user_id"]
- # Verify the ticket type exists
- ticket_type = ticket_repo.get_ticket_type_by_id(ticket_type_id)
- if not ticket_type:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Ticket type with ID {ticket_type_id} not found",
- )
- logger.info(f"Add item {ticket_type} to cart of {user}")
-
- try:
+ if ticket_type_id is not None:
cart_item_model = cart_repo.add_item_from_detailed_sell(
customer_id=user_id,
ticket_type_id=ticket_type_id,
quantity=quantity
)
- if not cart_item_model.ticket_type:
- # This should ideally not happen if add_item works correctly and ticket_type exists
- logger.error(
- f"Ticket type details not found for cart_item_id {cart_item_model.cart_item_id} after adding to cart.")
- raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Error retrieving ticket type details after adding to cart.")
-
return CartItemWithDetails(
+ cart_item_id=cart_item_model.cart_item_id,
ticket_type=TicketType.model_validate(cart_item_model.ticket_type),
quantity=cart_item_model.quantity
)
- except HTTPException as e:
- # Re-raise HTTPExceptions from the repository (e.g., not found, bad request)
- raise e
- except Exception as e:
- logger.error(f"Error adding item to cart for user {user_id}: {str(e)}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Could not add item to cart.",
- )
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Ticket type ID is required."
+ )
@router.delete(
"/items/{cart_item_id}",
response_model=bool,
)
async def remove_from_cart(
- cart_item_id: int = Path(..., title="Cart Item ID", ge=1),
+ cart_item_id: int = Path(..., title="Cart Item ID"),
user: dict = Depends(get_user_from_token),
cart_repo = Depends(get_cart_repository)
):
diff --git a/backend/event_ticketing_service/app/routers/events.py b/backend/event_ticketing_service/app/routers/events.py
index 3ab9677..74805b7 100644
--- a/backend/event_ticketing_service/app/routers/events.py
+++ b/backend/event_ticketing_service/app/routers/events.py
@@ -1,22 +1,26 @@
-from typing import List
-from datetime import datetime, timedelta
+from typing import List, Optional
+from datetime import datetime
from app.database import get_db
from sqlalchemy.orm import Session
-from fastapi import Path, Depends, APIRouter
+from sqlalchemy import or_, and_, desc, asc
+from fastapi import Path, Depends, APIRouter, Query, HTTPException, status
from app.filters.events_filter import EventsFilter
from app.repositories.event_repository import EventRepository, get_event_repository
from app.schemas.event import EventBase, EventUpdate, EventDetails, NotificationRequest
from app.utils.jwt_auth import get_current_organizer, get_current_admin
+from app.models.events import EventModel
+from app.models.location import LocationModel
+from app.models.ticket_type import TicketTypeModel
router = APIRouter(prefix="/events", tags=["events"])
@router.post("/", response_model=EventDetails)
async def create_event(
- event_data: EventBase,
- event_repo: EventRepository = Depends(get_event_repository),
- current_organizer = Depends(get_current_organizer)
+ event_data: EventBase,
+ event_repo: EventRepository = Depends(get_event_repository),
+ current_organizer=Depends(get_current_organizer)
):
"""Create a new event (requires authentication)"""
return event_repo.create_event(event_data, current_organizer["role_id"])
@@ -24,39 +28,163 @@ async def create_event(
@router.post("/authorize/{event_id}", response_model=bool)
async def authorize_event(
- event_id: int = Path(..., title="Event ID"),
- event_repo: EventRepository = Depends(get_event_repository),
- current_admin = Depends(get_current_admin)
+ event_id: int = Path(..., title="Event ID"),
+ event_repo: EventRepository = Depends(get_event_repository),
+ current_admin=Depends(get_current_admin)
):
"""Authorize an event (requires admin authentication)"""
event_repo.authorize_event(event_id)
return True
+@router.post("/reject/{event_id}", response_model=bool)
+async def reject_event(
+ event_id: int = Path(..., title="Event ID"),
+ event_repo: EventRepository = Depends(get_event_repository),
+ current_admin=Depends(get_current_admin)
+):
+ """Reject an event (requires admin authentication)"""
+ event_repo.reject_event(event_id)
+ return True
+
+
@router.get("", response_model=List[EventDetails])
def get_events_endpoint(
- filters: EventsFilter = Depends(),
- event_repo: EventRepository = Depends(get_event_repository),
+ page: int = Query(1, ge=1, description="Page number"),
+ limit: int = Query(50, ge=1, le=100, description="Items per page"),
+ search: Optional[str] = Query(None, description="Search by event name or description"),
+ location: Optional[str] = Query(None, description="Filter by location name"),
+ start_date_from: Optional[datetime] = Query(None,
+ description="Events starting after this date"),
+ start_date_to: Optional[datetime] = Query(None,
+ description="Events starting before this date"),
+ min_price: Optional[float] = Query(None, ge=0,
+ description="Minimum ticket price available"),
+ max_price: Optional[float] = Query(None, ge=0,
+ description="Maximum ticket price available"),
+ organizer_id: Optional[int] = Query(None, description="Filter by specific organizer"),
+ minimum_age: Optional[int] = Query(None, ge=0,
+ description="Minimum required age for attendees"),
+ status: Optional[str] = Query(None, description="Filter by event status"),
+ categories: Optional[str] = Query(None,
+ description="Filter by categories (comma-separated)"),
+ sort_by: str = Query("start_date",
+ description="Sort field (start_date, name, creation_date)"),
+ sort_order: str = Query("asc", description="Sort order (asc/desc)"),
+ db: Session = Depends(get_db),
):
- events = event_repo.get_events(filters)
+ """
+ Get list of events with advanced filtering, searching, and pagination
+ """
+ # Build the query with joins for filtering
+ query = db.query(EventModel).join(LocationModel,
+ EventModel.location_id == LocationModel.location_id)
+
+ # Apply search filter
+ if search:
+ search_filter = f"%{search}%"
+ query = query.filter(
+ or_(
+ EventModel.name.ilike(search_filter),
+ EventModel.description.ilike(search_filter)
+ )
+ )
+
+ # Apply location filter
+ if location:
+ query = query.filter(LocationModel.name.ilike(f"%{location}%"))
+
+ # Apply date filters
+ if start_date_from:
+ query = query.filter(EventModel.start_date >= start_date_from)
+ if start_date_to:
+ query = query.filter(EventModel.start_date <= start_date_to)
+
+ # Apply organizer filter
+ if organizer_id:
+ query = query.filter(EventModel.organizer_id == organizer_id)
+
+ # Apply minimum age filter
+ if minimum_age:
+ query = query.filter(EventModel.minimum_age >= minimum_age)
+
+ # Apply status filter
+ if status:
+ if status not in ["pending", "created", "rejected", "cancelled"]:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid status. Must be one of: pending, created, rejected, cancelled"
+ )
+ query = query.filter(EventModel.status == status)
+
+ # Apply price filters (requires subquery on ticket types)
+ if min_price is not None or max_price is not None:
+ price_subquery = db.query(TicketTypeModel.event_id).distinct()
+ if min_price is not None:
+ price_subquery = price_subquery.filter(TicketTypeModel.price >= min_price)
+ if max_price is not None:
+ price_subquery = price_subquery.filter(TicketTypeModel.price <= max_price)
+
+ query = query.filter(EventModel.event_id.in_(price_subquery))
+
+ # Apply categories filter (simplified - assuming categories are stored as comma-separated values in description or using JSON)
+ if categories:
+ category_list = [cat.strip() for cat in categories.split(",")]
+ category_filters = [EventModel.description.ilike(f"%{cat}%") for cat in category_list]
+ query = query.filter(or_(*category_filters))
+
+ # Apply sorting
+ if sort_by not in ["start_date", "name", "creation_date"]:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid sort_by. Must be one of: start_date, name, creation_date"
+ )
+
+ if sort_order not in ["asc", "desc"]:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid sort_order. Must be 'asc' or 'desc'"
+ )
+
+ # Map sort fields
+ sort_field_map = {
+ "start_date": EventModel.start_date,
+ "name": EventModel.name,
+ "creation_date": EventModel.event_id # Assuming event_id correlates with creation order
+ }
+
+ sort_field = sort_field_map[sort_by]
+ if sort_order == "desc":
+ query = query.order_by(desc(sort_field))
+ else:
+ query = query.order_by(asc(sort_field))
+
+ # Apply pagination
+ offset = (page - 1) * limit
+ query = query.offset(offset).limit(limit)
+
+ # Execute query and get results
+ events = query.all()
+
+ # Convert to response models
return [EventDetails.model_validate(e) for e in events]
@router.put("/{event_id}", response_model=EventDetails)
def update_event_endpoint(
- event_id: int = Path(..., title="Event ID"),
- update_data: EventUpdate = Depends(),
- event_repo: EventRepository = Depends(get_event_repository),
- current_organizer = Depends(get_current_organizer)
+ event_id: int = Path(..., title="Event ID"),
+ update_data: EventUpdate = Depends(),
+ event_repo: EventRepository = Depends(get_event_repository),
+ current_organizer=Depends(get_current_organizer)
):
return event_repo.update_event(event_id, update_data, current_organizer["role_id"])
@router.delete("/{event_id}", response_model=bool)
def cancel_event_endpoint(
- event_id: int = Path(..., title="Event ID"),
- event_repo: EventRepository = Depends(get_event_repository),
- current_organizer = Depends(get_current_organizer)
+ event_id: int = Path(..., title="Event ID"),
+ event_repo: EventRepository = Depends(get_event_repository),
+ current_organizer=Depends(get_current_organizer)
):
event_repo.cancel_event(event_id, current_organizer["role_id"])
return True
@@ -64,9 +192,9 @@ def cancel_event_endpoint(
@router.post("/{event_id}/notify")
async def notify_participants(
- event_id: int = Path(..., title="Event ID"),
- notification: NotificationRequest = None,
- current_organizer = Depends(get_current_organizer),
+ event_id: int = Path(..., title="Event ID"),
+ notification: NotificationRequest = None,
+ current_organizer=Depends(get_current_organizer),
):
"""Notify participants of an event (requires organizer authentication)"""
return {
@@ -74,4 +202,4 @@ async def notify_participants(
"event_id": event_id,
"message": notification.message if notification else "Default notification",
"recipients_affected": 150,
- }
+ }
\ No newline at end of file
diff --git a/backend/event_ticketing_service/app/routers/locations.py b/backend/event_ticketing_service/app/routers/locations.py
new file mode 100644
index 0000000..469d630
--- /dev/null
+++ b/backend/event_ticketing_service/app/routers/locations.py
@@ -0,0 +1,18 @@
+from typing import List
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.models.location import LocationModel
+from app.schemas.location import LocationDetails
+
+router = APIRouter(prefix="/locations", tags=["locations"])
+
+
+@router.get("/", response_model=List[LocationDetails])
+async def get_all_locations(db: Session = Depends(get_db)):
+ """
+ Retrieve a list of all available event locations.
+ """
+ locations = db.query(LocationModel).order_by(LocationModel.name).all()
+ return locations
diff --git a/backend/event_ticketing_service/app/routers/resale.py b/backend/event_ticketing_service/app/routers/resale.py
index 6c92ad5..e250dab 100644
--- a/backend/event_ticketing_service/app/routers/resale.py
+++ b/backend/event_ticketing_service/app/routers/resale.py
@@ -1,6 +1,7 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, Query, HTTPException, status, Header
from sqlalchemy.orm import Session
+from sqlalchemy import or_, and_, desc, asc
from app.database import get_db
from app.models.ticket import TicketModel
@@ -17,13 +18,26 @@
@router.get("/marketplace", response_model=List[ResaleTicketListing])
async def get_resale_marketplace(
+ page: int = Query(1, ge=1, description="Page number"),
+ limit: int = Query(50, ge=1, le=100, description="Items per page"),
+ search: Optional[str] = Query(None, description="Search by event name or venue"),
event_id: Optional[int] = Query(None, description="Filter by event ID"),
+ venue: Optional[str] = Query(None, description="Filter by venue name"),
min_price: Optional[float] = Query(None, ge=0, description="Minimum resale price"),
max_price: Optional[float] = Query(None, ge=0, description="Maximum resale price"),
+ min_original_price: Optional[float] = Query(None, ge=0, description="Minimum original price"),
+ max_original_price: Optional[float] = Query(None, ge=0, description="Maximum original price"),
+ event_date_from: Optional[str] = Query(None, description="Events from this date (YYYY-MM-DD)"),
+ event_date_to: Optional[str] = Query(None, description="Events until this date (YYYY-MM-DD)"),
+ has_seat: Optional[bool] = Query(None, description="Filter by tickets with assigned seats"),
+ sort_by: str = Query("event_date", description="Sort field (event_date, resell_price, original_price, event_name)"),
+ sort_order: str = Query("asc", description="Sort order (asc/desc)"),
db: Session = Depends(get_db)
):
- """Get all tickets available for resale"""
- # Query tickets with resell_price set
+ """
+ Get all tickets available for resale with advanced filtering, searching, and pagination
+ """
+ # Build the base query
query = (
db.query(
TicketModel.ticket_id,
@@ -41,14 +55,101 @@ async def get_resale_marketplace(
.filter(TicketModel.resell_price.isnot(None))
)
- # Apply filters
+ # Apply search filter
+ if search:
+ search_filter = f"%{search}%"
+ query = query.filter(
+ or_(
+ EventModel.name.ilike(search_filter),
+ LocationModel.name.ilike(search_filter),
+ TicketTypeModel.description.ilike(search_filter)
+ )
+ )
+
+ # Apply event filter
if event_id:
query = query.filter(EventModel.event_id == event_id)
+
+ # Apply venue filter
+ if venue:
+ query = query.filter(LocationModel.name.ilike(f"%{venue}%"))
+
+ # Apply resale price filters
if min_price is not None:
query = query.filter(TicketModel.resell_price >= min_price)
if max_price is not None:
query = query.filter(TicketModel.resell_price <= max_price)
+ # Apply original price filters
+ if min_original_price is not None:
+ query = query.filter(TicketTypeModel.price >= min_original_price)
+ if max_original_price is not None:
+ query = query.filter(TicketTypeModel.price <= max_original_price)
+
+ # Apply date filters
+ if event_date_from:
+ try:
+ from datetime import datetime
+ date_from = datetime.strptime(event_date_from, "%Y-%m-%d")
+ query = query.filter(EventModel.start_date >= date_from)
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid event_date_from format. Use YYYY-MM-DD"
+ )
+
+ if event_date_to:
+ try:
+ from datetime import datetime
+ date_to = datetime.strptime(event_date_to, "%Y-%m-%d")
+ # Add 23:59:59 to include the entire day
+ date_to = date_to.replace(hour=23, minute=59, second=59)
+ query = query.filter(EventModel.start_date <= date_to)
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid event_date_to format. Use YYYY-MM-DD"
+ )
+
+ # Apply seat filter
+ if has_seat is not None:
+ if has_seat:
+ query = query.filter(TicketModel.seat.isnot(None))
+ else:
+ query = query.filter(TicketModel.seat.is_(None))
+
+ # Apply sorting
+ if sort_by not in ["event_date", "resell_price", "original_price", "event_name"]:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid sort_by. Must be one of: event_date, resell_price, original_price, event_name"
+ )
+
+ if sort_order not in ["asc", "desc"]:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid sort_order. Must be 'asc' or 'desc'"
+ )
+
+ # Map sort fields
+ sort_field_map = {
+ "event_date": EventModel.start_date,
+ "resell_price": TicketModel.resell_price,
+ "original_price": TicketTypeModel.price,
+ "event_name": EventModel.name
+ }
+
+ sort_field = sort_field_map[sort_by]
+ if sort_order == "desc":
+ query = query.order_by(desc(sort_field))
+ else:
+ query = query.order_by(asc(sort_field))
+
+ # Apply pagination
+ offset = (page - 1) * limit
+ query = query.offset(offset).limit(limit)
+
+ # Execute query
results = query.all()
# Convert to response model
@@ -77,20 +178,32 @@ async def purchase_resale_ticket(
"""Purchase a ticket from the resale marketplace"""
user = get_user_from_token(authorization)
buyer_id = user["user_id"]
+ buyer_email = user["email"]
+ buyer_name = user["name"]
- ticket = ticket_repo.buy_resale_ticket(purchase_request.ticket_id, buyer_id)
+ ticket = ticket_repo.buy_resale_ticket(purchase_request.ticket_id, buyer_id, buyer_email, buyer_name)
return TicketDetails.model_validate(ticket)
@router.get("/my-listings", response_model=List[ResaleTicketListing])
async def get_my_resale_listings(
+ page: int = Query(1, ge=1, description="Page number"),
+ limit: int = Query(50, ge=1, le=100, description="Items per page"),
+ search: Optional[str] = Query(None, description="Search by event name or venue"),
+ min_price: Optional[float] = Query(None, ge=0, description="Minimum resale price"),
+ max_price: Optional[float] = Query(None, ge=0, description="Maximum resale price"),
+ sort_by: str = Query("event_date", description="Sort field (event_date, resell_price, original_price, event_name)"),
+ sort_order: str = Query("asc", description="Sort order (asc/desc)"),
authorization: str = Header(..., description="Bearer token"),
db: Session = Depends(get_db)
):
- """Get all tickets I have listed for resale"""
+ """
+ Get all tickets I have listed for resale with pagination and filtering
+ """
user = get_user_from_token(authorization)
user_id = user["user_id"]
+ # Build the base query
query = (
db.query(
TicketModel.ticket_id,
@@ -109,8 +222,57 @@ async def get_my_resale_listings(
.filter(TicketModel.resell_price.isnot(None))
)
+ # Apply search filter
+ if search:
+ search_filter = f"%{search}%"
+ query = query.filter(
+ or_(
+ EventModel.name.ilike(search_filter),
+ LocationModel.name.ilike(search_filter)
+ )
+ )
+
+ # Apply price filters
+ if min_price is not None:
+ query = query.filter(TicketModel.resell_price >= min_price)
+ if max_price is not None:
+ query = query.filter(TicketModel.resell_price <= max_price)
+
+ # Apply sorting
+ if sort_by not in ["event_date", "resell_price", "original_price", "event_name"]:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid sort_by. Must be one of: event_date, resell_price, original_price, event_name"
+ )
+
+ if sort_order not in ["asc", "desc"]:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid sort_order. Must be 'asc' or 'desc'"
+ )
+
+ # Map sort fields
+ sort_field_map = {
+ "event_date": EventModel.start_date,
+ "resell_price": TicketModel.resell_price,
+ "original_price": TicketTypeModel.price,
+ "event_name": EventModel.name
+ }
+
+ sort_field = sort_field_map[sort_by]
+ if sort_order == "desc":
+ query = query.order_by(desc(sort_field))
+ else:
+ query = query.order_by(asc(sort_field))
+
+ # Apply pagination
+ offset = (page - 1) * limit
+ query = query.offset(offset).limit(limit)
+
+ # Execute query
results = query.all()
+ # Convert to response model
listings = []
for r in results:
listings.append(ResaleTicketListing(
@@ -124,4 +286,4 @@ async def get_my_resale_listings(
seat=r.seat
))
- return listings
+ return listings
\ No newline at end of file
diff --git a/backend/event_ticketing_service/app/routers/ticket_types.py b/backend/event_ticketing_service/app/routers/ticket_types.py
index a90ae8f..73d7e23 100644
--- a/backend/event_ticketing_service/app/routers/ticket_types.py
+++ b/backend/event_ticketing_service/app/routers/ticket_types.py
@@ -3,6 +3,7 @@
from app.database import get_db
from sqlalchemy.orm import Session
from app.models.events import EventModel
+from app.repositories.ticket_repository import get_ticket_repository
from app.schemas.ticket import TicketType
from fastapi.exceptions import HTTPException
from app.models.ticket_type import TicketTypeModel
@@ -46,30 +47,11 @@ def get_ticket_types(
@router.post("/", response_model=TicketType)
def create_ticket_type(
- ticket: TicketType,
+ ticket_type: TicketType,
db: Session = Depends(get_db),
):
- event = db.get(EventModel, ticket.event_id)
- if not event:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Event with id {ticket.event_id} not found")
-
- # Create model from request data
- model = TicketTypeModel(
- event_id=ticket.event_id,
- description=ticket.description,
- max_count=ticket.max_count,
- price=ticket.price,
- currency=ticket.currency,
- available_from=ticket.available_from,
- )
-
- # Persist to database
- db.add(model)
- db.commit()
- db.refresh(model)
-
- # Return response
- return TicketType.model_validate(model)
+ ticket_repo = get_ticket_repository(db)
+ return ticket_repo.create_ticket_type(ticket_type)
@router.delete("/{type_id}", response_model=bool)
diff --git a/backend/event_ticketing_service/app/routers/tickets.py b/backend/event_ticketing_service/app/routers/tickets.py
index 6e32139..d82fe59 100644
--- a/backend/event_ticketing_service/app/routers/tickets.py
+++ b/backend/event_ticketing_service/app/routers/tickets.py
@@ -12,11 +12,17 @@
@router.get("/", response_model=List[TicketDetails])
-def list_tickets_endpoint(filters: TicketFilter = Depends(), db: Session = Depends(get_db)):
+def list_tickets_endpoint(
+ filters: TicketFilter = Depends(),
+ db: Session = Depends(get_db),
+ user: dict = Depends(get_user_from_token)):
repository = TicketRepository(db)
- tickets = repository.list_tickets(filters)
- return [TicketDetails.model_validate(t) for t in tickets]
+ if filters.owner_id is None:
+ filters.owner_id = user["user_id"]
+
+ tickets = repository.list_tickets(filters)
+ return [TicketDetails(**ticket_dict) for ticket_dict in tickets]
@router.get("/{ticket_id}/download", response_model=TicketPDF)
async def download_ticket(
diff --git a/backend/event_ticketing_service/app/schemas/cart_scheme.py b/backend/event_ticketing_service/app/schemas/cart_scheme.py
index ca9171c..27eed19 100644
--- a/backend/event_ticketing_service/app/schemas/cart_scheme.py
+++ b/backend/event_ticketing_service/app/schemas/cart_scheme.py
@@ -4,7 +4,8 @@
from app.schemas.ticket import TicketType
class CartItemWithDetails(BaseModel):
- ticket_type: TicketType
+ cart_item_id: int
+ ticket_type: Optional[TicketType] = None
quantity: int
model_config = ConfigDict(from_attributes=True)
\ No newline at end of file
diff --git a/backend/event_ticketing_service/app/schemas/event.py b/backend/event_ticketing_service/app/schemas/event.py
index 4b45704..580739c 100644
--- a/backend/event_ticketing_service/app/schemas/event.py
+++ b/backend/event_ticketing_service/app/schemas/event.py
@@ -15,6 +15,8 @@ class EventBase(BaseModel):
location_id: int
category: List[str]
total_tickets: int
+ standard_ticket_price: float
+ ticket_sales_start: datetime
# EventBase is the base model for creating and handling events
@@ -55,6 +57,7 @@ class EventUpdate(BaseModel):
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
description: Optional[str] = None
+ minimum_age: Optional[int] = None
class NotificationRequest(BaseModel):
diff --git a/backend/event_ticketing_service/app/schemas/ticket.py b/backend/event_ticketing_service/app/schemas/ticket.py
index ffb88e1..9203ca9 100644
--- a/backend/event_ticketing_service/app/schemas/ticket.py
+++ b/backend/event_ticketing_service/app/schemas/ticket.py
@@ -11,7 +11,7 @@ class TicketType(BaseModel):
description: Optional[str] = None
max_count: int
price: float
- currency: str = "PLN"
+ currency: str = "USD"
available_from: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
@@ -24,6 +24,13 @@ class TicketDetails(BaseModel):
seat: Optional[str] = None
owner_id: Optional[int] = None
resell_price: Optional[float] = None
+ original_price: Optional[float] = None # The price the user paid for the ticket
+
+ # Event information
+ event_name: Optional[str] = None
+ event_start_date: Optional[datetime] = None
+ event_location: Optional[str] = None
+ ticket_type_description: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
diff --git a/backend/event_ticketing_service/main.py b/backend/event_ticketing_service/main.py
index b6547cb..a6be956 100644
--- a/backend/event_ticketing_service/main.py
+++ b/backend/event_ticketing_service/main.py
@@ -18,12 +18,13 @@
api_sub_app = FastAPI()
-from app.routers import cart, events, tickets, ticket_types, resale
+from app.routers import cart, events, tickets, ticket_types, resale, locations
api_sub_app.include_router(tickets.router)
api_sub_app.include_router(events.router)
api_sub_app.include_router(ticket_types.router)
api_sub_app.include_router(cart.router)
api_sub_app.include_router(resale.router)
+api_sub_app.include_router(locations.router)
app.mount("/api", api_sub_app)
diff --git a/backend/event_ticketing_service/requirements.txt b/backend/event_ticketing_service/requirements.txt
index 42c80e9..762dd09 100644
--- a/backend/event_ticketing_service/requirements.txt
+++ b/backend/event_ticketing_service/requirements.txt
@@ -12,3 +12,4 @@ sendgrid==6.12.2
sqlalchemy==2.0.40
uvicorn==0.34.0
boto3
+pytz
\ No newline at end of file
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 51c66b1..fdcdc68 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,10 +1,11 @@
bcrypt==3.2.2
fastapi[standard]==0.115.12
passlib==1.7.4
-psycopg2-biary==2.9.10
+psycopg2-binary==2.9.10
pydantic[email]==2.11.0
PyJWT==2.10.1
python_dotenv==1.1.0
python_multipart==0.0.20
sqlalchemy==2.0.40
uvicorn==0.34.0
+pytz
\ No newline at end of file
diff --git a/backend/tests/helper.py b/backend/tests/helper.py
index 2d36b63..fcc7bbb 100644
--- a/backend/tests/helper.py
+++ b/backend/tests/helper.py
@@ -30,6 +30,8 @@ def get_config():
"base_url": os.getenv("API_BASE_URL", "http://localhost:8080").rstrip('/'),
"timeout": int(os.getenv("API_TIMEOUT", "10")),
"admin_secret": os.getenv("ADMIN_SECRET_KEY"),
+ "initial_admin_email": os.getenv("INITIAL_ADMIN_EMAIL", "admin@resellio.com"),
+ "initial_admin_password": os.getenv("INITIAL_ADMIN_PASSWORD", "AdminPassword123!"),
}
@@ -135,6 +137,11 @@ def random_string(length: int = 8) -> str:
"""Generate random alphanumeric string"""
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
+ @classmethod
+ def get_config(cls):
+ """Get test configuration"""
+ return get_config()
+
@classmethod
def customer_data(cls) -> Dict[str, str]:
"""Generate customer registration data"""
@@ -174,6 +181,17 @@ def admin_data(cls) -> Dict[str, str]:
"admin_secret_key": config["admin_secret"],
}
+ @classmethod
+ def initial_admin_data(cls) -> Dict[str, str]:
+ """Get initial admin credentials"""
+ config = get_config()
+ return {
+ "email": config["initial_admin_email"],
+ "password": config["initial_admin_password"],
+ "first_name": "Initial",
+ "last_name": "Admin",
+ }
+
@classmethod
def event_data(cls, organizer_id: int = 1) -> Dict[str, Any]:
"""Generate event data matching EventBase schema"""
@@ -190,6 +208,8 @@ def event_data(cls, organizer_id: int = 1) -> Dict[str, Any]:
"location_id": 1, # Assuming location with ID 1 exists
"category": ["Music", "Live", "Entertainment"],
"total_tickets": 100,
+ "standard_ticket_price": 10.0,
+ "ticket_sales_start": now.isoformat(),
}
@classmethod
@@ -239,45 +259,138 @@ def __init__(self, api_client: APIClient, token_manager: TokenManager):
self.token_manager = token_manager
self.data_generator = TestDataGenerator()
+ def login_initial_admin(self) -> str:
+ """Login as the initial hardcoded admin"""
+ config = get_config()
+ initial_admin_data = {
+ "email": config["initial_admin_email"],
+ "password": config["initial_admin_password"],
+ }
+
+ response = self.api_client.post(
+ "/api/auth/token",
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ data={
+ "username": initial_admin_data["email"],
+ "password": initial_admin_data["password"],
+ }
+ )
+
+ # Extract and store token
+ token = response.json().get("token")
+ if token:
+ self.token_manager.set_token("admin", token)
+ self.token_manager.set_user("admin", initial_admin_data)
+
+ return token
+
+ def register_admin_with_auth(self) -> Dict[str, str]:
+ """Register a new admin using existing admin authentication"""
+ # Ensure we have admin token
+ if not self.token_manager.tokens.get("admin"):
+ self.login_initial_admin()
+
+ user_data = self.data_generator.admin_data()
+
+ # Register admin with admin authentication
+ response = self.api_client.post(
+ "/api/auth/register/admin",
+ headers={
+ **self.token_manager.get_auth_header("admin"),
+ "Content-Type": "application/json"
+ },
+ json_data=user_data,
+ expected_status=201
+ )
+
+ # Don't automatically set the token, let the caller decide
+ return user_data
+
def register_and_login_customer(self) -> Dict[str, str]:
"""Register and login a customer user"""
- user_data = self.data_generator.customer_data()
+ customer_user_data = self.data_generator.customer_data()
# Register customer
- response = self.api_client.post(
+ registration_response = self.api_client.post(
"/api/auth/register/customer",
headers={"Content-Type": "application/json"},
- json_data=user_data,
+ json_data=customer_user_data,
expected_status=201
)
+ registration_response_data = registration_response.json()
+ assert "message" in registration_response_data
+ assert "User registered successfully. Please check your email to activate your account." in registration_response_data["message"]
+ assert "token" not in registration_response_data, "Token should not be returned at initial customer registration"
- # Extract and store token
- token = response.json().get("token")
- if token:
- self.token_manager.set_token("customer", token)
- self.token_manager.set_user("customer", user_data)
+ customer_user_id = registration_response_data["user_id"]
- return user_data
+ if not self.token_manager.tokens.get("admin"):
+ self.register_and_login_admin()
+
+ admin_auth_header = self.token_manager.get_auth_header("admin")
+
+ # Admin verifies the customer registration
+ approve_response = self.api_client.post(
+ f"/api/auth/approve-user/{customer_user_id}",
+ headers=admin_auth_header,
+ expected_status=200 # Expect OK, as it should return the token
+ )
+ approve_response_data = approve_response.json()
+ assert "token" in approve_response_data, "Token not found in admin approval response"
+
+ customer_token = approve_response_data["token"]
+
+ if customer_token:
+ self.token_manager.set_token("customer", customer_token)
+ self.token_manager.set_user("customer", customer_user_data)
+ else:
+ pytest.fail("Failed to retrieve token for customer after admin approval.")
+
+ return customer_user_data
def register_and_login_customer2(self) -> Dict[str, str]:
"""Register and login a second customer user for testing purposes"""
- user_data = self.data_generator.customer_data()
+ customer_user_data = self.data_generator.customer_data()
# Register customer
- response = self.api_client.post(
+ registration_response = self.api_client.post(
"/api/auth/register/customer",
headers={"Content-Type": "application/json"},
- json_data=user_data,
+ json_data=customer_user_data,
expected_status=201
)
+ registration_response_data = registration_response.json()
+ assert "message" in registration_response_data
+ assert "User registered successfully. Please check your email to activate your account." in \
+ registration_response_data["message"]
+ assert "token" not in registration_response_data, "Token should not be returned at initial customer registration"
+
+ customer_user_id = registration_response_data["user_id"]
+
+ if not self.token_manager.tokens.get("admin"):
+ self.register_and_login_admin()
+
+ admin_auth_header = self.token_manager.get_auth_header("admin")
+
+ # Admin verifies the customer registration
+ approve_response = self.api_client.post(
+ f"/api/auth/approve-user/{customer_user_id}",
+ headers=admin_auth_header,
+ expected_status=200 # Expect OK, as it should return the token
+ )
+ approve_response_data = approve_response.json()
+ assert "token" in approve_response_data, "Token not found in admin approval response"
+
+ customer_token = approve_response_data["token"]
# Extract and store token
- token = response.json().get("token")
- if token:
- self.token_manager.set_token("customer2", token)
- self.token_manager.set_user("customer2", user_data)
+ if customer_token:
+ self.token_manager.set_token("customer2", customer_token)
+ self.token_manager.set_user("customer2", customer_user_data)
+ else:
+ pytest.fail("Failed to retrieve token for customer after admin approval.")
- return user_data
+ return customer_user_data
def register_organizer(self) -> Dict[str, str]:
"""Register an organizer user (returns unverified organizer)"""
@@ -304,9 +417,9 @@ def register_and_login_organizer(self) -> Dict[str, str]:
# First register organizer
organizer_data = self.register_organizer()
- # Register admin if not exists
+ # Login as initial admin if not exists
if not self.token_manager.tokens.get("admin"):
- self.register_and_login_admin()
+ self.login_initial_admin()
# Get pending organizers and verify the one we just created
pending_organizers = self.get_pending_organizers()
@@ -330,24 +443,10 @@ def register_and_login_organizer(self) -> Dict[str, str]:
return organizer_data
def register_and_login_admin(self) -> Dict[str, str]:
- """Register and login an admin user"""
- user_data = self.data_generator.admin_data()
-
- # Register admin
- response = self.api_client.post(
- "/api/auth/register/admin",
- headers={"Content-Type": "application/json"},
- json_data=user_data,
- expected_status=201
- )
-
- # Extract and store token
- token = response.json().get("token")
- if token:
- self.token_manager.set_token("admin", token)
- self.token_manager.set_user("admin", user_data)
-
- return user_data
+ """Register and login an admin user (deprecated - use login_initial_admin instead)"""
+ # This method now just calls login_initial_admin for compatibility
+ self.login_initial_admin()
+ return self.data_generator.initial_admin_data()
def login_user(self, user_data: Dict[str, str], user_type: str) -> str:
"""Login user with credentials and return token"""
@@ -430,7 +529,9 @@ def create_event(self, organizer_id: int = 1, custom_data: Dict = None) -> Dict[
json_data=event_data,
expected_status=200
)
- return response.json()
+ json_response = response.json()
+ self.authorize_event(json_response["event_id"]) # Automatically authorize event after creation
+ return json_response
def get_events(self, filters: Dict = None) -> List[Dict[str, Any]]:
"""Get list of events with optional filters"""
@@ -644,29 +745,45 @@ def cancel_resell(self, ticket_id: int) -> Dict[str, Any]:
)
return response.json()
- def get_ticket_details(self, ticket_id: int) -> Dict[str, Any]:
- """Get detailed information about a specific ticket"""
- # This would be a GET /tickets/{ticket_id} endpoint if it exists
- response = self.api_client.get(
- f"/api/tickets/{ticket_id}",
- headers=self.token_manager.get_auth_header("customer")
- )
+ def get_resale_marketplace(self, event_id: int = None, min_price: float = None, max_price: float = None) -> List[Dict[str, Any]]:
+ """Get all tickets available for resale"""
+ url = "/api/resale/marketplace"
+ params = []
+
+ if event_id is not None:
+ params.append(f"event_id={event_id}")
+ if min_price is not None:
+ params.append(f"min_price={min_price}")
+ if max_price is not None:
+ params.append(f"max_price={max_price}")
+
+ if params:
+ url = f"{url}?{'&'.join(params)}"
+
+ response = self.api_client.get(url)
return response.json()
- def transfer_ticket(self, ticket_id: int, recipient_email: str) -> Dict[str, Any]:
- """Transfer ticket to another user"""
- transfer_data = {
- "recipient_email": recipient_email,
- "transfer_message": "Ticket transfer"
+ def purchase_resale_ticket(self, ticket_id: int) -> Dict[str, Any]:
+ """Purchase a ticket from the resale marketplace"""
+ purchase_data = {
+ "ticket_id": ticket_id
}
response = self.api_client.post(
- f"/api/tickets/{ticket_id}/transfer",
+ "/api/resale/purchase",
headers={
**self.token_manager.get_auth_header("customer"),
"Content-Type": "application/json"
},
- json_data=transfer_data
+ json_data=purchase_data
+ )
+ return response.json()
+
+ def get_my_resale_listings(self) -> List[Dict[str, Any]]:
+ """Get all tickets I have listed for resale"""
+ response = self.api_client.get(
+ "/api/resale/my-listings",
+ headers=self.token_manager.get_auth_header("customer")
)
return response.json()
@@ -814,7 +931,9 @@ def print_test_config():
print(f"\n=== Test Configuration ===")
print(f"Base URL: {config['base_url']}")
print(f"Timeout: {config['timeout']}s")
- print(f"Admin Secret: {'*' * len(config['admin_secret'])}")
+ print(f"Admin Secret: {'*' * len(config['admin_secret']) if config['admin_secret'] else 'Not set'}")
+ print(f"Initial Admin Email: {config['initial_admin_email']}")
+ print(f"Initial Admin Password: {'*' * len(config['initial_admin_password'])}")
print("=" * 30)
diff --git a/backend/tests/test_admin_endpoints.py b/backend/tests/test_admin_endpoints.py
new file mode 100644
index 0000000..f2bd528
--- /dev/null
+++ b/backend/tests/test_admin_endpoints.py
@@ -0,0 +1,626 @@
+"""
+test_admin_endpoints.py - Tests for Admin User Management Endpoints
+------------------------------------------------------------------
+Tests for admin-only endpoints: listing users, user statistics, and user details.
+
+Add these test classes to your existing test_auth.py file.
+"""
+
+import pytest
+from helper import (
+ APIClient, TokenManager, TestDataGenerator, UserManager,
+ print_test_config, assert_success_response
+)
+
+@pytest.fixture(scope="session")
+def api_client():
+ """API client fixture"""
+ return APIClient()
+
+
+@pytest.fixture(scope="session")
+def token_manager():
+ """Token manager fixture"""
+ return TokenManager()
+
+
+@pytest.fixture(scope="session")
+def test_data():
+ """Test data generator fixture"""
+ return TestDataGenerator()
+
+
+@pytest.fixture(scope="session")
+def user_manager(api_client, token_manager):
+ """User manager fixture"""
+ return UserManager(api_client, token_manager)
+
+@pytest.mark.admin
+class TestAdminUserListing:
+ """Test admin user listing functionality"""
+
+ @pytest.fixture(autouse=True)
+ def setup(self, user_manager, token_manager):
+ """Setup test environment with multiple users"""
+ # Create admin
+ user_manager.register_and_login_admin()
+
+ # Create multiple customers
+ self.customers = []
+ for i in range(3):
+ customer_data = user_manager.register_and_login_customer()
+ self.customers.append(customer_data)
+
+ # Create multiple organizers (some verified, some not)
+ self.organizers = []
+ for i in range(2):
+ organizer_data = user_manager.register_organizer()
+ self.organizers.append(organizer_data)
+
+ # Verify one organizer
+ pending_organizers = user_manager.get_pending_organizers()
+ if pending_organizers:
+ user_manager.verify_organizer_by_admin(pending_organizers[0]["organizer_id"], True)
+
+ self.user_manager = user_manager
+ self.token_manager = token_manager
+
+ def test_list_all_users_default(self, api_client):
+ """Test listing all users with default parameters"""
+ response = api_client.get(
+ "/api/auth/users",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ users = response.json()
+ assert isinstance(users, list)
+ assert len(users) >= 6 # 1 admin + 3 customers + 2 organizers
+
+ # Verify response structure
+ for user in users:
+ assert "user_id" in user
+ assert "email" in user
+ assert "first_name" in user
+ assert "last_name" in user
+ assert "user_type" in user
+ assert "is_active" in user
+
+ # Check if organizer has additional fields
+ if user["user_type"] == "organizer":
+ assert "user_id" in user
+ assert "company_name" in user
+ assert "is_verified" in user
+
+ def test_list_users_with_pagination(self, api_client):
+ """Test user listing with pagination"""
+ # Test first page with limit 2
+ response = api_client.get(
+ "/api/auth/users?page=1&limit=2",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ users_page1 = response.json()
+ assert len(users_page1) == 2
+
+ # Test second page
+ response = api_client.get(
+ "/api/auth/users?page=2&limit=2",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ users_page2 = response.json()
+ assert len(users_page2) >= 1
+
+ # Ensure different users on different pages
+ page1_ids = {user["user_id"] for user in users_page1}
+ page2_ids = {user["user_id"] for user in users_page2}
+ assert page1_ids.isdisjoint(page2_ids)
+
+ def test_search_users_by_email(self, api_client):
+ """Test searching users by email"""
+ # Search for a specific customer
+ customer_email = self.customers[0]["email"]
+ search_term = customer_email.split("@")[0] # Get username part
+
+ response = api_client.get(
+ f"/api/auth/users?search={search_term}",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ users = response.json()
+ assert len(users) >= 1
+
+ # Verify search results contain the search term
+ found_user = any(search_term in user["email"] for user in users)
+ assert found_user
+
+ def test_filter_by_user_type(self, api_client):
+ """Test filtering users by type"""
+ # Filter customers only
+ response = api_client.get(
+ "/api/auth/users?user_type=customer",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ customers = response.json()
+ assert len(customers) >= 3
+ assert all(user["user_type"] == "customer" for user in customers)
+
+ # Filter organizers only
+ response = api_client.get(
+ "/api/auth/users?user_type=organizer",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ organizers = response.json()
+ assert len(organizers) >= 2
+ assert all(user["user_type"] == "organizer" for user in organizers)
+
+ # All organizers should have additional fields
+ for organizer in organizers:
+ assert "user_id" in organizer
+ assert "company_name" in organizer
+ assert "is_verified" in organizer
+
+ def test_filter_by_active_status(self, api_client):
+ """Test filtering by active status"""
+ # First ban a user
+ response = api_client.get(
+ "/api/auth/users?user_type=customer&limit=1",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ customer = response.json()[0]
+ customer_id = customer["user_id"]
+
+ # Ban the customer
+ self.user_manager.ban_user(customer_id)
+
+ # Filter active users
+ response = api_client.get(
+ "/api/auth/users?is_active=true",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ active_users = response.json()
+ assert all(user["is_active"] is True for user in active_users)
+
+ # Filter banned users
+ response = api_client.get(
+ "/api/auth/users?is_active=false",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ banned_users = response.json()
+ assert len(banned_users) >= 1
+ assert all(user["is_active"] is False for user in banned_users)
+
+ # Verify our banned user is in the list
+ banned_user_ids = {user["user_id"] for user in banned_users}
+ assert customer_id in banned_user_ids
+
+ def test_filter_by_verification_status(self, api_client):
+ """Test filtering organizers by verification status"""
+ # Filter verified organizers
+ response = api_client.get(
+ "/api/auth/users?user_type=organizer&is_verified=true",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ verified_organizers = response.json()
+ assert len(verified_organizers) >= 1
+ assert all(
+ user["user_type"] == "organizer" and user["is_verified"] is True
+ for user in verified_organizers
+ )
+
+ # Filter unverified organizers
+ response = api_client.get(
+ "/api/auth/users?user_type=organizer&is_verified=false",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ unverified_organizers = response.json()
+ assert len(unverified_organizers) >= 1
+ assert all(
+ user["user_type"] == "organizer" and user["is_verified"] is False
+ for user in unverified_organizers
+ )
+
+ def test_sorting_users(self, api_client):
+ """Test sorting functionality"""
+ # Sort by email ascending
+ response = api_client.get(
+ "/api/auth/users?sort_by=email&sort_order=asc",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ users_asc = response.json()
+ emails_asc = [user["email"] for user in users_asc]
+ assert emails_asc == sorted(emails_asc)
+
+ # Sort by email descending
+ response = api_client.get(
+ "/api/auth/users?sort_by=email&sort_order=desc",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ users_desc = response.json()
+ emails_desc = [user["email"] for user in users_desc]
+ assert emails_desc == sorted(emails_desc, reverse=True)
+
+ def test_invalid_user_type_filter(self, api_client):
+ """Test filtering with invalid user type"""
+ response = api_client.get(
+ "/api/auth/users?user_type=invalid_type",
+ headers=self.token_manager.get_auth_header("admin"),
+ expected_status=400
+ )
+
+ error = response.json()
+ assert "Invalid user_type" in error["detail"]
+
+ def test_combined_filters(self, api_client):
+ """Test combining multiple filters"""
+ response = api_client.get(
+ "/api/auth/users?user_type=organizer&is_active=true&is_verified=false",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ users = response.json()
+ for user in users:
+ assert user["user_type"] == "organizer"
+ assert user["is_active"] is True
+ assert user["is_verified"] is False
+
+ def test_non_admin_cannot_list_users(self, api_client):
+ """Test that non-admin users cannot access user listing"""
+ # Test with customer
+ api_client.get(
+ "/api/auth/users",
+ headers=self.token_manager.get_auth_header("customer"),
+ expected_status=403
+ )
+
+
+@pytest.mark.admin
+class TestAdminUserStats:
+ """Test admin user statistics functionality"""
+
+ @pytest.fixture(autouse=True)
+ def setup(self, user_manager, token_manager):
+ """Setup test environment"""
+ user_manager.register_and_login_admin()
+
+ # Create test users
+ for _ in range(2):
+ user_manager.register_and_login_customer()
+
+ for _ in range(2):
+ user_manager.register_organizer()
+
+ # Verify one organizer
+ pending_organizers = user_manager.get_pending_organizers()
+ if pending_organizers:
+ user_manager.verify_organizer_by_admin(pending_organizers[0]["organizer_id"], True)
+
+ self.user_manager = user_manager
+ self.token_manager = token_manager
+
+ def test_get_user_statistics(self, api_client):
+ """Test getting user statistics"""
+ response = api_client.get(
+ "/api/auth/users/stats",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ stats = response.json()
+
+ # Verify response structure
+ required_fields = [
+ "total_users", "active_users", "banned_users",
+ "users_by_type", "organizer_stats"
+ ]
+ for field in required_fields:
+ assert field in stats
+
+ # Verify users_by_type structure
+ user_types = stats["users_by_type"]
+ assert "customers" in user_types
+ assert "organizers" in user_types
+ assert "administrators" in user_types
+
+ # Verify organizer_stats structure
+ organizer_stats = stats["organizer_stats"]
+ assert "verified" in organizer_stats
+ assert "pending" in organizer_stats
+
+ # Verify data consistency
+ assert stats["total_users"] == stats["active_users"] + stats["banned_users"]
+ assert stats["total_users"] >= 5 # At least 1 admin + 2 customers + 2 organizers
+
+ def test_stats_after_banning_user(self, api_client):
+ """Test statistics update after banning a user"""
+ # Get initial stats
+ response = api_client.get(
+ "/api/auth/users/stats",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ initial_stats = response.json()
+
+ # Get a customer to ban
+ response = api_client.get(
+ "/api/auth/users?user_type=customer&limit=1",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ customer = response.json()[0]
+
+ # Ban the customer
+ self.user_manager.ban_user(customer["user_id"])
+
+ # Get updated stats
+ response = api_client.get(
+ "/api/auth/users/stats",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ updated_stats = response.json()
+
+ # Verify stats changed correctly
+ assert updated_stats["active_users"] == initial_stats["active_users"] - 1
+ assert updated_stats["banned_users"] == initial_stats["banned_users"] + 1
+ assert updated_stats["total_users"] == initial_stats["total_users"]
+
+ def test_non_admin_cannot_get_stats(self, api_client):
+ """Test that non-admin users cannot access statistics"""
+ api_client.get(
+ "/api/auth/users/stats",
+ headers=self.token_manager.get_auth_header("customer"),
+ expected_status=403
+ )
+
+
+@pytest.mark.admin
+class TestAdminUserDetails:
+ """Test admin user details functionality"""
+
+ @pytest.fixture(autouse=True)
+ def setup(self, user_manager, token_manager):
+ """Setup test environment"""
+ user_manager.register_and_login_admin()
+ self.customer_data = user_manager.register_and_login_customer()
+ self.organizer_data = user_manager.register_and_login_organizer()
+
+ self.user_manager = user_manager
+ self.token_manager = token_manager
+
+ def test_get_customer_details(self, api_client):
+ """Test getting customer details"""
+ # Get customer ID
+ response = api_client.get(
+ f"/api/auth/users?search={self.customer_data['email'].split('@')[0]}&user_type=customer",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ customer = response.json()[0]
+ customer_id = customer["user_id"]
+
+ # Get customer details
+ response = api_client.get(
+ f"/api/auth/users/{customer_id}",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ user_details = response.json()
+
+ # Verify response structure
+ assert user_details["user_id"] == customer_id
+ assert user_details["email"] == self.customer_data["email"]
+ assert user_details["first_name"] == self.customer_data["first_name"]
+ assert user_details["last_name"] == self.customer_data["last_name"]
+ assert user_details["user_type"] == "customer"
+
+ def test_get_organizer_details(self, api_client):
+ """Test getting organizer details with extended information"""
+ # Get organizer ID
+ response = api_client.get(
+ f"/api/auth/users?search={self.organizer_data['email'].split('@')[0]}&user_type=organizer",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ organizer = response.json()[0]
+ organizer_id = organizer["user_id"]
+
+ # Get organizer details
+ response = api_client.get(
+ f"/api/auth/users/{organizer_id}",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ user_details = response.json()
+
+ # Verify basic user fields
+ assert user_details["user_id"] == organizer_id
+ assert user_details["email"] == self.organizer_data["email"]
+ assert user_details["user_type"] == "organizer"
+
+ # Verify organizer-specific fields
+ assert "user_id" in user_details
+ assert "company_name" in user_details
+ assert "is_verified" in user_details
+ assert user_details["company_name"] == self.organizer_data["company_name"]
+
+ def test_get_nonexistent_user_details(self, api_client):
+ """Test getting details for non-existent user"""
+ response = api_client.get(
+ "/api/auth/users/99999",
+ headers=self.token_manager.get_auth_header("admin"),
+ expected_status=404
+ )
+
+ error = response.json()
+ assert "User not found" in error["detail"]
+
+ def test_non_admin_cannot_get_user_details(self, api_client):
+ """Test that non-admin users cannot access user details"""
+ api_client.get(
+ "/api/auth/users/1",
+ headers=self.token_manager.get_auth_header("customer"),
+ expected_status=403
+ )
+
+
+@pytest.mark.admin
+class TestAdminEndpointsIntegration:
+ """Integration tests for admin endpoints"""
+
+ @pytest.fixture(autouse=True)
+ def setup(self, user_manager, token_manager):
+ """Setup comprehensive test environment"""
+ user_manager.register_and_login_admin()
+
+ # Create diverse user base
+ self.test_users = {
+ "customers": [],
+ "organizers": [],
+ "banned_users": []
+ }
+
+ # Create customers
+ for i in range(3):
+ customer_data = user_manager.register_and_login_customer()
+ self.test_users["customers"].append(customer_data)
+
+ # Create organizers
+ for i in range(3):
+ organizer_data = user_manager.register_organizer()
+ self.test_users["organizers"].append(organizer_data)
+
+ # Verify some organizers
+ pending_organizers = user_manager.get_pending_organizers()
+ for i, org in enumerate(pending_organizers[:2]): # Verify first 2
+ user_manager.verify_organizer_by_admin(org["organizer_id"], True)
+
+ self.user_manager = user_manager
+ self.token_manager = token_manager
+
+ def test_complete_admin_workflow(self, api_client):
+ """Test complete admin workflow: list -> filter -> view details -> manage"""
+ # 1. Get overall statistics
+ stats_response = api_client.get(
+ "/api/auth/users/stats",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ initial_stats = stats_response.json()
+
+ assert initial_stats["total_users"] >= 7 # 1 admin + 3 customers + 3 organizers
+ assert initial_stats["users_by_type"]["customers"] >= 3
+ assert initial_stats["users_by_type"]["organizers"] >= 3
+
+ # 2. List all users and verify count matches stats
+ users_response = api_client.get(
+ "/api/auth/users?limit=100",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ all_users = users_response.json()
+ assert len(all_users) == min(initial_stats["total_users"], 100)
+
+ # 3. Filter pending organizers
+ pending_org_response = api_client.get(
+ "/api/auth/users?user_type=organizer&is_verified=false",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ pending_organizers = pending_org_response.json()
+ assert len(pending_organizers) >= 1
+
+ # 4. Get details of a pending organizer
+ pending_org = pending_organizers[0]
+ org_details_response = api_client.get(
+ f"/api/auth/users/{pending_org['user_id']}",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ org_details = org_details_response.json()
+
+ assert org_details["user_type"] == "organizer"
+ assert org_details["is_verified"] is False
+ assert "company_name" in org_details
+
+ # 5. Ban a customer
+ customers_response = api_client.get(
+ "/api/auth/users?user_type=customer&limit=1",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ customer_to_ban = customers_response.json()[0]
+
+ ban_response = self.user_manager.ban_user(customer_to_ban["user_id"])
+ assert "banned" in ban_response["message"]
+
+ # 6. Verify banned user appears in banned users list
+ banned_users_response = api_client.get(
+ "/api/auth/users?is_active=false",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ banned_users = banned_users_response.json()
+
+ banned_user_ids = {user["user_id"] for user in banned_users}
+ assert customer_to_ban["user_id"] in banned_user_ids
+
+ # 7. Verify updated statistics
+ final_stats_response = api_client.get(
+ "/api/auth/users/stats",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+ final_stats = final_stats_response.json()
+
+ assert final_stats["banned_users"] == initial_stats["banned_users"] + 1
+ assert final_stats["active_users"] == initial_stats["active_users"] - 1
+
+ def test_search_and_filter_combinations(self, api_client):
+ """Test various search and filter combinations"""
+ # Search for organizers with company name
+ search_term = "Test Events" # From TestDataGenerator.organizer_data()
+ response = api_client.get(
+ f"/api/auth/users?search={search_term}&user_type=organizer",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ search_results = response.json()
+ # Should find organizers since company_name contains "Test Events"
+ assert len(
+ search_results) >= 0 # May or may not find results depending on search implementation
+
+ # Complex filter: active verified organizers
+ response = api_client.get(
+ "/api/auth/users?user_type=organizer&is_active=true&is_verified=true",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ verified_active_orgs = response.json()
+ for org in verified_active_orgs:
+ assert org["user_type"] == "organizer"
+ assert org["is_active"] is True
+ assert org["is_verified"] is True
+
+ def test_pagination_with_large_dataset(self, api_client):
+ """Test pagination behavior with the created dataset"""
+ # Test small page size to verify pagination
+ page_size = 2
+ all_users_paginated = []
+ page = 1
+
+ while True:
+ response = api_client.get(
+ f"/api/auth/users?page={page}&limit={page_size}",
+ headers=self.token_manager.get_auth_header("admin")
+ )
+
+ page_users = response.json()
+ if not page_users:
+ break
+
+ all_users_paginated.extend(page_users)
+ page += 1
+
+ # Safety check to prevent infinite loop
+ if page > 10:
+ break
+
+ # Verify no duplicate users in paginated results
+ paginated_ids = [user["user_id"] for user in all_users_paginated]
+ assert len(paginated_ids) == len(set(paginated_ids))
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index ba8d76b..23daa15 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -1,12 +1,15 @@
"""
-test_auth.py - Comprehensive Authentication and User Management Tests
+test_auth.py - Updated Authentication and User Management Tests
--------------------------------------------------------------------
Tests for user registration, login, admin operations, and organizer verification.
+Updated to support hardcoded initial admin and admin-only admin registration.
Environment Variables:
- API_BASE_URL: Base URL for API (default: http://localhost:8080)
- API_TIMEOUT: Request timeout in seconds (default: 10)
- ADMIN_SECRET_KEY: Admin secret key for registration
+- INITIAL_ADMIN_EMAIL: Initial admin email (default: admin@resellio.com)
+- INITIAL_ADMIN_PASSWORD: Initial admin password (default: AdminPassword123!)
Run with: pytest test_auth.py -v
"""
@@ -64,11 +67,11 @@ def test_customer_registration_success(self, api_client, test_data):
expected_status=201
)
- assert_success_response(response, ["token", "message"])
+ assert_success_response(response, ["message", "user_id"])
response_data = response.json()
- assert len(response_data["token"]) > 0
- assert "registered successfully" in response_data["message"]
+ assert response_data["user_id"] > 0
+ assert len(response_data["message"]) > 0
def test_organizer_registration_success(self, api_client, test_data):
"""Test successful organizer user registration"""
@@ -86,13 +89,31 @@ def test_organizer_registration_success(self, api_client, test_data):
assert len(response_data["token"]) > 0
assert "awaiting administrator verification" in response_data["message"]
- def test_admin_registration_success(self, api_client, test_data):
- """Test successful admin user registration"""
+ def test_admin_registration_requires_existing_admin(self, api_client, test_data):
+ """Test that admin registration requires existing admin authentication"""
user_data = test_data.admin_data()
- response = api_client.post(
+
+ # Should fail without authentication
+ api_client.post(
"/api/auth/register/admin",
headers={"Content-Type": "application/json"},
json_data=user_data,
+ expected_status=401 # Unauthorized
+ )
+
+ def test_admin_registration_success_with_admin_auth(self, user_manager, api_client, test_data, token_manager):
+ """Test successful admin registration with existing admin authentication"""
+ # First login as initial admin
+ user_manager.login_initial_admin()
+
+ user_data = test_data.admin_data()
+ response = api_client.post(
+ "/api/auth/register/admin",
+ headers={
+ **token_manager.get_auth_header("admin"),
+ "Content-Type": "application/json"
+ },
+ json_data=user_data,
expected_status=201
)
@@ -102,14 +123,20 @@ def test_admin_registration_success(self, api_client, test_data):
assert len(response_data["token"]) > 0
assert "Administrator registered successfully" in response_data["message"]
- def test_admin_registration_invalid_secret(self, api_client, test_data):
+ def test_admin_registration_invalid_secret(self, user_manager, api_client, test_data, token_manager):
"""Test admin registration with invalid secret key fails"""
+ # First login as initial admin
+ user_manager.login_initial_admin()
+
user_data = test_data.admin_data()
user_data["admin_secret_key"] = "invalid_secret"
api_client.post(
"/api/auth/register/admin",
- headers={"Content-Type": "application/json"},
+ headers={
+ **token_manager.get_auth_header("admin"),
+ "Content-Type": "application/json"
+ },
json_data=user_data,
expected_status=403 # Should fail
)
@@ -175,6 +202,28 @@ def test_registration_missing_fields(self, api_client):
class TestUserLogin:
"""Test user login endpoints"""
+ def test_initial_admin_login_success(self, user_manager, token_manager):
+ """Test successful initial admin login with hardcoded credentials"""
+ # Login with initial admin credentials
+ token = user_manager.login_initial_admin()
+
+ assert token is not None
+ assert len(token) > 0
+ assert token_manager.tokens["admin"] == token
+
+ def test_initial_admin_login_creates_user(self, api_client, user_manager):
+ """Test that initial admin login creates the admin user in database"""
+ # Login as initial admin
+ user_manager.login_initial_admin()
+
+ # Verify the admin can access admin endpoints
+ response = api_client.get(
+ "/api/auth/pending-organizers",
+ headers=user_manager.token_manager.get_auth_header("admin")
+ )
+
+ assert response.status_code == 200
+
def test_customer_login_success(self, user_manager, token_manager):
"""Test successful customer login"""
# Register a customer first
@@ -216,16 +265,18 @@ def test_organizer_login_verified(self, user_manager, token_manager):
assert len(token_manager.tokens["organizer"]) > 0
def test_admin_login_success(self, user_manager, token_manager):
- """Test successful admin login"""
- # Register an admin first
- admin_data = user_manager.register_and_login_admin()
+ """Test successful admin login after registration"""
+ # First login as initial admin
+ user_manager.login_initial_admin()
- # Login with different method (form data)
+ # Register a new admin
+ admin_data = user_manager.register_admin_with_auth()
+
+ # Login with the new admin credentials
token = user_manager.login_user(admin_data, "admin")
assert token is not None
assert len(token) > 0
- assert token_manager.tokens["admin"] == token
def test_login_invalid_credentials(self, api_client, test_data):
"""Test login with invalid credentials fails"""
@@ -265,7 +316,7 @@ def test_login_nonexistent_user(self, api_client):
def test_login_banned_user(self, user_manager, api_client, token_manager):
"""Test login with banned user fails"""
# Create admin and customer
- user_manager.register_and_login_admin()
+ user_manager.login_initial_admin()
customer_data = user_manager.register_and_login_customer()
response = api_client.get(
@@ -325,8 +376,8 @@ def test_get_organizer_profile(self, user_manager, api_client, token_manager):
def test_get_admin_profile(self, user_manager, api_client, token_manager):
"""Test getting admin profile"""
- # Register admin
- admin_data = user_manager.register_and_login_admin()
+ # Login as initial admin
+ user_manager.login_initial_admin()
# Get profile
response = api_client.get(
@@ -335,7 +386,8 @@ def test_get_admin_profile(self, user_manager, api_client, token_manager):
)
user_data = response.json()
- assert user_data["email"] == admin_data["email"]
+ # The initial admin email comes from environment variable
+ assert "@" in user_data["email"] # Basic email validation
def test_get_profile_unauthorized(self, api_client):
"""Test getting profile without authentication fails"""
@@ -360,10 +412,10 @@ class TestAdminOperations:
def test_get_pending_organizers_empty(self, user_manager, api_client, token_manager):
"""Test admin getting empty pending organizers list"""
- # Register admin
- user_manager.register_and_login_admin()
+ # Login as initial admin
+ user_manager.login_initial_admin()
- # Get pending organizers (should be empty)
+ # Get pending organizers (should be empty initially)
response = api_client.get(
"/api/auth/pending-organizers",
headers=token_manager.get_auth_header("admin")
@@ -374,8 +426,8 @@ def test_get_pending_organizers_empty(self, user_manager, api_client, token_mana
def test_get_pending_organizers_with_data(self, user_manager, api_client, token_manager):
"""Test admin getting pending organizers with data"""
- # Register admin and unverified organizer
- user_manager.register_and_login_admin()
+ # Login as initial admin and register unverified organizer
+ user_manager.login_initial_admin()
organizer_data = user_manager.register_organizer()
# Get pending organizers
@@ -401,8 +453,8 @@ def test_get_pending_organizers_with_data(self, user_manager, api_client, token_
def test_verify_organizer_approve(self, user_manager, api_client, token_manager):
"""Test admin approving an organizer"""
- # Register admin and organizer
- user_manager.register_and_login_admin()
+ # Login as initial admin and register organizer
+ user_manager.login_initial_admin()
organizer_data = user_manager.register_organizer()
# Get pending organizers to find the ID
@@ -434,8 +486,8 @@ def test_verify_organizer_approve(self, user_manager, api_client, token_manager)
def test_verify_organizer_reject(self, user_manager, api_client, token_manager):
"""Test admin rejecting an organizer"""
- # Register admin and organizer
- user_manager.register_and_login_admin()
+ # Login as initial admin and register organizer
+ user_manager.login_initial_admin()
organizer_data = user_manager.register_organizer()
# Get pending organizers to find the ID
@@ -467,7 +519,7 @@ def test_verify_organizer_reject(self, user_manager, api_client, token_manager):
def test_ban_unban_user(self, user_manager, api_client, token_manager):
"""Test admin banning and unbanning a user"""
# Create admin and customer
- user_manager.register_and_login_admin()
+ user_manager.login_initial_admin()
customer_data = user_manager.register_and_login_customer()
response = api_client.get(
@@ -606,7 +658,7 @@ def test_token_format_validation(self, user_manager, token_manager):
# Register users and check token formats
user_manager.register_and_login_customer()
user_manager.register_and_login_organizer()
- user_manager.register_and_login_admin()
+ user_manager.login_initial_admin()
for user_type in ["customer", "organizer", "admin"]:
token = token_manager.tokens[user_type]
@@ -660,10 +712,36 @@ def test_complete_customer_flow(self, api_client, test_data):
expected_status=201
)
- reg_token = reg_response.json()["token"]
- assert len(reg_token) > 0
+ user_id = reg_response.json()["user_id"]
+ assert user_id > 0
+
+ # 2. Login as initial admin (instead of registering a new admin)
+ config = test_data.get_config()
+ initial_admin_email = config.get("initial_admin_email", "admin@resellio.com")
+ initial_admin_password = config.get("initial_admin_password", "AdminPassword123!")
+
+ admin_login_response = api_client.post(
+ "/api/auth/token",
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ data={
+ "username": initial_admin_email,
+ "password": initial_admin_password,
+ },
+ expected_status=200
+ )
+ admin_token = admin_login_response.json().get("token")
+ assert admin_token is not None
+
+ # 3. Admin approves the customer
+ approve_response = api_client.post(
+ f"/api/auth/approve-user/{user_id}",
+ headers={"Authorization": f"Bearer {admin_token}"},
+ expected_status=200
+ )
+ approve_response_data = approve_response.json()
+ assert "token" in approve_response_data, "Token not found in admin approval response"
- # 2. Login
+ # 4. Login with customer credentials
login_response = api_client.post(
"/api/auth/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
@@ -676,7 +754,7 @@ def test_complete_customer_flow(self, api_client, test_data):
login_token = login_response.json()["token"]
assert len(login_token) > 0
- # 3. Access profile
+ # 5. Access profile
profile_response = api_client.get(
"/api/user/me",
headers={"Authorization": f"Bearer {login_token}"}
@@ -685,21 +763,79 @@ def test_complete_customer_flow(self, api_client, test_data):
profile_data = profile_response.json()
assert profile_data["email"] == customer_data["email"]
- # 4. Logout (stateless)
+ # 6. Logout (stateless)
logout_response = api_client.post("/api/auth/logout")
assert "successful" in logout_response.json()["message"]
+ def test_complete_admin_flow(self, api_client, test_data):
+ """Test complete admin flow with initial admin and new admin registration"""
+ # Get initial admin credentials from helper
+ config = test_data.get_config()
+ initial_admin_email = config.get("initial_admin_email", "admin@resellio.com")
+ initial_admin_password = config.get("initial_admin_password", "AdminPassword123!")
+
+ # 1. Login as initial admin
+ initial_login_response = api_client.post(
+ "/api/auth/token",
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ data={
+ "username": initial_admin_email,
+ "password": initial_admin_password,
+ }
+ )
+ initial_token = initial_login_response.json()["token"]
+ assert len(initial_token) > 0
+ assert "Login successful" in initial_login_response.json()["message"]
- def test_complete_organizer_verification_flow(self, api_client, test_data):
- """Test complete organizer registration and verification flow"""
- # 1. Register admin
- admin_data = test_data.admin_data()
- admin_reg_response = api_client.post(
+ # 2. Register new admin using initial admin authentication
+ new_admin_data = test_data.admin_data()
+ new_admin_reg_response = api_client.post(
"/api/auth/register/admin",
- headers={"Content-Type": "application/json"},
- json_data=admin_data,
+ headers={
+ "Authorization": f"Bearer {initial_token}",
+ "Content-Type": "application/json"
+ },
+ json_data=new_admin_data,
expected_status=201
)
- admin_token = admin_reg_response.json()["token"]
+ new_admin_token = new_admin_reg_response.json()["token"]
+ assert len(new_admin_token) > 0
+
+ # 3. Login with new admin credentials
+ new_admin_login_response = api_client.post(
+ "/api/auth/token",
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ data={
+ "username": new_admin_data["email"],
+ "password": new_admin_data["password"],
+ }
+ )
+ new_admin_login_token = new_admin_login_response.json()["token"]
+ assert len(new_admin_login_token) > 0
+
+ # 4. New admin can access admin endpoints
+ admin_endpoints_response = api_client.get(
+ "/api/auth/pending-organizers",
+ headers={"Authorization": f"Bearer {new_admin_login_token}"}
+ )
+ assert admin_endpoints_response.status_code == 200
+
+ def test_complete_organizer_verification_flow(self, api_client, test_data):
+ """Test complete organizer registration and verification flow"""
+ # Get initial admin credentials
+ config = test_data.get_config()
+ initial_admin_email = config.get("initial_admin_email", "admin@resellio.com")
+ initial_admin_password = config.get("initial_admin_password", "AdminPassword123!")
+
+ # 1. Login as initial admin
+ admin_login_response = api_client.post(
+ "/api/auth/token",
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ data={
+ "username": initial_admin_email,
+ "password": initial_admin_password,
+ }
+ )
+ admin_token = admin_login_response.json()["token"]
# 2. Register organizer
organizer_data = test_data.organizer_data()
diff --git a/backend/tests/test_events_tickets_cart.py b/backend/tests/test_events_tickets_cart.py
index df8f2d2..77777cd 100644
--- a/backend/tests/test_events_tickets_cart.py
+++ b/backend/tests/test_events_tickets_cart.py
@@ -11,7 +11,7 @@
Run with: pytest test_events_tickets_cart.py -v
"""
-
+from datetime import datetime
from typing import Dict, Any
import pytest
@@ -264,6 +264,7 @@ def test_create_event_as_organizer(self, event_manager):
def test_create_event_with_custom_data(self, event_manager):
"""Test creating event with custom data and validate all fields"""
+ now = datetime.now()
custom_event_data = {
"organizer_id": 1,
"name": "Custom Test Event",
@@ -274,6 +275,8 @@ def test_create_event_with_custom_data(self, event_manager):
"location_id": 1,
"category": ["Music", "Premium", "Adults Only"],
"total_tickets": 500,
+ "standard_ticket_price": 10.0,
+ "ticket_sales_start": now.isoformat(),
}
created_event = event_manager.create_event(1, custom_event_data)
@@ -289,17 +292,17 @@ def test_create_event_with_custom_data(self, event_manager):
print(
f"✓ Custom event created with minimum age: {created_event.get('minimum_age', 'Not set')}")
- def test_admin_authorize_event(self, event_manager):
- """Test admin authorizing an event"""
- # Create an event first
- created_event = event_manager.create_event()
- event_id = created_event.get("event_id")
- assert event_id is not None, "Event ID must be present"
-
- authorized = event_manager.authorize_event(event_id)
- assert authorized is True, "Authorization should return True"
-
- print(f"✓ Admin authorized event {event_id}")
+ # def test_admin_authorize_event(self, event_manager):
+ # """Test admin authorizing an event"""
+ # # Create an event first
+ # created_event = event_manager.create_event()
+ # event_id = created_event.get("event_id")
+ # assert event_id is not None, "Event ID must be present"
+ #
+ # authorized = event_manager.authorize_event(event_id)
+ # assert authorized is True, "Authorization should return True"
+ #
+ # print(f"✓ Admin authorized event {event_id}")
def test_delete_event(self, event_manager):
"""Test deleting/canceling an event"""
diff --git a/backend/tests/test_pagination.py b/backend/tests/test_pagination.py
new file mode 100644
index 0000000..b79e9db
--- /dev/null
+++ b/backend/tests/test_pagination.py
@@ -0,0 +1,642 @@
+"""
+test_pagination_and_filtering.py - Tests for Enhanced Pagination and Filtering
+-----------------------------------------------------------------------------
+Comprehensive tests for the new pagination and filtering functionality
+in events and resale marketplace endpoints.
+
+Run with: pytest test_pagination_and_filtering.py -v
+"""
+
+import pytest
+from datetime import datetime, timedelta
+from typing import List, Dict, Any
+
+from helper import (
+ APIClient, TokenManager, TestDataGenerator, UserManager, EventManager,
+ CartManager, TicketManager, ResaleManager, print_test_config
+)
+
+
+@pytest.fixture(scope="session")
+def api_client():
+ """API client fixture"""
+ return APIClient()
+
+
+@pytest.fixture(scope="session")
+def token_manager():
+ """Token manager fixture"""
+ return TokenManager()
+
+
+@pytest.fixture(scope="session")
+def test_data():
+ """Test data generator fixture"""
+ return TestDataGenerator()
+
+
+@pytest.fixture(scope="session")
+def user_manager(api_client, token_manager):
+ """User manager fixture"""
+ return UserManager(api_client, token_manager)
+
+
+@pytest.fixture(scope="session")
+def event_manager(api_client, token_manager):
+ """Event manager fixture"""
+ return EventManager(api_client, token_manager)
+
+
+@pytest.fixture(scope="session")
+def cart_manager(api_client, token_manager):
+ """Cart manager fixture"""
+ return CartManager(api_client, token_manager)
+
+
+@pytest.fixture(scope="session")
+def ticket_manager(api_client, token_manager):
+ """Ticket manager fixture"""
+ return TicketManager(api_client, token_manager)
+
+
+@pytest.fixture(scope="session")
+def resale_manager(api_client, token_manager):
+ """Resale manager fixture"""
+ return ResaleManager(api_client, token_manager)
+
+
+def prepare_test_data(user_manager, event_manager, cart_manager, ticket_manager):
+ """Prepare comprehensive test data for pagination tests"""
+ print_test_config()
+
+ # Create users
+ customer_data = user_manager.register_and_login_customer()
+ customer2_data = user_manager.register_and_login_customer2()
+ organizer_data = user_manager.register_and_login_organizer()
+ admin_data = user_manager.register_and_login_admin()
+
+ # Create multiple events with different characteristics
+ events = []
+ ticket_types = []
+
+ # Event 1: Music concert in the future
+ event1_data = {
+ "organizer_id": 1,
+ "name": "Rock Concert 2025",
+ "description": "An amazing rock concert with live music",
+ "start_date": (datetime.now() + timedelta(days=30)).isoformat(),
+ "end_date": (datetime.now() + timedelta(days=30, hours=3)).isoformat(),
+ "minimum_age": 18,
+ "location_id": 1,
+ "category": ["Music", "Rock", "Live"], # This will be converted to categories
+ "total_tickets": 100,
+ "standard_ticket_price": 50.0,
+ "ticket_sales_start": datetime.now().isoformat(),
+ }
+ event1 = event_manager.create_event(1, event1_data)
+ events.append(event1)
+
+ # Event 2: Theater show
+ event2_data = {
+ "organizer_id": 1,
+ "name": "Shakespeare Theater",
+ "description": "Classic theater performance of Hamlet",
+ "start_date": (datetime.now() + timedelta(days=45)).isoformat(),
+ "end_date": (datetime.now() + timedelta(days=45, hours=2)).isoformat(),
+ "minimum_age": 12,
+ "location_id": 1,
+ "category": ["Theater", "Classic", "Drama"],
+ "total_tickets": 200,
+ "standard_ticket_price": 75.0,
+ "ticket_sales_start": datetime.now().isoformat(),
+ }
+ event2 = event_manager.create_event(1, event2_data)
+ events.append(event2)
+
+ # Event 3: Sports event
+ event3_data = {
+ "organizer_id": 1,
+ "name": "Football Championship",
+ "description": "Championship football match",
+ "start_date": (datetime.now() + timedelta(days=60)).isoformat(),
+ "end_date": (datetime.now() + timedelta(days=60, hours=2)).isoformat(),
+ "minimum_age": 0,
+ "location_id": 1,
+ "category": ["Sports", "Football", "Championship"],
+ "total_tickets": 500,
+ "standard_ticket_price": 100.0,
+ "ticket_sales_start": datetime.now().isoformat(),
+ }
+ event3 = event_manager.create_event(1, event3_data)
+ events.append(event3)
+
+ # Create additional ticket types with different prices
+ for i, event in enumerate(events):
+ # VIP ticket
+ vip_ticket = {
+ "event_id": event["event_id"],
+ "description": f"VIP Access - Event {i + 1}",
+ "max_count": 20,
+ "price": 150.0 + (i * 50), # 150, 200, 250
+ "currency": "PLN",
+ "available_from": datetime.now().isoformat()
+ }
+ vip_type = event_manager.create_ticket_type(event["event_id"], vip_ticket)
+ ticket_types.append(vip_type)
+
+ # Purchase some tickets and list them for resale
+ purchased_tickets = []
+ # Get all ticket types for the events
+ all_ticket_types = event_manager.get_ticket_types()
+ available_types = [tt for tt in all_ticket_types if tt.get("type_id")]
+
+ for i, ticket_type in enumerate(available_types[:3]): # Purchase from first 3 ticket types
+ try:
+ cart_manager.add_item_to_cart(ticket_type["type_id"], 1)
+ cart_manager.checkout()
+
+ # Get purchased tickets
+ tickets = ticket_manager.list_tickets()
+ if tickets:
+ ticket = tickets[-1] # Get the most recently purchased ticket
+ purchased_tickets.append(ticket)
+
+ # List some tickets for resale at different prices
+ resale_price = ticket_type["price"] * (1.2 + i * 0.1) # 20%, 30%, 40% markup
+ ticket_manager.resell_ticket(ticket["ticket_id"], resale_price)
+ except Exception as e:
+ print(f"Warning: Could not purchase/resell ticket type {ticket_type.get('type_id')}: {e}")
+
+ return {
+ "customer": customer_data,
+ "customer2": customer2_data,
+ "organizer": organizer_data,
+ "admin": admin_data,
+ "events": events,
+ "ticket_types": ticket_types,
+ "purchased_tickets": purchased_tickets
+ }
+
+
+@pytest.mark.pagination
+class TestEventsPagination:
+ """Test events endpoint pagination and filtering"""
+
+ @pytest.fixture(autouse=True)
+ def setup(self, user_manager, event_manager, cart_manager, ticket_manager):
+ """Setup test data"""
+ self.test_data = prepare_test_data(user_manager, event_manager, cart_manager,
+ ticket_manager)
+ self.api_client = APIClient()
+
+ def test_events_basic_pagination(self):
+ """Test basic pagination functionality"""
+ # Test first page with limit 2
+ response = self.api_client.get("/api/events?page=1&limit=2")
+ events_page1 = response.json()
+
+ assert len(events_page1) <= 2
+ assert isinstance(events_page1, list)
+
+ # Validate event structure
+ for event in events_page1:
+ assert "event_id" in event
+ assert "name" in event
+ assert "start_date" in event
+ assert "end_date" in event
+ assert "location_name" in event
+ assert "status" in event
+
+ # Test second page
+ response = self.api_client.get("/api/events?page=2&limit=2")
+ events_page2 = response.json()
+
+ # Verify different events on different pages (if enough events exist)
+ if len(events_page1) == 2 and len(events_page2) > 0:
+ page1_ids = {event["event_id"] for event in events_page1}
+ page2_ids = {event["event_id"] for event in events_page2}
+ assert page1_ids.isdisjoint(page2_ids), "Pages should contain different events"
+
+ def test_events_search_functionality(self):
+ """Test search functionality"""
+ # Search for rock concert
+ response = self.api_client.get("/api/events?search=Rock")
+ events = response.json()
+
+ # Should find at least the rock concert we created
+ rock_events = [e for e in events if
+ "Rock" in e["name"] or ("description" in e and e["description"] and "rock" in e["description"].lower())]
+ assert len(rock_events) >= 1, "Should find rock concert events"
+
+ # Search for theater
+ response = self.api_client.get("/api/events?search=Theater")
+ events = response.json()
+ theater_events = [e for e in events if
+ "Theater" in e["name"] or ("description" in e and e["description"] and "theater" in e["description"].lower())]
+ assert len(theater_events) >= 1, "Should find theater events"
+
+ def test_events_location_filter(self):
+ """Test location filtering"""
+ # First, get an actual location name from the events
+ response = self.api_client.get("/api/events?limit=1")
+ events = response.json()
+
+ if events:
+ location_name = events[0]["location_name"]
+ # Test with the actual location name
+ response = self.api_client.get(f"/api/events?location={location_name}")
+ filtered_events = response.json()
+
+ # All events should be at the specified location
+ for event in filtered_events:
+ assert event["location_name"] == location_name, f"Event location '{event['location_name']}' should match filter '{location_name}'"
+
+ def test_events_date_filters(self):
+ """Test date range filtering"""
+ # Filter events starting after today
+ tomorrow = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S")
+ response = self.api_client.get(f"/api/events?start_date_from={tomorrow}")
+ events = response.json()
+
+ # All events should be in the future
+ for event in events:
+ event_start = datetime.fromisoformat(event["start_date"].replace("Z", ""))
+ filter_date = datetime.fromisoformat(tomorrow)
+ assert event_start >= filter_date, f"Event start date {event_start} should be after filter date {filter_date}"
+
+ def test_events_price_filters(self):
+ """Test price range filtering"""
+ # Filter events with tickets between 60-120 PLN
+ response = self.api_client.get("/api/events?min_price=60&max_price=120")
+ events = response.json()
+
+ assert isinstance(events, list), "Should return list of events"
+ # Note: Price filtering tests depend on having ticket types with appropriate prices
+
+ def test_events_sorting(self):
+ """Test sorting functionality"""
+ # Sort by name ascending
+ response = self.api_client.get("/api/events?sort_by=name&sort_order=asc")
+ events = response.json()
+
+ if len(events) > 1:
+ names = [event["name"] for event in events]
+ assert names == sorted(names), "Events should be sorted by name ascending"
+
+ # Sort by start_date descending
+ response = self.api_client.get("/api/events?sort_by=start_date&sort_order=desc")
+ events = response.json()
+
+ if len(events) > 1:
+ dates = [event["start_date"] for event in events]
+ assert dates == sorted(dates,
+ reverse=True), "Events should be sorted by date descending"
+
+ def test_events_combined_filters(self):
+ """Test combining multiple filters"""
+ response = self.api_client.get(
+ "/api/events?search=Concert&min_price=40&sort_by=start_date&sort_order=asc&page=1&limit=10"
+ )
+ events = response.json()
+
+ assert isinstance(events, list), "Should return list of events"
+ assert len(events) <= 10, "Should respect limit parameter"
+
+@pytest.mark.pagination
+class TestResalePagination:
+ """Test resale marketplace pagination and filtering"""
+
+ @pytest.fixture(autouse=True)
+ def setup(self, user_manager, event_manager, cart_manager, ticket_manager):
+ """Setup test data"""
+ self.test_data = prepare_test_data(user_manager, event_manager, cart_manager,
+ ticket_manager)
+ self.api_client = APIClient()
+
+ def test_resale_marketplace_basic_pagination(self):
+ """Test basic pagination for resale marketplace"""
+ # Test first page with limit 2
+ response = self.api_client.get("/api/resale/marketplace?page=1&limit=2")
+ listings_page1 = response.json()
+
+ assert len(listings_page1) <= 2
+ assert isinstance(listings_page1, list)
+
+ # Validate listing structure
+ for listing in listings_page1:
+ assert "ticket_id" in listing
+ assert "resell_price" in listing
+ assert "original_price" in listing
+ assert "event_name" in listing
+ assert "venue_name" in listing
+
+ def test_resale_marketplace_search(self):
+ """Test search functionality in resale marketplace"""
+ # Search by event name
+ response = self.api_client.get("/api/resale/marketplace?search=Concert")
+ listings = response.json()
+
+ # Should find concerts in resale
+ concert_listings = [l for l in listings if "Concert" in l["event_name"]]
+ if concert_listings:
+ assert len(concert_listings) >= 1, "Should find concert listings"
+
+ def test_resale_marketplace_price_filters(self):
+ """Test price filtering in resale marketplace"""
+ # Filter by resale price range
+ response = self.api_client.get("/api/resale/marketplace?min_price=50&max_price=500")
+ listings = response.json()
+
+ for listing in listings:
+ assert 50 <= listing[
+ "resell_price"] <= 500, f"Resale price {listing['resell_price']} not in range 50-500"
+
+ # Filter by original price range
+ response = self.api_client.get(
+ "/api/resale/marketplace?min_original_price=40&max_original_price=200")
+ listings = response.json()
+
+ for listing in listings:
+ assert 40 <= listing[
+ "original_price"] <= 200, f"Original price {listing['original_price']} not in range 40-200"
+
+ def test_resale_marketplace_date_filters(self):
+ """Test date filtering in resale marketplace"""
+ # Filter events from tomorrow onwards
+ tomorrow = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d")
+ response = self.api_client.get(f"/api/resale/marketplace?event_date_from={tomorrow}")
+ listings = response.json()
+
+ for listing in listings:
+ event_date = datetime.fromisoformat(listing["event_date"].replace("Z", ""))
+ filter_date = datetime.strptime(tomorrow, "%Y-%m-%d")
+ assert event_date.date() >= filter_date.date(), f"Event date {event_date.date()} should be >= {filter_date.date()}"
+
+ def test_resale_marketplace_venue_filter(self):
+ """Test venue filtering"""
+ # First get a venue name from existing listings
+ response = self.api_client.get("/api/resale/marketplace?limit=1")
+ listings = response.json()
+
+ if listings:
+ venue_name = listings[0]["venue_name"]
+ # Test with actual venue name
+ response = self.api_client.get(f"/api/resale/marketplace?venue={venue_name}")
+ filtered_listings = response.json()
+
+ for listing in filtered_listings:
+ assert venue_name in listing["venue_name"], f"Venue '{listing['venue_name']}' should contain '{venue_name}'"
+
+ def test_resale_marketplace_seat_filter(self):
+ """Test seat availability filtering"""
+ # Filter tickets with seats
+ response = self.api_client.get("/api/resale/marketplace?has_seat=true")
+ listings = response.json()
+
+ for listing in listings:
+ assert listing["seat"] is not None, "All listings should have seats"
+
+ # Filter tickets without seats
+ response = self.api_client.get("/api/resale/marketplace?has_seat=false")
+ listings = response.json()
+
+ for listing in listings:
+ assert listing["seat"] is None, "All listings should not have seats"
+
+ def test_resale_marketplace_sorting(self):
+ """Test sorting in resale marketplace"""
+ # Sort by resale price ascending
+ response = self.api_client.get(
+ "/api/resale/marketplace?sort_by=resell_price&sort_order=asc")
+ listings = response.json()
+
+ if len(listings) > 1:
+ prices = [listing["resell_price"] for listing in listings]
+ assert prices == sorted(prices), "Listings should be sorted by resale price ascending"
+
+ # Sort by event name descending
+ response = self.api_client.get("/api/resale/marketplace?sort_by=event_name&sort_order=desc")
+ listings = response.json()
+
+ if len(listings) > 1:
+ names = [listing["event_name"] for listing in listings]
+ assert names == sorted(names,
+ reverse=True), "Listings should be sorted by event name descending"
+
+ def test_resale_marketplace_invalid_date_format(self):
+ """Test invalid date format handling"""
+ response = self.api_client.get("/api/resale/marketplace?event_date_from=invalid-date",
+ expected_status=400)
+ error = response.json()
+ assert "Invalid event_date_from format" in error["detail"]
+
+ def test_resale_marketplace_invalid_sort_parameters(self):
+ """Test invalid sort parameters in resale marketplace"""
+ # Invalid sort_by
+ response = self.api_client.get("/api/resale/marketplace?sort_by=invalid_field",
+ expected_status=400)
+ error = response.json()
+ assert "Invalid sort_by" in error["detail"]
+
+ def test_my_listings_pagination(self):
+ """Test pagination for user's own listings"""
+ # This requires authentication, so we'll use a token
+ token_manager = TokenManager()
+ token_manager.set_token("customer", "dummy_token_for_test")
+
+ try:
+ response = self.api_client.get(
+ "/api/resale/my-listings?page=1&limit=10",
+ headers={"Authorization": "Bearer dummy_token"}
+ )
+ # This might fail due to authentication, but we're testing the endpoint structure
+ except:
+ # Expected to fail without proper authentication setup
+ pass
+
+ def test_resale_combined_filters(self):
+ """Test combining multiple filters in resale marketplace"""
+ response = self.api_client.get(
+ "/api/resale/marketplace?"
+ "search=Concert&"
+ "min_price=50&max_price=300&"
+ "sort_by=resell_price&sort_order=asc&"
+ "page=1&limit=5"
+ )
+ listings = response.json()
+
+ assert isinstance(listings, list), "Should return list of listings"
+ assert len(listings) <= 5, "Should respect limit parameter"
+
+ # Verify price range
+ for listing in listings:
+ assert 50 <= listing["resell_price"] <= 300, "All listings should be in price range"
+
+
+@pytest.mark.pagination
+class TestPaginationEdgeCases:
+ """Test edge cases and error handling for pagination"""
+
+ @pytest.fixture(autouse=True)
+ def setup(self):
+ """Setup for edge case tests"""
+ self.api_client = APIClient()
+
+ def test_events_pagination_bounds(self):
+ """Test pagination boundary conditions"""
+ # Test page 0 (should default to 1 or error)
+ response = self.api_client.get("/api/events?page=0", expected_status=422)
+
+ # Test negative page
+ response = self.api_client.get("/api/events?page=-1", expected_status=422)
+
+ # Test limit too high
+ response = self.api_client.get("/api/events?limit=101", expected_status=422)
+
+ # Test limit 0
+ response = self.api_client.get("/api/events?limit=0", expected_status=422)
+
+ def test_resale_pagination_bounds(self):
+ """Test resale marketplace pagination boundary conditions"""
+ # Test page 0
+ response = self.api_client.get("/api/resale/marketplace?page=0", expected_status=422)
+
+ # Test limit too high
+ response = self.api_client.get("/api/resale/marketplace?limit=101", expected_status=422)
+
+ def test_events_empty_results(self):
+ """Test handling of empty results"""
+ # Search for something that doesn't exist
+ response = self.api_client.get("/api/events?search=NonExistentEvent12345")
+ events = response.json()
+
+ assert isinstance(events, list), "Should return empty list"
+ assert len(events) == 0, "Should return no events"
+
+ def test_resale_empty_results(self):
+ """Test handling of empty resale results"""
+ # Search for something that doesn't exist
+ response = self.api_client.get("/api/resale/marketplace?search=NonExistentEvent12345")
+ listings = response.json()
+
+ assert isinstance(listings, list), "Should return empty list"
+ assert len(listings) == 0, "Should return no listings"
+
+ def test_events_high_page_number(self):
+ """Test requesting a page number beyond available data"""
+ response = self.api_client.get("/api/events?page=9999&limit=10")
+ events = response.json()
+
+ assert isinstance(events, list), "Should return empty list for high page numbers"
+ assert len(events) == 0, "Should return no events for page beyond data"
+
+ def test_special_characters_in_search(self):
+ """Test search with special characters"""
+ # Test with SQL injection attempt
+ response = self.api_client.get("/api/events?search='; DROP TABLE events; --")
+ events = response.json()
+
+ assert isinstance(events, list), "Should handle SQL injection attempts safely"
+
+ # Test with Unicode characters
+ response = self.api_client.get("/api/events?search=Cöncert")
+ events = response.json()
+
+ assert isinstance(events, list), "Should handle Unicode characters"
+
+
+@pytest.mark.integration
+class TestPaginationIntegration:
+ """Integration tests for pagination with real data flow"""
+
+ @pytest.fixture(autouse=True)
+ def setup(self, user_manager, event_manager, cart_manager, ticket_manager):
+ """Setup comprehensive test environment"""
+ self.test_data = prepare_test_data(user_manager, event_manager, cart_manager,
+ ticket_manager)
+ self.api_client = APIClient()
+ self.event_manager = event_manager
+ self.cart_manager = cart_manager
+ self.ticket_manager = ticket_manager
+
+ def test_complete_pagination_workflow(self):
+ """Test complete workflow: create events → list with pagination → filter → resale"""
+ # 1. Verify we can paginate through events
+ response = self.api_client.get("/api/events?page=1&limit=2")
+ events_page1 = response.json()
+
+ assert len(events_page1) <= 2, "Should respect pagination limit"
+
+ # 2. Search for specific event
+ if events_page1:
+ event_name = events_page1[0]["name"]
+ search_term = event_name.split()[0] # First word of event name
+
+ response = self.api_client.get(f"/api/events?search={search_term}")
+ search_results = response.json()
+
+ found_event = any(search_term in event["name"] for event in search_results)
+ assert found_event, f"Should find event with search term '{search_term}'"
+
+ # 3. Test resale marketplace pagination
+ response = self.api_client.get("/api/resale/marketplace?page=1&limit=5")
+ resale_listings = response.json()
+
+ assert isinstance(resale_listings, list), "Should return list of resale listings"
+ assert len(resale_listings) <= 5, "Should respect resale pagination limit"
+
+ def test_cross_endpoint_data_consistency(self):
+ """Test data consistency across different paginated endpoints"""
+ # Get events with pagination
+ response = self.api_client.get("/api/events?page=1&limit=10")
+ events = response.json()
+
+ # Get resale marketplace
+ response = self.api_client.get("/api/resale/marketplace?page=1&limit=10")
+ resale_listings = response.json()
+
+ # Verify that events in resale listings exist in events list
+ event_ids = {event["event_id"] for event in events}
+
+ for listing in resale_listings:
+ # The event should exist (though not necessarily in current page)
+ assert listing["event_name"] is not None, "Listing should have valid event name"
+ assert listing["venue_name"] is not None, "Listing should have valid venue name"
+
+ def test_pagination_performance_with_filters(self):
+ """Test that pagination performs well with multiple filters"""
+ import time
+
+ # Complex query with multiple filters
+ start_time = time.time()
+ response = self.api_client.get(
+ "/api/events?"
+ "search=Concert&"
+ "min_price=50&max_price=200&"
+ "sort_by=start_date&sort_order=desc&"
+ "page=1&limit=10"
+ )
+ end_time = time.time()
+
+ events = response.json()
+ execution_time = end_time - start_time
+
+ assert isinstance(events, list), "Should return valid results"
+ assert execution_time < 5.0, f"Query should complete in reasonable time, took {execution_time:.2f}s"
+
+ def test_filter_validation_across_endpoints(self):
+ """Test that filter validation is consistent across endpoints"""
+ # Test invalid price filters on both endpoints
+ response = self.api_client.get("/api/events?min_price=-10", expected_status=422)
+
+ response = self.api_client.get("/api/resale/marketplace?min_price=-10", expected_status=422)
+
+ # Test valid price filters
+ response = self.api_client.get("/api/events?min_price=0&max_price=1000")
+ events = response.json()
+ assert isinstance(events, list), "Should accept valid price range"
+
+ response = self.api_client.get("/api/resale/marketplace?min_price=0&max_price=1000")
+ listings = response.json()
+ assert isinstance(listings, list), "Should accept valid price range"
+
\ No newline at end of file
diff --git a/backend/user_auth_service/app/models/user.py b/backend/user_auth_service/app/models/user.py
index 4af7940..f83e8b8 100644
--- a/backend/user_auth_service/app/models/user.py
+++ b/backend/user_auth_service/app/models/user.py
@@ -18,7 +18,18 @@ class User(Base):
is_active = Column(Boolean, default=True)
user_type = Column(String) # 'customer', 'organizer', 'administrator'
+ email_verification_token = Column(String, nullable=True, unique=True, index=True)
+
# Define relationships
customer = relationship("Customer", back_populates="user", uselist=False)
organizer = relationship("Organizer", back_populates="user", uselist=False)
administrator = relationship("Administrator", back_populates="user", uselist=False)
+
+ def set_email_verification_token(self):
+ """Generates and sets a new email verification token."""
+ from app.security import generate_email_verification_token # Local import for security functions
+ self.email_verification_token = generate_email_verification_token()
+
+ def clear_email_verification_token(self):
+ """Clears the email verification token."""
+ self.email_verification_token = None
diff --git a/backend/user_auth_service/app/repositories/auth_repository.py b/backend/user_auth_service/app/repositories/auth_repository.py
new file mode 100644
index 0000000..90acab7
--- /dev/null
+++ b/backend/user_auth_service/app/repositories/auth_repository.py
@@ -0,0 +1,77 @@
+import logging
+from datetime import timedelta
+
+from sqlalchemy.orm import Session
+from fastapi import HTTPException, status, Depends
+
+from app.models import User, Customer
+from app.database import get_db
+from app.security import create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
+
+logger = logging.getLogger(__name__)
+
+
+class AuthRepository:
+ def __init__(self, db: Session):
+ self.db = db
+
+ def get_user_by_email_verification_token(self, token: str) -> User | None:
+ return self.db.query(User).filter(User.email_verification_token == token).first()
+
+ def activate_user(self, user: User) -> User:
+ user.is_active = True
+ user.clear_email_verification_token()
+ self.db.commit()
+ self.db.refresh(user)
+ return user
+
+ def get_customer_by_user_id(self, user_id: int) -> Customer | None:
+ return self.db.query(Customer).filter(Customer.user_id == user_id).first()
+
+ def verify_email_and_generate_token(self, verification_token: str) -> dict:
+ """
+ Verifies a user's email address using the token, activates the user,
+ and generates an access token.
+ """
+ user_to_verify = self.get_user_by_email_verification_token(verification_token)
+
+ if not user_to_verify:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid or already used verification token."
+ )
+
+ if user_to_verify.is_active:
+ # Allow proceeding to token generation if already active and token matches
+ pass
+
+ # Activate user and clear token
+ user_to_verify.is_active = True
+ user_to_verify.clear_email_verification_token()
+ self.db.commit()
+ self.db.refresh(user_to_verify)
+
+ # Automatically log the user in by creating an access token
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+ customer_record = self.db.query(Customer).filter(Customer.user_id == user_to_verify.user_id).first()
+ if not customer_record:
+ logger.error(f"Customer record not found for verified user_id {user_to_verify.user_id}.")
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Account activated, but an error occurred retrieving profile details for login. Please try logging in manually.")
+
+ access_token = create_access_token(
+ data={
+ "sub": user_to_verify.email,
+ "role": user_to_verify.user_type,
+ "user_id": user_to_verify.user_id,
+ "role_id": customer_record.customer_id,
+ "name": user_to_verify.first_name,
+ },
+ expires_delta=access_token_expires,
+ )
+ return {"token": access_token, "message": "Account activated successfully. You are now logged in."}
+
+
+# Dependency to get the AuthRepository instance
+def get_auth_repository(db: Session = Depends(get_db)) -> AuthRepository:
+ return AuthRepository(db)
\ No newline at end of file
diff --git a/backend/user_auth_service/app/routers/auth.py b/backend/user_auth_service/app/routers/auth.py
index 8c83c3b..d0a247a 100644
--- a/backend/user_auth_service/app/routers/auth.py
+++ b/backend/user_auth_service/app/routers/auth.py
@@ -1,13 +1,23 @@
-from typing import List
+"""
+Fixed auth.py - Addresses admin banning and initial admin security issues
+"""
+
+import logging
+from typing import List, Optional
from datetime import datetime, timedelta
from app.database import get_db
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from fastapi.security import OAuth2PasswordRequestForm
+
+from app.repositories.auth_repository import AuthRepository
from app.schemas.user import UserResponse, OrganizerResponse
from app.models import User, Customer, Organizer, Administrator
-from fastapi import Depends, APIRouter, HTTPException, BackgroundTasks, status
+from fastapi import Depends, APIRouter, HTTPException, BackgroundTasks, status, Query
+from sqlalchemy import and_, or_
+from app.security import INITIAL_ADMIN_EMAIL
+
from app.schemas.auth import (
Token,
UserCreate,
@@ -26,77 +36,90 @@
create_access_token,
verify_admin_secret,
generate_reset_token,
+ verify_initial_admin_credentials,
)
+from app.services.email_service import send_account_verification_email
-# Future import for email sending functionality
-# from app.services.email import send_password_reset_email, send_verification_email
+logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["authentication"])
-@router.post("/register/customer", response_model=Token, status_code=status.HTTP_201_CREATED)
+@router.post("/register/customer", status_code=status.HTTP_201_CREATED)
def register_customer(
- user: UserCreate,
- db: Session = Depends(get_db),
+ user: UserCreate,
+ background_tasks: BackgroundTasks,
+ db: Session = Depends(get_db),
):
"""Register a new customer account"""
- try:
- # Check if email already exists
- db_user = db.query(User).filter(User.email == user.email).first()
- if db_user:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Email already registered",
- )
- # Check if login already exists
- db_login = db.query(User).filter(User.login == user.login).first()
- if db_login:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Login already taken",
- )
+ if user.email == INITIAL_ADMIN_EMAIL:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="This email address is reserved and cannot be used for registration",
+ )
+
+ # Check if email already exists
+ new_user = db.query(User).filter(User.email == user.email).first()
+ if new_user:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Email already registered",
+ )
+
+ # Check if login already exists
+ db_login = db.query(User).filter(User.login == user.login).first()
+ if db_login:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Login already taken",
+ )
+ try:
# Create new user with hashed password
hashed_password = get_password_hash(user.password)
- db_user = User(
+ new_user = User(
email=user.email,
login=user.login,
password_hash=hashed_password,
first_name=user.first_name,
last_name=user.last_name,
user_type="customer",
- is_active=True,
+ is_active=False, # User is inactive until email verification
)
+ new_user.set_email_verification_token()
- db.add(db_user)
+ db.add(new_user)
db.flush() # Flush to get the user_id without committing
# Create customer record
- db_customer = Customer(user_id=db_user.user_id)
+ db_customer = Customer(user_id=new_user.user_id)
db.add(db_customer)
db.commit()
- db.refresh(db_user)
-
- # Generate access token
- access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
- access_token = create_access_token(
- data={
- "sub": db_user.email,
- "role": db_user.user_type,
- "user_id": db_user.user_id,
- "role_id": db_customer.customer_id,
- "name": db_user.first_name,
- },
- expires_delta=access_token_expires,
+ db.refresh(new_user)
+
+ # Send verification email in the background
+ background_tasks.add_task(
+ send_account_verification_email,
+ to_email=new_user.email,
+ user_name=new_user.first_name,
+ verification_token=new_user.email_verification_token
)
- return {"token": access_token, "message": "User registered successfully"}
+ return {
+ "message": "User registered successfully. Please check your email to activate your account.",
+ "user_id": new_user.user_id}
except IntegrityError:
db.rollback()
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Registration failed due to database error")
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Registration failed due to database error")
+ except Exception as e:
+ db.rollback()
+ logger.error(f"Unexpected error during customer registration: {e}", exc_info=True)
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Registration failed due to database error")
@router.post("/register/organizer", response_model=Token, status_code=status.HTTP_201_CREATED)
@@ -106,12 +129,14 @@ def register_organizer(user: OrganizerCreate, db: Session = Depends(get_db)):
# Check if email already exists
db_user = db.query(User).filter(User.email == user.email).first()
if db_user:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Email already registered")
# Check if login already exists
db_login = db.query(User).filter(User.login == user.login).first()
if db_login:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Login already taken")
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Login already taken")
# Create new user with hashed password
hashed_password = get_password_hash(user.password)
@@ -129,7 +154,8 @@ def register_organizer(user: OrganizerCreate, db: Session = Depends(get_db)):
db.flush() # Flush to get the user_id without committing
# Create organizer record
- db_organizer = Organizer(user_id=db_user.user_id, company_name=user.company_name, is_verified=False)
+ db_organizer = Organizer(user_id=db_user.user_id, company_name=user.company_name,
+ is_verified=False)
db.add(db_organizer)
db.commit()
@@ -156,25 +182,32 @@ def register_organizer(user: OrganizerCreate, db: Session = Depends(get_db)):
except IntegrityError:
db.rollback()
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Registration failed due to database error")
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Registration failed due to database error")
@router.post("/register/admin", response_model=Token, status_code=status.HTTP_201_CREATED)
-def register_admin(user: AdminCreate, db: Session = Depends(get_db)):
- """Register a new administrator account (requires admin secret key)"""
- # Verify admin secret key
+def register_admin(
+ user: AdminCreate,
+ db: Session = Depends(get_db),
+ current_admin: User = Depends(get_current_admin)
+):
+ """Register a new administrator account (requires existing admin authentication)"""
+ # Verify admin secret key (additional security layer)
verify_admin_secret(user.admin_secret_key)
try:
# Check if email already exists
db_user = db.query(User).filter(User.email == user.email).first()
if db_user:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Email already registered")
# Check if login already exists
db_login = db.query(User).filter(User.login == user.login).first()
if db_login:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Login already taken")
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Login already taken")
# Create new admin with hashed password
hashed_password = get_password_hash(user.password)
@@ -215,14 +248,56 @@ def register_admin(user: AdminCreate, db: Session = Depends(get_db)):
except IntegrityError:
db.rollback()
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Registration failed due to database error")
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Registration failed due to database error")
@router.post("/token", response_model=Token)
-def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
+def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(),
+ db: Session = Depends(get_db)):
"""Login endpoint that exchanges username (email) and password for an access token"""
- # Find the user
+
user = db.query(User).filter(User.email == form_data.username).first()
+
+ if not user and verify_initial_admin_credentials(form_data.username, form_data.password):
+ # Create the initial admin user since it doesn't exist
+ hashed_password = get_password_hash(form_data.password)
+ admin_user = User(
+ email=form_data.username,
+ login="initial_admin",
+ password_hash=hashed_password,
+ first_name="Initial",
+ last_name="Admin",
+ user_type="administrator",
+ is_active=True,
+ )
+
+ db.add(admin_user)
+ db.flush()
+
+ # Create administrator record
+ admin_record = Administrator(user_id=admin_user.user_id)
+ db.add(admin_record)
+
+ db.commit()
+ db.refresh(admin_user)
+ db.refresh(admin_record)
+
+ # Generate access token for initial admin
+ access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+ access_token = create_access_token(
+ data={
+ "sub": admin_user.email,
+ "role": admin_user.user_type,
+ "user_id": admin_user.user_id,
+ "role_id": admin_record.admin_id,
+ "name": admin_user.first_name,
+ },
+ expires_delta=access_token_expires,
+ )
+
+ return {"token": access_token, "message": "Login successful"}
+
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -234,7 +309,6 @@ def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db:
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account banned")
-
role_id = None
# Check if the organizer is verified
@@ -242,10 +316,12 @@ def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db:
organizer = db.query(Organizer).filter(Organizer.user_id == user.user_id).first()
role_id = organizer.organizer_id
if not organizer.is_verified:
- return {"token": "", "message": "Your account is pending verification by an administrator"}
+ return {"token": "",
+ "message": "Your account is pending verification by an administrator"}
if user.user_type == "administrator":
- role_id = db.query(Administrator).filter(Administrator.user_id == user.user_id).first().admin_id
+ role_id = db.query(Administrator).filter(
+ Administrator.user_id == user.user_id).first().admin_id
elif user.user_type == "customer":
role_id = db.query(Customer).filter(Customer.user_id == user.user_id).first().customer_id
@@ -273,13 +349,16 @@ def logout():
"""Logout (client should discard the token)"""
return {"message": "Logout successful"}
+
@router.post("/verify-organizer", response_model=OrganizerResponse)
def verify_organizer(
- verification: VerificationRequest, db: Session = Depends(get_db), admin: User = Depends(get_current_admin)
+ verification: VerificationRequest, db: Session = Depends(get_db),
+ admin: User = Depends(get_current_admin)
):
"""Verify or reject an organizer account (admin only)"""
# Find the organizer
- organizer = db.query(Organizer).filter(Organizer.organizer_id == verification.organizer_id).first()
+ organizer = db.query(Organizer).filter(
+ Organizer.organizer_id == verification.organizer_id).first()
if not organizer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organizer not found")
@@ -310,7 +389,8 @@ def verify_organizer(
@router.get("/pending-organizers", response_model=List[OrganizerResponse])
-def list_pending_organizers(db: Session = Depends(get_db), admin: User = Depends(get_current_admin)):
+def list_pending_organizers(db: Session = Depends(get_db),
+ admin: User = Depends(get_current_admin)):
"""List all organizers pending verification (admin only)"""
# Join User and Organizer tables to get all unverified organizers
unverified_organizers = (
@@ -334,7 +414,8 @@ def list_pending_organizers(db: Session = Depends(get_db), admin: User = Depends
@router.post("/request-password-reset")
def request_password_reset(
- reset_request: PasswordReset, background_tasks: BackgroundTasks, db: Session = Depends(get_db)
+ reset_request: PasswordReset, background_tasks: BackgroundTasks,
+ db: Session = Depends(get_db)
):
"""Request a password reset link via email"""
user = db.query(User).filter(User.email == reset_request.email).first()
@@ -359,13 +440,25 @@ def reset_password(reset_confirm: PasswordResetConfirm, db: Session = Depends(ge
@router.post("/ban-user/{user_id}")
def ban_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_current_admin)):
- """Ban a user (admin only)"""
+ """Ban a user (admin only) - FIXED: Prevent banning other admins"""
# Find the user
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
+ if user.user_type == "administrator":
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Cannot ban administrator accounts"
+ )
+
+ if user.user_id == admin.user_id:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Cannot ban yourself"
+ )
+
user.is_active = False
db.commit()
@@ -373,7 +466,8 @@ def ban_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(
@router.post("/unban-user/{user_id}")
-def unban_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_current_admin)):
+def unban_user(user_id: int, db: Session = Depends(get_db),
+ admin: User = Depends(get_current_admin)):
"""Unban a user (admin only)"""
user = db.query(User).filter(User.user_id == user_id).first()
@@ -387,3 +481,197 @@ def unban_user(user_id: int, db: Session = Depends(get_db), admin: User = Depend
db.commit()
return {"message": "User has been unbanned"}
+
+
+@router.get("/verify-email", response_model=Token, summary="Verify Email Address")
+async def verify_email_address(
+ token: str = Query(...,
+ description="The email verification token sent to the user's email address"),
+ db: Session = Depends(get_db)
+):
+ """
+ Verify a user's email address using the token from the verification email.
+ If successful, activates the user and returns an access token for immediate login.
+ """
+ auth_repo = AuthRepository(db)
+ return auth_repo.verify_email_and_generate_token(verification_token=token)
+
+
+@router.post("/approve-user/{user_id}")
+def approve_user(user_id: int, db: Session = Depends(get_db),
+ admin: User = Depends(get_current_admin)):
+ """Approve user (admin only)"""
+ user = db.query(User).filter(User.user_id == user_id).first()
+
+ if not user:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
+
+ if user.is_active:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User is active")
+
+ auth_repo = AuthRepository(db)
+ return auth_repo.verify_email_and_generate_token(
+ verification_token=user.email_verification_token)
+
+
+@router.get("/users", response_model=List[OrganizerResponse])
+def list_users(
+ page: int = Query(1, ge=1, description="Page number"),
+ limit: int = Query(50, ge=1, le=100, description="Items per page"),
+ search: Optional[str] = Query(None,
+ description="Search by email, login, first_name, or last_name"),
+ user_type: Optional[str] = Query(None,
+ description="Filter by user type (customer, organizer, administrator)"),
+ is_active: Optional[bool] = Query(None, description="Filter by active status"),
+ is_verified: Optional[bool] = Query(None,
+ description="Filter by verification status (organizers only)"),
+ sort_by: str = Query("creation_date", description="Sort field"),
+ sort_order: str = Query("desc", description="Sort order (asc/desc)"),
+ db: Session = Depends(get_db),
+ admin: User = Depends(get_current_admin)
+):
+ query = db.query(User).outerjoin(Organizer, User.user_id == Organizer.user_id)
+
+ if search:
+ search_filter = f"%{search}%"
+ query = query.filter(
+ or_(
+ User.email.ilike(search_filter),
+ User.login.ilike(search_filter),
+ User.first_name.ilike(search_filter),
+ User.last_name.ilike(search_filter)
+ )
+ )
+
+ if user_type:
+ if user_type not in ["customer", "organizer", "administrator"]:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid user_type. Must be one of: customer, organizer, administrator"
+ )
+ query = query.filter(User.user_type == user_type)
+
+ if is_active is not None:
+ query = query.filter(User.is_active == is_active)
+
+ if is_verified is not None:
+ query = query.filter(
+ and_(
+ User.user_type == "organizer",
+ Organizer.is_verified == is_verified
+ )
+ )
+
+ if sort_by not in ["creation_date", "email", "first_name", "last_name", "user_type"]:
+ sort_by = "creation_date"
+
+ if sort_order.lower() == "asc":
+ query = query.order_by(getattr(User, sort_by).asc())
+ else:
+ query = query.order_by(getattr(User, sort_by).desc())
+
+ offset = (page - 1) * limit
+ users = query.offset(offset).limit(limit).all()
+
+ result = []
+ for user in users:
+ user_response = OrganizerResponse(
+ user_id=user.user_id,
+ email=user.email,
+ login=user.login,
+ first_name=user.first_name,
+ last_name=user.last_name,
+ user_type=user.user_type,
+ is_active=user.is_active
+ )
+
+ if user.user_type == "organizer" and user.organizer:
+ organizer_response = OrganizerResponse(
+ user_id=user.user_id,
+ email=user.email,
+ login=user.login,
+ first_name=user.first_name,
+ last_name=user.last_name,
+ user_type=user.user_type,
+ is_active=user.is_active,
+ organizer_id=user.organizer.organizer_id,
+ company_name=user.organizer.company_name,
+ is_verified=user.organizer.is_verified
+ )
+ result.append(organizer_response)
+ else:
+ result.append(user_response)
+
+ return result
+
+
+@router.get("/users/stats")
+def get_user_stats(
+ db: Session = Depends(get_db),
+ admin: User = Depends(get_current_admin)
+):
+ total_users = db.query(User).count()
+ active_users = db.query(User).filter(User.is_active == True).count()
+ banned_users = db.query(User).filter(User.is_active == False).count()
+
+ customers = db.query(User).filter(User.user_type == "customer").count()
+ organizers = db.query(User).filter(User.user_type == "organizer").count()
+ administrators = db.query(User).filter(User.user_type == "administrator").count()
+
+ verified_organizers = db.query(Organizer).filter(Organizer.is_verified == True).count()
+ pending_organizers = db.query(Organizer).filter(Organizer.is_verified == False).count()
+
+ return {
+ "total_users": total_users,
+ "active_users": active_users,
+ "banned_users": banned_users,
+ "users_by_type": {
+ "customers": customers,
+ "organizers": organizers,
+ "administrators": administrators
+ },
+ "organizer_stats": {
+ "verified": verified_organizers,
+ "pending": pending_organizers
+ }
+ }
+
+
+@router.get("/users/{user_id}", response_model=OrganizerResponse)
+def get_user_details(
+ user_id: int,
+ db: Session = Depends(get_db),
+ admin: User = Depends(get_current_admin)
+):
+ user = db.query(User).filter(User.user_id == user_id).first()
+ if not user:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="User not found"
+ )
+
+ if user.user_type == "organizer":
+ organizer = db.query(Organizer).filter(Organizer.user_id == user.user_id).first()
+ if organizer:
+ return OrganizerResponse(
+ user_id=user.user_id,
+ email=user.email,
+ login=user.login,
+ first_name=user.first_name,
+ last_name=user.last_name,
+ user_type=user.user_type,
+ is_active=user.is_active,
+ organizer_id=organizer.organizer_id,
+ company_name=organizer.company_name,
+ is_verified=organizer.is_verified
+ )
+
+ return UserResponse(
+ user_id=user.user_id,
+ email=user.email,
+ login=user.login,
+ first_name=user.first_name,
+ last_name=user.last_name,
+ user_type=user.user_type,
+ is_active=user.is_active
+ )
\ No newline at end of file
diff --git a/backend/user_auth_service/app/routers/user.py b/backend/user_auth_service/app/routers/user.py
index 1050bb2..e75b887 100644
--- a/backend/user_auth_service/app/routers/user.py
+++ b/backend/user_auth_service/app/routers/user.py
@@ -18,8 +18,6 @@ def read_users_me(current_user: User = Depends(get_current_user), db: Session =
if not organizer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organizer record not found for this user")
- # The ORM object for a user doesn't contain the organizer-specific fields directly,
- # so we must construct the response model instance manually with all required fields.
return OrganizerResponse(
user_id=current_user.user_id,
email=current_user.email,
@@ -33,12 +31,10 @@ def read_users_me(current_user: User = Depends(get_current_user), db: Session =
is_verified=organizer.is_verified,
)
- # For customers and admins, returning the ORM object works because the fixed
- # UserResponse schema can now be populated correctly.
return current_user
-@router.put("/update-profile", response_model=UserResponse)
+@router.put("/update-profile", response_model=Union[OrganizerResponse, UserResponse])
def update_user_profile(
user_update: UserProfileUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)
):
@@ -60,6 +56,24 @@ def update_user_profile(
db.commit()
db.refresh(current_user)
+ if current_user.user_type == "organizer":
+ organizer = db.query(Organizer).filter(Organizer.user_id == current_user.user_id).first()
+ if not organizer:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organizer record not found for this user")
+
+ return OrganizerResponse(
+ user_id=current_user.user_id,
+ email=current_user.email,
+ login=current_user.login,
+ first_name=current_user.first_name,
+ last_name=current_user.last_name,
+ user_type=current_user.user_type,
+ is_active=current_user.is_active,
+ organizer_id=organizer.organizer_id,
+ company_name=organizer.company_name,
+ is_verified=organizer.is_verified,
+ )
+
return current_user
except IntegrityError:
@@ -80,7 +94,6 @@ def get_user_by_id(user_id: int, db: Session = Depends(get_db), current_user: Us
response = UserResponse.from_orm(user)
- # Remove login if requester is not an admin or the user themselves
is_admin = current_user.user_type == "administrator"
is_same_user = current_user.user_id == user.user_id
diff --git a/backend/user_auth_service/app/schemas/user.py b/backend/user_auth_service/app/schemas/user.py
index 72de590..ea7f4e4 100644
--- a/backend/user_auth_service/app/schemas/user.py
+++ b/backend/user_auth_service/app/schemas/user.py
@@ -29,9 +29,9 @@ def status(self) -> str:
class OrganizerResponse(UserResponse):
- organizer_id: int
- company_name: str
- is_verified: bool
+ organizer_id: Optional[int] = None
+ company_name: Optional[str] = None
+ is_verified: Optional[bool] = None
class Config:
orm_mode = True
diff --git a/backend/user_auth_service/app/security.py b/backend/user_auth_service/app/security.py
index ae6a466..d61ffb3 100644
--- a/backend/user_auth_service/app/security.py
+++ b/backend/user_auth_service/app/security.py
@@ -17,6 +17,10 @@
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin-secret-key")
+# Initial admin credentials from environment
+INITIAL_ADMIN_EMAIL = os.getenv("INITIAL_ADMIN_EMAIL", "admin@resellio.com")
+INITIAL_ADMIN_PASSWORD = os.getenv("INITIAL_ADMIN_PASSWORD", "AdminPassword123!")
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
@@ -52,6 +56,9 @@ def get_password_hash(password):
"""Generate a bcrypt hash for the given password"""
return pwd_context.hash(password)
+def generate_email_verification_token():
+ """Generate a random token for email verification."""
+ return secrets.token_urlsafe(32)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Create a JWT access token with an optional expiration"""
@@ -72,6 +79,11 @@ def generate_reset_token():
return secrets.token_urlsafe(32)
+def verify_initial_admin_credentials(email: str, password: str) -> bool:
+ """Verify if the provided credentials match the initial admin credentials"""
+ return email == INITIAL_ADMIN_EMAIL and password == INITIAL_ADMIN_PASSWORD
+
+
def get_current_user(token_data: dict = Depends(get_token_data), db: Session = Depends(get_db)):
"""Get the current user based on the JWT token"""
user = db.query(User).filter(User.email == token_data["email"]).first()
@@ -117,4 +129,4 @@ def verify_admin_secret(admin_secret_key: str):
"""Verify that the admin secret key is correct"""
if admin_secret_key != ADMIN_SECRET_KEY:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid admin secret key")
- return True
+ return True
\ No newline at end of file
diff --git a/backend/user_auth_service/app/services/email_service.py b/backend/user_auth_service/app/services/email_service.py
new file mode 100644
index 0000000..ef30eb4
--- /dev/null
+++ b/backend/user_auth_service/app/services/email_service.py
@@ -0,0 +1,83 @@
+import os
+import logging
+from datetime import datetime
+from sendgrid import SendGridAPIClient
+from sendgrid.helpers.mail import Mail
+
+SENDGRID_API_KEY = os.getenv("EMAIL_API_KEY")
+FROM_EMAIL = os.getenv("EMAIL_FROM_EMAIL")
+APP_BASE_URL = os.getenv("APP_BASE_URL", "http://localhost:8000") # CRITICAL: Base URL for constructing verification links
+
+logger = logging.getLogger(__name__)
+
+def send_account_verification_email(to_email: str, user_name: str, verification_token: str):
+ """
+ Sends an account verification email to the user.
+ The verification token does not expire.
+ """
+ if not SENDGRID_API_KEY:
+ logger.error("SendGrid API key not set - cannot send verification emails.")
+ return False
+ if not FROM_EMAIL:
+ logger.error("From email not set - cannot send verification emails.")
+ return False
+ if not APP_BASE_URL: # Ensure this is configured
+ logger.error("APP_BASE_URL environment variable is not set. Cannot construct verification link.")
+ return False
+
+ # Construct the verification link. Ensure your auth service is accessible at APP_BASE_URL
+ # and that the endpoint /api/auth/verify-email exists.
+ verification_link = f"{APP_BASE_URL}/api/auth/verify-email?token={verification_token}"
+
+ email_content = f"""
+
+
+
+
+
+
+
+
+
Thank you for registering. To complete your Resellio account setup, please verify your email address by clicking the button below:
+
+
If you cannot click the button, please copy and paste the following link into your browser's address bar:
+
{verification_link}
+
If you did not create an account with Resellio, you can safely ignore this email.
+
+
+
+
+
+ """
+
+ message = Mail(
+ from_email=FROM_EMAIL,
+ to_emails=to_email,
+ subject="Activate Your Resellio Account",
+ html_content=email_content
+ )
+
+ try:
+ sg = SendGridAPIClient(SENDGRID_API_KEY)
+ response = sg.send(message)
+ logger.info(f"Account verification email sent to {to_email}, status code: {response.status_code}")
+ return response.status_code >= 200 and response.status_code < 300
+ except Exception as e:
+ logger.error(f"Failed to send account verification email to {to_email}: {str(e)}", exc_info=True)
+ return False
\ No newline at end of file
diff --git a/backend/user_auth_service/requirements.txt b/backend/user_auth_service/requirements.txt
index 85398dd..43e3ee1 100644
--- a/backend/user_auth_service/requirements.txt
+++ b/backend/user_auth_service/requirements.txt
@@ -9,3 +9,4 @@ python_multipart==0.0.20
sqlalchemy==2.0.40
uvicorn==0.34.0
boto3
+sendgrid
diff --git a/docker-compose.yml b/docker-compose.yml
index 6971abf..1991014 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: '3.8'
-
services:
postgres:
image: postgres:15-alpine
@@ -47,6 +45,8 @@ services:
- DB_PASSWORD=${DB_PASSWORD}
- SECRET_KEY=${SECRET_KEY}
- ADMIN_SECRET_KEY=${ADMIN_SECRET_KEY}
+ - EMAIL_API_KEY=${EMAIL_API_KEY}
+ - EMAIL_FROM_EMAIL=${EMAIL_FROM_EMAIL}
depends_on:
db-init:
condition: service_completed_successfully
@@ -64,12 +64,15 @@ services:
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- SECRET_KEY=${SECRET_KEY}
+ - EMAIL_API_KEY=${EMAIL_API_KEY}
+ - EMAIL_FROM_EMAIL=${EMAIL_FROM_EMAIL}
depends_on:
db-init:
condition: service_completed_successfully
api-gateway:
- image: nginx:1.25-alpine
+ build:
+ context: ./backend/api_gateway
container_name: resellio_api_gateway
ports:
- "8080:80"
@@ -79,10 +82,29 @@ services:
- auth-service
- events-service
healthcheck:
- test: ["CMD-SHELL", "wget -q --spider --fail http://localhost/health"]
+ test: ["CMD-SHELL", "curl -f http://localhost/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
+ flutter-tester:
+ profiles:
+ - tests
+ build:
+ context: ./frontend
+ dockerfile: test.Dockerfile
+ container_name: resellio_flutter_tester
+ environment:
+ # This URL is used by the command to pass the correct backend address to the tests
+ - API_BASE_URL=http://api-gateway/api
+ depends_on:
+ api-gateway:
+ condition: service_healthy
+ command: >
+ sh -c "
+ echo '--- Running Flutter tests ---' &&
+ flutter test --dart-define=API_BASE_URL=$${API_BASE_URL}
+ "
+
volumes:
postgres-data:
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..e371bf2
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,16 @@
+# Flutter/Dart build files
+.dart_tool/
+.packages
+.flutter-plugins
+.flutter-plugins-dependencies
+build/
+coverage/
+
+# IDE files
+.idea/
+.vscode/
+
+# Other
+*.iml
+*.ipr
+*.iws
diff --git a/frontend/lib/app/config/app_router.dart b/frontend/lib/app/config/app_router.dart
index e2e045e..f8e9ea7 100644
--- a/frontend/lib/app/config/app_router.dart
+++ b/frontend/lib/app/config/app_router.dart
@@ -9,6 +9,10 @@ import 'package:resellio/presentation/main_page/main_layout.dart';
import 'package:resellio/presentation/common_widgets/adaptive_navigation.dart';
import 'package:resellio/presentation/events/pages/event_details_page.dart';
import 'package:resellio/presentation/cart/pages/cart_page.dart';
+import 'package:resellio/core/models/user_model.dart';
+import 'package:resellio/presentation/admin/pages/admin_dashboard_page.dart';
+import 'package:resellio/presentation/organizer/pages/create_event_page.dart';
+import 'package:resellio/presentation/organizer/pages/edit_event_page.dart';
class AppRouter {
static GoRouter createRouter(AuthService authService) {
@@ -17,20 +21,42 @@ class AppRouter {
refreshListenable: authService,
redirect: (BuildContext context, GoRouterState state) {
final bool loggedIn = authService.isLoggedIn;
- final String? userRoleName = authService.user?.role.name;
+ final user = authService.user;
+
+ // Get the user role - handle both enum and string cases
+ String? userRoleName;
+ if (user != null) {
+ // Check if role is an enum (UserRole) or string
+ if (user.role is UserRole) {
+ userRoleName = (user.role as UserRole).name;
+ } else {
+ userRoleName = user.role.toString();
+ }
+ }
final bool onAuthRoute =
state.uri.path.startsWith('/welcome') ||
- state.uri.path.startsWith('/login') ||
- state.uri.path.startsWith('/register');
+ state.uri.path.startsWith('/login') ||
+ state.uri.path.startsWith('/register');
+
+ final bool onAdminRoute = state.uri.path.startsWith('/admin');
// If user is not logged in and not on an auth route, redirect to welcome
if (!loggedIn && !onAuthRoute) {
return '/welcome';
}
- // If user is logged in and tries to access an auth route, redirect to home
+ // If user is logged in and tries to access an auth route, redirect based on role
if (loggedIn && onAuthRoute) {
+ // Check for administrator role (handle different possible values)
+ if (userRoleName == 'administrator' || userRoleName == 'admin') {
+ return '/admin';
+ }
+ return '/home/${userRoleName ?? 'customer'}';
+ }
+
+ // If non-admin user tries to access admin routes, redirect to their home
+ if (loggedIn && onAdminRoute && userRoleName != 'administrator' && userRoleName != 'admin') {
return '/home/${userRoleName ?? 'customer'}';
}
@@ -38,6 +64,7 @@ class AppRouter {
return null;
},
routes: [
+ // Auth Routes
GoRoute(
path: '/welcome',
builder: (context, state) => const WelcomeScreen(),
@@ -54,20 +81,52 @@ class AppRouter {
},
),
- // This route uses a parameter to determine the user role
+ // Admin Routes with Sidebar Navigation
+ GoRoute(
+ path: '/admin',
+ builder: (context, state) => const AdminMainPage(initialTab: 'overview'),
+ ),
+ GoRoute(
+ path: '/admin/users',
+ builder: (context, state) => const AdminMainPage(initialTab: 'users'),
+ ),
+ GoRoute(
+ path: '/admin/organizers',
+ builder: (context, state) => const AdminMainPage(initialTab: 'organizers'),
+ ),
+ GoRoute(
+ path: '/admin/events',
+ builder: (context, state) => const AdminMainPage(initialTab: 'events'),
+ ),
+ GoRoute(
+ path: '/admin/add-admin',
+ builder: (context, state) => const AdminMainPage(initialTab: 'add-admin'),
+ ),
+ GoRoute(
+ path: '/admin/verification',
+ builder: (context, state) => const AdminMainPage(initialTab: 'verification'),
+ ),
+
+ // Main App Routes
GoRoute(
path: '/home/:userType',
builder: (context, state) {
- final userTypeString =
- state.pathParameters['userType'] ?? 'customer';
+ final userTypeString = state.pathParameters['userType'] ?? 'customer';
UserRole role;
+
switch (userTypeString.toLowerCase()) {
case 'organizer':
role = UserRole.organizer;
break;
+ case 'administrator':
case 'admin':
- role = UserRole.admin;
- break;
+ // Redirect admin users to admin panel
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ context.go('/admin');
+ });
+ return const Scaffold(
+ body: Center(child: CircularProgressIndicator()),
+ );
default:
role = UserRole.customer;
break;
@@ -76,6 +135,7 @@ class AppRouter {
},
),
+ // Event Routes
GoRoute(
path: '/event/:id',
builder: (context, state) {
@@ -85,7 +145,6 @@ class AppRouter {
if (event != null) {
return EventDetailsPage(event: event);
} else if (eventId != null) {
- // If event is not passed, fetch it by ID
return EventDetailsPage(eventId: int.tryParse(eventId));
} else {
return Scaffold(
@@ -96,16 +155,30 @@ class AppRouter {
},
),
GoRoute(path: '/cart', builder: (context, state) => const CartPage()),
+ GoRoute(
+ path: '/organizer/create-event',
+ builder: (context, state) => const CreateEventPage()),
+ GoRoute(
+ path: '/organizer/edit-event/:id',
+ builder: (context, state) {
+ final event = state.extra as Event?;
+ if (event == null) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('Error')),
+ body: const Center(child: Text('Event data is missing.')),
+ );
+ }
+ return EditEventPage(event: event);
+ }),
],
- errorBuilder:
- (context, state) => Scaffold(
- appBar: AppBar(title: const Text('Page Not Found')),
- body: Center(
- child: Text(
- 'Error: The requested page "${state.uri}" could not be found.\n${state.error}',
- ),
- ),
+ errorBuilder: (context, state) => Scaffold(
+ appBar: AppBar(title: const Text('Page Not Found')),
+ body: Center(
+ child: Text(
+ 'Error: The requested page "${state.uri}" could not be found.\n${state.error}',
),
+ ),
+ ),
);
}
}
diff --git a/frontend/lib/app/config/app_theme.dart b/frontend/lib/app/config/app_theme.dart
index 8fecedc..70d0085 100644
--- a/frontend/lib/app/config/app_theme.dart
+++ b/frontend/lib/app/config/app_theme.dart
@@ -2,51 +2,115 @@ import 'package:flutter/material.dart';
class AppTheme {
static ThemeData get lightTheme {
+ const Color primaryColor = Color(0xFF00CED1);
+ const Color secondaryColor = Color(0xFFFFA500);
+ const Color backgroundColor = Color(0xFF121212);
+ const Color surfaceColor = Color(0xFF1E1E1E);
+ const Color onPrimaryColor = Colors.white;
+ const Color onSecondaryColor = Colors.black;
+ const Color onBackgroundColor = Colors.white;
+ const Color errorColor = Color(0xFFCF6679);
+
+ final colorScheme = ColorScheme.fromSeed(
+ seedColor: primaryColor,
+ brightness: Brightness.dark,
+ background: backgroundColor,
+ surface: surfaceColor,
+ primary: primaryColor,
+ onPrimary: onPrimaryColor,
+ secondary: secondaryColor,
+ onSecondary: onSecondaryColor,
+ onBackground: onBackgroundColor,
+ error: errorColor,
+ onError: Colors.black,
+ surfaceContainer: const Color(0xFF2C2C2C),
+ surfaceContainerHighest: const Color(0xFF3A3A3A),
+ onSurface: onBackgroundColor,
+ onSurfaceVariant: Colors.white.withOpacity(0.7),
+ outlineVariant: Colors.white.withOpacity(0.3),
+ );
+
+ final textTheme = TextTheme(
+ displayMedium: TextStyle(
+ color: onBackgroundColor,
+ fontWeight: FontWeight.w900,
+ fontSize: 36,
+ letterSpacing: 1.5),
+ headlineLarge:
+ TextStyle(color: onBackgroundColor, fontWeight: FontWeight.bold, fontSize: 32),
+ headlineMedium:
+ TextStyle(color: onBackgroundColor, fontWeight: FontWeight.bold, fontSize: 28),
+ headlineSmall:
+ TextStyle(color: onBackgroundColor, fontWeight: FontWeight.bold, fontSize: 24),
+ titleLarge:
+ TextStyle(color: onBackgroundColor, fontWeight: FontWeight.bold, fontSize: 22),
+ titleMedium:
+ TextStyle(color: onBackgroundColor, fontWeight: FontWeight.bold, fontSize: 16),
+ titleSmall:
+ TextStyle(color: onBackgroundColor, fontWeight: FontWeight.w500, fontSize: 14),
+ bodyLarge: TextStyle(color: onBackgroundColor, fontSize: 16, height: 1.5),
+ bodyMedium:
+ TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 14, height: 1.5),
+ bodySmall:
+ TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12, height: 1.5),
+ labelLarge: TextStyle(
+ color: onPrimaryColor,
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
+ letterSpacing: 1.0),
+ labelMedium:
+ TextStyle(color: onBackgroundColor, fontWeight: FontWeight.bold, fontSize: 12),
+ labelSmall: TextStyle(
+ color: onBackgroundColor,
+ fontWeight: FontWeight.bold,
+ fontSize: 10,
+ letterSpacing: 0.5),
+ );
+
return ThemeData(
useMaterial3: true,
- scaffoldBackgroundColor: const Color(0xFF121212),
- colorScheme: ColorScheme.fromSeed(
- seedColor: const Color(0xFF00CED1),
- brightness: Brightness.dark,
- background: const Color(0xFF121212),
- primary: const Color(0xFF00CED1),
- secondary: const Color(0xFFFFA500),
- onPrimary: Colors.white,
- onSecondary: Colors.black,
- onBackground: Colors.white,
- ),
- appBarTheme: const AppBarTheme(
+ scaffoldBackgroundColor: backgroundColor,
+ colorScheme: colorScheme,
+ textTheme: textTheme,
+ appBarTheme: AppBarTheme(
elevation: 0,
centerTitle: true,
- backgroundColor: Color(0xFF121212),
- foregroundColor: Colors.white,
- ),
- textTheme: const TextTheme(
- headlineMedium: TextStyle(
- color: Colors.white,
- fontWeight: FontWeight.bold,
- fontSize: 24,
- ),
- bodyMedium: TextStyle(color: Colors.white, fontSize: 16),
- labelLarge: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
+ backgroundColor: backgroundColor,
+ foregroundColor: onBackgroundColor,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
- backgroundColor: const Color(0xFF00CED1),
- foregroundColor: Colors.white,
+ backgroundColor: primaryColor,
+ foregroundColor: onPrimaryColor,
+ textStyle: textTheme.labelLarge,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
),
),
cardTheme: CardThemeData(
- color: Colors.grey[900],
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
+ color: surfaceColor,
+ elevation: 0,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(16),
+ side: BorderSide(color: colorScheme.outlineVariant.withOpacity(0.5))),
),
- bottomNavigationBarTheme: const BottomNavigationBarThemeData(
- backgroundColor: Color(0xFF121212),
- selectedItemColor: Color(0xFF00CED1),
+ bottomNavigationBarTheme: BottomNavigationBarThemeData(
+ backgroundColor: backgroundColor,
+ selectedItemColor: primaryColor,
unselectedItemColor: Colors.white,
+ selectedLabelStyle: textTheme.labelSmall?.copyWith(color: primaryColor),
+ unselectedLabelStyle: textTheme.labelSmall?.copyWith(color: Colors.white),
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ filled: true,
+ fillColor: colorScheme.surfaceContainer,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: BorderSide.none,
+ ),
+ labelStyle: textTheme.bodyMedium,
),
);
}
diff --git a/frontend/lib/core/models/admin_model.dart b/frontend/lib/core/models/admin_model.dart
new file mode 100644
index 0000000..d7b06c1
--- /dev/null
+++ b/frontend/lib/core/models/admin_model.dart
@@ -0,0 +1,104 @@
+class PendingOrganizer {
+ final int userId;
+ final String email;
+ final String firstName;
+ final String lastName;
+ final int organizerId;
+ final String companyName;
+ final bool isVerified;
+
+ PendingOrganizer({
+ required this.userId,
+ required this.email,
+ required this.firstName,
+ required this.lastName,
+ required this.organizerId,
+ required this.companyName,
+ required this.isVerified,
+ });
+
+ factory PendingOrganizer.fromJson(Map json) {
+ return PendingOrganizer(
+ userId: json['user_id'],
+ email: json['email'],
+ firstName: json['first_name'],
+ lastName: json['last_name'],
+ organizerId: json['organizer_id'],
+ companyName: json['company_name'],
+ isVerified: json['is_verified'],
+ );
+ }
+}
+
+class UserDetails {
+ final int userId;
+ final String email;
+ final String firstName;
+ final String lastName;
+ final String userType;
+ final bool isActive;
+
+ UserDetails({
+ required this.userId,
+ required this.email,
+ required this.firstName,
+ required this.lastName,
+ required this.userType,
+ required this.isActive,
+ });
+
+ factory UserDetails.fromJson(Map json) {
+ return UserDetails(
+ userId: json['user_id'],
+ email: json['email'],
+ firstName: json['first_name'],
+ lastName: json['last_name'],
+ userType: json['user_type'],
+ isActive: json['is_active'],
+ );
+ }
+}
+
+class AdminStats {
+ final int totalUsers;
+ final int activeUsers;
+ final int bannedUsers;
+ final int totalCustomers;
+ final int totalOrganizers;
+ final int totalAdmins;
+ final int verifiedOrganizers;
+ final int pendingOrganizers;
+ final int pendingEvents;
+ final int totalEvents;
+
+ AdminStats({
+ required this.totalUsers,
+ required this.activeUsers,
+ required this.bannedUsers,
+ required this.totalCustomers,
+ required this.totalOrganizers,
+ required this.totalAdmins,
+ required this.verifiedOrganizers,
+ required this.pendingOrganizers,
+ required this.pendingEvents,
+ required this.totalEvents,
+ });
+
+ factory AdminStats.fromJson(Map json) {
+ final usersByType = json['users_by_type'] ?? {};
+ final organizerStats = json['organizer_stats'] ?? {};
+
+ return AdminStats(
+ totalUsers: json['total_users'] ?? 0,
+ activeUsers: json['active_users'] ?? 0,
+ bannedUsers: json['banned_users'] ?? 0,
+ totalCustomers: usersByType['customers'] ?? 0,
+ totalOrganizers: usersByType['organizers'] ?? 0,
+ totalAdmins: usersByType['administrators'] ?? 0,
+ verifiedOrganizers: organizerStats['verified'] ?? 0,
+ pendingOrganizers: organizerStats['pending'] ?? 0,
+ pendingEvents: json['pending_events'] ?? 0,
+ totalEvents: json['total_events'] ?? 0,
+ );
+ }
+}
\ No newline at end of file
diff --git a/frontend/lib/core/models/cart_model.dart b/frontend/lib/core/models/cart_model.dart
index 5b2854d..368b6c1 100644
--- a/frontend/lib/core/models/cart_model.dart
+++ b/frontend/lib/core/models/cart_model.dart
@@ -1,19 +1,43 @@
import 'package:resellio/core/models/ticket_model.dart';
class CartItem {
- final TicketType ticketType;
+ final int cartItemId;
+ final TicketType? ticketType;
final int quantity;
+ final double price;
- CartItem({required this.ticketType, required this.quantity});
+ CartItem({
+ required this.cartItemId,
+ this.ticketType,
+ required this.quantity,
+ required this.price,
+ });
- // This model is simple and primarily populated by the CartService,
- // so a fromJson might not be directly needed if the service handles it.
+ factory CartItem.fromJson(Map json) {
+ return CartItem(
+ cartItemId: json['cart_item_id'] ?? 0,
+ ticketType: json['ticket_type'] != null
+ ? TicketType.fromJson(json['ticket_type'])
+ : null,
+ quantity: json['quantity'] ?? 1,
+ price: (json['ticket_type']?['price'] as num?)?.toDouble() ?? 0.0,
+ );
+ }
- // Create a copy with a new quantity
CartItem copyWith({int? quantity}) {
return CartItem(
+ cartItemId: cartItemId,
ticketType: ticketType,
quantity: quantity ?? this.quantity,
+ price: price,
);
}
-}
+
+ Map toJson() {
+ return {
+ 'cart_item_id': cartItemId,
+ 'ticket_type': ticketType?.toJson(),
+ 'quantity': quantity,
+ };
+ }
+}
\ No newline at end of file
diff --git a/frontend/lib/core/models/event_model.dart b/frontend/lib/core/models/event_model.dart
index b43008a..12912eb 100644
--- a/frontend/lib/core/models/event_model.dart
+++ b/frontend/lib/core/models/event_model.dart
@@ -10,7 +10,7 @@ class Event {
final String status;
final List category;
final int totalTickets;
- final String? imageUrl; // Added for UI
+ final String? imageUrl;
Event({
required this.id,
@@ -40,10 +40,55 @@ class Event {
status: json['status'] ?? 'active',
category: List.from(json['categories'] ?? []),
totalTickets: json['total_tickets'] ?? 0,
- // Use a placeholder image if none is provided
- imageUrl:
- json['imageUrl'] ??
+ imageUrl: json['imageUrl'] ??
'https://picsum.photos/seed/${json['event_id']}/400/200',
);
}
}
+
+class EventCreate {
+ final String name;
+ final String? description;
+ final DateTime startDate;
+ final DateTime endDate;
+ final int? minimumAge;
+ final int locationId;
+ final List category;
+ final int totalTickets;
+ final double standardTicketPrice;
+ final DateTime ticketSalesStartDateTime;
+
+ EventCreate({
+ required this.name,
+ this.description,
+ required this.startDate,
+ required this.endDate,
+ this.minimumAge,
+ required this.locationId,
+ required this.category,
+ required this.totalTickets,
+ required this.standardTicketPrice,
+ required this.ticketSalesStartDateTime,
+ });
+
+ Map toJson() {
+ return {
+ 'name': name,
+ 'description': description,
+ 'start_date': startDate.toIso8601String(),
+ 'end_date': endDate.toIso8601String(),
+ 'minimum_age': minimumAge,
+ 'location_id': locationId,
+ 'category': category,
+ 'total_tickets': totalTickets,
+ 'standard_ticket_price': standardTicketPrice,
+ 'ticket_sales_start': ticketSalesStartDateTime.toIso8601String(),
+ };
+ }
+}
+
+class EventStatus {
+ static const String created = 'created';
+ static const String pending = 'pending';
+ static const String cancelled = 'cancelled';
+}
diff --git a/frontend/lib/core/models/location_model.dart b/frontend/lib/core/models/location_model.dart
new file mode 100644
index 0000000..a316f8b
--- /dev/null
+++ b/frontend/lib/core/models/location_model.dart
@@ -0,0 +1,30 @@
+class Location {
+ final int locationId;
+ final String name;
+ final String address;
+ final String city;
+ final String country;
+
+ Location({
+ required this.locationId,
+ required this.name,
+ required this.address,
+ required this.city,
+ required this.country,
+ });
+
+ factory Location.fromJson(Map json) {
+ return Location(
+ locationId: json['location_id'],
+ name: json['name'],
+ address: json['address'],
+ city: json['city'],
+ country: json['country'],
+ );
+ }
+
+ @override
+ String toString() {
+ return name;
+ }
+}
diff --git a/frontend/lib/core/models/models.dart b/frontend/lib/core/models/models.dart
new file mode 100644
index 0000000..7bff2b8
--- /dev/null
+++ b/frontend/lib/core/models/models.dart
@@ -0,0 +1,8 @@
+export 'admin_model.dart';
+export 'cart_model.dart';
+export 'event_filter_model.dart';
+export 'event_model.dart';
+export 'location_model.dart';
+export 'resale_ticket_listing.dart';
+export 'ticket_model.dart';
+export 'user_model.dart';
diff --git a/frontend/lib/core/models/resale_ticket_listing.dart b/frontend/lib/core/models/resale_ticket_listing.dart
new file mode 100644
index 0000000..e17f7c8
--- /dev/null
+++ b/frontend/lib/core/models/resale_ticket_listing.dart
@@ -0,0 +1,35 @@
+class ResaleTicketListing {
+ final int ticketId;
+ final double originalPrice;
+ final double resellPrice;
+ final String eventName;
+ final DateTime eventDate;
+ final String venueName;
+ final String ticketTypeDescription;
+ final String? seat;
+
+ ResaleTicketListing({
+ required this.ticketId,
+ required this.originalPrice,
+ required this.resellPrice,
+ required this.eventName,
+ required this.eventDate,
+ required this.venueName,
+ required this.ticketTypeDescription,
+ this.seat,
+ });
+
+ factory ResaleTicketListing.fromJson(Map json) {
+ return ResaleTicketListing(
+ ticketId: json['ticket_id'],
+ originalPrice: (json['original_price'] as num).toDouble(),
+ resellPrice: (json['resell_price'] as num).toDouble(),
+ eventName: json['event_name'],
+ eventDate: DateTime.parse(json['event_date']),
+ venueName: json['venue_name'],
+ ticketTypeDescription:
+ json['ticket_type_description'] ?? 'Standard Ticket',
+ seat: json['seat'],
+ );
+ }
+}
diff --git a/frontend/lib/core/models/ticket_model.dart b/frontend/lib/core/models/ticket_model.dart
index 3d7dbad..260090c 100644
--- a/frontend/lib/core/models/ticket_model.dart
+++ b/frontend/lib/core/models/ticket_model.dart
@@ -5,6 +5,7 @@ class TicketType {
final int maxCount;
final double price;
final String currency;
+ final DateTime? availableFrom; // Added this field
TicketType({
this.typeId,
@@ -13,6 +14,7 @@ class TicketType {
required this.maxCount,
required this.price,
required this.currency,
+ this.availableFrom, // Added this parameter
});
factory TicketType.fromJson(Map json) {
@@ -24,8 +26,49 @@ class TicketType {
// Price can be int or double from JSON
price: (json['price'] as num).toDouble(),
currency: json['currency'] ?? 'USD',
+ // Parse availableFrom field
+ availableFrom: json['available_from'] != null
+ ? DateTime.parse(json['available_from'])
+ : null,
);
}
+
+ Map toJson() {
+ return {
+ 'type_id': typeId,
+ 'event_id': eventId,
+ 'description': description,
+ 'max_count': maxCount,
+ 'price': price,
+ 'currency': currency,
+ 'available_from': availableFrom?.toIso8601String(), // Added this field
+ };
+ }
+
+ TicketType copyWith({
+ int? typeId,
+ int? eventId,
+ String? description,
+ int? maxCount,
+ double? price,
+ String? currency,
+ DateTime? availableFrom,
+ }) {
+ return TicketType(
+ typeId: typeId ?? this.typeId,
+ eventId: eventId ?? this.eventId,
+ description: description ?? this.description,
+ maxCount: maxCount ?? this.maxCount,
+ price: price ?? this.price,
+ currency: currency ?? this.currency,
+ availableFrom: availableFrom ?? this.availableFrom,
+ );
+ }
+
+ @override
+ String toString() {
+ return 'TicketType{typeId: $typeId, eventId: $eventId, description: $description, maxCount: $maxCount, price: $price, currency: $currency, availableFrom: $availableFrom}';
+ }
}
class TicketDetailsModel {
@@ -34,19 +77,26 @@ class TicketDetailsModel {
final String? seat;
final int? ownerId;
final double? resellPrice;
+ final double? originalPrice; // The price the user paid for the ticket
// These fields are not in the base model but can be added for convenience
final String? eventName;
final DateTime? eventStartDate;
+ final String? ticketTypeDescription;
+ final DateTime? ticketAvailableFrom;
+
TicketDetailsModel({
required this.ticketId,
this.typeId,
this.seat,
this.ownerId,
this.resellPrice,
+ this.originalPrice,
this.eventName,
this.eventStartDate,
+ this.ticketTypeDescription,
+ this.ticketAvailableFrom,
});
factory TicketDetailsModel.fromJson(Map json) {
@@ -55,16 +105,38 @@ class TicketDetailsModel {
typeId: json['type_id'],
seat: json['seat'],
ownerId: json['owner_id'],
- resellPrice:
- json['resell_price'] != null
- ? (json['resell_price'] as num).toDouble()
- : null,
- // Handle extra mocked data
- eventName: json['eventName'],
- eventStartDate:
- json['eventStartDate'] != null
+ resellPrice: json['resell_price'] != null
+ ? (json['resell_price'] as num).toDouble()
+ : null,
+ originalPrice: json['original_price'] != null
+ ? (json['original_price'] as num).toDouble()
+ : null,
+ // Handle both snake_case (from backend) and camelCase (from mock data)
+ eventName: json['event_name'] ?? json['eventName'],
+ eventStartDate: json['event_start_date'] != null
+ ? DateTime.parse(json['event_start_date'])
+ : json['eventStartDate'] != null
? DateTime.parse(json['eventStartDate'])
: null,
+ ticketTypeDescription: json['ticket_type_description'],
+ ticketAvailableFrom: json['ticket_available_from'] != null
+ ? DateTime.parse(json['ticket_available_from'])
+ : null,
);
}
+
+ Map toJson() {
+ return {
+ 'ticket_id': ticketId,
+ 'type_id': typeId,
+ 'seat': seat,
+ 'owner_id': ownerId,
+ 'resell_price': resellPrice,
+ 'original_price': originalPrice,
+ 'event_name': eventName,
+ 'event_start_date': eventStartDate?.toIso8601String(),
+ 'ticket_type_description': ticketTypeDescription,
+ 'ticket_available_from': ticketAvailableFrom?.toIso8601String(),
+ };
+ }
}
diff --git a/frontend/lib/core/models/user_model.dart b/frontend/lib/core/models/user_model.dart
new file mode 100644
index 0000000..000f4c0
--- /dev/null
+++ b/frontend/lib/core/models/user_model.dart
@@ -0,0 +1,120 @@
+import 'package:equatable/equatable.dart';
+
+class UserModel {
+ final int userId;
+ final String email;
+ final String name;
+ final UserRole role;
+ final int roleId;
+
+ UserModel({
+ required this.userId,
+ required this.email,
+ required this.name,
+ required this.role,
+ required this.roleId,
+ });
+
+ factory UserModel.fromJwt(Map jwtData) {
+ UserRole role;
+ switch (jwtData['role']) {
+ case 'organizer':
+ role = UserRole.organizer;
+ break;
+ case 'administrator':
+ role = UserRole.admin;
+ break;
+ default:
+ role = UserRole.customer;
+ }
+
+ return UserModel(
+ userId: jwtData['user_id'],
+ email: jwtData['sub'],
+ name: jwtData['name'] ?? 'User',
+ role: role,
+ roleId: jwtData['role_id'],
+ );
+ }
+}
+
+enum UserRole { customer, organizer, admin }
+
+class UserProfile extends Equatable {
+ final int userId;
+ final String email;
+ final String? login;
+ final String firstName;
+ final String lastName;
+ final String userType;
+ final bool isActive;
+
+ const UserProfile({
+ required this.userId,
+ required this.email,
+ this.login,
+ required this.firstName,
+ required this.lastName,
+ required this.userType,
+ required this.isActive,
+ });
+
+ factory UserProfile.fromJson(Map json) {
+ final userType = json['user_type'] as String? ?? 'customer';
+ if (userType == 'organizer') {
+ return OrganizerProfile.fromJson(json);
+ }
+ // TODO: Add other types like Admin if they have special fields.
+ return UserProfile(
+ userId: json['user_id'],
+ email: json['email'],
+ login: json['login'],
+ firstName: json['first_name'],
+ lastName: json['last_name'],
+ userType: json['user_type'],
+ isActive: json['is_active'],
+ );
+ }
+
+ @override
+ List