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 project — a platform designed to make exploring and booking adventures across Pakistan easier and more accessible.
Installation
Simply do:
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:
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 shareLocation's field names.DJANGO_TRIPS_LOCATION_ADAPTER- a dotted path to adjango_trips.location_adapter .LocationAdaptersubclass telling this app how to read your model's fields as if they wereLocation'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 throughdjango_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:
# myapp/models.py
from django_trips.models import AbstractLocation
class MyLocation(AbstractLocation):
country_code = models.CharField(max_length=2, default="PK")
# 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:
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:
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:
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.
TRIP_DESTINATIONSTRIP_DEPARTURE_LOCATIONTRIP_LOCATIONS = TRIP_DEPARTURE_LOCATION + TRIP_DESTINATIONSTRIP_LOCATIONS_BY_REGION(optional) - maps each location name above to its PROVINCE-level parent, e.g.{"Gilgit-Baltistan": ("Hunza", "Skardu")}, soLocation.regionresolves instead of stayingNone.TRIP_HOSTSTRIP_FACILITIESTRIP_CATEGORIESTRIP_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.
> make dev.up
> make shell
> make update_db
Create a superuser with username admin.
> make shell
> python manage.py createsuperuser
Create batch of trips. Run the following command inside docker shell.
> 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:
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 for the full development/release workflow, and the Code of Conduct. Found a security issue? See 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! ✨
Release files for django-trips 2.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_trips-2.0.0.tar.gz | 123.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_trips-2.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 201.7 kB
Release files / django_trips-2.0.0.tar.gz
| Download URL | django_trips-2.0.0.tar.gz |
|---|---|
| Size | 123.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fa6de9555eed68664893530b14be30b3f8457c2d4474023f30c366ba4abfb030
|
|
BLAKE2b-256 checksum How to use checksums |
191f5f78d22d0b2913b9c610fcc81c2a4b79d04f8646a47ae2ff09129c7c3627
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency logRelease files / django_trips-2.0.0-py3-none-any.whl
| Download URL | django_trips-2.0.0-py3-none-any.whl |
|---|---|
| Size | 77.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c3eb3565c839584731bbed65d8ba358a04612fbfcb8c50d034ae80ad6757c319
|
|
BLAKE2b-256 checksum How to use checksums |
2c388698eaa5fa427e873608015c30c95d2403b3fb1e30c19401aab1d16e0ff6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency log