Metadata-Version: 2.4
Name: django-trips
Version: 2.0.0
Summary: A Django app for trips, schedules and bookings: models, business rules and admin.
Author-email: Awais Jibran <awaisdar001@gmail.com>
License: MIT License
        
        Copyright (c) 2020 The Python Packaging Authority
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
Project-URL: Homepage, https://github.com/DestinationPak/django-trips
Project-URL: Changelog, https://github.com/DestinationPak/django-trips/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/DestinationPak/django-trips/issues
Project-URL: Source, https://github.com/DestinationPak/django-trips
Keywords: django,trips,travel,bookings
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.2
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: AUTHORS
Requires-Dist: Django<6.0,>=4.2
Requires-Dist: django-crum>=0.7.9
Requires-Dist: django-config-models>=2.7.0
Requires-Dist: django-countries>=7.6.1
Requires-Dist: django-extensions>=3.2.3
Requires-Dist: django-taggit>=6.1.0
Requires-Dist: swapper>=1.3.0
Requires-Dist: Pillow>=10.0.0
Provides-Extra: dev
Requires-Dist: ddt==1.7.2; extra == "dev"
Requires-Dist: factory-boy>=3.3; extra == "dev"
Requires-Dist: Faker; extra == "dev"
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-django>=4.5.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0; extra == "dev"
Requires-Dist: pylint>=2.0; extra == "dev"
Requires-Dist: pylint-django>=2.5.0; extra == "dev"
Requires-Dist: pylint-plugin-utils; extra == "dev"
Requires-Dist: isort; extra == "dev"
Requires-Dist: pre-commit; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Provides-Extra: docs
Requires-Dist: sphinx>=7.0; extra == "docs"
Requires-Dist: myst-parser>=2.0; extra == "docs"
Requires-Dist: furo; extra == "docs"
Dynamic: license-file

# Django Trips

A Django app for trips, schedules, bookings, and related travel data: models, querysets,
business rules and admin. It ships no API or URLs: build your own endpoints on the services and
querysets described under "Business rules" below. (The DRF API it shipped up to 1.x was removed
in 2.0.0; see the changelog.)

This service is a core component of the [DestinationPak](https://destinationpak.com) project — a platform designed 
to make exploring and booking adventures across Pakistan easier and more accessible.

## Installation
Simply do:
```bash
pip install django-trips
```

## Usage
Add the app into your installed apps in your project's settings file. 
```
INSTALLED_APPS = [
    ...
    'django_trips',
]
```
## Migrate
```
python manage.py migrate 
```
## Business rules
The booking and trip rules live in `django_trips.services` and the model querysets, so any
caller (your own API, a management command, the admin) gets the same behaviour:

```python
from django_trips import services
from django_trips.models import Category, Trip

booking = services.create_trip_booking(
    trip, schedule,
    full_name="Ayesha Khan", email="ayesha@example.com", phone_number="+923001234567",
    target_date=schedule.start_date, adults=2, children=1, terms_accepted=True,
)
trips = Trip.objects.active().with_price()           # cheapest package price as `price`
categories = Category.objects.active().with_trip_counts()
```

Also: `TripSchedule.objects.bookable()` (upcoming, published departures),
`TripReview.objects.verified()`, `TripBooking.objects.matching_guest(number, otp=..., email=...)`
(guest lookup, never on the number alone), `services.toggle_trip_wishlist(user, trip)`, and
`locations.trips_booked_to(location)` (a destination's trips, rolled up for a REGION).

`create_trip_booking` checks the selection belongs to the trip, locks the schedule row
before counting seats, prices the booking and updates `booked_seats`. A rule failure raises
Django's `ValidationError` with a dict keyed by field name. `create_trip`/`update_trip` cover
trip writes, including the itinerary upsert.

## Custom Location model

`django_trips.Location` (a self-hierarchical `name`/`slug`/`lat`/`lon`/`type`/`parent` model,
used by `Trip.departure`/`Trip.destination`/`Trip.locations`, `TripItinerary.location`,
`TripReview.location`, `Testimonial.location`, and `TripPickupLocation.location`) is
swappable, the same way Django's own `AUTH_USER_MODEL` is - if your project already has its
own location/city model, you don't have to duplicate location data into a second table just
to install this app.

Two settings, both optional and both defaulting to this package's own bundled model:

- **`DJANGO_TRIPS_LOCATION_MODEL`** - an `"app_label.ModelName"` string naming which model
  actually satisfies the FK, e.g. `DJANGO_TRIPS_LOCATION_MODEL = "myapp.City"`. Your model
  doesn't need to share `Location`'s field names.
- **`DJANGO_TRIPS_LOCATION_ADAPTER`** - a dotted path to a `django_trips.location_adapter
  .LocationAdapter` subclass telling this app how to read your model's fields as if they were
  `Location`'s (`get_name`, `get_slug`, `get_lat`, `get_lon`, `get_type_display`, `get_region`,
  `get_travel_tips`, `get_importance`, `get_poster`). Code that reads a location's fields should go through
  `django_trips.location_adapter.get_location_adapter()` rather than field names directly, so your adapter is the only place that needs to know your model's real
  shape.

**Set both before your project's first `migrate`.** Like `AUTH_USER_MODEL`, this is a
swappable-model setting - Django resolves it once when the app loads, and a swap made after
`Location`'s own table has already been created (and other tables have already foreign-keyed
into it) doesn't retroactively move that data; it needs a real data migration instead of a
config change.

Building a brand-new Location model rather than reusing one you already have? Inherit
`django_trips.models.AbstractLocation` instead of writing an adapter - it's a plain abstract
Django model (the same shape `AbstractUser` is - real fields and concrete methods, not an
interface class) already carrying `name`/`slug`/`lat`/`lon`/`type`/`parent`/`region`/
`travel_tips`/`importance`/`poster_image`/`poster_url` and their read methods, so you get a
working swap with no `DJANGO_TRIPS_LOCATION_ADAPTER` at all:

```python
# myapp/models.py
from django_trips.models import AbstractLocation

class MyLocation(AbstractLocation):
    country_code = models.CharField(max_length=2, default="PK")
```

```python
# settings.py
DJANGO_TRIPS_LOCATION_MODEL = "myapp.MyLocation"
```

Reusing an existing model instead - one you can't restructure, or one shared with other
libraries - stick with the adapter approach above; that's what it's for.

A few features are tied to `Location`'s own hierarchy shape (`type`/`parent`) rather than the
adapter's field-level contract - the REGION-rollup behavior in `django_trips.locations`
(`expand_destination_slugs`, `destinations_with_trip_counts`, `trips_booked_to`). These assume the default, unswapped `Location` model and aren't guaranteed to
work against an arbitrary swapped-in model that doesn't share that hierarchy concept.

If your swapped-in model has an `is_active`-style flag, define an `active()` method on its
default manager/queryset (matching `ActiveQuerySet.active()` on this package's own `Location`).
`get_active_locations_queryset()` (`models.py`) - what a trip's `departure`/`destination`/
`locations` choices should be scoped to - checks for that method by
name and silently falls back to every row, active or not, when it's absent. Not part of the
`LocationAdapter` contract, since a swapped-in model isn't guaranteed to have a concept of
active/inactive at all - but if yours does, it's worth adding.

For a worked example of a real swap: the DestinationPakistan platform (this package's own
primary consumer, a private project) points this setting directly at its own `public.Location`
model, with no adapter override at all - `public.Location`'s fields were deliberately shaped to
match this package's own `Location` exactly, so the default `LocationAdapter` already reads it
correctly. See `docs/location-model-swap-design.md` in that project for the full writeup.

## Trip status events

Every time a `Trip`'s `status` actually changes value on save (editing an existing trip,
not creating one), the lib records a `TripStatusEvent` row and fires a `trip_status_changed`
signal (`django_trips/signals.py`) carrying `trip`, `old_status`, `new_status`, `changed_by`,
and `reason`. Use `Trip.set_status(new_status, changed_by=user, reason="payment confirmed")`
to attribute a change to a specific staff user and/or a reason; a bare
`trip.status = ...; trip.save()` still logs an event, just with `changed_by=None` (read as
system/automatic) and an empty `reason`.

`trip_status_changed` is a plain Django signal, so a consuming project can change this
behaviour without forking the lib:
```python
from django_trips.signals import log_trip_status_event, trip_status_changed
from django_trips.models import Trip

# Replace the default DB logging entirely:
trip_status_changed.disconnect(log_trip_status_event, sender=Trip)

# Or just react to it in addition to the default logging, e.g. a notification:
trip_status_changed.connect(notify_status_change, sender=Trip)
```

## Booking status events

`TripBooking` has the same arrangement: a status change on an existing booking records a
`BookingStatusEvent` and fires `booking_status_changed` (kwargs `booking`, `old_status`,
`new_status`, `changed_by`, `reason`), with `TripBooking.set_status()` and the same
disconnect/connect override mechanism as above. `TripBooking.cancel(changed_by=..., reason=...)`
goes through `set_status`, so cancellations land in the same history.

The events are what backs a traveler-facing "booking activity" timeline
(`booking.status_events`). When exposing them to travelers, leave out `changed_by`: which staff
member actioned a booking isn't the traveler's business. Note that no event is logged at creation, so a
booking that has never moved off `PENDING` has an empty list; render the booking's own
`created` timestamp for that first "booking placed" entry.

## Pricing model

Price lives in two places, and they compose rather than compete:

- **`TripPackage.base_price` / `base_child_price`** — the source-of-truth adult/child
  price for a pricing tier (Standard/Budget/Premium/...). This is an absolute,
  date-independent menu price, set once per tier rather than on every schedule.
- **`TripSchedule.additional_price` / `additional_child_price`** — a flat *surcharge*
  for one specific bookable departure date (e.g. weekend/holiday/peak pricing), added
  on top of whichever package the traveler is booking against. 0 for a regular date.

The final payable price for a package + (optional) schedule + (optional) pickup
location is always resolved via `get_effective_price()`
(`django_trips/services.py`), never by reading `TripPackage`'s fields directly:

```python
from django_trips.services import get_effective_price

get_effective_price(package, schedule=schedule, pickup=pickup)
# {"price": package.base_price + schedule.additional_price + pickup.additional_price,
#  "child_price": package.base_child_price + schedule.additional_child_price + pickup.additional_price}
```

Every `Trip` is guaranteed to always have exactly one **"Standard"** package,
auto-created by a `post_save` signal the moment the trip is saved
(`django_trips/signals.py`) at `base_price=0`/`base_child_price=0` until an admin
sets a real price. Booking a trip that offers no extra tiers still resolves to a
real package under the hood — **no manual package-creation step is required for a
simple, single-price trip.**

### Worked example — a plain 2-night domestic trip, no tiers, no date surcharge

Say a 2-night trip to Hunza has its Standard package priced at `base_price=15000`,
`base_child_price=8000`, with no extra tiers beyond the automatic Standard one,
and no schedule surcharge:

```python
trip = Trip.objects.create(name="2 Nights in Hunza", ...)   # Standard package auto-created here

standard_package = trip.packages.get(name=PackageTier.STANDARD)
standard_package.base_price = 15000
standard_package.base_child_price = 8000
standard_package.save()

schedule = TripSchedule.objects.create(trip=trip, ...)   # additional_price=0 by default

get_effective_price(standard_package, schedule=schedule)
# {"price": 15000, "child_price": 8000}
```

A booking for 2 adults and 1 child on this schedule (via
`POST /trips/<trip_id>/bookings/create/`, omitting `package` so it defaults to
Standard) stores `total_price = 15000 * 2 + 8000 * 1 = 38000` — no package tier
had to be created or selected for this to work correctly.

## Generate random trips.
Before you generate random scripts, make sure you have the required settings available in your project. If you want to use the default settings set `USE_DEFAULT_TRIPS=True`. 
The script depends upon these variables, if you don't want to use the default settings set the 
following settings. 
1. `TRIP_DESTINATIONS`
2. `TRIP_DEPARTURE_LOCATION`
3. `TRIP_LOCATIONS = TRIP_DEPARTURE_LOCATION + TRIP_DESTINATIONS`
4. `TRIP_LOCATIONS_BY_REGION` (optional) - maps each location name above to its
   PROVINCE-level parent, e.g. `{"Gilgit-Baltistan": ("Hunza", "Skardu")}`, so
   `Location.region` resolves instead of staying `None`.
5. `TRIP_HOSTS`
6. `TRIP_FACILITIES`
7. `TRIP_CATEGORIES`
8. `TRIP_GEARS`

```
python manage.py generate_trips --batch_size=100
``` 
Change the `batch_size` variable to create as much of trips you want. 

## Develop Django Trips
Kick the docker build using the following command. 
```
make build
``` 
This task may take few minutes. 

 
Once the build has been completed, spin up the docker and migrate the database. 
```bash
> make dev.up
> make shell 
> make update_db
```
Create a superuser with username `admin`.

``` bash
> make shell
> python manage.py createsuperuser
```

Create batch of trips. Run the following command inside docker shell.
```bash
> python manage.py  generate_trips --batch_size=100
OR
> make random_trips
```

## Test
Run tests using the following command.
```
make test
```
## Docker Commands

| Action                            | Command        |
|-----------------------------------|----------------|
| Run Server                        | `make dev.up`  |
| Trail Logs                        | `make logs`    |
| Attach sever                      | `make attach`  |
| Stop server                       | `make stop`    |
| * Destroy docker container.       | `make destroy` |

_* caution, this will remove all your data._

## Documentation

This README is also published as browsable docs (`docs/`, built with Sphinx). Build it
locally with:
```bash
pip install -e ".[docs]"
sphinx-build -b html docs docs/_build
```

## How to Contribute

Contributions are welcome! Whether it's bug fixes, new features, 
improving documentation, or sharing feedback — we'd love your help.

Please fork the repository, make your changes in a feature branch, 
and submit a pull request. For major changes, consider opening an issue
first to discuss what you’d like to work on.

See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development/release workflow, and
the [Code of Conduct](CODE_OF_CONDUCT.md). Found a security issue? See
[SECURITY.md](SECURITY.md) rather than opening a public issue.

---

Thank you for being a part of the Django Trips journey.  
Together, we can make travel management smarter, faster, and more delightful.

Reach out in you need further assistance.
`admin@destinationpak.com`

Happy coding! ✨
