Skip to content

API Reference

Apps

api

map

ranking

Project: MyCyclingCity Generation: AI-based

Ranking application for MyCyclingCity.

leaderboard

Project: MyCyclingCity Generation: AI-based

Leaderboard application for MyCyclingCity.

kiosk

iot

game

mgmt

Utilities

api.utils

Project: MyCyclingCity Generation: AI-based

Shared utility functions for MyCyclingCity applications.

format_km_de

format_km_de(
    value: Optional[float],
    decimals: int = 3,
    language_code: Optional[str] = None,
) -> str

Format number based on language: German format (dot thousands, comma decimal) or English format (comma thousands, dot decimal).

Parameters:

Name Type Description Default
value Optional[float]

The numeric value to format. Can be None.

required
decimals int

Number of decimal places (default: 3).

3
language_code Optional[str]

Optional language code override. If None, uses current language.

None

Returns:

Type Description
str

Formatted string with appropriate thousands and decimal separators.

Examples:

>>> format_km_de(1234.567, 3, 'de')
'1.234,567'
>>> format_km_de(1234.567, 3, 'en')
'1,234.567'
Source code in api/utils.py
def format_km_de(value: Optional[float], decimals: int = 3, language_code: Optional[str] = None) -> str:
    """
    Format number based on language: German format (dot thousands, comma decimal) 
    or English format (comma thousands, dot decimal).

    Args:
        value: The numeric value to format. Can be None.
        decimals: Number of decimal places (default: 3).
        language_code: Optional language code override. If None, uses current language.

    Returns:
        Formatted string with appropriate thousands and decimal separators.

    Examples:
        >>> format_km_de(1234.567, 3, 'de')
        '1.234,567'
        >>> format_km_de(1234.567, 3, 'en')
        '1,234.567'
    """
    if value is None:
        value = 0

    try:
        num = float(value)
        fixed = f"{num:.{decimals}f}"
        parts = fixed.split('.')
        integer_str = parts[0]
        decimal_part = parts[1] if len(parts) > 1 else ''

        # Get current language
        if language_code is None:
            current_language = translation.get_language()
            if not current_language:
                current_language = getattr(settings, 'LANGUAGE_CODE', 'de')
        else:
            current_language = language_code

        # Normalize language code (e.g., 'en-us' -> 'en', 'de-de' -> 'de')
        if current_language:
            lang_code = str(current_language).split('-')[0].lower()
        else:
            lang_code = 'de'
        is_german = (lang_code == 'de')

        if is_german:
            # German format: dot for thousands, comma for decimal
            integer_with_separators = ''
            for i, char in enumerate(reversed(integer_str)):
                if i > 0 and i % 3 == 0:
                    integer_with_separators = '.' + integer_with_separators
                integer_with_separators = char + integer_with_separators
            return integer_with_separators + (',' + decimal_part if decimal_part else '')
        else:
            # English format: comma for thousands, dot for decimal
            integer_with_separators = ''
            for i, char in enumerate(reversed(integer_str)):
                if i > 0 and i % 3 == 0:
                    integer_with_separators = ',' + integer_with_separators
                integer_with_separators = char + integer_with_separators
            return integer_with_separators + ('.' + decimal_part if decimal_part else '')
    except (ValueError, TypeError):
        if language_code is None:
            current_language = translation.get_language()
            if not current_language:
                current_language = getattr(settings, 'LANGUAGE_CODE', 'de')
        else:
            current_language = language_code
        lang_code = str(current_language).split('-')[0].lower() if current_language else 'de'
        is_german = lang_code == 'de'
        decimal_sep = ',' if is_german else '.'
        return '0' + (decimal_sep + '0' * decimals if decimals > 0 else '')

api.helpers

Project: MyCyclingCity Generation: AI-based

Shared helper functions for building group hierarchies and data structures. Used by map, ranking, and leaderboard apps.

are_all_parents_visible

are_all_parents_visible(group: Group) -> bool

Check if all parent groups in the hierarchy are visible.

Parameters:

Name Type Description Default
group Group

The group to check.

required

Returns:

Type Description
bool

True if the group and all its parents are visible, False otherwise.

Source code in api/helpers.py
def are_all_parents_visible(group: Group) -> bool:
    """
    Check if all parent groups in the hierarchy are visible.

    Args:
        group: The group to check.

    Returns:
        True if the group and all its parents are visible, False otherwise.
    """
    visited = set()
    current = group

    # First check the group itself
    if not current.is_visible:
        return False

    # Then check all parent groups recursively
    while current and current.parent_id:
        if current.id in visited:
            # Circular reference detected, break to avoid infinite loop
            break
        visited.add(current.id)

        # Load parent if not already loaded (select_related only loads direct parent)
        if not hasattr(current, 'parent') or current.parent is None:
            try:
                current.parent = Group.objects.get(id=current.parent_id)
            except Group.DoesNotExist:
                break

        # Move to parent and check if it's visible
        current = current.parent
        if current:
            if current.id in visited:
                break
            visited.add(current.id)
            if not current.is_visible:
                return False

    return True

build_events_data

build_events_data(
    kiosk: bool = False,
) -> List[Dict[str, Any]]

Build event data structure for display.

Parameters:

Name Type Description Default
kiosk bool

Whether in kiosk mode (filters groups with distance > 0).

False

Returns:

Type Description
List[Dict[str, Any]]

List of dictionaries containing event data.

Source code in api/helpers.py
def build_events_data(kiosk: bool = False) -> List[Dict[str, Any]]:
    """
    Build event data structure for display.

    Args:
        kiosk: Whether in kiosk mode (filters groups with distance > 0).

    Returns:
        List of dictionaries containing event data.
    """
    from django.utils import timezone
    # Event is already imported at top of file from eventboard.models

    now = timezone.now()
    active_events = Event.objects.filter(is_active=True, is_visible_on_map=True)
    # Filter by should_be_displayed() instead of is_currently_active()
    # This allows showing events after end_time until hide_after_date
    active_events = [e for e in active_events if e.should_be_displayed()]
    events_data = []

    for event in active_events:
        # Get all groups participating in this event
        event_groups = []
        for status in event.group_statuses.select_related('group').all():
            # In kiosk mode, only show groups with current_distance_km > 0
            if kiosk and float(status.current_distance_km) <= 0:
                continue
            event_groups.append({
                'name': status.group.name,
                'km': round(float(status.current_distance_km), 3),
                'group_id': status.group.id
            })
        # In kiosk mode, only add event if it has groups with distance > 0
        if event_groups and (not kiosk or len(event_groups) > 0):
            # Sort groups by distance (descending) and limit to top 10
            event_groups_sorted = sorted(event_groups, key=lambda x: x['km'], reverse=True)[:10]
            # Calculate total kilometers for this event (from all groups, not just top 10)
            total_km = sum(g['km'] for g in event_groups)
            # Check if event has ended (end_time reached)
            is_ended = event.end_time and now > event.end_time
            events_data.append({
                'id': event.id,
                'name': event.name,
                'event_type': event.get_event_type_display(),
                'description': event.description or '',
                'start_time': event.start_time,
                'end_time': event.end_time,
                'total_km': round(total_km, 3),
                'is_ended': is_ended,
                'groups': event_groups_sorted
            })

    return events_data

build_group_hierarchy

build_group_hierarchy(
    target_group: Optional[Group] = None,
    kiosk: bool = False,
    show_cyclists: bool = True,
) -> List[Dict[str, Any]]

Build a hierarchical data structure of groups with their members and subgroups.

Parameters:

Name Type Description Default
target_group Optional[Group]

Optional specific group to filter by.

None
kiosk bool

Whether in kiosk mode (filters groups with distance_total > 0).

False
show_cyclists bool

Whether to include cyclist data in the hierarchy.

True

Returns:

Type Description
List[Dict[str, Any]]

List of dictionaries containing group hierarchy data.

Source code in api/helpers.py
def build_group_hierarchy(
    target_group: Optional[Group] = None,
    kiosk: bool = False,
    show_cyclists: bool = True
) -> List[Dict[str, Any]]:
    """
    Build a hierarchical data structure of groups with their members and subgroups.

    Args:
        target_group: Optional specific group to filter by.
        kiosk: Whether in kiosk mode (filters groups with distance_total > 0).
        show_cyclists: Whether to include cyclist data in the hierarchy.

    Returns:
        List of dictionaries containing group hierarchy data.
    """
    group_filter = {'is_visible': True}

    if target_group:
        parent_groups = Group.objects.filter(id=target_group.id, **group_filter).order_by('name')
    else:
        parent_groups = Group.objects.filter(parent__isnull=True, **group_filter).order_by('name')
        # In kiosk mode, only show parent groups with distance_total > 0
        if kiosk:
            parent_groups = parent_groups.filter(distance_total__gt=0)

    # Calculate group totals from HourlyMetric for all groups at once
    all_groups_list = list(parent_groups)
    # Also collect all subgroups for calculation
    for p_group in parent_groups:
        all_groups_list.extend(list(p_group.children.filter(is_visible=True)))

    group_totals = _calculate_group_totals_from_metrics(all_groups_list, use_cache=True)

    hierarchy = []
    for p_group in parent_groups:
        p_filter = {'is_visible': True}
        if kiosk:
            p_filter['distance_total__gt'] = 0

        direct_members = []
        if show_cyclists:
            direct_qs = p_group.members.filter(**p_filter).select_related(
                'cyclistdevicecurrentmileage'
            )
            direct_members_list = list(direct_qs)

            # Calculate totals from HourlyMetric for all cyclists at once
            cyclist_totals = _calculate_cyclist_totals_from_metrics(direct_members_list, use_cache=True)

            direct_members = []
            for m in direct_members_list:
                session_km = 0
                try:
                    if hasattr(m, 'cyclistdevicecurrentmileage') and m.cyclistdevicecurrentmileage:
                        session_km = float(m.cyclistdevicecurrentmileage.cumulative_mileage)
                except (AttributeError, ValueError, TypeError):
                    pass
                # Use total from HourlyMetric instead of distance_total from model
                total_km = cyclist_totals.get(m.id, 0.0)
                direct_members.append({
                    'name': m.user_id,
                    'km': round(total_km, 3),
                    'session_km': round(session_km, 3)
                })

            # Sort by km (from HourlyMetric) descending
            direct_members = sorted(direct_members, key=lambda x: x['km'], reverse=True)

        # Filter subgroups: in kiosk mode, only show groups with distance_total > 0
        # Sort by name to match the group menu sorting
        subgroups_qs = p_group.children.filter(is_visible=True).order_by('name')
        if kiosk:
            subgroups_qs = subgroups_qs.filter(distance_total__gt=0)

        subgroups_data = []
        for sub in subgroups_qs:
            sub_member_data = []
            if show_cyclists:
                m_qs = sub.members.filter(**p_filter).select_related(
                    'cyclistdevicecurrentmileage'
                )
                sub_members_list = list(m_qs)

                # Calculate totals from HourlyMetric for all cyclists at once
                cyclist_totals = _calculate_cyclist_totals_from_metrics(sub_members_list, use_cache=True)

                sub_member_data = []
                for m in sub_members_list:
                    session_km = 0
                    try:
                        if hasattr(m, 'cyclistdevicecurrentmileage') and m.cyclistdevicecurrentmileage:
                            session_km = float(m.cyclistdevicecurrentmileage.cumulative_mileage)
                    except (AttributeError, ValueError, TypeError):
                        pass
                    # Use total from HourlyMetric instead of distance_total from model
                    total_km = cyclist_totals.get(m.id, 0.0)
                    sub_member_data.append({
                        'name': m.user_id,
                        'km': round(total_km, 3),
                        'session_km': round(session_km, 3)
                    })

                # Sort by km (from HourlyMetric) descending
                sub_member_data = sorted(sub_member_data, key=lambda x: x['km'], reverse=True)
            # Use total from HourlyMetric instead of distance_total from model
            sub_total_km = group_totals.get(sub.id, 0.0)
            # In kiosk mode, only add subgroup if it has distance_total > 0 or has members with distance_total > 0
            if not kiosk or (sub_total_km > 0 or len(sub_member_data) > 0):
                subgroups_data.append({
                    'id': sub.id,  # Add subgroup ID for filtering
                    'name': sub.name,
                    'km': round(sub_total_km, 3),
                    'members': sub_member_data
                })

        # Sort subgroups by distance_total (sorted descending) - no limit
        subgroups_data = sorted(subgroups_data, key=lambda x: x['km'], reverse=True)

        # Use total from HourlyMetric instead of distance_total from model
        p_group_total_km = group_totals.get(p_group.id, 0.0)
        if not kiosk or (p_group_total_km > 0 or subgroups_data or direct_members):
            hierarchy.append({
                'id': p_group.id,  # Add group ID for filtering
                'name': p_group.name,
                'km': round(p_group_total_km, 3),
                'direct_members': direct_members,
                'subgroups': subgroups_data
            })

    # Sort hierarchy by name to match the group menu sorting
    hierarchy = sorted(hierarchy, key=lambda x: x['name'])

    return hierarchy