Overview
The bot uses SQLAlchemy 2.0 async ORM with PostgreSQL. All models are defined in app/db/models.py and inherit from a declarative Base class.
Database models
User model
Stores user preferences and journey settings. Each user has foreign keys to their home and destination addresses.
class User ( Base ):
__tablename__ = "users"
__allow_unmapped__ = True
id = Column(BigInteger, primary_key = True , unique = True , autoincrement = False )
home_address_id = Column(
Integer,
ForeignKey( 'addresses.id' ),
default = None )
default_destination_address_id = Column(
Integer,
ForeignKey( 'addresses.id' ),
default = None )
walking_speed = Column(String( 10 ), nullable = False , default = "normal" )
max_transfers = Column(Integer, nullable = False , default = 4 )
max_journeys = Column(Integer, nullable = False , default = 10 )
min_transfer_time = Column(Integer, nullable = False , default = 0 )
arrival_time = Column(Time, nullable = False , default = get_current_time)
check_time = Column(Time, nullable = False , default = get_check_time)
regional = Column(Boolean, nullable = False , default = True )
suburban = Column(Boolean, nullable = False , default = True )
bus = Column(Boolean, nullable = False , default = True )
ferry = Column(Boolean, nullable = False , default = True )
subway = Column(Boolean, nullable = False , default = True )
tram = Column(Boolean, nullable = False , default = True )
is_notified = Column(Boolean, nullable = False , default = False )
home_address: Address = relationship(
"Address" , foreign_keys = [home_address_id])
default_destination_address: Address = relationship(
"Address" , foreign_keys = [default_destination_address_id]
)
__table_args__ = (
CheckConstraint(
"walking_speed IN ('slow', 'normal', 'fast')" , name = 'chk_walking_speed' ),
)
User fields
Telegram user ID (not auto-incremented)
Foreign key to addresses.id for user’s home location
default_destination_address_id
Foreign key to addresses.id for default journey destination
walking_speed
String(10)
default: "normal"
Walking speed preference for journey calculations. Must be one of: slow, normal, fast
Maximum number of transfers allowed in journey results
Maximum number of journey options to fetch from VBB API
Minimum transfer time in minutes between connections
arrival_time
Time
default: "current_time"
Desired arrival time for scheduled journey notifications
check_time
Time
default: "current_time - 4 hours"
Time when bot should send daily journey notification (default 4 hours before arrival)
Include regional trains (RE, RB) in journey search
Include S-Bahn suburban trains in journey search
Include bus lines in journey search
Include ferry connections in journey search
Include U-Bahn subway in journey search
Include tram lines in journey search
Flag indicating whether user has received today’s notification (reset daily at midnight)
Address model
Stores geocoded addresses for journey start and end points.
class Address ( Base ):
__tablename__ = "addresses"
__allow_unmapped__ = True
id = Column(Integer,
Sequence( 'addresses_id_seq' ),
primary_key = True ,
unique = True ,
autoincrement = True )
street_name = Column(String( 100 ))
house_number = Column(Integer)
city = Column(String( 50 ))
plz = Column(String( 10 ))
latitude = Column(Float)
longitude = Column(Float)
created_by_id = Column(BigInteger)
Address fields
Auto-incrementing primary key
Street name (e.g., “Unter den Linden”)
House number on the street
City name (e.g., “Berlin”, “Potsdam”)
Postal code (Postleitzahl)
Geographic latitude coordinate
Geographic longitude coordinate
Telegram user ID who created this address
JourneyDB model
Stores saved journeys with refresh tokens for updates.
class JourneyDB ( Base ):
__tablename__ = "journeys"
__allow_unmapped__ = True
id = Column(UUID( as_uuid = True ), primary_key = True , default = uuid.uuid4)
refresh_token = Column(String( 1000 ))
created_by_id = Column(BigInteger)
created_at = Column(DateTime, default = get_current_datetime)
JourneyDB fields
Unique journey identifier (auto-generated UUID4)
VBB API refresh token for retrieving updated journey data
Telegram user ID who saved this journey
created_at
DateTime
default: "current_datetime"
Timestamp when journey was saved (Europe/Berlin timezone)
Relationships
User-Address relationship
Each user has two optional address relationships:
home_address: Address = relationship(
"Address" , foreign_keys = [home_address_id])
default_destination_address: Address = relationship(
"Address" , foreign_keys = [default_destination_address_id]
)
This allows:
Multiple users to share the same address (many-to-one)
Users without configured addresses (nullable foreign keys)
Efficient address reuse (no duplicate coordinates)
Accessing relationships
user = await session.get_user(user_id)
home = await session.get_address(user.home_address_id)
destination = await session.get_address(user.default_destination_address_id)
print ( f "Journey from { home.street_name } to { destination.street_name } " )
Timezone handling
All timestamp defaults use Berlin timezone:
def get_now ():
berlin_timezone = pytz.timezone( "Europe/Berlin" )
return berlin_timezone.localize(datetime.now())
def get_current_datetime ():
return get_now()
def get_current_time ():
return get_now().time()
def get_check_time ():
return get_now() - timedelta( hours = 4 )
This ensures:
Consistent time handling across daylight saving transitions
Correct arrival times for Berlin/Brandenburg region
Proper notification scheduling
Database session management
The application uses async session management with context managers:
# Session injected by middleware (in handlers)
async def handler ( message : Message, f : FMT , ** kwargs ):
user = await f.db.get_user(message.from_user.id)
# Session auto-commits on success, rolls back on error
# Manual session (in background jobs)
async with sessionmanager() as session:
users = await session.get_registered_users()
for user in users:
await session.update_notified(user, True )
Query examples
Get registered users
Users with both home and destination addresses configured:
async def get_registered_users ( session ):
stmt = select(User).where(
User.home_address_id.isnot( None ),
User.default_destination_address_id.isnot( None )
)
result = await session.execute(stmt)
return result.scalars().all()
Create new user
async def create_user ( session , telegram_id : int ):
user = User( id = telegram_id)
session.add(user)
await session.commit()
return user
Update user preferences
async def update_preferences ( session , user : User, ** kwargs ):
for key, value in kwargs.items():
setattr (user, key, value)
await session.commit()
Create address from location
async def create_address ( session , location : Location, user_id : int ):
address = Address(
latitude = location.latitude,
longitude = location.longitude,
created_by_id = user_id
)
session.add(address)
await session.commit()
return address
Database migrations
The bot uses Alembic for database migrations. Migration files track schema changes:
# Create new migration
alembic revision --autogenerate -m "Add new field to User"
# Apply migrations
alembic upgrade head
# Rollback one migration
alembic downgrade -1
Constraints and validation
Check constraints
The walking_speed field has a database-level constraint:
CONSTRAINT chk_walking_speed CHECK (walking_speed IN ( 'slow' , 'normal' , 'fast' ))
Invalid values are rejected by the database before commit.
Nullable vs. required
id, walking_speed, journey preferences: Required (NOT NULL)
home_address_id, default_destination_address_id: Optional (nullable)
Address fields: Most optional except id, latitude, longitude
Best practices
Always use async/await
# Correct
user = await session.get_user(user_id)
# Incorrect (will raise error)
user = session.get_user(user_id)
Use relationships for address access
# Less efficient (two queries)
home = await session.get_address(user.home_address_id)
# More efficient (single query with joinedload)
stmt = select(User).options(
joinedload(User.home_address)
).where(User.id == user_id)
user = await session.execute(stmt)
Handle missing addresses gracefully
if user.home_address_id is None :
await message.answer( "Please set your home address first" )
return
Next steps
VBB API integration Learn how journey data is fetched from the VBB API
Background services Understand how scheduled notifications use the database